diff --git a/README.md b/README.md index b680fb7..2f53080 100644 --- a/README.md +++ b/README.md @@ -108,26 +108,26 @@ const Canvas = class extends React.Component { ## List of Props -| Props | Expected datatype | Default value | Description | -|------------------------------------| ----------------- | --------------------- |----------------------------------------------------------------------------------------------------| -| width | PropTypes.string | 100% | canvas width (em/rem/px) | -| height | PropTypes.string | 100% | canvas width (em/rem/px) | -| id | PropTypes.string | "react-sketch-canvas" | ID field to uniquely identify a SVG canvas (Supports multiple canvases in a single page) | -| className | PropTypes.string | "" | Class for using with CSS selectors | -| strokeColor | PropTypes.string | black | Pen color | -| canvasColor | PropTypes.string | white | canvas color (HTML colors) | -| backgroundImage | PropTypes.string | '' | Set SVG background with image URL | -| exportWithBackgroundImage | PropTypes.bool | false | Keep background image on image/SVG export (on false, canvasColor will be set as background) | +| Props | Expected datatype | Default value | Description | +| ---------------------------------- | ----------------- | --------------------- | --------------------------------------------------------------------------------------------------- | +| width | PropTypes.string | 100% | canvas width (em/rem/px) | +| height | PropTypes.string | 100% | canvas width (em/rem/px) | +| id | PropTypes.string | "react-sketch-canvas" | ID field to uniquely identify a SVG canvas (Supports multiple canvases in a single page) | +| className | PropTypes.string | "" | Class for using with CSS selectors | +| strokeColor | PropTypes.string | black | Pen color | +| canvasColor | PropTypes.string | white | canvas color (HTML colors) | +| backgroundImage | PropTypes.string | '' | Set SVG background with image URL | +| exportWithBackgroundImage | PropTypes.bool | false | Keep background image on image/SVG export (on false, canvasColor will be set as background) | | preserveBackgroundImageAspectRatio | PropTypes.string | none | Set aspect ratio of the background image. For possible values check [MDN docs][preserveaspectratio] | -| strokeWidth | PropTypes.number | 4 | Pen stroke size | -| eraserWidth | PropTypes.number | 8 | Erase size | -| allowOnlyPointerType | PropTypes.string | all | allow pointer type ("all"/"mouse"/"pen"/"touch") | -| onChange | PropTypes.func | | Returns the current sketch path in `CanvasPath` type on every path change | -| onStroke | PropTypes.func | | Returns the the last stroke path and whether it is an eraser stroke on every pointer up event | -| style | PropTypes.object | false | Add CSS styling as CSS-in-JS object | -| svgStyle | PropTypes.object | {} | Add CSS styling as CSS-in-JS object for the SVG | -| withTimestamp | PropTypes.bool | false | Add timestamp to individual strokes for measuring sketching time | -| readOnly | PropTypes.bool | false | Disable drawing on the canvas (undo/redo, clear & reset will still work.) | +| strokeWidth | PropTypes.number | 4 | Pen stroke size | +| eraserWidth | PropTypes.number | 8 | Erase size | +| allowOnlyPointerType | PropTypes.string | all | allow pointer type ("all"/"mouse"/"pen"/"touch") | +| onChange | PropTypes.func | | Returns the current sketch path in `CanvasPath` type on every path change | +| onStroke | PropTypes.func | | Returns the the last stroke path and whether it is an eraser stroke on every pointer up event | +| style | PropTypes.object | false | Add CSS styling as CSS-in-JS object | +| svgStyle | PropTypes.object | {} | Add CSS styling as CSS-in-JS object for the SVG | +| withTimestamp | PropTypes.bool | false | Add timestamp to individual strokes for measuring sketching time | +| readOnly | PropTypes.bool | false | Disable drawing on the canvas (undo/redo, clear & reset will still work.) | Set SVG background using CSS [background][css-bg] value diff --git a/packages/react-sketch-canvas/README.md b/packages/react-sketch-canvas/README.md index b680fb7..2f53080 100644 --- a/packages/react-sketch-canvas/README.md +++ b/packages/react-sketch-canvas/README.md @@ -108,26 +108,26 @@ const Canvas = class extends React.Component { ## List of Props -| Props | Expected datatype | Default value | Description | -|------------------------------------| ----------------- | --------------------- |----------------------------------------------------------------------------------------------------| -| width | PropTypes.string | 100% | canvas width (em/rem/px) | -| height | PropTypes.string | 100% | canvas width (em/rem/px) | -| id | PropTypes.string | "react-sketch-canvas" | ID field to uniquely identify a SVG canvas (Supports multiple canvases in a single page) | -| className | PropTypes.string | "" | Class for using with CSS selectors | -| strokeColor | PropTypes.string | black | Pen color | -| canvasColor | PropTypes.string | white | canvas color (HTML colors) | -| backgroundImage | PropTypes.string | '' | Set SVG background with image URL | -| exportWithBackgroundImage | PropTypes.bool | false | Keep background image on image/SVG export (on false, canvasColor will be set as background) | +| Props | Expected datatype | Default value | Description | +| ---------------------------------- | ----------------- | --------------------- | --------------------------------------------------------------------------------------------------- | +| width | PropTypes.string | 100% | canvas width (em/rem/px) | +| height | PropTypes.string | 100% | canvas width (em/rem/px) | +| id | PropTypes.string | "react-sketch-canvas" | ID field to uniquely identify a SVG canvas (Supports multiple canvases in a single page) | +| className | PropTypes.string | "" | Class for using with CSS selectors | +| strokeColor | PropTypes.string | black | Pen color | +| canvasColor | PropTypes.string | white | canvas color (HTML colors) | +| backgroundImage | PropTypes.string | '' | Set SVG background with image URL | +| exportWithBackgroundImage | PropTypes.bool | false | Keep background image on image/SVG export (on false, canvasColor will be set as background) | | preserveBackgroundImageAspectRatio | PropTypes.string | none | Set aspect ratio of the background image. For possible values check [MDN docs][preserveaspectratio] | -| strokeWidth | PropTypes.number | 4 | Pen stroke size | -| eraserWidth | PropTypes.number | 8 | Erase size | -| allowOnlyPointerType | PropTypes.string | all | allow pointer type ("all"/"mouse"/"pen"/"touch") | -| onChange | PropTypes.func | | Returns the current sketch path in `CanvasPath` type on every path change | -| onStroke | PropTypes.func | | Returns the the last stroke path and whether it is an eraser stroke on every pointer up event | -| style | PropTypes.object | false | Add CSS styling as CSS-in-JS object | -| svgStyle | PropTypes.object | {} | Add CSS styling as CSS-in-JS object for the SVG | -| withTimestamp | PropTypes.bool | false | Add timestamp to individual strokes for measuring sketching time | -| readOnly | PropTypes.bool | false | Disable drawing on the canvas (undo/redo, clear & reset will still work.) | +| strokeWidth | PropTypes.number | 4 | Pen stroke size | +| eraserWidth | PropTypes.number | 8 | Erase size | +| allowOnlyPointerType | PropTypes.string | all | allow pointer type ("all"/"mouse"/"pen"/"touch") | +| onChange | PropTypes.func | | Returns the current sketch path in `CanvasPath` type on every path change | +| onStroke | PropTypes.func | | Returns the the last stroke path and whether it is an eraser stroke on every pointer up event | +| style | PropTypes.object | false | Add CSS styling as CSS-in-JS object | +| svgStyle | PropTypes.object | {} | Add CSS styling as CSS-in-JS object for the SVG | +| withTimestamp | PropTypes.bool | false | Add timestamp to individual strokes for measuring sketching time | +| readOnly | PropTypes.bool | false | Disable drawing on the canvas (undo/redo, clear & reset will still work.) | Set SVG background using CSS [background][css-bg] value diff --git a/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx b/packages/react-sketch-canvas/src/ReactSketchCanvas/index.tsx index 6095e34..20a5d28 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,104 +91,170 @@ export const ReactSketchCanvas = React.forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPaths]); - React.useImperativeHandle(ref, () => ({ - eraseMode: (erase: boolean): void => { - setDrawMode(!erase); - }, - clearCanvas: (): void => { - setResetStack([...currentPaths]); - setCurrentPaths([]); - }, - 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)); - }, - redo: (): void => { - // Nothing to Redo - if (undoStack.length === 0) return; - - setCurrentPaths((paths) => [...paths, ...undoStack.slice(-1)]); - setUndoStack((paths) => paths.slice(0, -1)); - }, - exportImage: ( - imageType: ExportImageType, - options?: ExportImageOptions, - ): Promise => { - const exportImage = svgCanvas.current?.exportImage; - - if (!exportImage) { - throw Error("Export function called before canvas loaded"); - } else { - return exportImage(imageType, options); + 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; + default: + throw new Error(`Unknown operation type: ${operation.type}`); } - }, - exportSvg: (): Promise => - new Promise((resolve, reject) => { - const exportSvg = svgCanvas.current?.exportSvg; + } finally { + setOperationQueue((queue) => queue.slice(1)); + setIsProcessingQueue(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + operationQueue, + isProcessingQueue, + historyPos, + history, + currentPaths, + addLastStroke, + ]); - if (!exportSvg) { - reject(Error("Export function called before canvas loaded")); + 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 => { + enqueueOperation({ type: "clear" }); + }, + undo: (): void => { + enqueueOperation({ type: "undo" }); + }, + redo: (): void => { + enqueueOperation({ type: "redo" }); + }, + exportImage: ( + imageType: ExportImageType, + options?: ExportImageOptions, + ): Promise => { + const exportImage = svgCanvas.current?.exportImage; + + if (!exportImage) { + throw Error("Export function called before canvas loaded"); } else { - exportSvg() - .then((data) => { - resolve(data); - }) - .catch((e) => { - reject(e); - }); - } - }), - exportPaths: (): Promise => - new Promise((resolve, reject) => { - try { - resolve(currentPaths); - } catch (e) { - reject(e); - } - }), - loadPaths: (paths: CanvasPath[]): void => { - setCurrentPaths((path) => [...path, ...paths]); - }, - getSketchingTime: (): Promise => - new Promise((resolve, reject) => { - if (!withTimestamp) { - reject(new Error("Set 'withTimestamp' prop to get sketching time")); - } - - try { - const sketchingTime = currentPaths.reduce( - (totalSketchingTime, path) => { - const startTimestamp = path.startTimestamp ?? 0; - const endTimestamp = path.endTimestamp ?? 0; - - return totalSketchingTime + (endTimestamp - startTimestamp); - }, - 0, - ); - - resolve(sketchingTime); - } catch (e) { - reject(e); + return exportImage(imageType, options); } - }), - resetCanvas: (): void => { - setResetStack([]); - setUndoStack([]); - setCurrentPaths([]); - }, - })); + }, + exportSvg: (): Promise => + new Promise((resolve, reject) => { + const exportSvg = svgCanvas.current?.exportSvg; + + if (!exportSvg) { + reject(Error("Export function called before canvas loaded")); + } else { + exportSvg() + .then((data) => { + resolve(data); + }) + .catch((e) => { + reject(e); + }); + } + }), + exportPaths: (): Promise => + new Promise((resolve, reject) => { + try { + resolve(currentPaths); + } catch (e) { + reject(e); + } + }), + loadPaths: (paths: CanvasPath[]): void => { + enqueueOperation({ type: "loadPaths", payload: paths }); + }, + getSketchingTime: (): Promise => + new Promise((resolve, reject) => { + if (!withTimestamp) { + reject(new Error("Set 'withTimestamp' prop to get sketching time")); + } + + try { + const sketchingTime = currentPaths.reduce( + (totalSketchingTime, path) => { + const startTimestamp = path.startTimestamp ?? 0; + const endTimestamp = path.endTimestamp ?? 0; + + return totalSketchingTime + (endTimestamp - startTimestamp); + }, + 0, + ); + + resolve(sketchingTime); + } catch (e) { + reject(e); + } + }), + resetCanvas: (): void => { + setHistory([]); + setHistoryPos(0); + setCurrentPaths([]); + setOperationQueue([]); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + currentPaths, + history, + historyPos, + svgCanvas, + withTimestamp, + addLastStroke, + enqueueOperation, + ], + ); const handlePointerDown = (point: Point, isEraser = false): void => { setIsDrawing(true); - setUndoStack([]); const isDraw = !isEraser && drawMode; @@ -190,7 +272,9 @@ export const ReactSketchCanvas = React.forwardRef< endTimestamp: 0, }; } - + addLastStroke(); + setHistoryPos((pos) => pos + 1); + setHistorySynced(false); setCurrentPaths((paths) => [...paths, stroke]); }; @@ -210,14 +294,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..c3297d8 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 +
); }