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

1,196 阅读13分钟

一、背景

之前在公司开发了很多工具类的项目,其中开发了一个流程图的基础组件,做着感觉还挺有意思的,当时方案是基于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.数据初始化

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

2.画笔

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

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

绑定事件:

  useEffect(() => {
    const canvas = canvasEle.current;
    canvas.addEventListener("mousedown", mousedownEvent);
    canvas.addEventListener("mousemove", mousemoveEvent);
    window.addEventListener("mouseup", mouseupEvent);
    return () => {
      canvas.removeEventListener("mousedown", mousedownEvent);
      canvas.removeEventListener("mousemove", mousemoveEvent);
      window.removeEventListener("mouseup", mouseupEvent);
    };
  });

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

/**
   * 鼠标移动事件
   * @param {*} e 
   * @returns 
   */
function mousemoveEvent(e) {
  if (!isStart) return;
  const [x, y] = [e.offsetX, e.offsetY];
  switch (mode) {
    case "line":
      lineMove(x, y);
      break;
    case "circle":
      circleMove(x, y);
      break;
    case "rect":
      rectMove(x, y);
      break;
    case "move":
      canvasMove(x, y);
      break;
    default:
      abraseMove(x, y);
  }
}

/**
 * 鼠标按下事件
 * @param {*} e 事件对象
 * @returns
 */
function mousedownEvent(e) {
  setIsStart(true);
  ctx.beginPath();
  ctx.moveTo(e.offsetX, e.offsetY);
  const [x, y] = [e.offsetX, e.offsetY];
  setInitLayout({ x, y });
}

/**
 * 鼠标松开事件
 * @param {*} e
 * @returns
 */
function mouseupEvent(e) {
  if (!isStart) return;
  setIsStart(false);
}

其中,lineMove的逻辑:

const lineMove = (newX, newY) => {
  const [x, y] = initPosition;
  ctx.lineTo(newX, newY);
  ctx.stroke();
  setInitPosition({ x: newX, y: newY });
};

效果:

画线.gif

3.前进/后退

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

状态记录.png

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

  const getCurState = (path, fill) => {
    return {
      type: "path",
      scale,
      path,
      lineWidth: lineWidth,
      lineColor: lineColor,
      fill,
    };
  };

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

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

useEffect(() => {
  if (!canvasState.length) return;
  const ctx = canvasEle.current.getContext("2d");
  ctx.clearRect(0, 0, defaultWidth, defaultHeight);

  const contentState = canvasState.slice(0, recordIndex);
  for (const item of contentState) {
    ctx.beginPath();
    ctx.lineWidth = item.lineWidth;
    ctx.strokeStyle = item.lineColor;
    if (item.fill) {
      ctx.fillStyle = "#fff";
      ctx.fill(item.path);
    } else {
      ctx.stroke(item.path);
    }
  }
}, [canvasState, recordIndex]);

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

  const lineMove = (x, y) => {
    const pre = canvasState[canvasState.length - 1].path;
    const path = new Path2D();

    path.addPath(pre);
    path.lineTo(x, y);

    replaceState(getCurState(path));
  };
  
  //添加状态记录。
  const replaceState = (newState) => {
    const state = canvasState.slice(0, canvasState.length - 1);
    setCanvasState(state.concat(newState));
  };
  
function mousedownEvent(e) {
  setIsStart(true);

  const [x, y] = [e.offsetX, e.offsetY];

  const newState = canvasState.slice(0, recordIndex);
  const newPath = new Path2D();
  newPath.moveTo(x, y);
  setCanvasState(newState.concat(getCurState(newPath)));
  setRecordIndex(recordIndex + 1);
  setInitPosition({ x, y });
}


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

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

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

效果:

前进.gif

4. 矩形/椭圆

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

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

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

处理逻辑:

