使用Canvas实现画图程序的撤销/重做功能

17
我需要为我的画图程序实现一个撤销/恢复系统: http://www.taffatech.com/Paint.html 我想到的方法是,使用两个数组栈,一个用于撤销,另一个用于恢复。每当你画完并释放鼠标时,它就会通过“push”将画布图像保存到“undo”数组堆栈中。如果您再画其他东西并释放,则会执行相同的操作。但是,如果您单击撤销,它将弹出撤销数组的顶部图像,并将其打印到画布上,然后将其推入恢复数组。
当单击恢复时,它将从自身弹出并推入撤销。每次鼠标离开时都会打印出撤销的顶部。
这样做正确吗?还有更好的方法吗?

你可以尝试使用fabric.js,它允许自由绘制并将每个形状包装成一个对象(参见这里),这应该使操作更简单。 - Jacopofar
1
当在撤销堆栈上保存新操作时,请不要忘记清除重做堆栈。 - Bergi
1
保存整个图像可能会占用大量内存。您可以限制堆栈大小,或尝试仅保存图像之间的更改(基本上是每个笔画)。 - Waleed Khan
是的,每一笔画都是我想这样做的,也许使用从0到9的10个栈。但是我似乎无法使其工作 :/ 我正在遵循http://www.yankov.us/canvasundo/的指导。 - Steven Barrett
3个回答

23

警告!

将整个画布保存为图像以进行撤销/重做会消耗大量内存并且会影响性能。

然而,你逐步保存用户的绘图到一个数组的想法仍然是一个好主意。

不要保存整个画布作为图像,只需创建一个点数组来记录用户绘制时每次mousemove事件。这就是您的“绘图数组”,可以用它完全重新绘制画布。

当用户拖动鼠标时,他们正在创建折线(一组相连的线段)。 当用户拖动以创建线条时,请将该mousemove点保存到绘图数组中,并将其折线延伸到当前mousemove位置。

function handleMouseMove(e) {

    // calc where the mouse is on the canvas
    mouseX = parseInt(e.clientX - offsetX);
    mouseY = parseInt(e.clientY - offsetY);

    // if the mouse is being dragged (mouse button is down)
    // then keep drawing a polyline to this new mouse position
    if (isMouseDown) {

        // extend the polyline
        ctx.lineTo(mouseX, mouseY);
        ctx.stroke();

        // save this x/y because we might be drawing from here
        // on the next mousemove
        lastX = mouseX;
        lastY = mouseY;

        // Command pattern stuff: Save the mouse position and 
        // the size/color of the brush to the "undo" array
        points.push({
            x: mouseX,
            y: mouseY,
            size: brushSize,
            color: brushColor,
            mode: "draw"
        });
    }
}

如果用户想要“撤消”,只需从绘图数组中弹出最后一个点:

function undoLastPoint() {

    // remove the last drawn point from the drawing array
    var lastPoint=points.pop();

    // add the "undone" point to a separate redo array
    redoStack.unshift(lastPoint);

    // redraw all the remaining points
    redrawAll();
}

Redo通常更为复杂。

最简单的Redo是当用户只能在撤销之后立即重做。将每个“撤销”点保存在单独的“重做”数组中。然后,如果用户想要重做,您只需将重做位添加回到主数组中即可。

问题在于,如果允许用户在进行更多绘图后“重新执行”,就会变得复杂。

例如,您可能会得到一只带有2条尾巴的狗:一个新绘制的尾巴和第二个“重做”尾巴!因此,如果允许在额外绘图后执行重做,则需要一种方式来防止用户在重做期间感到困惑。Matt Greer的“层叠”重做思路非常好。只需修改该思路,保存重做点而不是整个画布图像。然后,用户可以切换重做的开/关状态以查看是否要保留重做。

这里是我为先前问题创建的undo数组的示例:Drawing to canvas like in paint

这是该代码和一个Fiddle示例:http://jsfiddle.net/m1erickson/AEYYq/

<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
<script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>
<!--[if lt IE 9]><script type="text/javascript" src="../excanvas.js"></script><![endif]-->

<style>
    body{ background-color: ivory; }
    canvas{border:1px solid red;}
</style>

