| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 | import { AppState } from "./types";import { ExcalidrawElement } from "./element/types";import { isLinearElement } from "./element/typeChecks";import { deepCopyElement } from "./element/newElement";export interface HistoryEntry {  appState: ReturnType<typeof clearAppStatePropertiesForHistory>;  elements: ExcalidrawElement[];}interface DehydratedExcalidrawElement {  id: string;  versionNonce: number;}interface DehydratedHistoryEntry {  appState: string;  elements: DehydratedExcalidrawElement[];}const clearAppStatePropertiesForHistory = (appState: AppState) => {  return {    selectedElementIds: appState.selectedElementIds,    selectedGroupIds: appState.selectedGroupIds,    viewBackgroundColor: appState.viewBackgroundColor,    editingLinearElement: appState.editingLinearElement,    editingGroupId: appState.editingGroupId,    name: appState.name,  };};class History {  private elementCache = new Map<string, Map<number, ExcalidrawElement>>();  private recording: boolean = true;  private stateHistory: DehydratedHistoryEntry[] = [];  private redoStack: DehydratedHistoryEntry[] = [];  private lastEntry: HistoryEntry | null = null;  private hydrateHistoryEntry({    appState,    elements,  }: DehydratedHistoryEntry): HistoryEntry {    return {      appState: JSON.parse(appState),      elements: elements.map((dehydratedExcalidrawElement) => {        const element = this.elementCache          .get(dehydratedExcalidrawElement.id)          ?.get(dehydratedExcalidrawElement.versionNonce);        if (!element) {          throw new Error(            `Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,          );        }        return element;      }),    };  }  private dehydrateHistoryEntry({    appState,    elements,  }: HistoryEntry): DehydratedHistoryEntry {    return {      appState: JSON.stringify(appState),      elements: elements.map((element: ExcalidrawElement) => {        if (!this.elementCache.has(element.id)) {          this.elementCache.set(element.id, new Map());        }        const versions = this.elementCache.get(element.id)!;        if (!versions.has(element.versionNonce)) {          versions.set(element.versionNonce, deepCopyElement(element));        }        return {          id: element.id,          versionNonce: element.versionNonce,        };      }),    };  }  getSnapshotForTest() {    return {      recording: this.recording,      stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>        this.hydrateHistoryEntry(dehydratedHistoryEntry),      ),      redoStack: this.redoStack.map((dehydratedHistoryEntry) =>        this.hydrateHistoryEntry(dehydratedHistoryEntry),      ),    };  }  clear() {    this.stateHistory.length = 0;    this.redoStack.length = 0;    this.lastEntry = null;    this.elementCache.clear();  }  private generateEntry = (    appState: AppState,    elements: readonly ExcalidrawElement[],  ): DehydratedHistoryEntry =>    this.dehydrateHistoryEntry({      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({            ...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,          });        } else {          elements.push(element);        }        return elements;      }, [] as Mutable<typeof elements>),    });  shouldCreateEntry(nextEntry: HistoryEntry): boolean {    const { lastEntry } = this;    if (!lastEntry) {      return true;    }    if (nextEntry.elements.length !== lastEntry.elements.length) {      return true;    }    // loop from right to left as changes are likelier to happen on new elements    for (let i = nextEntry.elements.length - 1; i > -1; i--) {      const prev = nextEntry.elements[i];      const next = lastEntry.elements[i];      if (        !prev ||        !next ||        prev.id !== next.id ||        prev.versionNonce !== next.versionNonce      ) {        return true;      }    }    // note: this is safe because entry's appState is guaranteed no excess props    let key: keyof typeof nextEntry.appState;    for (key in nextEntry.appState) {      if (key === "editingLinearElement") {        if (          nextEntry.appState[key]?.elementId ===          lastEntry.appState[key]?.elementId        ) {          continue;        }      }      if (key === "selectedElementIds" || key === "selectedGroupIds") {        continue;      }      if (nextEntry.appState[key] !== lastEntry.appState[key]) {        return true;      }    }    return false;  }  pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {    const newEntryDehydrated = this.generateEntry(appState, elements);    const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);    if (newEntry) {      if (!this.shouldCreateEntry(newEntry)) {        return;      }      this.stateHistory.push(newEntryDehydrated);      this.lastEntry = newEntry;      // As a new entry was pushed, we invalidate the redo stack      this.clearRedoStack();    }  }  clearRedoStack() {    this.redoStack.splice(0, this.redoStack.length);  }  redoOnce(): HistoryEntry | null {    if (this.redoStack.length === 0) {      return null;    }    const entryToRestore = this.redoStack.pop();    if (entryToRestore !== undefined) {      this.stateHistory.push(entryToRestore);      return this.hydrateHistoryEntry(entryToRestore);    }    return null;  }  undoOnce(): HistoryEntry | 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.hydrateHistoryEntry(entryToRestore);    }    return null;  }  /**   * Updates history's `lastEntry` to latest app state. This is necessary   *  when doing undo/redo which itself doesn't commit to history, but updates   *  app state in a way that would break `shouldCreateEntry` which relies on   *  `lastEntry` to reflect last comittable history state.   * We can't update `lastEntry` from within history when calling undo/redo   *  because the action potentially mutates appState/elements before storing   *  it.   */  setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {    this.lastEntry = this.hydrateHistoryEntry(      this.generateEntry(appState, elements),    );  }  // 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 default History;
 |