const circleMove = (x, y) => {
  const path = new Path2D();
  const { x: initX, y: initY } = initPosition;
  path.ellipse(
    (initX + x) / 2,
    (initY + y) / 2,
    Math.abs((initX - x) / 2),
    Math.abs((initY - y) / 2),
    0,
    0,
    2 * Math.PI
  );
  replaceState(getCurState(path));
};
  const rectMove = (x, y) => {
    const path = new Path2D();
    const { x: initX, y: initY } = initPosition;
    path.rect(
      Math.min(initX, x),
      Math.min(initY, y),
      Math.abs(x - initX),
      Math.abs(y - initY)
    );
    replaceState(getCurState(path));

  };

效果:

矩形.gif

5.橡皮擦

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

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

const getLinearRect = (x, y, x2, y2, step = 5) => {
  const path = new Path2D();
  const disx = x2 - x;
  const disy = y2 - y;
  let c = Math.abs(disx / step);
  const ypercent = disy / c;
  let flag = x2 >= x ? 1 : -1;
  for (let i = 0; i <= c; i++) {
    path.rect(x2 - i * 5 * flag - 8, y2 - i * ypercent - 8, 16, 16);
  }
  return path;
};
const abraseMove = (x, y) => {
  const pre = canvasState[canvasState.length - 1].path;

  const path = getLinearRect(initPosition.x, initPosition.y, x, y);
  path.addPath(pre);
  setInitPosition({ x, y });
  replaceState(getCurState(path, true));
};

效果:

橡皮.gif

6.线宽/线条颜色

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

  const [lineWidth, setLineWidth] = useState(1);
  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事件的处理逻辑为例:

  //移动模式时,鼠标按下事件
  const moveMouseDown = (x, y) => {
    if (!canvasState.length) return;
    setInitPosition({ x, y });
    const preState = canvasState.slice(0, recordIndex);
    setCanvasState(
      preState.concat({
        type: "transform",
        transform: [
          [scale, scale],
          [canvasTranslate[0], canvasTranslate[1]],
        ],
      })
    );
    setRecordIndex(recordIndex + 1);
  };
  /**
   * 画布移动事件
   * @param {*} x
   * @param {*} y
   */
  const canvasMove = (x, y) => {
    const [preX, preY] = canvasTranslate;
    const { x: initX, y: initY } = initPosition;
    setTranslate([preX + x - initX, preY + y - initY]);
    replaceState({
      type: "transform",
      transform: [
        [scale, scale],
        [preX + x - initX, preY + y - initY],
      ],
    });

  };

数据转换过程:

  /**
   * 鼠标移动事件
   * @param {*} e
   * @returns
   */
  function mousemoveEvent(e) {
    if (!isStart) return;
    ctx.lineJoin = "round";
    const [translateX, translateY] = canvasTranslate;
    const [x, y] = [
      ((e.offsetX - translateX) * 100) / scale,
      ((e.offsetY - translateY) * 100) / scale,
    ];
    ....
   }

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

useEffect(() => {
  const ctx = canvasEle.current.getContext("2d");
  ctx.resetTransform();
  ctx.clearRect(0, 0, defaultWidth, defaultHeight);

  const contentState = canvasState
    .slice(0, recordIndex)
    .filter((item) => item.type !== "transform");
  const transformState = canvasState
    .slice(0, recordIndex)
    .filter((item) => item.type === "transform")
    .pop()?.transform;
  if (!transformState) {
  } else {
    const [[x, y], [x2, y2]] = transformState;
    ctx.translate(x2, y2);
    ctx.scale(x / 100, y / 100);
    setScale(x);
    setTranslate([x2, y2]);
  }
  for (const item of contentState) {
    ctx.beginPath();
    ctx.lineWidth = item.lineWidth;
    ctx.strokeStyle = item.lineColor;
    if (item.fill) {
      ctx.fillStyle = "#fff";
      ctx.fill(item.path);
    } else {
      ctx.stroke(item.path);
    }
  }
}, [canvasState, recordIndex]);

效果:

变化.gif

8.加载图片

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

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

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

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

  const selectFile = (e) => {
    const url = window.URL.createObjectURL(e.file);

    const img = new Image();
    img.src = url;
    img.onload = () => {
      const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
      const size = [img.width * n, img.height * n];
      initCanvas(...size, [
        {
          type: "img",
          img,
          size,
        },
      ]);
    };
    return false;
  };

