Ojay
Ojay

Reputation: 703

Canvas Freehand drawing Undo and Redo functionality in Reactjs

After my attempt to creating a Freehand drawing using HTML5 canvas implemented using React, I want to proceed to Add an undo and redo functionality onclick of the undo and redo button respectively. I'll be grateful for any help rendered.

function App(props) {
    const canvasRef = useRef(null);
    const contextRef = useRef(null);
    const [isDrawing, setIsDrawing] = useState(false);

    useEffect(() => {
        const canvas = canvasRef.current;
        canvas.width = window.innerWidth * 2;
        canvas.height = window.innerHeight * 2;
        canvas.style.width = `${window.innerWidth}px`;
        canvas.style.height = `${window.innerHeight}px`;

        const context = canvas.getContext('2d');
        context.scale(2, 2);
        context.lineCap = 'round';
        context.strokeStyle = 'black';
        context.lineWidth = 5;
        contextRef.current = context;
    }, []);

    const startDrawing = ({ nativeEvent }) => {
        const { offsetX, offsetY } = nativeEvent;
        contextRef.current.beginPath();
        contextRef.current.moveTo(offsetX, offsetY);
        setIsDrawing(true);
    };

    const finishDrawing = () => {
        contextRef.current.closePath();
        setIsDrawing(false);
    };

    const draw = ({ nativeEvent }) => {
        if (!isDrawing) {
            return;
        }
        const { offsetX, offsetY } = nativeEvent;
        contextRef.current.lineTo(offsetX, offsetY);
        contextRef.current.stroke();
    };

    return <canvas onMouseDown={startDrawing} onMouseUp={finishDrawing} onMouseMove={draw} ref={canvasRef} />;
   
}

Upvotes: 3

Views: 2664

Answers (2)

deepak
deepak

Reputation: 1375

Here is the simplest solution with variables. Code sandbox solution to work on live https://codesandbox.io/s/suspicious-breeze-lmlcq.

import React, { useEffect, useRef, useState } from "react";
import "./styles.css";

