From d0365933a93d7ecb136510f0060a8ea1321cda34 Mon Sep 17 00:00:00 2001 From: Gasim Gasimzada Date: Mon, 6 Jan 2020 21:58:48 +0400 Subject: [PATCH] Extract history (#213) * Extract History into its own module * Encapsulate undo and redo actions within history * Encapsulate clearing redo stack within History * Add private access modifiers to scene history class member variables * Remove duplicate files --- src/history.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ src/index.tsx | 65 ++++++++------------------------------- src/roundRect.ts | 37 ---------------------- 3 files changed, 92 insertions(+), 90 deletions(-) create mode 100644 src/history.ts delete mode 100644 src/roundRect.ts diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 00000000..8076e0b5 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,80 @@ +import { ExcalidrawElement } from "./element/types"; +import { generateDraw } from "./element"; + +class SceneHistory { + private recording: boolean = true; + private stateHistory: string[] = []; + private redoStack: string[] = []; + + generateCurrentEntry(elements: ExcalidrawElement[]) { + return JSON.stringify( + elements.map(element => ({ ...element, isSelected: false })) + ); + } + + pushEntry(newEntry: string) { + if ( + this.stateHistory.length > 0 && + this.stateHistory[this.stateHistory.length - 1] === newEntry + ) { + // If the last entry is the same as this one, ignore it + return; + } + this.stateHistory.push(newEntry); + } + + restoreEntry(elements: ExcalidrawElement[], entry: string) { + const newElements = JSON.parse(entry); + elements.splice(0, elements.length); + newElements.forEach((newElement: ExcalidrawElement) => { + generateDraw(newElement); + elements.push(newElement); + }); + // When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back + this.skipRecording(); + } + + clearRedoStack() { + this.redoStack.splice(0, this.redoStack.length); + } + + redoOnce(elements: ExcalidrawElement[]) { + const currentEntry = this.generateCurrentEntry(elements); + const entryToRestore = this.redoStack.pop(); + if (entryToRestore !== undefined) { + this.restoreEntry(elements, entryToRestore); + this.stateHistory.push(currentEntry); + } + } + + undoOnce(elements: ExcalidrawElement[]) { + const currentEntry = this.generateCurrentEntry(elements); + let entryToRestore = this.stateHistory.pop(); + + // If nothing was changed since last, take the previous one + if (currentEntry === entryToRestore) { + entryToRestore = this.stateHistory.pop(); + } + if (entryToRestore !== undefined) { + this.restoreEntry(elements, entryToRestore); + this.redoStack.push(currentEntry); + } + } + + isRecording() { + return this.recording; + } + + skipRecording() { + this.recording = false; + } + + resumeRecording() { + this.recording = true; + } +} + +export const createHistory: () => { history: SceneHistory } = () => { + const history = new SceneHistory(); + return { history }; +}; diff --git a/src/index.tsx b/src/index.tsx index 5f1056d2..32f4e314 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,43 +33,15 @@ import { EditableText } from "./components/EditableText"; import { ButtonSelect } from "./components/ButtonSelect"; import { ColorPicker } from "./components/ColorPicker"; import { SHAPES, findShapeByKey, shapesShortcutKeys } from "./shapes"; +import { createHistory } from "./history"; import "./styles.scss"; const { elements } = createScene(); +const { history } = createHistory(); const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`; -let skipHistory = false; -const stateHistory: string[] = []; -const redoStack: string[] = []; - -function generateHistoryCurrentEntry() { - return JSON.stringify( - elements.map(element => ({ ...element, isSelected: false })) - ); -} -function pushHistoryEntry(newEntry: string) { - if ( - stateHistory.length > 0 && - stateHistory[stateHistory.length - 1] === newEntry - ) { - // If the last entry is the same as this one, ignore it - return; - } - stateHistory.push(newEntry); -} -function restoreHistoryEntry(entry: string) { - const newElements = JSON.parse(entry); - elements.splice(0, elements.length); - newElements.forEach((newElement: ExcalidrawElement) => { - generateDraw(newElement); - elements.push(newElement); - }); - // When restoring, we shouldn't add an history entry otherwise we'll be stuck with it and can't go back - skipHistory = true; -} - const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_TOP = 0; @@ -231,25 +203,12 @@ class App extends React.Component<{}, AppState> { } else if (shapesShortcutKeys.includes(event.key.toLowerCase())) { this.setState({ elementType: findShapeByKey(event.key) }); } else if (event.metaKey && event.code === "KeyZ") { - const currentEntry = generateHistoryCurrentEntry(); if (event.shiftKey) { // Redo action - const entryToRestore = redoStack.pop(); - if (entryToRestore !== undefined) { - restoreHistoryEntry(entryToRestore); - stateHistory.push(currentEntry); - } + history.redoOnce(elements); } else { // undo action - let lastEntry = stateHistory.pop(); - // If nothing was changed since last, take the previous one - if (currentEntry === lastEntry) { - lastEntry = stateHistory.pop(); - } - if (lastEntry !== undefined) { - restoreHistoryEntry(lastEntry); - redoStack.push(currentEntry); - } + history.undoOnce(elements); } this.forceUpdate(); event.preventDefault(); @@ -798,7 +757,7 @@ class App extends React.Component<{}, AppState> { lastX = x; lastY = y; // We don't want to save history when resizing an element - skipHistory = true; + history.skipRecording(); this.forceUpdate(); return; } @@ -818,7 +777,7 @@ class App extends React.Component<{}, AppState> { lastX = x; lastY = y; // We don't want to save history when dragging an element to initially size it - skipHistory = true; + history.skipRecording(); this.forceUpdate(); return; } @@ -850,7 +809,7 @@ class App extends React.Component<{}, AppState> { setSelection(elements, draggingElement); } // We don't want to save history when moving an element - skipHistory = true; + history.skipRecording(); this.forceUpdate(); }; @@ -892,7 +851,7 @@ class App extends React.Component<{}, AppState> { window.addEventListener("mouseup", onMouseUp); // We don't want to save history on mouseDown, only on mouseUp when it's fully configured - skipHistory = true; + history.skipRecording(); this.forceUpdate(); }} onDoubleClick={e => { @@ -952,11 +911,11 @@ class App extends React.Component<{}, AppState> { viewBackgroundColor: this.state.viewBackgroundColor }); saveToLocalStorage(elements, this.state); - if (!skipHistory) { - pushHistoryEntry(generateHistoryCurrentEntry()); - redoStack.splice(0, redoStack.length); + if (history.isRecording()) { + history.pushEntry(history.generateCurrentEntry(elements)); + history.clearRedoStack(); } - skipHistory = false; + history.resumeRecording(); } } diff --git a/src/roundRect.ts b/src/roundRect.ts deleted file mode 100644 index bba0e6de..00000000 --- a/src/roundRect.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * https://stackoverflow.com/a/3368118 - * Draws a rounded rectangle using the current state of the canvas. - * @param {CanvasRenderingContext2D} context - * @param {Number} x The top left x coordinate - * @param {Number} y The top left y coordinate - * @param {Number} width The width of the rectangle - * @param {Number} height The height of the rectangle - * @param {Number} radius The corner radius - */ -export function roundRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number -) { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo( - x + width, - y + height, - x + width - radius, - y + height - ); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); - context.fill(); - context.stroke(); -}