history.ts 7.3 KB

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