diff --git a/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx b/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx index 6095e34..fc9e4ef 100644 --- a/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx +++ b/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import {useCallback} from "react"; import { Canvas } from "../Canvas"; import { CanvasPath, @@ -9,6 +10,11 @@ import { import { CanvasRef } from "../Canvas/types"; import { ReactSketchCanvasProps, ReactSketchCanvasRef } from "./types"; +type Operation = { + type: 'undo' | 'redo' | 'clear' | 'loadPaths'; + payload?: CanvasPath[]; +}; + /** * ReactSketchCanvas is a wrapper around Canvas component to provide a controlled way to manage the canvas paths. * It provides a set of methods to manage the canvas paths, undo, redo, clear and reset the canvas. @@ -50,9 +56,19 @@ export const ReactSketchCanvas = React.forwardRef< const svgCanvas = React.createRef(); const [drawMode, setDrawMode] = React.useState(true); const [isDrawing, setIsDrawing] = React.useState(false); - const [resetStack, setResetStack] = React.useState([]); - const [undoStack, setUndoStack] = React.useState([]); + const [history, setHistory] = React.useState([[]]); + const [historyPos, setHistoryPos] = React.useState(0); + const [historySynced, setHistorySynced] = React.useState(false); const [currentPaths, setCurrentPaths] = React.useState([]); + const [operationQueue, setOperationQueue] = React.useState([]); + const [isProcessingQueue, setIsProcessingQueue] = React.useState(false); + + const addLastStroke = useCallback(():void => { + if (!historySynced) { + setHistory(his => [...his.slice(0, historyPos), [...currentPaths]]); + setHistorySynced(true); + } + }, [currentPaths, historyPos, historySynced]); const liftStrokeUp = React.useCallback((): void => { const lastStroke = currentPaths.slice(-1)?.[0] ?? null; @@ -75,32 +91,75 @@ export const ReactSketchCanvas = React.forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPaths]); + const processQueue = React.useCallback(async () => { + if (isProcessingQueue || operationQueue.length === 0) return; + + setIsProcessingQueue(true); + const operation = operationQueue[0]; + + try { + switch (operation.type) { + case 'undo': + if (historyPos > 0) { + addLastStroke(); + setCurrentPaths(history[historyPos - 1]); + setHistoryPos(pos => pos - 1); + } + break; + case 'redo': + if (historyPos < history.length - 1) { + addLastStroke(); + setCurrentPaths(history[historyPos + 1]); + setHistoryPos(pos => pos + 1); + } + break; + case 'clear': + addLastStroke(); + setCurrentPaths([]); + setHistory(his => [...his.slice(0, historyPos + 1), []]); + setHistoryPos(pos => pos + 1); + break; + case 'loadPaths': + if (operation.payload) { + addLastStroke(); + setCurrentPaths((paths) => { + const newPaths = [...paths, ...operation.payload!]; + setHistory(his => { + const newHistoryPos = historyPos + 1; + setHistoryPos(newHistoryPos); + return [...his.slice(0, newHistoryPos), newPaths]; + }); + return newPaths; + }); + } + break; + } + } finally { + setOperationQueue(queue => queue.slice(1)); + setIsProcessingQueue(false); + } + }, [operationQueue, isProcessingQueue, historyPos, history, currentPaths, addLastStroke]); + + React.useEffect(() => { + processQueue(); + }, [processQueue, operationQueue]); + + const enqueueOperation = useCallback((operation: Operation) => { + setOperationQueue(queue => [...queue, operation]); + }, []); + React.useImperativeHandle(ref, () => ({ eraseMode: (erase: boolean): void => { setDrawMode(!erase); }, clearCanvas: (): void => { - setResetStack([...currentPaths]); - setCurrentPaths([]); + enqueueOperation({ type: 'clear' }); }, undo: (): void => { - // If there was a last reset then - if (resetStack.length !== 0) { - setCurrentPaths([...resetStack]); - setResetStack([]); - - return; - } - - setUndoStack((paths) => [...paths, ...currentPaths.slice(-1)]); - setCurrentPaths((paths) => paths.slice(0, -1)); + enqueueOperation({ type: 'undo' }); }, redo: (): void => { - // Nothing to Redo - if (undoStack.length === 0) return; - - setCurrentPaths((paths) => [...paths, ...undoStack.slice(-1)]); - setUndoStack((paths) => paths.slice(0, -1)); + enqueueOperation({ type: 'redo' }); }, exportImage: ( imageType: ExportImageType, @@ -139,7 +198,7 @@ export const ReactSketchCanvas = React.forwardRef< } }), loadPaths: (paths: CanvasPath[]): void => { - setCurrentPaths((path) => [...path, ...paths]); + enqueueOperation({ type: 'loadPaths', payload: paths }); }, getSketchingTime: (): Promise => new Promise((resolve, reject) => { @@ -164,15 +223,15 @@ export const ReactSketchCanvas = React.forwardRef< } }), resetCanvas: (): void => { - setResetStack([]); - setUndoStack([]); + setHistory([]); + setHistoryPos(0); setCurrentPaths([]); + setOperationQueue([]); }, - })); + }), [currentPaths, history, historyPos, svgCanvas, withTimestamp, addLastStroke, enqueueOperation]); const handlePointerDown = (point: Point, isEraser = false): void => { setIsDrawing(true); - setUndoStack([]); const isDraw = !isEraser && drawMode; @@ -190,7 +249,9 @@ export const ReactSketchCanvas = React.forwardRef< endTimestamp: 0, }; } - + addLastStroke(); + setHistoryPos(pos => pos + 1); + setHistorySynced(false); setCurrentPaths((paths) => [...paths, stroke]); }; @@ -210,14 +271,14 @@ export const ReactSketchCanvas = React.forwardRef< return; } + const currentStroke = currentPaths.slice(-1)?.[0] ?? null; + setIsDrawing(false); if (!withTimestamp) { return; } - const currentStroke = currentPaths.slice(-1)?.[0] ?? null; - if (currentStroke === null) { return; } diff --git a/packages/tests/src/actions/undoRedo.spec.tsx b/packages/tests/src/actions/undoRedo.spec.tsx index 5f73ede..3e514b6 100644 --- a/packages/tests/src/actions/undoRedo.spec.tsx +++ b/packages/tests/src/actions/undoRedo.spec.tsx @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/experimental-ct-react"; import { drawEraserLine, drawLine, getCanvasIds } from "../commands"; import { WithUndoRedoButtons } from "../stories/WithUndoRedoButtons"; +import penStrokes from "../fixtures/penStroke.json"; test.use({ viewport: { width: 500, height: 500 } }); @@ -10,6 +11,7 @@ const undoButtonId = "undo-button"; const redoButtonId = "redo-button"; const clearCanvasButtonId = "clear-canvas-button"; const resetCanvasButtonId = "reset-canvas-button"; +const loadPathsButtonId = "load-paths-button"; const { firstStrokeGroupId, eraserStrokeGroupId } = getCanvasIds(canvasId); @@ -22,6 +24,7 @@ test.describe("undo", () => { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); @@ -53,6 +56,7 @@ test.describe("undo", () => { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); @@ -100,6 +104,7 @@ test.describe("redo", () => { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); @@ -137,6 +142,7 @@ test.describe("redo", () => { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); @@ -193,6 +199,7 @@ test("should still keep the stack on clearCanvas", async ({ mount }) => { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); @@ -250,6 +257,143 @@ test("should still keep the stack on clearCanvas", async ({ mount }) => { ).toHaveCount(1); }); +test("should undo a stroke after clear canvas", async ({ mount }) => { + const component = await mount( + , + ); + + const canvas = component.locator(`#${canvasId}`); + const undoButton = component.locator(`#${undoButtonId}`); + const clearCanvasButton = component.locator(`#${clearCanvasButtonId}`); + + await drawLine(canvas, { + length: 50, + originX: 0, + originY: 10, + }); + + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(1); + + // Clear 1 stroke + await clearCanvasButton.click(); + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(0); + + await drawLine(canvas, { + length: 50, + originX: 10, + originY: 10, + }); + await drawLine(canvas, { + length: 50, + originX: 20, + originY: 10, + }); + await drawLine(canvas, { + length: 50, + originX: 30, + originY: 10, + }); + + // Undo 1 of 3 new strokes => 2 left + await undoButton.click(); + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(2); +}); + +test("should undo loaded paths", async ({ mount }) => { + const component = await mount( + , + ); + + const canvas = component.locator(`#${canvasId}`); + const undoButton = component.locator(`#${undoButtonId}`); + const loadPathsButton = component.locator(`#${loadPathsButtonId}`); + + await drawLine(canvas, { + length: 50, + originX: 0, + originY: 10, + }); + + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(1); + + await loadPathsButton.click(); + + // Load 1 stroke + 1 existing = 2 + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(2); + + // Undo load action should reset to 1 original stroke + await undoButton.click(); + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(1); +}); + +test("should undo draw after load", async ({ mount }) => { + const component = await mount( + , + ); + + const canvas = component.locator(`#${canvasId}`); + const undoButton = component.locator(`#${undoButtonId}`); + const loadPathsButton = component.locator(`#${loadPathsButtonId}`); + await loadPathsButton.click(); + + // Load 1 stroke + 1 existing = 2 + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(1); + + await drawLine(canvas, { + length: 50, + originX: 0, + originY: 10, + }); + + // Load 1 stroke + 1 new stroke = 2 + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(2); + + // Undo => 2 total - 1 last stroke = 1 + await undoButton.click(); + await expect( + component.locator(firstStrokeGroupId).locator("path"), + ).toHaveCount(1); +}); + test("should clear the stack on resetCanvas", async ({ mount }) => { const component = await mount( { redoButtonId={redoButtonId} clearCanvasButtonId={clearCanvasButtonId} resetCanvasButtonId={resetCanvasButtonId} + paths={penStrokes} />, ); diff --git a/packages/tests/src/stories/WithUndoRedoButtons.tsx b/packages/tests/src/stories/WithUndoRedoButtons.tsx index 4507cc9..a3f83b8 100644 --- a/packages/tests/src/stories/WithUndoRedoButtons.tsx +++ b/packages/tests/src/stories/WithUndoRedoButtons.tsx @@ -3,6 +3,7 @@ import { ReactSketchCanvas, ReactSketchCanvasProps, ReactSketchCanvasRef, + CanvasPath } from "react-sketch-canvas"; interface WithUndoRedoButtonsProps extends ReactSketchCanvasProps { @@ -10,6 +11,8 @@ interface WithUndoRedoButtonsProps extends ReactSketchCanvasProps { redoButtonId?: string; clearCanvasButtonId?: string; resetCanvasButtonId?: string; + loadPathsButtonId?: string; + paths: CanvasPath[]; } export function WithUndoRedoButtons({ @@ -17,6 +20,8 @@ export function WithUndoRedoButtons({ redoButtonId = "redo-button", clearCanvasButtonId = "clear-canvas-button", resetCanvasButtonId = "reset-canvas-button", + loadPathsButtonId = "load-paths-button", + paths, ...canvasProps }: WithUndoRedoButtonsProps) { const canvasRef = useRef(null); @@ -37,6 +42,10 @@ export function WithUndoRedoButtons({ canvasRef.current?.resetCanvas(); }; + const handleLoadPathsClick = () => { + canvasRef.current?.loadPaths(paths); + }; + return (
{/* eslint-disable-next-line react/jsx-props-no-spreading */} @@ -61,6 +70,9 @@ export function WithUndoRedoButtons({ > Reset Canvas +
); }