React Konva - 撤消自由绘制线

React Konva - undo free draw lines

我一直在关注 this tutorial 如何使用 React 和 Konva 构建白板,它为形状提供了撤消功能,但对线条不起作用,因为线条不会以相同的方式添加到图层中。如何实现自由绘制线的撤消?

编辑:

为了扩展我的问题,这里是相关代码:

我有一个 public 回购协议,您可以查看(如果更容易,还可以进行 PR)。

https://github.com/ChristopherHButler/Sandbox-react-whiteboard

我也有一个演示,您可以在这里试用:

https://whiteboard-rho.now.sh/

这里是相关代码

行分量:

import Konva from "konva";

export const addLine = (stage, layer, mode = "brush") => {

  let isPaint = false;
  let lastLine;

  stage.on("mousedown touchstart", function(e) {
    isPaint = true;
    let pos = stage.getPointerPosition();
    lastLine = new Konva.Line({
      stroke: mode == "brush" ? "red" : "white",
      strokeWidth: mode == "brush" ? 5 : 20,
      globalCompositeOperation:
        mode === "brush" ? "source-over" : "destination-out",
      points: [pos.x, pos.y],
      draggable: mode == "brush",
    });
    layer.add(lastLine);
  });

  stage.on("mouseup touchend", function() {
    isPaint = false;
  });

  stage.on("mousemove touchmove", function() {
    if (!isPaint) {
      return;
    }

  const pos = stage.getPointerPosition();
    let newPoints = lastLine.points().concat([pos.x, pos.y]);
    lastLine.points(newPoints);
    layer.batchDraw();
  });

};

主页组件:

import React, { useState, createRef } from "react";
import { v1 as uuidv1 } from 'uuid';
import ButtonGroup from "react-bootstrap/ButtonGroup";
import Button from "react-bootstrap/Button";

import { Stage, Layer } from "react-konva";

import Rectangle from "../Shapes/Rectangle";
import Circle from "../Shapes/Circle";
import { addLine } from "../Shapes/Line";
import { addTextNode } from "../Shapes/Text";
import Image from "../Shapes/Image";