function App(props) {
  const canvasRef = useRef(null);
  const contextRef = useRef(null);
  const [undoSteps, setUndoSteps] = useState({});
  const [redoStep, setRedoStep] = useState({});

  const [undo, setUndo] = useState(0);
  const [redo, setRedo] = useState(0);
  const [isDrawing, setIsDrawing] = useState(false);

  useEffect(() => {
    const canvas = canvasRef.current;
    canvas.width = window.innerWidth * 2;
    canvas.height = window.innerHeight * 2;
    canvas.style.width = `${window.innerWidth}px`;
    canvas.style.height = `${window.innerHeight}px`;

    const context = canvas.getContext("2d");
    context.scale(2, 2);
    context.lineCap = "round";
    context.strokeStyle = "black";
    context.lineWidth = 5;
    contextRef.current = context;
  }, []);

  const startDrawing = ({ nativeEvent }) => {
    const { offsetX, offsetY } = nativeEvent;

    contextRef.current.beginPath();
    contextRef.current.moveTo(offsetX, offsetY);
    const temp = {
      ...undoSteps,
      [undo + 1]: []
    };
    temp[undo + 1].push({ offsetX, offsetY });
    setUndoSteps(temp);
    setUndo(undo + 1);
    setIsDrawing(true);
  };

  const finishDrawing = () => {
    contextRef.current.closePath();
    setIsDrawing(false);
  };

  const draw = ({ nativeEvent }) => {
    if (!isDrawing) {
      return;
    }
    const { offsetX, offsetY } = nativeEvent;
    contextRef.current.lineTo(offsetX, offsetY);
    contextRef.current.stroke();
    const temp = {
      ...undoSteps
    };
    temp[undo].push({ offsetX, offsetY });
    setUndoSteps(temp);
  };

  const undoLastOperation = () => {
    if (undo > 0) {
      const data = undoSteps[undo];
      contextRef.current.strokeStyle = "white";
      contextRef.current.beginPath();
      contextRef.current.lineWidth = 5;
      contextRef.current.moveTo(data[0].offsetX, data[0].offsetY);
      data.forEach((item, index) => {
        if (index !== 0) {
          contextRef.current.lineTo(item.offsetX, item.offsetY);
          contextRef.current.stroke();
        }
      });
      contextRef.current.closePath();
      contextRef.current.strokeStyle = "black";
      const temp = {
        ...undoSteps,
        [undo]: []
      };
      const te = {
        ...redoStep,
        [redo + 1]: [...data]
      };
      setUndo(undo - 1);
      setRedo(redo + 1);
      setRedoStep(te);
      setUndoSteps(temp);
    }
  };

  const redoLastOperation = () => {
    if (redo > 0) {
      const data = redoStep[redo];
      contextRef.current.strokeStyle = "black";
      contextRef.current.beginPath();
      contextRef.current.lineWidth = 5;
      contextRef.current.moveTo(data[0].offsetX, data[0].offsetY);
      data.forEach((item, index) => {
        if (index !== 0) {
          contextRef.current.lineTo(item.offsetX, item.offsetY);
          contextRef.current.stroke();
        }
      });
      contextRef.current.closePath();
      const temp = {
        ...redoStep,
        [redo]: []
      };
      setUndo(undo + 1);
      setRedo(redo - 1);
      setRedoStep(temp);
      setUndoSteps({
        ...undoSteps,
        [undo + 1]: [...data]
      });
    }
  };

  return (
    <>
      <p>check</p>
      <button type="button"  disabled={ undo === 0} onClick={undoLastOperation}>
        Undo
      </button>
      &nbsp;
      <button type="button"  disabled={ redo === 0} onClick={redoLastOperation}>
        Redo
      </button>
      <canvas
        onMouseDown={startDrawing}
        onMouseUp={finishDrawing}
        onMouseMove={draw}
        ref={canvasRef}
      ></canvas>
    </>
  );
}

export default App;

Upvotes: 1

Blindman67
Blindman67

Reputation: 54128

Options

You have several options.

  • A Save all points used to render each stroke in a buffer (array) after on mouse up. To undo clear the canvas and redraw all strokes up to the appropriate undo position. To redo just draw the next stroke in the undo buffer.

    Note This approach requires an infinite (well larger than all possible strokes) undo buffer or it will not work.

  • B On mouse up save the canvas pixels and store in a buffer. Do not use getImageData as the buffer is uncompressed and will quickly consume a lot of memory. Rather store the pixel data as a blob or DataURL. The default image format is PNG which is lossless and compressed and thus greatly reduces the RAM needed. To undo / redo, clear the canvas, create an image and set the source to the blob or dataURL at the appropriate undo position. When the image has loaded draw it to the canvas.

    Note that blobs must be revoked and as such the undo buffer must ensure any deleted references are revoked before you lose the reference.

  • C A combination of the two methods above. Save strokes and every so often save the pixels.

Simple undo buffer object

You can implement a generic undo buffer object that will store any data.

The undo buffer is independent of react's state

The example snippet shows how it is used.

Note That the undo function takes the argument all If this is true then calling undo returns all buffers from the first update to the current position - 1. This is needed if you need to reconstruct the image.

function UndoBuffer(maxUndos = Infinity) {
    const buffer = [];
    var position = 0;
    const API = {
        get canUndo() { return position > 0 },
        get canRedo() { return position < buffer.length },
        update(data) {
            if (position === maxUndos) { 
                buffer.shift();
                position--;
            }
            if (position < buffer.length) { buffer.length = position }
            buffer.push(data);
            position ++;
        },
        undo(all = true) {
            if (API.canUndo) { 
                if (all) {
                    const buf = [...buffer];
                    buf.length = --position;
                    return buf;
                }
                return buffer[--position];
            }
        },
        redo() {
            if (API.canRedo) { return buffer[position++] }
        },
    };
    return API;
}