<script>
$(function(){

    var canvas=document.getElementById("canvas");
    var ctx=canvas.getContext("2d");
    var lastX;
    var lastY;
    var mouseX;
    var mouseY;
    var canvasOffset=$("#canvas").offset();
    var offsetX=canvasOffset.left;
    var offsetY=canvasOffset.top;
    var isMouseDown=false;
    var brushSize=20;
    var brushColor="#ff0000";
    var points=[];


    function handleMouseDown(e){
      mouseX=parseInt(e.clientX-offsetX);
      mouseY=parseInt(e.clientY-offsetY);

      // Put your mousedown stuff here
      ctx.beginPath();
      if(ctx.lineWidth!=brushSize){ctx.lineWidth=brushSize;}
      if(ctx.strokeStyle!=brushColor){ctx.strokeStyle=brushColor;}
      ctx.moveTo(mouseX,mouseY);
      points.push({x:mouseX,y:mouseY,size:brushSize,color:brushColor,mode:"begin"});
      lastX=mouseX;
      lastY=mouseY;
      isMouseDown=true;
    }

    function handleMouseUp(e){
      mouseX=parseInt(e.clientX-offsetX);
      mouseY=parseInt(e.clientY-offsetY);

      // Put your mouseup stuff here
      isMouseDown=false;
      points.push({x:mouseX,y:mouseY,size:brushSize,color:brushColor,mode:"end"});
    }


    function handleMouseMove(e){
      mouseX=parseInt(e.clientX-offsetX);
      mouseY=parseInt(e.clientY-offsetY);

      // Put your mousemove stuff here
      if(isMouseDown){
          ctx.lineTo(mouseX,mouseY);
          ctx.stroke();     
          lastX=mouseX;
          lastY=mouseY;
          // command pattern stuff
          points.push({x:mouseX,y:mouseY,size:brushSize,color:brushColor,mode:"draw"});
      }
    }


    function redrawAll(){

        if(points.length==0){return;}

        ctx.clearRect(0,0,canvas.width,canvas.height);

        for(var i=0;i<points.length;i++){

          var pt=points[i];

          var begin=false;

          if(ctx.lineWidth!=pt.size){
              ctx.lineWidth=pt.size;
              begin=true;
          }
          if(ctx.strokeStyle!=pt.color){
              ctx.strokeStyle=pt.color;
              begin=true;
          }
          if(pt.mode=="begin" || begin){
              ctx.beginPath();
              ctx.moveTo(pt.x,pt.y);
          }
          ctx.lineTo(pt.x,pt.y);
          if(pt.mode=="end" || (i==points.length-1)){
              ctx.stroke();
          }
        }
        ctx.stroke();
    }

    function undoLast(){
        points.pop();
        redrawAll();
    }

    ctx.lineJoin = "round";
    ctx.fillStyle=brushColor;
    ctx.lineWidth=brushSize;

    $("#brush5").click(function(){ brushSize=5; });
    $("#brush10").click(function(){ brushSize=10; });
    // Important!  Brush colors must be defined in 6-digit hex format only
    $("#brushRed").click(function(){ brushColor="#ff0000"; });
    $("#brushBlue").click(function(){ brushColor="#0000ff"; });

    $("#canvas").mousedown(function(e){handleMouseDown(e);});
    $("#canvas").mousemove(function(e){handleMouseMove(e);});
    $("#canvas").mouseup(function(e){handleMouseUp(e);});

    // hold down the undo button to erase the last line segment
    var interval;
    $("#undo").mousedown(function() {
      interval = setInterval(undoLast, 100);
    }).mouseup(function() {
      clearInterval(interval);
    });


}); // end $(function(){});
</script>

</head>

<body>
    <p>Drag to draw. Use buttons to change lineWidth/color</p>
    <canvas id="canvas" width=300 height=300></canvas><br>
    <button id="undo">Hold this button down to Undo</button><br><br>
    <button id="brush5">5px Brush</button>
    <button id="brush10">10px Brush</button>
    <button id="brushRed">Red Brush</button>
    <button id="brushBlue">Blue Brush</button>
</body>
</html>

2
我也考虑过这种方法,但在我看来,当添加更多工具(比如填充桶)时,它的可扩展性并不好。但实话说,在这个页面提到的系统中,没有一个系统能够良好地扩展,因为当你超越了工具的撤消/重做(例如,撤销图层的删除)时,这个系统很容易崩溃。我是走了一些弯路才意识到的 :) - Matt Greer
2
@MattGreer:你好...很高兴见到你!如果你加入填充桶的概念,我明白你所说的关于扩展性的问题。但在这种情况下,你可以引入元命令。例如,不是记录每个被填充的像素,而是记录只有单词“填充”以及对被填充路径的引用。当你重放“填充”命令时,将在指定的路径上执行泛洪填充函数。可扩展性得到保持!这在我做的一个项目中非常有效,该项目允许检查员地图缺陷。 - markE
泛洪填充算法在JavaScript中执行起来不便宜。想象一种情况,用户将整个画布板涂上不同的颜色,比如说100次。如果现在要撤销操作,我们将不得不对之前的99个事件都进行泛洪填充操作,这将占用线程很长时间。 - vighnesh153
@vighnesh153 是的,这只是为了说明算法而已。在实际生产中肯定还有一些可以提高效率的地方...可能是缓存等等。 - markE

8
这是我为我的 画图应用 所做的基本思路;它确实很有效,但这种方法可能会占用大量内存。因此,我进行了一些微调,只存储与用户最后一次操作相同大小的撤销/重做剪辑。因此,如果他们只是在画布上画了一个小点,你可以存储一个小画布,它只是完整尺寸的一小部分,并节省大量内存。
我的撤销/重做系统位于 Painter.js 中。我两年前写了这个应用程序,所以我的记忆有点模糊,但如果你决定解码我的代码,我可以帮助解释一些东西。

1
+1 我喜欢你分层的想法...这是帮助用户保持组织的好方法。 - markE
4
目前相关链接已经失效。 - 1valdis

1

我发现了这个网站http://www.yankov.us/canvasundo/,但它似乎不能与我的代码一起使用,你认为有什么原因吗? - Steven Barrett

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接