const HomePage = () => {

  const [rectangles, setRectangles] = useState([]);
  const [circles, setCircles] = useState([]);
  const [images, setImages] = useState([]);
  const [selectedId, selectShape] = useState(null);
  const [shapes, setShapes] = useState([]);
  const [, updateState] = useState();
  const stageEl = createRef();
  const layerEl = createRef();
  const fileUploadEl = createRef();

  const getRandomInt = max => {
    return Math.floor(Math.random() * Math.floor(max));
  };

  const addRectangle = () => {
    const rect = {
      x: getRandomInt(100),
      y: getRandomInt(100),
      width: 100,
      height: 100,
      fill: "red",
      id: `rect${rectangles.length + 1}`,
    };
    const rects = rectangles.concat([rect]);
    setRectangles(rects);
    const shs = shapes.concat([`rect${rectangles.length + 1}`]);
    setShapes(shs);
  };

  const addCircle = () => {
    const circ = {
      x: getRandomInt(100),
      y: getRandomInt(100),
      width: 100,
      height: 100,
      fill: "red",
      id: `circ${circles.length + 1}`,
    };
    const circs = circles.concat([circ]);
    setCircles(circs);
    const shs = shapes.concat([`circ${circles.length + 1}`]);
    setShapes(shs);
  };

  const drawLine = () => {
    addLine(stageEl.current.getStage(), layerEl.current);
  };

  const eraseLine = () => {
    addLine(stageEl.current.getStage(), layerEl.current, "erase");
  };

  const drawText = () => {
    const id = addTextNode(stageEl.current.getStage(), layerEl.current);
    const shs = shapes.concat([id]);
    setShapes(shs);
  };

  const drawImage = () => {
    fileUploadEl.current.click();
  };

  const forceUpdate = React.useCallback(() => updateState({}), []);

  const fileChange = ev => {
    let file = ev.target.files[0];
    let reader = new FileReader();
    reader.addEventListener(
      "load",
      () => {
        const id = uuidv1();
        images.push({
          content: reader.result,
          id,
        });
        setImages(images);
        fileUploadEl.current.value = null;
        shapes.push(id);
        setShapes(shapes);
        forceUpdate();
      },
      false
    );
    if (file) {
      reader.readAsDataURL(file);
    }
  };

  const undo = () => {
    const lastId = shapes[shapes.length - 1];
    let index = circles.findIndex(c => c.id == lastId);
    if (index != -1) {
      circles.splice(index, 1);
      setCircles(circles);
    }
    index = rectangles.findIndex(r => r.id == lastId);
    if (index != -1) {
      rectangles.splice(index, 1);
      setRectangles(rectangles);
    }
    index = images.findIndex(r => r.id == lastId);
    if (index != -1) {
      images.splice(index, 1);
      setImages(images);
    }
    shapes.pop();
    setShapes(shapes);
    forceUpdate();
  };

  document.addEventListener("keydown", ev => {
    if (ev.code == "Delete") {
      let index = circles.findIndex(c => c.id == selectedId);
      if (index != -1) {
        circles.splice(index, 1);
        setCircles(circles);
      }
      index = rectangles.findIndex(r => r.id == selectedId);
      if (index != -1) {
        rectangles.splice(index, 1);
        setRectangles(rectangles);
      }
      index = images.findIndex(r => r.id == selectedId);
      if (index != -1) {
        images.splice(index, 1);
        setImages(images);
      }
      forceUpdate();
    }
  });

  return (
    <div className="home-page">
      <ButtonGroup style={{ marginTop: '1em', marginLeft: '1em' }}>
        <Button variant="secondary" onClick={addRectangle}>
          Rectangle
        </Button>
        <Button variant="secondary" onClick={addCircle}>
          Circle
        </Button>
        <Button variant="secondary" onClick={drawLine}>
          Line
        </Button>
        <Button variant="secondary" onClick={eraseLine}>
          Erase
        </Button>
        <Button variant="secondary" onClick={drawText}>
          Text
        </Button>
        <Button variant="secondary" onClick={drawImage}>
          Image
        </Button>
        <Button variant="secondary" onClick={undo}>
          Undo
        </Button>
      </ButtonGroup>
      <input
        style={{ display: "none" }}
        type="file"
        ref={fileUploadEl}
        onChange={fileChange}
      />
      <Stage
        style={{ margin: '1em', border: '2px solid grey' }}
        width={window.innerWidth * 0.9}
        height={window.innerHeight - 150}
        ref={stageEl}
        onMouseDown={e => {
          // deselect when clicked on empty area
          const clickedOnEmpty = e.target === e.target.getStage();
          if (clickedOnEmpty) {
            selectShape(null);
          }
        }}
      >
        <Layer ref={layerEl}>
          {rectangles.map((rect, i) => {
            return (
              <Rectangle
                key={i}
                shapeProps={rect}
                isSelected={rect.id === selectedId}
                onSelect={() => {
                  selectShape(rect.id);
                }}
                onChange={newAttrs => {
                  const rects = rectangles.slice();
                  rects[i] = newAttrs;
                  setRectangles(rects);
                }}
              />
            );
          })}
          {circles.map((circle, i) => {
            return (
              <Circle
                key={i}
                shapeProps={circle}
                isSelected={circle.id === selectedId}
                onSelect={() => {
                  selectShape(circle.id);
                }}
                onChange={newAttrs => {
                  const circs = circles.slice();
                  circs[i] = newAttrs;
                  setCircles(circs);
                }}
              />
            );
          })}
          {images.map((image, i) => {
            return (
              <Image
                key={i}
                imageUrl={image.content}
                isSelected={image.id === selectedId}
                onSelect={() => {
                  selectShape(image.id);
                }}
                onChange={newAttrs => {
                  const imgs = images.slice();
                  imgs[i] = newAttrs;
                }}
              />
            );
          })}
        </Layer>
      </Stage>
    </div>
  );
}

export default HomePage;

如果我理解正确,你是说对于单独添加的形状有一个简单的 'undo' 过程,但是对于使用点数组作为线段的线,没有简单的撤消 - 和您正在关注的教程中没有代码?

我不能给你一个反应代码示例,但我可以解释一些你需要编写代码的概念。

白板上的 'freehand line' 是作为点序列创建的。你 mousedown 并注意到第一个点,然后你移动鼠标并在每个触发当前鼠标位置的 movemove 事件上添加到数组的末尾。当您完成线和 mouseup 触发时,您已将多个点放入线阵列中。

Konvajs line tutorial 中指出:

To define the path of the line you should use points property. If you have three points with x and y coordinates you should define points property as: [x1, y1, x2, y2, x3, y3].

[Because...] Flat array of numbers should work faster and use less memory than array of objects.

因此 - 您的行 points 作为单独的值添加到 line.points 数组中。

现在让我们考虑一下撤消——你可能已经在那里了,但我还是会把它写出来——要撤消一行的单个片段,你需要删除数组中的最后 2 个条目。要擦除整行 - 您可以使用标准 shape.remove() or shape.destroy() 方法。

