React Konva - undo free draw lines

2.7k Views Asked by At

I was following this tutorial on how to build a whiteboard with react and konva and it provides an undo function for shapes but does not work for lines because lines are not added to the layer in the same way. How can I implement undo for free draw line?

EDIT:

To expand on my question, here is the relevant code:

I have a public repo that you can check out (and make a PR if that's easier).

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

I have also have a demo you can try out here:

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

Here is the relevant code

line component:

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();
  });

};

HomePage component:

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;
2

There are 2 best solutions below

7
On BEST ANSWER

As a solution, you should just use the same react modal for lines. It is not recommended to create shape instances manually (like new Konva.Line) when you work with react-konva.

Just define your state and make a correct render() from it, as you do in HomePage component.

You may store all shapes in one array. Or use a separate for lines. So to draw lines in react-konva way you can do this:

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>
  );
};

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

Then the next step is how to implement undo/redo. You just need to keep a history of state changes. Take a look here for demo: https://konvajs.org/docs/react/Undo-Redo.html

2
On

If I understand this right you saying that for shapes which are added individually there is an easy 'undo' process, but for lines which use an array of points for their segments, there is no simple undo - and no code in the tutorial you are following?

I can't give you a react code sample but I can explain some of the concepts you need to code up.

The 'freehand line' in your whiteboard is created as a sequence of points. You mousedown and the first point is noted, then you move the mouse and on each movemove event that fires the current mouse position is added to the end of the array. By the time you complete the line and mouseup fires, you have thrown multiple points into the line array.

In the Konvajs line tutorial it states:

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.

So - your line points are added as separate values into the line.points array.

Now lets think about undo - you are probably there already but I'll write it out anyway - to undo a single segment of the line you need to erase the last 2 entries in the array. To erase the entire line - well you can use the standard shape.remove() or shape.destroy() methods.

In the following snippet the two buttons link to code to 'undo' lines. The 'Undo by segment' button shows how to pop the last two entries in the line.points array to remove a segment of the line, and the 'Undo by line' button removes entire lines. This is not a react example specifically, but you will in the end create something very close to this in your react case.

// 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>