一步一步实现PS工具+画板工具

一、背景

之前在公司开发了很多工具类的项目,其中开发了一个流程图的基础组件,做着感觉还挺有意思的,当时方案是基于canvas的(为啥不用插件呢,因为功能还有UI给的样式都比较特别,有时间就自己做了)。

因为公司内网不对外,所以代码没办法拿出来。正好趁着入职新公司之前做点之前一直想做的小功能,顺便适应一下react。

PS:之前用的vue,react的hook写起来没问题,用起来总是出点问题,然后删删改改,现在看着自己的代码感觉好乱emm。

二、功能介绍

整体界面:

界面.png

上面是工具栏,下面是操作栏。
工具栏从左到右的按钮功能是:画笔、矩形、椭圆、橡皮擦、移动、线条颜色、线宽、前进、后退、缩放,以及最右的选择文件。

下方的按钮从左到右功能是:清空(还原)画布、下载图片到本地。

三、开发环境

框架:React 17.0.1

UI框架: ant design 4.16.9 (用了一些图标和按钮)

四、一些功能实现方案的选择

1.数据存储

前进后退是对数据的保存和提取,要把画布状态保存起来,用数据标识当前处于第几个状态,在此基础上对数据进行管理。存储的话用数组存储,然后用下标表示当前的状态,利用下标的移动实现前进/后退功能。

重点是保存什么数据。

(1)方案

1.保存画布的结果,及当前画布的像素。这可以用CanvasRenderingContext2D.getImageData()方法获取,然后用CanvasRenderingContext2D.putImageData()重新渲染;

2.保存每次操作的状态和轨迹。比如每次画线的颜色、线宽、移动路径等等,每次渲染时取出路径和对应的状态重新渲染;

(2)对比

imageData:优点:逻辑简单,操作方便;缺点:每次保存像素数据的话,内存消耗太大了,尤其是当图片复杂的时候;

path:优点:只记录每次操作的状态,相对更轻量;缺点:操作次数多时,渲染流程会比较长(因为每一次操作都需要单独渲染);状态维护更繁琐一点。

imageData的一个比较致命的问题是,当缩放之后内容超出画布时,它无法记录超出区域的像素,这样就会造成内容的丢失。

所以,这里采用第二种方案。

那么,如何记录操作路径呢?记录每一个坐标点吗?答案是,用path2d对象记录路径,而不是具体的每一个坐标。

path2D对象记录运动路径,但是不记录画布状态(线宽、颜色等),可以用rect、arc、lineTo等方式添加路径,具体使用可以参考MDN

2.缩放/平移

缩放/平移是对画布的整体操作,并且是可以记录的操作。在缩放和平移之后往往会影响交互的数据,因为鼠标的交互数据是不变的,所有交互数据都需要考虑平移/变换的影响。

(1)方案

1.根据缩放和平移修改对应的坐标数据;

2.使用transform对应的canvas元素;

3.对canvas的坐标系进行转换;

(2)对比

1.直接pass。缺点太过明显,如果要使用这种方案,那么操作数据就要保存每一次的真实坐标,并且每次修改缩放比例或者平移时,都要全量修改所有数据,太繁琐了,性能开销也大;

2.可以接受。优点是操作简单,易实现,可通过对数据的代数转换实现等效的交互效果;缺点是,放大之后渲染性能是个问题(canvas越大,单次渲染开销越大);

3.可以接受。优点:处理不算麻烦(相对第二种方案多了几步步骤),且不会影响性能。缺点:需要考虑代数转换。

最终采纳的是第三种方案,对canvas的坐标系进行伸缩变换,同时所有的交互数据均根据缩放和平移数据进行等效的代数替换,并且不需要修改之前记录的path对象。

五、功能实现

先暂定画布大小为1000*750

1.数据初始化

  1. const [mode, setMode] = useState("line");//当前的绘制类型
  2. const [isStart, setIsStart] = useState(false);//是否开始绘制
  3. const [initPosition, setInitPosition] = useState({ x: 0, y: 0 });//当前绘制的起点
  4. const [lineWidth, setLineWidth] = useState(1);//线宽
  5. const [recordIndex, setRecordIndex] = useState(1);//当前状态下标
  6. const [lineColor, setLineColor] = useState("#000000");//颜色
  7. const [canvasTranslate, setTranslate] = useState([500, 375]);//画布坐标系默认平移距离
  8. const [scale, setScale] = useState(100);//缩放大小
  9. const [canvasSize, setCanvasSize] = useState([defaultWidth, defaultHeight]);//画布大小
  10. const [canvasState, setCanvasState] = useState([getInitState()]); //画布数据