在以下代码段中,两个按钮 link 编码为 'undo' 行。 'Undo by segment' 按钮显示如何弹出 line.points 数组中的最后两个条目以删除行的一部分,'Undo by line' 按钮删除整行。这不是一个具体的 React 示例,但您最终会在您的 React 案例中创建非常接近于此的内容。

// Code to erase line one segment at a time.
$('#undosegment').on('click', function(){

  // get the last line we added to the canvas - tracked via lines array in this demo
  if (lines.length === 0){
    return;
  }
  lastLine = lines[lines.length - 1];
  
  let pointsArray = lastLine.points(); // get current points in line

  if (pointsArray.length === 0){  // no more points so destroy this line object.
    lastLine.destroy();
    layer.batchDraw();
    lines.pop();  // remove from our lines-tracking array.
    return;
  }

  // remove last x & y entrie, pop appears to be fastest way to achieve AND adjust array length
  pointsArray.pop();  // remove the last Y pos
  pointsArray.pop();  // remove the last X pos

  lastLine.points(pointsArray); // give the points back into the line

  layer.batchDraw();

})

// Code to erase entire lines.
$('#undoline').on('click', function(){

  // get the last line we added to the canvas - tracked via lines array in this demo
  if (lines.length === 0){
    return;
  }
  lastLine = lines[lines.length - 1];
  lastLine.destroy();  // remove from our lines-tracking array.
  lines.pop();

  layer.batchDraw();

})



// code from here on is all about drawing the lines. 

let 
    stage = new Konva.Stage({
          container: 'container',
          width: $('#container').width(),
          height: $('#container').height()
        }),
        
      // add a layer to draw on
      layer = new Konva.Layer();
      
      stage.add(layer);
      stage.draw();        
      
let isPaint = false;
let lastLine;      

let lines = [];

stage.on('mousedown', function(){

    isPaint = true;
    let pos = stage.getPointerPosition();
    
    lastLine = new Konva.Line({ stroke: 'magenta', strokeWidth: 4, points: [pos.x, pos.y]});
    layer.add(lastLine);
    
    lines.push(lastLine);
    
})

stage.on("mouseup touchend", function() {
  isPaint = false;
});
  
stage.on("mousemove touchmove", function() {
  if (!isPaint) {
    return;
  }
  const pos = stage.getPointerPosition();
  let newPoints = lastLine.points().concat([pos.x, pos.y]);

  lastLine.points(newPoints);
  layer.batchDraw();

});
body {
  margin: 10;
  padding: 10;
  overflow: hidden;
  background-color: #f0f0f0;
}
#container {
border: 1px solid silver;
width: 500px;
height: 300px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://unpkg.com/konva@^3/konva.min.js"></script>
<p>Click and drag to draw a line </p>
<p>
  <button id='undosegment'>Undo by segment</button>  <button id='undoline'>Undo by line</button>
</p>
<div id="container"></div>

作为解决方案,您应该只对线条使用相同的反应模式。当您使用 react-konva.

时,不建议手动创建形状实例(如新的 Konva.Line

只需定义您的状态并从中创建正确的 render(),就像您在 HomePage 组件中所做的那样。

您可以将所有形状存储在一个数组中。或者使用单独的行。所以要以 react-konva 的方式画线,你可以这样做:

const App = () => {
  const [lines, setLines] = React.useState([]);
  const isDrawing = React.useRef(false);

  const handleMouseDown = (e) => {
    isDrawing.current = true;
    const pos = e.target.getStage().getPointerPosition();
    setLines([...lines, [pos.x, pos.y]]);
  };

  const handleMouseMove = (e) => {
    // no drawing - skipping
    if (!isDrawing.current) {
      return;
    }
    const stage = e.target.getStage();
    const point = stage.getPointerPosition();
    let lastLine = lines[lines.length - 1];
    // add point
    lastLine = lastLine.concat([point.x, point.y]);

    // replace last
    lines.splice(lines.length - 1, 1, lastLine);
    setLines(lines.concat());
  };

  const handleMouseUp = () => {
    isDrawing.current = false;
  };

  return (
    <Stage
      width={window.innerWidth}
      height={window.innerHeight}
      onMouseDown={handleMouseDown}
      onMousemove={handleMouseMove}
      onMouseup={handleMouseUp}
    >
      <Layer>
        <Text text="Just start drawing" />
        {lines.map((line, i) => (
          <Line key={i} points={line} stroke="red" />
        ))}
      </Layer>
    </Stage>
  );
};

演示:https://codesandbox.io/s/hungry-architecture-v380jlvwrl?file=/index.js

那么下一步就是如何实现undo/redo。您只需要保留状态更改的历史记录。在这里查看演示:https://konvajs.org/docs/react/Undo-Redo.html