Example

Using above UndoBuffer to implement undo and redo using buffered strokes.

const ctx = canvas.getContext("2d");
undo.addEventListener("click", undoDrawing);
redo.addEventListener("click", redoDrawing);
const undoBuffer = UndoBuffer();
updateUndo();
function createImage(w, h){
    const can = document.createElement("canvas");
    can.width = w;
    can.height = h;
    can.ctx = can.getContext("2d");
    return can;
}
const drawing = createImage(canvas.width, canvas.height);
const mouse  = {x : 0, y : 0, button : false, target: canvas};
function mouseEvents(e){
    var updateTarget = false
    if (mouse.target === e.target || mouse.button) {
        mouse.x = e.pageX;
        mouse.y = e.pageY;
        if (e.type === "mousedown") { mouse.button = true  }
        updateTarget = true;
    }
    if (e.type === "mouseup" && mouse.button) {
        mouse.button = false;
        updateTarget = true;
    }
    updateTarget && update(e.type);

}
["down", "up", "move"].forEach(name => document.addEventListener("mouse" + name, mouseEvents));

const stroke = [];
function drawStroke(ctx, stroke, r = false) {
    var i = 0;
    ctx.lineWidth = 5;
    ctx.lineCap = ctx.lineJoin = "round";
    ctx.strokeStyle = "black";
    ctx.beginPath();
    while (i < stroke.length) { ctx.lineTo(stroke[i++],stroke[i++]) }
    ctx.stroke();
}
function updateView() {
    ctx.globalCompositeOperation = "copy";
    ctx.drawImage(drawing, 0, 0);
    ctx.globalCompositeOperation = "source-over";
}
function update(event) {
    var i = 0;
    if (mouse.button) {
        updateView()
        stroke.push(mouse.x - 1, mouse.y - 29);
        drawStroke(ctx, stroke);
    }
    if (event === "mouseup") {
        drawing.ctx.globalCompositeOperation = "copy";
        drawing.ctx.drawImage(canvas, 0, 0);
        drawing.ctx.globalCompositeOperation = "source-over";
        addUndoable(stroke);
        stroke.length = 0;
    }
}
function updateUndo() {
    undo.disabled = !undoBuffer.canUndo;
    redo.disabled = !undoBuffer.canRedo;
}
function undoDrawing() {
    drawing.ctx.clearRect(0, 0, drawing.width, drawing.height);
    undoBuffer.undo(true).forEach(stroke => drawStroke(drawing.ctx, stroke, true));
    updateView();
    updateUndo();
}
function redoDrawing() {
    drawStroke(drawing.ctx, undoBuffer.redo());
    updateView();
    updateUndo();
}
function addUndoable(data) {
    undoBuffer.update([...data]);
    updateUndo();
}
function UndoBuffer(maxUndos = Infinity) {
    const buffer = [];
    var position = 0;
    const API = {
        get canUndo() { return position > 0 },
        get canRedo() { return position < buffer.length },
        update(data) {
            if (position === maxUndos) { 
                buffer.shift();
                position--;
            }
            if (position < buffer.length) { buffer.length = position }
            buffer.push(data);
            position ++;
        },
        reset() { position = buffer.length = 0 },
        undo(all = true) {
            if (API.canUndo) { 
                if (all) {
                    const buf = [...buffer];
                    buf.length = --position;
                    return buf;
                }
                return buffer[--position];
            }
        },
        redo() {
            if (API.canRedo) { return buffer[position++] }
        },
    };
    return API;
}
canvas { 
   position : absolute; 
   top : 28px; 
   left : 0px; 
   border: 1px solid black;
}
button {
   position : absolute;
   top: 4px;
}
#undo {
   left: 4px;
}
#redo {
   left: 60px;

}
<canvas id="canvas"></canvas>
<button id="undo">Undo</button>
<button id="redo">Redo</button>

Upvotes: 2

Related Questions