2.画笔

画笔是最基础的功能,在画布上按下鼠标,跟随光标轨迹画出一系列线段。和三个事件相关:mousedown、mousemove、mouseup

可以在mousedown事件中记录初始的坐标,然后在mouse事件中实时获取当前的坐标并和上一次的坐标连线,然后在mouseup事件中推出编辑状态。(这个坐标一定要是基于canvas左上角的,所以这里用offsetX和offsetY,不能用pageX,pageY,因为后续有缩放功能,用pageX和pageY的话计算起来很麻烦)

绑定事件:

  1. useEffect(() => {
  2. const canvas = canvasEle.current;
  3. canvas.addEventListener("mousedown", mousedownEvent);
  4. canvas.addEventListener("mousemove", mousemoveEvent);
  5. window.addEventListener("mouseup", mouseupEvent);
  6. return () => {
  7. canvas.removeEventListener("mousedown", mousedownEvent);
  8. canvas.removeEventListener("mousemove", mousemoveEvent);
  9. window.removeEventListener("mouseup", mouseupEvent);
  10. };
  11. });

我们需要一个状态用于记录鼠标是否按下,然后三个事件分别为:

  1. /**
  2. * 鼠标移动事件
  3. * @param {*} e
  4. * @returns
  5. */
  6. function mousemoveEvent(e) {
  7. if (!isStart) return;
  8. const [x, y] = [e.offsetX, e.offsetY];
  9. switch (mode) {
  10. case "line":
  11. lineMove(x, y);
  12. break;
  13. case "circle":
  14. circleMove(x, y);
  15. break;
  16. case "rect":
  17. rectMove(x, y);
  18. break;
  19. case "move":
  20. canvasMove(x, y);
  21. break;
  22. default:
  23. abraseMove(x, y);
  24. }
  25. }
  26. /**
  27. * 鼠标按下事件
  28. * @param {*} e 事件对象
  29. * @returns
  30. */
  31. function mousedownEvent(e) {
  32. setIsStart(true);
  33. ctx.beginPath();
  34. ctx.moveTo(e.offsetX, e.offsetY);
  35. const [x, y] = [e.offsetX, e.offsetY];
  36. setInitLayout({ x, y });
  37. }
  38. /**
  39. * 鼠标松开事件
  40. * @param {*} e
  41. * @returns
  42. */
  43. function mouseupEvent(e) {
  44. if (!isStart) return;
  45. setIsStart(false);
  46. }

其中,lineMove的逻辑:

  1. const lineMove = (newX, newY) => {
  2. const [x, y] = initPosition;
  3. ctx.lineTo(newX, newY);
  4. ctx.stroke();
  5. setInitPosition({ x: newX, y: newY });
  6. };

效果:

画线.gif

3.前进/后退

前进/后退为什么要提前说,是因为这里涉及到数据存储的格式问题,和所有操作都有关。

状态记录.png

实现方案在前面详细介绍过了,所以,每一次操作对应的数据的格式为:

  1. const getCurState = (path, fill) => {
  2. return {
  3. type: "path",
  4. scale,
  5. path,
  6. lineWidth: lineWidth,
  7. lineColor: lineColor,
  8. fill,
  9. };
  10. };

path就是当前的路径对象,其他就是画布本身的状态数据。

因为每次操作只记录状态,具体的渲染交给hook来做,所以把渲染过程改一下:

  1. useEffect(() => {
  2. if (!canvasState.length) return;
  3. const ctx = canvasEle.current.getContext("2d");
  4. ctx.clearRect(0, 0, defaultWidth, defaultHeight);
  5. const contentState = canvasState.slice(0, recordIndex);
  6. for (const item of contentState) {
  7. ctx.beginPath();
  8. ctx.lineWidth = item.lineWidth;
  9. ctx.strokeStyle = item.lineColor;
  10. if (item.fill) {
  11. ctx.fillStyle = "#fff";
  12. ctx.fill(item.path);
  13. } else {
  14. ctx.stroke(item.path);
  15. }
  16. }
  17. }, [canvasState, recordIndex]);

