在 React 中实现涂鸦画布

124 阅读10分钟

背景

在前端开发工作中,我遇到需要对图片进行简单涂鸦的需求。起初为了快速实现这一功能,采用了 Canvas 结合鼠标监听的方式实现了一个简单版本的绘图工具。功能十分有限,后来我想尝试实现更多功能,开始对原有的简单版本进行不断改进。通过持续的优化和功能扩展,它不仅支持绘制矩形、椭圆、线条、箭头等多种图形,还具备撤销、重做、拖拽等实用操作。

录屏.gif

功能概述

  • 图形绘制:支持绘制矩形、椭圆、线条、箭头和添加文本备注等多种图形
  • 交互操作:可以选择、拖动和调整图形的大小,同时支持撤销、恢复、删除操作
  • 图片处理:允许选择本地图片作为画布背景,并根据视口大小及图片尺寸自适应显示图片

代码结构分析

项目地址:GitHubGitHub - JiYunDeveloper/canvas-board

主要文件和模块

  • index.tsx:组件的入口文件,负责处理鼠标事件、状态管理和渲染界面
  • utils.ts:包含一些实用工具函数,如计算画布尺寸、转换坐标、创建图形对象等
  • config.ts:定义了一些常量和枚举,如线条宽度列表、操作模式、光标类型等
  • entities 文件夹:包含各种图形类,如 RectangleEllipseLineArrow 和 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尺寸:

  • 画布尺寸:通widthheight属性设置,表示实际绘图的坐标系大小
  • 画布CSS尺寸:通过CSS的widthheight属性设置,控制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元素上。

同时将canvascanvasBrush关联起来,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 抽象类在整个项目中起到了非常关键的作用,它为所有具体的图形类(ArrowLineRectangle 等)提供了一个统一的接口和行为规范,使得代码具有良好的可扩展性和可维护性,方便添加新的图形类。同时CanvasBrush 类只关心调用方法而不用关心各个图形类的具体实现。

Graph 抽象类作用:

  • 定义图形共有的基本属性(color, lineWidth, isSelected),这些属性是图形类所必需的

  • 定义图形的位置类型常量PositionType,表示图形的不同位置类型,用于图形的移动和调整大小

  • 定义统一方法,这些方法需要在具体的图形类中实现,从而确保所有具体图形类都具有相同的行为

    • paint(ctx: CanvasRenderingContext2D): void:在画布上绘制图形
    • isInside(x: number, y: number): MouseInteractionResult | false:判断给定的坐标是否在图形内部,如果在内部则返回一个包含光标样式和位置类型的 MouseInteractionResult 对象,否则返回 false
    • selected(ctx: CanvasRenderingContext2D): void:绘制图形被选中时的样式,通常是绘制一个虚线框来表示选中状态
    • move(offsetX: number, offsetY: number): void:移动图形
    • resize(positionType: string, offsetX: number, offsetY: number): void:改变图形的大小

具体实现类

具体的实现类目前有:ArrowEllipseLineRectangleText。分别继承自抽象类 Graph,并实现了 Graph 中定义的抽象方法,以支持在画布上绘制、交互图形。

画布笔刷类

CanvasBrush 类是用于管理画布操作的核心类,提供了对画布上图形的绘制、选择、移动、调整大小、撤销、重做等操作。

CanvasBrush 类中的属性:

  • operates:存储画布上所有图形对象的数组,作为画布操作栈,记录画布上的绘图操作
  • backgroundImage:存储画布的背景图片
  • selectGraphIndex:当前选中图形在 operates 数组中的索引,可能为undefined
  • selectGraphResult:存储当前选中图形的交互结果,包含光标样式和位置类型等信息。可能为undefined
  • restores:撤销操作的栈,用于存储撤销的图形对象,以便后续进行重做操作

部分方法:

  • 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方法中判断点击的位置的边框还是内容,返回cursorStylepositionType。如果点击的是内容区,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!);
	      });
	    });
	  }
	// ...
}