背景
在前端开发工作中,我遇到需要对图片进行简单涂鸦的需求。起初为了快速实现这一功能,采用了 Canvas 结合鼠标监听的方式实现了一个简单版本的绘图工具。功能十分有限,后来我想尝试实现更多功能,开始对原有的简单版本进行不断改进。通过持续的优化和功能扩展,它不仅支持绘制矩形、椭圆、线条、箭头等多种图形,还具备撤销、重做、拖拽等实用操作。
功能概述
- 图形绘制:支持绘制矩形、椭圆、线条、箭头和添加文本备注等多种图形
- 交互操作:可以选择、拖动和调整图形的大小,同时支持撤销、恢复、删除操作
- 图片处理:允许选择本地图片作为画布背景,并根据视口大小及图片尺寸自适应显示图片
代码结构分析
项目地址:GitHubGitHub - JiYunDeveloper/canvas-board
主要文件和模块
index.tsx:组件的入口文件,负责处理鼠标事件、状态管理和渲染界面utils.ts:包含一些实用工具函数,如计算画布尺寸、转换坐标、创建图形对象等config.ts:定义了一些常量和枚举,如线条宽度列表、操作模式、光标类型等entities文件夹:包含各种图形类,如Rectangle、Ellipse、Line、Arrow和Text,每个类负责绘制和处理相应的图形entities/canvasBrush.ts:用于管理画布操作的核心类,它封装了光标和背景设置、图形选择和交互、绘图操作等相关的功能hooks文件夹:组件中使用到的hook,如创建canvas笔刷、监听屏幕尺寸、计算合适的cavas尺寸等components/operation-bar:操作栏组件,提供了选择图片、设置线条宽度和颜色、切换操作模式等功能
核心代码片段分析
状态管理
使用自定义的 useSetState Hook 来管理页面的数据,包括图片尺寸、画布大小、线条宽度、颜色、操作模式等。后续操作中更新数据可出发重新渲染页面。
/** 初始状态 */
export const InitialState = {
/** 图片宽度 */
imageWidth: 0,
/** 图片高度 */
imageHeight: 0,
/** canvas css 宽度 */
width: 0,
/** canvas css 高度 */
height: 0,
/** 缩放比例 图片宽度/显示宽度 */
scale: 0,
/** 鼠标动作 */
mouseAction: "move" as MouseActionType,
/** 画笔颜色 */
color: "#ffffff",
/** 画笔线条宽度 */
lineWidth: SmallLineWidth,
/** 画笔操作类型 */
operationMode: undefined as OperationMode | undefined,
/** 文字 */
text: "",
};
const [state, updateState] = useSetState(InitialState);
计算画布尺寸
useSuitableCanvasSize 的主要功能是根据图片的原始尺寸和容器的可用尺寸,计算出适合的canvas CSS尺寸以及缩放比例,并通过缓存机制避免重复计算,提高性能
画布尺寸及画布CSS尺寸:
- 画布尺寸:通
width和height属性设置,表示实际绘图的坐标系大小 - 画布CSS尺寸:通过CSS的
width和height属性设置,控制Canvas元素在页面中的显示大小
这里将画布的尺寸设置成图片的原始尺寸,再通过计算出合适的CSS尺寸显示在页面上。返回自适应后的宽高及缩放比,用于后续的计算。
// useSuitableCanvasSize.ts 代码片段
// 如果尺寸没有变化,直接返回缓存值
if (
cacheRef.current &&
cacheRef.current.imageWidth === imageWidth &&
cacheRef.current.imageHeight === imageHeight &&
cacheRef.current.containerWidth === containerWidth &&
cacheRef.current.containerHeight === containerHeight
) {
return cacheRef.current.result;
}
let width = containerWidth;
let height = containerHeight;
// 自适应宽高
const autoWidth = (imageWidth / imageHeight) * containerHeight;
const autoHeight = (imageHeight / imageWidth) * containerWidth;
if (imageWidth < width && imageHeight < height) {
// 照片宽高均小于容器宽高 => 正常显示图片
width = imageWidth;
height = imageHeight;
} else if (imageWidth < width && imageHeight >= height) {
// 照片高大于容器高,但照片宽小于容器宽 => 高度等于容器高,宽度等比缩放
width = autoWidth;
} else if (imageWidth >= width && imageHeight < height) {
// 照片宽大于容器宽,但照片高小于容器高 => 宽度等于容器宽,高度等比缩放
height = autoHeight;
} else if (imageWidth / imageHeight > containerWidth / containerHeight) {
// 照片宽高大于容器宽高,横向照片 => 宽度等于容器宽,高度等比缩放
height = autoHeight;
} else {
// 照片宽高大于容器宽高,纵向照片 => 高度等于容器高,宽度等比缩放
width = autoWidth;
}
// 缩放比例
const scale = imageWidth / width;
const result = { width, height, scale };
鼠标事件处理
在鼠标按下事件中,首先将页面坐标转换为画布坐标,然后检查是否选中了图形。如果选中了图形,则进入拖拽模式;否则,如果当前处于绘图模式,则创建一个新的图形对象并开始绘制。
页面坐标转换为画布坐标(transformCanvasCoord):上面提到画布尺寸和画布CSS尺寸的差异,鼠标点击的位置是页面坐标,需要转换成画布坐标使用。通过canvas.getBoundingClientRect()获取canvas元素在视口中位置(left, top坐标),鼠标点击位置(e.clientX, e.clientY)扣去left, top得出鼠标点击位置相对于画布 左/上 边缘的偏移量(dx, dy),再乘以缩放比例即可获得画布坐标。
图形拖拽逻辑:鼠标点击后,有选中的图形,执行拖拽逻辑(drag)。记录鼠标点击后的坐标,后续鼠标移动时也持续记录鼠标位置并执行canvasBrush.drag(offsetX, offsetY),canvasBrush会把偏移量传递给图形。通过鼠标点击的是内容区还是边框分别执行图形的move方法和resize方法。
图形绘制逻辑:鼠标点击后,没有选中的图形,执行绘制逻辑(draw)。记录鼠标点击后的坐标并创建新图形,执行canvasBrush.startDraw(tempOperate)开始绘制。鼠标移动的坐标作为结束点传递给canvasBrush,canvasBrush会更新图形的结束坐标。
浮在图形上逻辑:鼠标正常移动(move)时执行此逻辑。在canvasBrush.onPointerOver(endX, endY)方法中,判断画布操作栈operates里的图形是否有命中。根据图形的isInside方法返回的样式修改鼠标样式。
// 鼠标按下事件
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasBrush.getCanvasRef();
if (!canvas) return;
const { x, y } = transformCanvasCoord(canvas, e.clientX, e.clientY, scale);
const selectGraph = canvasBrush.getSelectGraph(x, y);
clickCoordRef.current = { x, y };
if (selectGraph) {
// 选中图像 => 拖拽
updateState({ mouseAction: "drag" });
} else if (operationMode) {
updateState({ mouseAction: "draw" });
const tempOperate = createGraph(
operationMode,
color,
lineWidth,
x,
y,
text
);
if (operationMode === OperationMode.Text)
updateState({ operationMode: undefined });
canvasBrush.startDraw(tempOperate);
}
};
在鼠标移动事件中,根据模式的不同分别执行不同的操作,包括拖拽图形、绘制图形和更新鼠标悬停状态。
如果是拖拽操作,即当前正在拖拽图形。计算鼠标移动的偏移量(offsetX 和 offsetY),并更新 clickCoordRef 中的鼠标点击位置。然后调用 canvasBrush.drag 方法,将偏移量传递给该方法,以实现图形的拖拽。这里使用Ref存储坐标位置,避免异步更新带来的延时。
如果是正常的移动操作,更加鼠标位置改变光标样式,实现拖拽、调整大小的样式变化。
// 鼠标移动事件
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
// ...
if (mouseAction === "drag") {
const { x: startX, y: startY } = clickCoordRef.current;
const offsetX = endX - startX;
const offsetY = endY - startY;
// 更新鼠标点击位置
clickCoordRef.current = { x: endX, y: endY };
canvasBrush.drag(offsetX, offsetY);
} else if (mouseAction === "draw" && operationMode !== undefined) {
canvasBrush.endDraw(endX, endY);
} else if (mouseAction === "move") {
canvasBrush.onPointerOver(endX, endY);
}
};
在鼠标松开事件中,将模式修改为移动并恢复光标默认样式。
以上三种事件监听共同协作,实现了画布上的交互功能。最后把这些事件注册到canvas元素上。
同时将canvas与canvasBrush关联起来,canvasBrush能够获取canvas及其上下文,后续用于绘图。这里使用bind()是因为如果直接传递canvasBrush.setCanvasRef会导致this丢失(指向windows),引起错误。所以使用bind修正this指向,确保setCanvasRef 方法内部的 this 始终指向 canvasBrush 实例。
<canvas
ref={canvasBrush.setCanvasRef.bind(canvasBrush)}
style={{ width, height }}
onMouseUp={handleMouseUp}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
/>
图形抽象类
Graph 抽象类在整个项目中起到了非常关键的作用,它为所有具体的图形类(Arrow、Line、Rectangle 等)提供了一个统一的接口和行为规范,使得代码具有良好的可扩展性和可维护性,方便添加新的图形类。同时CanvasBrush 类只关心调用方法而不用关心各个图形类的具体实现。
Graph 抽象类作用:
-
定义图形共有的基本属性(
color, lineWidth, isSelected),这些属性是图形类所必需的 -
定义图形的位置类型常量
PositionType,表示图形的不同位置类型,用于图形的移动和调整大小 -
定义统一方法,这些方法需要在具体的图形类中实现,从而确保所有具体图形类都具有相同的行为
paint(ctx: CanvasRenderingContext2D): void:在画布上绘制图形isInside(x: number, y: number): MouseInteractionResult | false:判断给定的坐标是否在图形内部,如果在内部则返回一个包含光标样式和位置类型的MouseInteractionResult对象,否则返回falseselected(ctx: CanvasRenderingContext2D): void:绘制图形被选中时的样式,通常是绘制一个虚线框来表示选中状态move(offsetX: number, offsetY: number): void:移动图形resize(positionType: string, offsetX: number, offsetY: number): void:改变图形的大小
具体实现类
具体的实现类目前有:Arrow、Ellipse、Line、Rectangle、Text。分别继承自抽象类 Graph,并实现了 Graph 中定义的抽象方法,以支持在画布上绘制、交互图形。
画布笔刷类
CanvasBrush 类是用于管理画布操作的核心类,提供了对画布上图形的绘制、选择、移动、调整大小、撤销、重做等操作。
CanvasBrush 类中的属性:
operates:存储画布上所有图形对象的数组,作为画布操作栈,记录画布上的绘图操作backgroundImage:存储画布的背景图片selectGraphIndex:当前选中图形在operates数组中的索引,可能为undefinedselectGraphResult:存储当前选中图形的交互结果,包含光标样式和位置类型等信息。可能为undefinedrestores:撤销操作的栈,用于存储撤销的图形对象,以便后续进行重做操作
部分方法:
getSelectGraph(x: number, y: number):根据鼠标点击的坐标查找是否有选中的图形。从后往前遍历operates数组,调用每个图形的isInside方法判断是否选中。如果选中,更新selectGraphIndex和selectGraphResult,并将图形标记为选中状态onPointerOver(x: number, y: number):处理鼠标在画布上移动的事件,根据鼠标位置查找是否有图形被鼠标覆盖。如果有,设置相应的光标样式;否则,将光标样式设置为默认样式drag(offsetX: number, offsetY: number):拖动选中的图形,根据selectGraphResult中的位置类型,判断选中的是内容还是边框分别调用move或resize方法进行移动或调整大小操作paint():绘制画布,先清空画布,然后绘制背景图片,最后遍历operates数组,调用每个图形的paint方法进行绘制startDraw(graph: Graph)、endDraw(endX: number, endY: number):绘制相关方法。开始绘制时把图形推入画布操作栈operates,结束画图时更新operates栈尾图形(栈尾图形默认是正在绘制的图形)的结束点
调整图形大小
通过在isInside方法中判断点击的位置的边框还是内容,返回cursorStyle和positionType。如果点击的是内容区,CanvasBrush实例会调用move方法进行移动。如果选中的是边框,调用resize方法,在resize方法中根据不同的positionType和偏移量更新图形的坐标。
PS:个人感觉实现的并不是很好,有没有大佬指点一二?
以Rectangle为例:
class Rectangle extends Graph {
// ...
isInside(x: number, y: number): MouseInteractionResult | false {
// ...
// 判断是否点击左上或左下
// 由于左上和左下鼠标样式相同,放在一起
const isLeftTop = isPointInRectangle(x,y,borderLeft,borderTop,contentLeft,contentTop);
const isRightBottom = isPointInRectangle(x,y,contentRight,contentBottom,borderRight,borderBottom);
if (isLeftTop || isRightBottom)
return {
cursorStyle: isTLToBR ? CursorStyle.NwseResize : CursorStyle.NeswResize,
positionType: isLeftTop
? Rectangle.PositionType.LeftTop // 点击位置的区分
: Rectangle.PositionType.RightBottom,
};
}
resize(positionType: string, offsetX: number, offsetY: number): void {
// 根据位置 positionType 的不同,分别操作起始点或结束点
if (positionType === Rectangle.PositionType.LeftTop) {
this.beginX += offsetX;
this.beginY += offsetY;
} else if (positionType === Rectangle.PositionType.RightBottom) {
this.endX += offsetX;
this.endY += offsetY;
}
// ...
}
// ...
}
// 在 CanvasBrush 类的 drag 方法中,根据MouseInteractionResult中的positionType调用move或resize
class CanvasBrush {
// ...
drag(offsetX: number, offsetY: number) {
// ...
const selectGraph = this.operates[this.selectGraphIndex];
if (this.selectGraphResult.positionType === Graph.PositionType.Content)
selectGraph.move(offsetX, offsetY);
else
selectGraph.resize(this.selectGraphResult.positionType, offsetX, offsetY);
this.paint();
}
}
优化调整
绘制线条增加节流处理。如果没有进行节流处理,绘制一条线会记录很多的坐标点。
const handleDrawLine = throttle((endX: number, endY: number) => {
canvasBrush.endDraw(endX, endY);
}, 50);
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
// ...
if (mouseAction === "draw" && operationMode !== undefined) {
if (operationMode === OperationMode.Line) handleDrawLine(endX, endY);
else canvasBrush.endDraw(endX, endY);
}
// ...
}
CanvasBrush绘制时,使用requestAnimationFrame提升动画和交互的性能与体验。
class CanvasBrush {
// ...
paint() {
// ...
this.operates.forEach((operate) => {
requestAnimationFrame(() => {
operate.paint(this.ctx!);
});
});
}
// ...
}