history.ts 7.4 KB

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