那么,之前画线的逻辑对应修改。考虑到前进后退,在mousedown事件里添加一个新的记录,然后在move事件里不断的替换最后一个记录(这样是为了每次操作生成一个记录,而不是每次事件的触发都生成一个记录)。

  1. const lineMove = (x, y) => {
  2. const pre = canvasState[canvasState.length - 1].path;
  3. const path = new Path2D();
  4. path.addPath(pre);
  5. path.lineTo(x, y);
  6. replaceState(getCurState(path));
  7. };
  8. //添加状态记录。
  9. const replaceState = (newState) => {
  10. const state = canvasState.slice(0, canvasState.length - 1);
  11. setCanvasState(state.concat(newState));
  12. };
  13. function mousedownEvent(e) {
  14. setIsStart(true);
  15. const [x, y] = [e.offsetX, e.offsetY];
  16. const newState = canvasState.slice(0, recordIndex);
  17. const newPath = new Path2D();
  18. newPath.moveTo(x, y);
  19. setCanvasState(newState.concat(getCurState(newPath)));
  20. setRecordIndex(recordIndex + 1);
  21. setInitPosition({ x, y });
  22. }

最后,前进后退的逻辑就很简单了,注意一下边界情况就行。

  1. const frontOrBack = (v) => {
  2. setRecordIndex(Math.max(1, Math.min(canvasState.length, recordIndex + v)));
  3. };

这里最小值是1,是因为初始化的时候要将画布的初始状态记录(画布大小、线宽、缩放等)进去,这个记录是一定要有的,不然会丢失最初的画布状态信息。

效果:

前进.gif

4. 矩形/椭圆

这两种放在一起说,是因为这两个很像。

和线段的区别是,线段的路径是连续的,每次mousemove时,要在上一个path的基础上添加新的path。

矩形和椭圆只需要mousedown的坐标,然后根据当前坐标计算对应的宽高/半径及左上角/中心点坐标即可,当前的path和上一次的path没有关系。

处理逻辑:

  1. const circleMove = (x, y) => {
  2. const path = new Path2D();
  3. const { x: initX, y: initY } = initPosition;
  4. path.ellipse(
  5. (initX + x) / 2,
  6. (initY + y) / 2,
  7. Math.abs((initX - x) / 2),
  8. Math.abs((initY - y) / 2),
  9. 0,
  10. 0,
  11. 2 * Math.PI
  12. );
  13. replaceState(getCurState(path));
  14. };
  15. const rectMove = (x, y) => {
  16. const path = new Path2D();
  17. const { x: initX, y: initY } = initPosition;
  18. path.rect(
  19. Math.min(initX, x),
  20. Math.min(initY, y),
  21. Math.abs(x - initX),
  22. Math.abs(y - initY)
  23. );
  24. replaceState(getCurState(path));
  25. };

效果:

矩形.gif

5.橡皮擦

橡皮擦的话,目前的思路是类似画线,区别就是每一次用连续的矩形填充白色,覆盖path上的图案。这里不用圆形是因为实践过程发现圆fill时有问题,和路径起点会有联系,用rect就不会。

为了防止移动过快,矩形不连续,可以根据前后坐标构造一组连续的矩形path

  1. const getLinearRect = (x, y, x2, y2, step = 5) => {
  2. const path = new Path2D();
  3. const disx = x2 - x;
  4. const disy = y2 - y;
  5. let c = Math.abs(disx / step);
  6. const ypercent = disy / c;
  7. let flag = x2 >= x ? 1 : -1;
  8. for (let i = 0; i <= c; i++) {
  9. path.rect(x2 - i * 5 * flag - 8, y2 - i * ypercent - 8, 16, 16);
  10. }
  11. return path;
  12. };
  13. const abraseMove = (x, y) => {
  14. const pre = canvasState[canvasState.length - 1].path;
  15. const path = getLinearRect(initPosition.x, initPosition.y, x, y);
  16. path.addPath(pre);
  17. setInitPosition({ x, y });
  18. replaceState(getCurState(path, true));
  19. };

效果:

橡皮.gif

6.线宽/线条颜色

这两个功能相对简单,因为不会对原有图形产生影响,只需要维护状态就行了。直接绑定组件即可。

  1. const [lineWidth, setLineWidth] = useState(1);
  2. const [lineColor, setLineColor] = useState("#000000");

效果:

线宽.gif

7.缩放/平移

平移的逻辑和画矩形/椭圆类似,记录mousedown的坐标,在mousemove时不断更新当前的相对偏移。

方案在前面介绍过了,对canvas的坐标系进行转换,可以用CanvasRenderingContext2D.setTransform(),每次更新当前的变换,也可以CanvasRenderingContext2D.transform(),叠加之前的变换。只应用某种变换,比如缩放,可以用CanvasRenderingContext2D.scale()。

应用之后,所有的交互数据均需要经过对应的变换处理,举个简单的例子:

执行CanvasRenderingContext2D.scale(2,2)之后,canvas的坐标系放大两倍,所以所有的交互数据要除以2,也就是缩小两倍。

平移是类似,坐标系平移x和y,数据就要减去x和y。

