import { AppState } from "./types"; import { ExcalidrawElement } from "./element/types"; import { clearAppStatePropertiesForHistory } from "./appState"; import { newElementWith } from "./element/mutateElement"; import { isLinearElement } from "./element/typeChecks"; type Result = { appState: AppState; elements: ExcalidrawElement[]; }; export class SceneHistory { private recording: boolean = true; private stateHistory: string[] = []; private redoStack: string[] = []; getSnapshotForTest() { return { recording: this.recording, stateHistory: this.stateHistory.map((s) => JSON.parse(s)), redoStack: this.redoStack.map((s) => JSON.parse(s)), }; } clear() { this.stateHistory.length = 0; this.redoStack.length = 0; } private generateEntry = ( appState: AppState, elements: readonly ExcalidrawElement[], ) => JSON.stringify({ appState: clearAppStatePropertiesForHistory(appState), elements: elements.reduce((elements, element) => { if ( isLinearElement(element) && appState.multiElement && appState.multiElement.id === element.id ) { // don't store multi-point arrow if still has only one point if ( appState.multiElement && appState.multiElement.id === element.id && element.points.length < 2 ) { return elements; } elements.push( newElementWith(element, { // don't store last point if not committed points: element.lastCommittedPoint !== element.points[element.points.length - 1] ? element.points.slice(0, -1) : element.points, // don't regenerate versionNonce else this will short-circuit our // bail-on-no-change logic in pushEntry() versionNonce: element.versionNonce, }), ); } else { elements.push( newElementWith(element, { versionNonce: element.versionNonce }), ); } return elements; }, [] as Mutable), }); pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) { const newEntry = this.generateEntry(appState, elements); 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); // As a new entry was pushed, we invalidate the redo stack this.clearRedoStack(); } restoreEntry(entry: string) { try { return JSON.parse(entry); } catch { return null; } } clearRedoStack() { this.redoStack.splice(0, this.redoStack.length); } redoOnce(): Result | null { if (this.redoStack.length === 0) { return null; } const entryToRestore = this.redoStack.pop(); if (entryToRestore !== undefined) { this.stateHistory.push(entryToRestore); return this.restoreEntry(entryToRestore); } return null; } undoOnce(): Result | null { if (this.stateHistory.length === 1) { return null; } const currentEntry = this.stateHistory.pop(); const entryToRestore = this.stateHistory[this.stateHistory.length - 1]; if (currentEntry !== undefined) { this.redoStack.push(currentEntry); return this.restoreEntry(entryToRestore); } return null; } // Suspicious that this is called so many places. Seems error-prone. resumeRecording() { this.recording = true; } record(state: AppState, elements: readonly ExcalidrawElement[]) { if (this.recording) { this.pushEntry(state, elements); this.recording = false; } } } export const createHistory: () => { history: SceneHistory } = () => { const history = new SceneHistory(); return { history }; };