修改渲染逻辑:

    for (const item of contentState) {
      const { img, size } = item;
      if (item.type === "img") {
        ctx.drawImage(
          img,
          0,
          0,
          img.width,
          img.height,
          (-1 * size[0]) / 2,
          (-1 * size[1]) / 2,
          size[0],
          size[1]
        );
        continue;
      }
      ctx.beginPath();
      ctx.lineWidth = item.lineWidth;
      ctx.strokeStyle = item.lineColor;
      if (item.fill) {
        ctx.fillStyle = "#fff";
        ctx.fill(item.path);
      } else {
        ctx.stroke(item.path);
      }
    }

效果:

图片.gif

9.清空画布

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

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

10.下载图片

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

  const downLoadImg = () => {
    const aEle = document.createElement("a");
    document.body.appendChild(aEle);
    aEle.href = canvasEle.current.toDataURL();
    aEle.download = `${Date.now()}.jpg`;
    aEle.click();
    document.body.removeChild(aEle);
  };

效果:

11.完整代码


function CanvasTool() {
  const defaultWidth = 1000;
  const defaultHeight = 750;
  const canvasEle = useRef();
  const ctx = canvasEle.current?.getContext("2d");
  const list = [
    {
      value: "line",
      title: "线条",
    },
    {
      value: "rect",
      title: "矩形",
    },
    {
      value: "circle",
      title: "圆",
    },
    // {
    //   value: "abrase",
    //   title: "马赛克",
    // },
  ];
  const lineWidthList = [1, 2, 4, 6];
  const [mode, setMode] = useState("line");
  const [isStart, setIsStart] = useState(false);
  const [initPosition, setInitPosition] = useState({ x: 0, y: 0 });
  const [recordIndex, setRecordIndex] = useState(1);
  const [lineWidth, setLineWidth] = useState(1);
  const [lineColor, setLineColor] = useState("#000000");
  const [canvasTranslate, setTranslate] = useState([500, 375]);
  const [scale, setScale] = useState(100);
  const [canvasSize, setCanvasSize] = useState([defaultWidth, defaultHeight]);

  /**
   * 获取默认的数据记录
   * @param {*} x
   * @param {*} y
   * @returns
   */
  const getInitState = (x = 500, y = 375) => {
    return {
      type: "transform",
      transform: [
        [100, 100],
        [x, y],
      ],
    };
  };
  const [canvasState, setCanvasState] = useState([getInitState()]);

  /**
   * 修改缩放比例
   * @param {*} v
   */
  const setScale2 = (v) => {
    setScale(v);
    const preState = canvasState.slice(0, recordIndex);
    setCanvasState(
      preState.concat({
        type: "transform",
        transform: [
          [v, v],
          [canvasTranslate[0], canvasTranslate[1]],
        ],
      })
    );
    setRecordIndex(recordIndex + 1);
  };

  /**
   * 修改线条颜色
   * @param {*} e
   */
  const setLineColor2 = (e) => {
    setLineColor(e.target.value);
  };

  /**
   * 替换最后一个操作记录
   * @param {*} newState
   */
  const replaceState = (newState) => {
    const state = canvasState.slice(0, canvasState.length - 1);
    setCanvasState(state.concat(newState));
  };

  /**
   * 画笔移动时的处理
   * @param {*} x
   * @param {*} y
   */
  const lineMove = (x, y) => {
    const pre = canvasState[canvasState.length - 1].path;
    const path = new Path2D();

    path.addPath(pre);
    path.lineTo(x, y);

    replaceState(getCurState(path));
  };

  /**
   * 画圆时的移动处理
   * @param {*} x
   * @param {*} y
   */
  const circleMove = (x, y) => {
    const path = new Path2D();
    const { x: initX, y: initY } = initPosition;
    path.ellipse(
      (initX + x) / 2,
      (initY + y) / 2,
      Math.abs((initX - x) / 2),
      Math.abs((initY - y) / 2),
      0,
      0,
      2 * Math.PI
    );
    replaceState(getCurState(path));
  };

  /**
   * 画矩形时的移动处理
   * @param {*} x
   * @param {*} y
   */
  const rectMove = (x, y) => {
    const path = new Path2D();
    const { x: initX, y: initY } = initPosition;
    path.rect(
      Math.min(initX, x),
      Math.min(initY, y),
      Math.abs(x - initX),
      Math.abs(y - initY)
    );
    replaceState(getCurState(path));
  };

  /**
   * 根据起点和重点生成连续的矩形路径
   * @param {*} x
   * @param {*} y
   * @param {*} x2
   * @param {*} y2
   * @param {*} step
   * @returns
   */
  const getLinearRect = (x, y, x2, y2, step = 5) => {
    const path = new Path2D();
    const disx = x2 - x;
    const disy = y2 - y;
    let c = Math.abs((disx * 100) / (step * scale));
    const ypercent = disy / c;
    let flag = x2 >= x ? 1 : -1;
    for (let i = 0; i <= c; i++) {
      path.rect(
        x2 - i * 5 * flag - 8,
        y2 - i * ypercent - 8,
        (16 * 100) / scale,
        (16 * 100) / scale
      );
    }
    return path;
  };

  /**
   * 橡皮擦时的移动处理
   * @param {*} x
   * @param {*} y
   */
  const abraseMove = (x, y) => {
    const pre = canvasState[canvasState.length - 1].path;

    const path = getLinearRect(initPosition.x, initPosition.y, x, y);
    path.addPath(pre);
    setInitPosition({ x, y });
    replaceState(getCurState(path, true));
  };

  /**
   * 移动模式时,鼠标按下事件
   * @param {*} x
   * @param {*} y
   * @returns
   */
  const moveMouseDown = (x, y) => {
    if (!canvasState.length) return;
    setInitPosition({ x, y });
    const preState = canvasState.slice(0, recordIndex);
    setCanvasState(
      preState.concat({
        type: "transform",
        transform: [
          [scale, scale],
          [canvasTranslate[0], canvasTranslate[1]],
        ],
      })
    );
    setRecordIndex(recordIndex + 1);
  };

  /**
   * 鼠标移动事件
   * @param {*} e
   * @returns
   */
  function mousemoveEvent(e) {
    if (!isStart) return;
    ctx.lineJoin = "round";
    const [translateX, translateY] = canvasTranslate;
    const [x, y] = [
      ((e.offsetX - translateX) * 100) / scale,
      ((e.offsetY - translateY) * 100) / scale,
    ];
    switch (mode) {
      case "line":
        lineMove(x, y);
        break;
      case "circle":
        circleMove(x, y);
        break;
      case "rect":
        rectMove(x, y);
        break;
      case "move":
        canvasMove(x, y);
        break;
      default:
        abraseMove(x, y);
    }
  }

  /**
   * 鼠标按下事件
   * @param {*} e 事件对象
   * @returns
   */
  function mousedownEvent(e) {
    setIsStart(true);
    const [translateX, translateY] = canvasTranslate;
    const [x, y] = [
      ((e.offsetX - translateX) * 100) / scale,
      ((e.offsetY - translateY) * 100) / scale,
    ];
    if (mode === "move") {
      moveMouseDown(x, y);
      return;
    }
    const newState = canvasState.slice(0, recordIndex);
    const newPath = new Path2D();
    newPath.moveTo(x, y);
    setCanvasState(newState.concat(getCurState(newPath)));
    setRecordIndex(recordIndex + 1);
    setInitPosition({ x, y });
  }

  /**
   * 鼠标松开事件
   * @param {*} e
   * @returns
   */
  function mouseupEvent(e) {
    if (!isStart) return;

    // recordState();
    setIsStart(false);
    // setCurPath(null);
  }

  /**
   * 画布移动事件
   * @param {*} x
   * @param {*} y
   */
  const canvasMove = (x, y) => {
    const [preX, preY] = canvasTranslate;
    const { x: initX, y: initY } = initPosition;
    setTranslate([preX + x - initX, preY + y - initY]);
    replaceState({
      type: "transform",
      transform: [
        [scale, scale],
        [preX + x - initX, preY + y - initY],
      ],
    });
  };

  //事件绑定
  useEffect(() => {
    const canvas = canvasEle.current;
    canvas.addEventListener("mousedown", mousedownEvent);
    canvas.addEventListener("mousemove", mousemoveEvent);
    window.addEventListener("mouseup", mouseupEvent);
    return () => {
      canvas.removeEventListener("mousedown", mousedownEvent);
      canvas.removeEventListener("mousemove", mousemoveEvent);
      window.removeEventListener("mouseup", mouseupEvent);
    };
  });

  //画布移动模式下,修改画布的cursor
  useEffect(() => {
    if (mode === "move") {
      canvasEle.current?.classList.add("move-canvas");
    } else {
      canvasEle.current?.classList.remove("move-canvas");
    }
  }, [mode]);

  /**
   * 前进/后退事件
   * @param {*} v
   */
  const frontOrBack = (v) => {
    setRecordIndex(Math.max(1, Math.min(canvasState.length, recordIndex + v)));
  };

  /**
   * 生成当前的操作记录
   * @param {*} path
   * @param {*} fill
   * @returns
   */
  const getCurState = (path, fill) => {
    return {
      type: "path",
      scale,
      path,
      lineWidth: lineWidth,
      lineColor: lineColor,
      fill,
    };
  };

  //画布渲染
  useEffect(() => {
    if (!canvasState.length) return;
    const ctx = canvasEle.current.getContext("2d");
    ctx.resetTransform();
    ctx.clearRect(0, 0, defaultWidth, defaultHeight);

    const contentState = canvasState
      .slice(0, recordIndex)
      .filter((item) => item.type !== "transform");
    const transformState = canvasState
      .slice(0, recordIndex)
      .filter((item) => item.type === "transform")
      .pop()?.transform;

    const [[x, y], [x2, y2]] = transformState;
    ctx.translate(x2, y2);
    ctx.scale(x / 100, y / 100);
    setScale(x);
    setTranslate([x2, y2]);

    for (const item of contentState) {
      const { img, size } = item;
      if (item.type === "img") {
        ctx.drawImage(
          img,
          0,
          0,
          img.width,
          img.height,
          (-1 * size[0]) / 2,
          (-1 * size[1]) / 2,
          size[0],
          size[1]
        );
        continue;
      }
      ctx.beginPath();
      ctx.lineWidth = item.lineWidth;
      ctx.strokeStyle = item.lineColor;
      if (item.fill) {
        ctx.fillStyle = "#fff";
        ctx.fill(item.path);
      } else {
        ctx.stroke(item.path);
      }
    }
  }, [canvasState, recordIndex]);

  /**
   * 初始化画布状态
   * @param {*} w
   * @param {*} h
   * @param {*} state
   */
  const initCanvas = (w = defaultWidth, h = defaultHeight, state = []) => {
    for (const item of canvasState) {
      if (item.type === "img") {
        window.URL.revokeObjectURL(item.img.src);
      }
    }
    setCanvasSize([w, h]);
    setScale(100);
    setTranslate([w / 2, h / 2]);
    setCanvasState([getInitState(w / 2, h / 2), ...state]);
    setRecordIndex(state.length + 1);
  };

  /**
   * 选择图片
   * @param {*} e
   * @returns
   */
  const selectFile = (e) => {
    const url = window.URL.createObjectURL(e.file);

    const img = new Image();
    img.src = url;
    img.onload = () => {
      const n = Math.min(defaultWidth / img.width, defaultHeight / img.height);
      const size = [img.width * n, img.height * n];
      initCanvas(...size, [
        {
          type: "img",
          img,
          size,
        },
      ]);
    };
    return false;
  };

  /**
   * 下载图片
   */
  const downLoadImg = () => {
    const aEle = document.createElement("a");
    document.body.appendChild(aEle);
    aEle.href = canvasEle.current.toDataURL();
    aEle.download = `${Date.now()}.jpg`;
    aEle.click();
    document.body.removeChild(aEle);
  };

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时的伸缩变换属性,再将当前的操作造成的变换叠加,然后添加一个新的记录),这样做也有可能生成更多的记录,后面实现的时候要再衡量一下。

难度:☆☆☆☆