为了区分图形绘制和坐标系变换的操作,在操作记录的数据里添加一个type属性用于区分,并用transform属性记录当前的坐标系状态。

以mousemove事件的处理逻辑为例:

  1. //移动模式时,鼠标按下事件
  2. const moveMouseDown = (x, y) => {
  3. if (!canvasState.length) return;
  4. setInitPosition({ x, y });
  5. const preState = canvasState.slice(0, recordIndex);
  6. setCanvasState(
  7. preState.concat({
  8. type: "transform",
  9. transform: [
  10. [scale, scale],
  11. [canvasTranslate[0], canvasTranslate[1]],
  12. ],
  13. })
  14. );
  15. setRecordIndex(recordIndex + 1);
  16. };
  17. /**
  18. * 画布移动事件
  19. * @param {*} x
  20. * @param {*} y
  21. */
  22. const canvasMove = (x, y) => {
  23. const [preX, preY] = canvasTranslate;
  24. const { x: initX, y: initY } = initPosition;
  25. setTranslate([preX + x - initX, preY + y - initY]);
  26. replaceState({
  27. type: "transform",
  28. transform: [
  29. [scale, scale],
  30. [preX + x - initX, preY + y - initY],
  31. ],
  32. });
  33. };

数据转换过程:

  1. /**
  2. * 鼠标移动事件
  3. * @param {*} e
  4. * @returns
  5. */
  6. function mousemoveEvent(e) {
  7. if (!isStart) return;
  8. ctx.lineJoin = "round";
  9. const [translateX, translateY] = canvasTranslate;
  10. const [x, y] = [
  11. ((e.offsetX - translateX) * 100) / scale,
  12. ((e.offsetY - translateY) * 100) / scale,
  13. ];
  14. ....
  15. }

修改渲染的处理逻辑,canvas应用伸缩变换

  1. useEffect(() => {
  2. const ctx = canvasEle.current.getContext("2d");
  3. ctx.resetTransform();
  4. ctx.clearRect(0, 0, defaultWidth, defaultHeight);
  5. const contentState = canvasState
  6. .slice(0, recordIndex)
  7. .filter((item) => item.type !== "transform");
  8. const transformState = canvasState
  9. .slice(0, recordIndex)
  10. .filter((item) => item.type === "transform")
  11. .pop()?.transform;
  12. if (!transformState) {
  13. } else {
  14. const [[x, y], [x2, y2]] = transformState;
  15. ctx.translate(x2, y2);
  16. ctx.scale(x / 100, y / 100);
  17. setScale(x);
  18. setTranslate([x2, y2]);
  19. }
  20. for (const item of contentState) {
  21. ctx.beginPath();
  22. ctx.lineWidth = item.lineWidth;
  23. ctx.strokeStyle = item.lineColor;
  24. if (item.fill) {
  25. ctx.fillStyle = "#fff";
  26. ctx.fill(item.path);
  27. } else {
  28. ctx.stroke(item.path);
  29. }
  30. }
  31. }, [canvasState, recordIndex]);

效果:

变化.gif

8.加载图片

思路是选择文件,然后生成一个本地链接,然后用canvas去绘制。

有个问题就是,选择的图片比例不一定是默认尺寸的比例,所以要先获取一下选择图片的原始宽高,用contain的方式计算出最大缩放比例,计算出此时的宽高,然后应用到canvas上。

因为修改了尺寸,所以加载图片时选择清空画布并还原伸缩变换,根据当前图片尺寸计算初始的translate的值。

为了区分图片的渲染和其他渲染,用type=’img’表示,并用img属性记录img对象,size记录尺寸信息。

  1. const selectFile = (e) => {
  2. const url = window.URL.createObjectURL(e.file);
  3. const img = new Image();
  4. img.src = url;
  5. img.onload = () => {
  6. const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
  7. const size = [img.width * n, img.height * n];
  8. initCanvas(...size, [
  9. {
  10. type: "img",
  11. img,
  12. size,
  13. },
  14. ]);
  15. };
  16. return false;
  17. };

修改渲染逻辑:

  1. for (const item of contentState) {
  2. const { img, size } = item;
  3. if (item.type === "img") {
  4. ctx.drawImage(
  5. img,
  6. 0,
  7. 0,
  8. img.width,
  9. img.height,
  10. (-1 * size[0]) / 2,
  11. (-1 * size[1]) / 2,
  12. size[0],
  13. size[1]
  14. );
  15. continue;
  16. }
  17. ctx.beginPath();
  18. ctx.lineWidth = item.lineWidth;
  19. ctx.strokeStyle = item.lineColor;
  20. if (item.fill) {
  21. ctx.fillStyle = "#fff";
  22. ctx.fill(item.path);
  23. } else {
  24. ctx.stroke(item.path);
  25. }
  26. }

