javascript全栈开发实践-web-4

187 阅读5分钟

接下来,我们继续完善功能。作为一个手写应用,undo/redo操作是必须的。要现实undo/redo,最容易想到的实现方式,就是我们记住每一次操作的结果,在用户undo的时候,显示之前的结果给用户就可以了。但是这样有一个很大的问题,就是我们的canvas,实际上一张图片。要记住结果,就需要记住这张图片的内容。而图片本身的数据量是很大的。因此我们没有办法记住很多次结果,也就是限制了undo的次数。 还有一种办法,就是我们记住用户的操作,例如我们记住第一次用户用铅笔,画了一条线,这条线上面每一个点的坐标是什么,我们都给记下来。在用户undo的时候,我们可以清空画板,然后从头到尾再画一次所有的操作,这样就可以实现undo功能了。由于用户操作相对的数据,相对于图片本身的数据来说,要小很多,因此,我们几乎可以无限制的undo/redo了。 要记住用户操作,那么在鼠标操作的时候,我们就需要记住每一个鼠标的位置,然后加入到数组里面。在用户鼠标松开的时候,我们再把整个路径的坐标,以及笔画粗细,颜色,作为一个完整的操作,添加到用户数组里面。 下面是完整的代码

<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body style='background:lightgrey'>
    <div>
      <button id='pencil' onclick="handleChoosePencil()">pencil</button>
      <button id='highlighter' onclick="handleChooseHighlighter()">highlighter</button>
      <button id='eraser' onclick="handleChooseEraser()">eraser</button>
      <button id='undo' onclick="handleUndo()">undo</button>
      <button id='redo' onclick="handleRedo()">redo</button>
    </div>
    <canvas id='pad' width='800px' height='600px' style='background:white'></canvas>
  </body>
  <script>
    //
    let actions = [];
    let points = [];
    let undoCursor = -1;
    //
    const pad = document.getElementById('pad');
    const ctx = pad.getContext('2d');
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'blue';
    //
    updateButtonStatus();
    //
    //
    pad.addEventListener('mousedown', handleMouseDown);
    //
    function handleMouseDown(event) {
      //
      if (undoCursor != -1) {
        actions = actions.slice(0, undoCursor);
      }
      undoCursor = -1;
      //
      ctx.beginPath();
      ctx.moveTo(event.offsetX, event.offsetY);
      //
      pad.addEventListener('mousemove', handleMouseMove);
      pad.addEventListener('mouseup', handleMouseUp);
      //
      points.push({x: event.offsetX, y: event.offsetY});      
    }
    //
    function handleMouseMove(event) {
      ctx.lineTo(event.offsetX, event.offsetY);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(event.offsetX, event.offsetY);
      points.push({x: event.offsetX, y: event.offsetY});      
    }
    //
    function handleMouseUp(event) {
      pad.removeEventListener('mousemove', handleMouseMove);
      pad.removeEventListener('mouseup', handleMouseUp);
      //
      actions.push({
        lineWidth: ctx.lineWidth,
        strokeStyle: ctx.strokeStyle,
        points,
      });
      //
      points = [];
      updateButtonStatus();
    }
    //
    function handleChoosePencil(event) {
      ctx.strokeStyle = 'rgb(0, 0, 255)';
      ctx.lineWidth = 2;
    }
    //
    function handleChooseHighlighter(event) {
      ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
      ctx.lineWidth = 8;
    }
    //
    function handleChooseEraser(event) {
      ctx.strokeStyle = 'white';
      ctx.lineWidth = 8;
    }
    //
    function canUndo() {
      if (actions.length == 0) {
        return false;
      }
      //
      if (undoCursor == 0) {
        return false;
      }
      //
      return true;
    }
    //
    function canRedo() {
      //
      if (actions.length == 0) {
        return false;
      }
      //
      if (undoCursor == -1 || undoCursor == actions.length) {
        return false;
      }
      //
      return true;
    }
    //
    function handleUndo(event) {
      if (!canUndo()) {
        return;
      }
      //
      if (undoCursor == -1) {
        undoCursor = actions.length;
      }
      //
      undoCursor--;
      //
      repaint();
      //
      updateButtonStatus();
    }
    //
    function handleRedo(event) {
      if (!canRedo()) {
        return;
      }
      //
      undoCursor++;
      //
      repaint();
      //
      updateButtonStatus();
    }
    //
    function updateButtonStatus() {
      const undoButton = document.getElementById('undo');
      const redoButton = document.getElementById('redo');
      undoButton.disabled = !canUndo();
      redoButton.disabled = !canRedo();
    }
    //
    function repaint() {
      ctx.clearRect(0, 0, pad.width, pad.height);
      //
      let toIndex = undoCursor == -1 ? actions.length : undoCursor;
      for (let i = 0; i < toIndex; i++) {
        //
        let action = actions[i];
        //
        ctx.beginPath();
        ctx.lineWidth = action.lineWidth;
        ctx.strokeStyle = action.strokeStyle;
        //
        let points = action.points;
        if (points.length == 0) {
          continue;
        }
        //
        let firstPoint = points[0];
        ctx.moveTo(firstPoint.x, firstPoint.y);
        for (let j = 1; j < points.length; j++) {
          const point = points[j];
          ctx.lineTo(point.x, point.y);
        }
        ctx.stroke();
        //
      }
    }
  </script>
