history.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import { AppState } from "./types";
  2. import { ExcalidrawElement } from "./element/types";
  3. import { isLinearElement } from "./element/typeChecks";
  4. import { deepCopyElement } from "./element/newElement";
  5. export interface HistoryEntry {
  6. appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
  7. elements: ExcalidrawElement[];
  8. }
  9. interface DehydratedExcalidrawElement {
  10. id: string;
  11. versionNonce: number;
  12. }
  13. interface DehydratedHistoryEntry {
  14. appState: string;
  15. elements: DehydratedExcalidrawElement[];
  16. }
  17. const clearAppStatePropertiesForHistory = (appState: AppState) => {
  18. return {
  19. selectedElementIds: appState.selectedElementIds,
  20. viewBackgroundColor: appState.viewBackgroundColor,
  21. name: appState.name,
  22. };
  23. };
  24. export class SceneHistory {
  25. private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
  26. private recording: boolean = true;
  27. private stateHistory: DehydratedHistoryEntry[] = [];
  28. private redoStack: DehydratedHistoryEntry[] = [];
  29. private lastEntry: HistoryEntry | null = null;
  30. private hydrateHistoryEntry({
  31. appState,
  32. elements,
  33. }: DehydratedHistoryEntry): HistoryEntry {
  34. return {
  35. appState: JSON.parse(appState),
  36. elements: elements.map((dehydratedExcalidrawElement) => {
  37. const element = this.elementCache
  38. .get(dehydratedExcalidrawElement.id)
  39. ?.get(dehydratedExcalidrawElement.versionNonce);
  40. if (!element) {
  41. throw new Error(
  42. `Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
  43. );
  44. }
  45. return element;
  46. }),
  47. };
  48. }
  49. private dehydrateHistoryEntry({
  50. appState,
  51. elements,
  52. }: HistoryEntry): DehydratedHistoryEntry {
  53. return {
  54. appState: JSON.stringify(appState),
  55. elements: elements.map((element: ExcalidrawElement) => {
  56. if (!this.elementCache.has(element.id)) {
  57. this.elementCache.set(element.id, new Map());
  58. }
  59. const versions = this.elementCache.get(element.id)!;
  60. if (!versions.has(element.versionNonce)) {
  61. versions.set(element.versionNonce, deepCopyElement(element));
  62. }
  63. return {
  64. id: element.id,
  65. versionNonce: element.versionNonce,
  66. };
  67. }),
  68. };
  69. }
  70. getSnapshotForTest() {
  71. return {
  72. recording: this.recording,
  73. stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
  74. this.hydrateHistoryEntry(dehydratedHistoryEntry),
  75. ),
  76. redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
  77. this.hydrateHistoryEntry(dehydratedHistoryEntry),
  78. ),
  79. };
  80. }
  81. clear() {
  82. this.stateHistory.length = 0;
  83. this.redoStack.length = 0;
  84. this.lastEntry = null;
  85. this.elementCache.clear();
  86. }
  87. private generateEntry = (
  88. appState: AppState,
  89. elements: readonly ExcalidrawElement[],
  90. ): DehydratedHistoryEntry =>
  91. this.dehydrateHistoryEntry({
  92. appState: clearAppStatePropertiesForHistory(appState),
  93. elements: elements.reduce((elements, element) => {
  94. if (
  95. isLinearElement(element) &&
  96. appState.multiElement &&
  97. appState.multiElement.id === element.id
  98. ) {
  99. // don't store multi-point arrow if still has only one point
  100. if (
  101. appState.multiElement &&
  102. appState.multiElement.id === element.id &&
  103. element.points.length < 2
  104. ) {
  105. return elements;
  106. }
  107. elements.push({
  108. ...element,
  109. // don't store last point if not committed
  110. points:
  111. element.lastCommittedPoint !==
  112. element.points[element.points.length - 1]
  113. ? element.points.slice(0, -1)
  114. : element.points,
  115. });
  116. } else {
  117. elements.push(element);
  118. }
  119. return elements;
  120. }, [] as Mutable<typeof elements>),
  121. });
  122. shouldCreateEntry(nextEntry: HistoryEntry): boolean {
  123. const { lastEntry } = this;
  124. if (!lastEntry) {
  125. return true;
  126. }
  127. if (nextEntry.elements.length !== lastEntry.elements.length) {
  128. return true;
  129. }
  130. // loop from right to left as changes are likelier to happen on new elements
  131. for (let i = nextEntry.elements.length - 1; i > -1; i--) {
  132. const prev = nextEntry.elements[i];
  133. const next = lastEntry.elements[i];
  134. if (
  135. !prev ||
  136. !next ||
  137. prev.id !== next.id ||
  138. prev.versionNonce !== next.versionNonce
  139. ) {
  140. return true;
  141. }
  142. }
  143. // note: this is safe because entry's appState is guaranteed no excess props
  144. let key: keyof typeof nextEntry.appState;
  145. for (key in nextEntry.appState) {
  146. if (key === "selectedElementIds") {
  147. continue;
  148. }
  149. if (nextEntry.appState[key] !== lastEntry.appState[key]) {
  150. return true;
  151. }
  152. }
  153. return false;
  154. }
  155. pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
  156. const newEntryDehydrated = this.generateEntry(appState, elements);
  157. const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
  158. if (newEntry) {
  159. if (!this.shouldCreateEntry(newEntry)) {
  160. return;
  161. }
  162. this.stateHistory.push(newEntryDehydrated);
  163. this.lastEntry = newEntry;
  164. // As a new entry was pushed, we invalidate the redo stack
  165. this.clearRedoStack();
  166. }
  167. }
  168. clearRedoStack() {
  169. this.redoStack.splice(0, this.redoStack.length);
  170. }
  171. redoOnce(): HistoryEntry | null {
  172. if (this.redoStack.length === 0) {
  173. return null;
  174. }
  175. const entryToRestore = this.redoStack.pop();
  176. if (entryToRestore !== undefined) {
  177. this.stateHistory.push(entryToRestore);
  178. return this.hydrateHistoryEntry(entryToRestore);
  179. }
  180. return null;
  181. }
  182. undoOnce(): HistoryEntry | null {
  183. if (this.stateHistory.length === 1) {
  184. return null;
  185. }
  186. const currentEntry = this.stateHistory.pop();
  187. const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
  188. if (currentEntry !== undefined) {
  189. this.redoStack.push(currentEntry);
  190. return this.hydrateHistoryEntry(entryToRestore);
  191. }
  192. return null;
  193. }
  194. /**
  195. * Updates history's `lastEntry` to latest app state. This is necessary
  196. * when doing undo/redo which itself doesn't commit to history, but updates
  197. * app state in a way that would break `shouldCreateEntry` which relies on
  198. * `lastEntry` to reflect last comittable history state.
  199. * We can't update `lastEntry` from within history when calling undo/redo
  200. * because the action potentially mutates appState/elements before storing
  201. * it.
  202. */
  203. setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
  204. this.lastEntry = this.hydrateHistoryEntry(
  205. this.generateEntry(appState, elements),
  206. );
  207. }
  208. // Suspicious that this is called so many places. Seems error-prone.
  209. resumeRecording() {
  210. this.recording = true;
  211. }
  212. record(state: AppState, elements: readonly ExcalidrawElement[]) {
  213. if (this.recording) {
  214. this.pushEntry(state, elements);
  215. this.recording = false;
  216. }
  217. }
  218. }
  219. export const createHistory: () => { history: SceneHistory } = () => {
  220. const history = new SceneHistory();
  221. return { history };
  222. };