效果:

图片.gif

9.清空画布

添加一个初始化状态的方法。下面的state参数,是加载图片时的数据,清空画布时不传即可。注意要清除之前加载图片生成的url

  1. const initCanvas = (w = defaultWidth, h = defaultHeight, state = []) => {
  2. for (const item of canvasState) {
  3. if (item.type === "img") {
  4. window.URL.revokeObjectURL(item.img.src);
  5. }
  6. }
  7. setCanvasSize([w, h]);
  8. setScale(100);
  9. setTranslate([w / 2, h / 2]);
  10. setCanvasState([getInitState(w / 2, h / 2), ...state]);
  11. setRecordIndex(state.length + 1);
  12. };

10.下载图片

用canvas.toDataURL()生成URI,并用a标签下载

  1. const downLoadImg = () => {
  2. const aEle = document.createElement("a");
  3. document.body.appendChild(aEle);
  4. aEle.href = canvasEle.current.toDataURL();
  5. aEle.download = `${Date.now()}.jpg`;
  6. aEle.click();
  7. document.body.removeChild(aEle);
  8. };

效果:

11.完整代码

  1. function CanvasTool() {
  2. const defaultWidth = 1000;
  3. const defaultHeight = 750;
  4. const canvasEle = useRef();
  5. const ctx = canvasEle.current?.getContext("2d");
  6. const list = [
  7. {
  8. value: "line",
  9. title: "线条",
  10. },
  11. {
  12. value: "rect",
  13. title: "矩形",
  14. },
  15. {
  16. value: "circle",
  17. title: "圆",
  18. },
  19. // {
  20. // value: "abrase",
  21. // title: "马赛克",
  22. // },
  23. ];
  24. const lineWidthList = [1, 2, 4, 6];
  25. const [mode, setMode] = useState("line");
  26. const [isStart, setIsStart] = useState(false);
  27. const [initPosition, setInitPosition] = useState({ x: 0, y: 0 });
  28. const [recordIndex, setRecordIndex] = useState(1);
  29. const [lineWidth, setLineWidth] = useState(1);
  30. const [lineColor, setLineColor] = useState("#000000");
  31. const [canvasTranslate, setTranslate] = useState([500, 375]);
  32. const [scale, setScale] = useState(100);
  33. const [canvasSize, setCanvasSize] = useState([defaultWidth, defaultHeight]);
  34. /**
  35. * 获取默认的数据记录
  36. * @param {*} x
  37. * @param {*} y
  38. * @returns
  39. */
  40. const getInitState = (x = 500, y = 375) => {
  41. return {
  42. type: "transform",
  43. transform: [
  44. [100, 100],
  45. [x, y],
  46. ],
  47. };
  48. };
  49. const [canvasState, setCanvasState] = useState([getInitState()]);
  50. /**
  51. * 修改缩放比例
  52. * @param {*} v
  53. */
  54. const setScale2 = (v) => {
  55. setScale(v);
  56. const preState = canvasState.slice(0, recordIndex);
  57. setCanvasState(
  58. preState.concat({
  59. type: "transform",
  60. transform: [
  61. [v, v],
  62. [canvasTranslate[0], canvasTranslate[1]],
  63. ],
  64. })
  65. );
  66. setRecordIndex(recordIndex + 1);
  67. };
  68. /**
  69. * 修改线条颜色
  70. * @param {*} e
  71. */
  72. const setLineColor2 = (e) => {
  73. setLineColor(e.target.value);
  74. };
  75. /**
  76. * 替换最后一个操作记录
  77. * @param {*} newState
  78. */
  79. const replaceState = (newState) => {
  80. const state = canvasState.slice(0, canvasState.length - 1);
  81. setCanvasState(state.concat(newState));
  82. };
  83. /**
  84. * 画笔移动时的处理
  85. * @param {*} x
  86. * @param {*} y
  87. */
  88. const lineMove = (x, y) => {
  89. const pre = canvasState[canvasState.length - 1].path;
  90. const path = new Path2D();
  91. path.addPath(pre);
  92. path.lineTo(x, y);
  93. replaceState(getCurState(path));
  94. };
  95. /**
  96. * 画圆时的移动处理
  97. * @param {*} x
  98. * @param {*} y
  99. */
  100. const circleMove = (x, y) => {
  101. const path = new Path2D();
  102. const { x: initX, y: initY } = initPosition;
  103. path.ellipse(
  104. (initX + x) / 2,
  105. (initY + y) / 2,
  106. Math.abs((initX - x) / 2),
  107. Math.abs((initY - y) / 2),
  108. 0,
  109. 0,
  110. 2 * Math.PI
  111. );
  112. replaceState(getCurState(path));
  113. };
  114. /**
  115. * 画矩形时的移动处理
  116. * @param {*} x
  117. * @param {*} y
  118. */
  119. const rectMove = (x, y) => {
  120. const path = new Path2D();
  121. const { x: initX, y: initY } = initPosition;
  122. path.rect(
  123. Math.min(initX, x),
  124. Math.min(initY, y),
  125. Math.abs(x - initX),
  126. Math.abs(y - initY)
  127. );
  128. replaceState(getCurState(path));
  129. };
  130. /**
  131. * 根据起点和重点生成连续的矩形路径
  132. * @param {*} x
  133. * @param {*} y
  134. * @param {*} x2
  135. * @param {*} y2
  136. * @param {*} step
  137. * @returns
  138. */
  139. const getLinearRect = (x, y, x2, y2, step = 5) => {
  140. const path = new Path2D();
  141. const disx = x2 - x;
  142. const disy = y2 - y;
  143. let c = Math.abs((disx * 100) / (step * scale));
  144. const ypercent = disy / c;
  145. let flag = x2 >= x ? 1 : -1;
  146. for (let i = 0; i <= c; i++) {
  147. path.rect(
  148. x2 - i * 5 * flag - 8,
  149. y2 - i * ypercent - 8,
  150. (16 * 100) / scale,
  151. (16 * 100) / scale
  152. );
  153. }
  154. return path;
  155. };
  156. /**
  157. * 橡皮擦时的移动处理
  158. * @param {*} x
  159. * @param {*} y
  160. */
  161. const abraseMove = (x, y) => {
  162. const pre = canvasState[canvasState.length - 1].path;
  163. const path = getLinearRect(initPosition.x, initPosition.y, x, y);
  164. path.addPath(pre);
  165. setInitPosition({ x, y });
  166. replaceState(getCurState(path, true));
  167. };
  168. /**
  169. * 移动模式时,鼠标按下事件
  170. * @param {*} x
  171. * @param {*} y
  172. * @returns
  173. */
  174. const moveMouseDown = (x, y) => {
  175. if (!canvasState.length) return;
  176. setInitPosition({ x, y });
  177. const preState = canvasState.slice(0, recordIndex);
  178. setCanvasState(
  179. preState.concat({
  180. type: "transform",
  181. transform: [
  182. [scale, scale],
  183. [canvasTranslate[0], canvasTranslate[1]],
  184. ],
  185. })
  186. );
  187. setRecordIndex(recordIndex + 1);
  188. };
  189. /**
  190. * 鼠标移动事件
  191. * @param {*} e
  192. * @returns
  193. */
  194. function mousemoveEvent(e) {
  195. if (!isStart) return;
  196. ctx.lineJoin = "round";
  197. const [translateX, translateY] = canvasTranslate;
  198. const [x, y] = [
  199. ((e.offsetX - translateX) * 100) / scale,
  200. ((e.offsetY - translateY) * 100) / scale,
  201. ];
  202. switch (mode) {
  203. case "line":
  204. lineMove(x, y);
  205. break;
  206. case "circle":
  207. circleMove(x, y);
  208. break;
  209. case "rect":
  210. rectMove(x, y);
  211. break;
  212. case "move":
  213. canvasMove(x, y);
  214. break;
  215. default:
  216. abraseMove(x, y);
  217. }
  218. }
  219. /**
  220. * 鼠标按下事件
  221. * @param {*} e 事件对象
  222. * @returns
  223. */
  224. function mousedownEvent(e) {
  225. setIsStart(true);
  226. const [translateX, translateY] = canvasTranslate;
  227. const [x, y] = [
  228. ((e.offsetX - translateX) * 100) / scale,
  229. ((e.offsetY - translateY) * 100) / scale,
  230. ];
  231. if (mode === "move") {
  232. moveMouseDown(x, y);
  233. return;
  234. }
  235. const newState = canvasState.slice(0, recordIndex);
  236. const newPath = new Path2D();
  237. newPath.moveTo(x, y);
  238. setCanvasState(newState.concat(getCurState(newPath)));
  239. setRecordIndex(recordIndex + 1);
  240. setInitPosition({ x, y });
  241. }
  242. /**
  243. * 鼠标松开事件
  244. * @param {*} e
  245. * @returns
  246. */
  247. function mouseupEvent(e) {
  248. if (!isStart) return;
  249. // recordState();
  250. setIsStart(false);
  251. // setCurPath(null);
  252. }
  253. /**
  254. * 画布移动事件
  255. * @param {*} x
  256. * @param {*} y
  257. */
  258. const canvasMove = (x, y) => {
  259. const [preX, preY] = canvasTranslate;
  260. const { x: initX, y: initY } = initPosition;
  261. setTranslate([preX + x - initX, preY + y - initY]);
  262. replaceState({
  263. type: "transform",
  264. transform: [
  265. [scale, scale],
  266. [preX + x - initX, preY + y - initY],
  267. ],
  268. });
  269. };
  270. //事件绑定
  271. useEffect(() => {
  272. const canvas = canvasEle.current;
  273. canvas.addEventListener("mousedown", mousedownEvent);
  274. canvas.addEventListener("mousemove", mousemoveEvent);
  275. window.addEventListener("mouseup", mouseupEvent);
  276. return () => {
  277. canvas.removeEventListener("mousedown", mousedownEvent);
  278. canvas.removeEventListener("mousemove", mousemoveEvent);
  279. window.removeEventListener("mouseup", mouseupEvent);
  280. };
  281. });
  282. //画布移动模式下,修改画布的cursor
  283. useEffect(() => {
  284. if (mode === "move") {
  285. canvasEle.current?.classList.add("move-canvas");
  286. } else {
  287. canvasEle.current?.classList.remove("move-canvas");
  288. }
  289. }, [mode]);
  290. /**
  291. * 前进/后退事件
  292. * @param {*} v
  293. */
  294. const frontOrBack = (v) => {
  295. setRecordIndex(Math.max(1, Math.min(canvasState.length, recordIndex + v)));
  296. };
  297. /**
  298. * 生成当前的操作记录
  299. * @param {*} path
  300. * @param {*} fill
  301. * @returns
  302. */
  303. const getCurState = (path, fill) => {
  304. return {
  305. type: "path",
  306. scale,
  307. path,
  308. lineWidth: lineWidth,
  309. lineColor: lineColor,
  310. fill,
  311. };
  312. };
  313. //画布渲染
  314. useEffect(() => {
  315. if (!canvasState.length) return;
  316. const ctx = canvasEle.current.getContext("2d");
  317. ctx.resetTransform();
  318. ctx.clearRect(0, 0, defaultWidth, defaultHeight);
  319. const contentState = canvasState
  320. .slice(0, recordIndex)
  321. .filter((item) => item.type !== "transform");
  322. const transformState = canvasState
  323. .slice(0, recordIndex)
  324. .filter((item) => item.type === "transform")
  325. .pop()?.transform;
  326. const [[x, y], [x2, y2]] = transformState;
  327. ctx.translate(x2, y2);
  328. ctx.scale(x / 100, y / 100);
  329. setScale(x);
  330. setTranslate([x2, y2]);
  331. for (const item of contentState) {
  332. const { img, size } = item;
  333. if (item.type === "img") {
  334. ctx.drawImage(
  335. img,
  336. 0,
  337. 0,
  338. img.width,
  339. img.height,
  340. (-1 * size[0]) / 2,
  341. (-1 * size[1]) / 2,
  342. size[0],
  343. size[1]
  344. );
  345. continue;
  346. }
  347. ctx.beginPath();
  348. ctx.lineWidth = item.lineWidth;
  349. ctx.strokeStyle = item.lineColor;
  350. if (item.fill) {
  351. ctx.fillStyle = "#fff";
  352. ctx.fill(item.path);
  353. } else {
  354. ctx.stroke(item.path);
  355. }
  356. }
  357. }, [canvasState, recordIndex]);
  358. /**
  359. * 初始化画布状态
  360. * @param {*} w
  361. * @param {*} h
  362. * @param {*} state
  363. */
  364. const initCanvas = (w = defaultWidth, h = defaultHeight, state = []) => {
  365. for (const item of canvasState) {
  366. if (item.type === "img") {
  367. window.URL.revokeObjectURL(item.img.src);
  368. }
  369. }
  370. setCanvasSize([w, h]);
  371. setScale(100);
  372. setTranslate([w / 2, h / 2]);
  373. setCanvasState([getInitState(w / 2, h / 2), ...state]);
  374. setRecordIndex(state.length + 1);
  375. };
  376. /**
  377. * 选择图片
  378. * @param {*} e
  379. * @returns
  380. */
  381. const selectFile = (e) => {
  382. const url = window.URL.createObjectURL(e.file);
  383. const img = new Image();
  384. img.src = url;
  385. img.onload = () => {
  386. const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
  387. const size = [img.width * n, img.height * n];
  388. initCanvas(...size, [
  389. {
  390. type: "img",
  391. img,
  392. size,
  393. },
  394. ]);
  395. };
  396. return false;
  397. };
  398. /**
  399. * 下载图片
  400. */
  401. const downLoadImg = () => {
  402. const aEle = document.createElement("a");
  403. document.body.appendChild(aEle);
  404. aEle.href = canvasEle.current.toDataURL();
  405. aEle.download = `${Date.now()}.jpg`;
  406. aEle.click();
  407. document.body.removeChild(aEle);
  408. };
  409. export default CanvasTool;

PS:页面部分就不放了

六、思考

1.待优化的功能

(1)橡皮擦

橡皮擦是用连续的白色填充矩形覆盖实现的,不过会覆盖任何像素,我想实现的是可以只覆盖此次编辑添加的图形,不覆盖加载的图片,大概的思路是区分图片和此次的绘制,然后用CanvasRenderingContext2D.globalCompositeOperation这个属性来实现。

并且感觉橡皮擦的坐标计算比较奇怪,很容易有不连续的点。这块后续还要再研究一下。

难度:☆☆☆☆☆

(2)渲染过程

因为是根据每一次的操作记录渲染的,如果记录次数过多的话(成千上万次),可能会造成渲染卡顿的问题。

优化思路,感觉可以在操作次数大于一定长度时,合并前面若干次记录并合成图片作为初始的记录,同时限制前进/后退的次数。

或者可以转换成buffer调用webgl去渲染,这样处理速度会快很多。

难度:不好说,看实现方式。感觉☆☆☆☆以上。

2.待添加的功能

(1)伸缩变换

目前变换只做了缩放和平移,考虑后面可以加个旋转和变形。这个要做的话,数据处理就更复杂了,可以考虑专门拆分一个模块作为中间层,在绘制和交互之间,负责处理数据的转换。

难度:☆☆☆☆☆

(2)文字

添加文字的功能,比图形麻烦点,重点是我拿捏不准交互方式。按照一般的,是圈定一个矩形作为Input区域,失焦时渲染文字(也就是添加文字的记录),这个透明的input区域,我老感觉有坑,也可能是我想多了,后面再看吧。

难度:☆☆☆

(3)直线/箭头

这个也挺常见,实现起来也还好,后续会试试。

难度:☆☆

(4)选中某个path并移动/高亮

这个单纯的选中,也还行,CanvasRenderingContext2D.isPointInStroke()可以实现(PS:这个交互区域太小了,如果要实现模糊选中最好处理linestyle,添加渐变,之前做公司的流程图的时候,因为这个模糊选中,就没用这个api,记录了坐标并使用数学计算的方式判断是否选中)。

选中之后的平移或者更改线宽/颜色等样式,平移可能要做一次额外的伸缩变换处理(找到这次path时的伸缩变换属性,再将当前的操作造成的变换叠加,然后添加一个新的记录),这样做也有可能生成更多的记录,后面实现的时候要再衡量一下。

难度:☆☆☆☆


文章标签:

原文连接:https://juejin.cn/post/6991064704449773576

相关推荐

[原创]移动相机九点标定工具原理及实现(包涵部分源码)

34个图片压缩工具集合,包含在线压缩和CLI工具

[原创]一种自动化九点标定工具原理(包涵部分源码)

接口文档管理工具,选yapi 还是 Apifox? 这里列出了两款软件的深度分析,看完再下载不迟。

使用ComposeDesktop开发一款桌面端多功能APK工具

这个好用的办公网优化工具,官宣免费了

Sunmao——一个开发低代码工具的开源框架

前端必备 | 3分钟白嫖我常用的免费效率工具!

好用的办公网优化工具OneDNS

KusionStack 开源|Kusion 模型库和工具链的探索实践

从“AI玩具”到“创作工具”的云原生改造之路

LabelImg(目标检测标注工具)的安装与使用教程

效率低?响应慢?报表工具痛点及其解决方案

接口文档进化图鉴,有些古早接口文档工具,你可能都没用过

用 JavaScript 复原何同学B站头图、对前端构建工具的一些理解、弹幕的常规设计与实现 丨酱酱的下午茶第31期

关于目前流行的 Redis 可视化管理工具的详细评测

【云原生 • DevOps】一文掌握持续集成工具 Jenkins

【一起学Rust】Rust包管理工具Cargo初步了解

窥探性能的接口测试工具,性能优化的开端

照妖镜:一个工具的自我超越