</html>

记录用户操作:

首先,我们定义了一个points数组,用来在鼠标按下以及移动的时候,记录完整的鼠标坐标。 我们还定义了一个actions数组,这个就是用来存放用户每一个操作的数组。在用户鼠标松开的时候,我们会把当前操作添加到这个actions数组后面:

    function handleMouseUp(event) {
      pad.removeEventListener('mousemove', handleMouseMove);
      pad.removeEventListener('mouseup', handleMouseUp);
      //
      actions.push({
        lineWidth: ctx.lineWidth,
        strokeStyle: ctx.strokeStyle,
        points,
      });
      //
      points = [];
      updateButtonStatus();
    }

我们在这里记录了当前路径所有的坐标(points),当前路径的宽度,以及颜色。把他们组合成一个对象添加到actions数组里面。在添加完之后,我们还需要记住,要把points数组重置,准备记录下一个操作的坐标。

执行undo

在用户进行undo的时候,最容易想到的办法,就是用户每执行一次undo,我们就把actions数组中最后一个元素删除,然后重新绘制actions里面的所有元素。但是这样以来,我们就没办法实现redo操作了。因此,在用户undo的时候,我们可以通过一个游标(actions数组下标),记录当前用户undo到哪一步了。当用户undo的时候,游标向数组头部移动。当用户redo的时候,游标向数组尾部移动。 首先,我们需要定义一个undoCursor,并把他的初始值设置成-1。之所以设置成-1,是因为数组的下标永远应该是大于等于0的。那么如果是-1,表示当前游标在数组最后面,用户没有任何undo操作。 下面的代码,可以判断当前是否允许undo/redo

    function canUndo() {
      if (actions.length == 0) {
        return false;
      }
      //
      if (undoCursor == 0) {
        return false;
      }
      //
      return true;
    }
    function canRedo() {
      //
      if (actions.length == 0) {
        return false;
      }
      //
      if (undoCursor == -1 || undoCursor == actions.length) {
        return false;
      }
      //
      return true;
    }

当用户undo/redo的时候,我们去移动游标:

    function handleUndo(event) {
      if (!canUndo()) {
        return;
      }
      //
      if (undoCursor == -1) {
        undoCursor = actions.length;
      }
      //
      undoCursor--;
      //
      repaint();
      //
      updateButtonStatus();
    }
    //
    function handleRedo(event) {
      if (!canRedo()) {
        return;
      }
      //
      undoCursor++;
      //
      repaint();
      //
      updateButtonStatus();
    }

在移动完游标后,我们还需要进行重绘。 重绘很简单,就是清空canvas,然后从头到尾重新绘制路径即可:

    function repaint() {
      ctx.clearRect(0, 0, pad.width, pad.height);
      //
      let toIndex = undoCursor == -1 ? actions.length : undoCursor;
      for (let i = 0; i < toIndex; i++) {
        //
        let action = actions[i];
        //
        ctx.beginPath();
        ctx.lineWidth = action.lineWidth;
        ctx.strokeStyle = action.strokeStyle;
        //
        let points = action.points;
        if (points.length == 0) {
          continue;
        }
        //
        let firstPoint = points[0];
        ctx.moveTo(firstPoint.x, firstPoint.y);
        for (let j = 1; j < points.length; j++) {
          const point = points[j];
          ctx.lineTo(point.x, point.y);
        }
        ctx.stroke();
        //
      }
    }

当用户undo/redo之后,继续进行新的绘画的时候,我们还需要删除游标后面的数据,并且重置游标的位置:

      if (undoCursor != -1) {
        actions = actions.slice(0, undoCursor);
      }
      undoCursor = -1;

最后,我们还需要在添加新的undo/redo按钮,并响应按钮消息,进行undo/redo处理。同时,在合适的时机,我们还需要更新undo/redo按钮的状态,以便告诉用户,什么时候可以undo/redo。