history.ts 7.5 KB

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