Browse Source

feat: add `onIncrement` API (#9450)

Marcel Mraz 2 tháng trước cách đây
mục cha
commit
3dc54a724a
63 tập tin đã thay đổi với 3260 bổ sung2424 xóa
  1. 82 2
      excalidraw-app/tests/collab.test.tsx
  2. 1 1
      packages/common/src/emitter.ts
  3. 1 0
      packages/common/src/index.ts
  4. 5 0
      packages/common/src/utility-types.ts
  5. 19 0
      packages/common/src/utils.ts
  6. 5 12
      packages/element/src/Scene.ts
  7. 257 215
      packages/element/src/delta.ts
  8. 6 5
      packages/element/src/index.ts
  9. 2 2
      packages/element/src/linearElementEditor.ts
  10. 968 0
      packages/element/src/store.ts
  11. 143 0
      packages/element/tests/delta.test.tsx
  12. 2 1
      packages/excalidraw/actions/actionAddToLibrary.ts
  13. 2 1
      packages/excalidraw/actions/actionAlign.tsx
  14. 2 2
      packages/excalidraw/actions/actionBoundText.tsx
  15. 2 1
      packages/excalidraw/actions/actionCanvas.tsx
  16. 2 2
      packages/excalidraw/actions/actionClipboard.tsx
  17. 2 1
      packages/excalidraw/actions/actionCropEditor.tsx
  18. 2 1
      packages/excalidraw/actions/actionDeleteSelected.tsx
  19. 2 1
      packages/excalidraw/actions/actionDistribute.tsx
  20. 2 1
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  21. 2 1
      packages/excalidraw/actions/actionElementLink.ts
  22. 2 1
      packages/excalidraw/actions/actionElementLock.ts
  23. 2 1
      packages/excalidraw/actions/actionEmbeddable.ts
  24. 2 1
      packages/excalidraw/actions/actionExport.tsx
  25. 2 1
      packages/excalidraw/actions/actionFinalize.tsx
  26. 2 1
      packages/excalidraw/actions/actionFlip.ts
  27. 2 1
      packages/excalidraw/actions/actionFrame.ts
  28. 2 1
      packages/excalidraw/actions/actionGroup.tsx
  29. 15 17
      packages/excalidraw/actions/actionHistory.tsx
  30. 2 1
      packages/excalidraw/actions/actionLinearEditor.tsx
  31. 2 1
      packages/excalidraw/actions/actionLink.tsx
  32. 2 2
      packages/excalidraw/actions/actionMenu.tsx
  33. 2 1
      packages/excalidraw/actions/actionNavigate.tsx
  34. 4 2
      packages/excalidraw/actions/actionProperties.tsx
  35. 2 2
      packages/excalidraw/actions/actionSelectAll.ts
  36. 2 1
      packages/excalidraw/actions/actionStyles.ts
  37. 2 1
      packages/excalidraw/actions/actionTextAutoResize.ts
  38. 2 1
      packages/excalidraw/actions/actionToggleGridMode.tsx
  39. 2 1
      packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
  40. 2 1
      packages/excalidraw/actions/actionToggleSearchMenu.ts
  41. 2 1
      packages/excalidraw/actions/actionToggleShapeSwitch.tsx
  42. 2 1
      packages/excalidraw/actions/actionToggleStats.tsx
  43. 2 1
      packages/excalidraw/actions/actionToggleViewMode.tsx
  44. 2 1
      packages/excalidraw/actions/actionToggleZenMode.tsx
  45. 2 1
      packages/excalidraw/actions/actionZindex.tsx
  46. 2 1
      packages/excalidraw/actions/types.ts
  47. 72 75
      packages/excalidraw/components/App.tsx
  48. 2 1
      packages/excalidraw/components/Stats/DragInput.tsx
  49. 1 1
      packages/excalidraw/data/library.ts
  50. 68 94
      packages/excalidraw/history.ts
  51. 1 1
      packages/excalidraw/hooks/useEmitter.ts
  52. 3 1
      packages/excalidraw/index.tsx
  53. 0 449
      packages/excalidraw/store.ts
  54. 434 468
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  55. 0 0
      packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
  56. 543 545
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  57. 6 6
      packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
  58. 477 470
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  59. 3 1
      packages/excalidraw/tests/contextmenu.test.tsx
  60. 19 17
      packages/excalidraw/tests/history.test.tsx
  61. 3 1
      packages/excalidraw/tests/regressionTests.test.tsx
  62. 44 0
      packages/excalidraw/tests/test-utils.ts
  63. 10 1
      packages/excalidraw/types.ts

+ 82 - 2
excalidraw-app/tests/collab.test.tsx

@@ -8,6 +8,13 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
 import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
 import { vi } from "vitest";
 
+import { StoreIncrement } from "@excalidraw/element/store";
+
+import type {
+  DurableIncrement,
+  EphemeralIncrement,
+} from "@excalidraw/element/store";
+
 import ExcalidrawApp from "../App";
 
 const { h } = window;
@@ -65,6 +72,79 @@ vi.mock("socket.io-client", () => {
  * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
  */
 describe("collaboration", () => {
+  it("should emit two ephemeral increments even though updates get batched", async () => {
+    const durableIncrements: DurableIncrement[] = [];
+    const ephemeralIncrements: EphemeralIncrement[] = [];
+
+    await render(<ExcalidrawApp />);
+
+    h.store.onStoreIncrementEmitter.on((increment) => {
+      if (StoreIncrement.isDurable(increment)) {
+        durableIncrements.push(increment);
+      } else {
+        ephemeralIncrements.push(increment);
+      }
+    });
+
+    // eslint-disable-next-line dot-notation
+    expect(h.store["scheduledMicroActions"].length).toBe(0);
+    expect(durableIncrements.length).toBe(0);
+    expect(ephemeralIncrements.length).toBe(0);
+
+    const rectProps = {
+      type: "rectangle",
+      id: "A",
+      height: 200,
+      width: 100,
+      x: 0,
+      y: 0,
+    } as const;
+
+    const rect = API.createElement({ ...rectProps });
+
+    API.updateScene({
+      elements: [rect],
+      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+    });
+
+    await waitFor(() => {
+      // expect(commitSpy).toHaveBeenCalledTimes(1);
+      expect(durableIncrements.length).toBe(1);
+    });
+
+    // simulate two batched remote updates
+    act(() => {
+      h.app.updateScene({
+        elements: [newElementWith(h.elements[0], { x: 100 })],
+        captureUpdate: CaptureUpdateAction.NEVER,
+      });
+      h.app.updateScene({
+        elements: [newElementWith(h.elements[0], { x: 200 })],
+        captureUpdate: CaptureUpdateAction.NEVER,
+      });
+
+      // we scheduled two micro actions,
+      // which confirms they are going to be executed as part of one batched component update
+      // eslint-disable-next-line dot-notation
+      expect(h.store["scheduledMicroActions"].length).toBe(2);
+    });
+
+    await waitFor(() => {
+      // altough the updates get batched,
+      // we expect two ephemeral increments for each update,
+      // and each such update should have the expected change
+      expect(ephemeralIncrements.length).toBe(2);
+      expect(ephemeralIncrements[0].change.elements.A).toEqual(
+        expect.objectContaining({ x: 100 }),
+      );
+      expect(ephemeralIncrements[1].change.elements.A).toEqual(
+        expect.objectContaining({ x: 200 }),
+      );
+      // eslint-disable-next-line dot-notation
+      expect(h.store["scheduledMicroActions"].length).toBe(0);
+    });
+  });
+
   it("should allow to undo / redo even on force-deleted elements", async () => {
     await render(<ExcalidrawApp />);
     const rect1Props = {
@@ -122,7 +202,7 @@ describe("collaboration", () => {
       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
     });
 
-    const undoAction = createUndoAction(h.history, h.store);
+    const undoAction = createUndoAction(h.history);
     act(() => h.app.actionManager.executeAction(undoAction));
 
     // with explicit undo (as addition) we expect our item to be restored from the snapshot!
@@ -154,7 +234,7 @@ describe("collaboration", () => {
       expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
     });
 
-    const redoAction = createRedoAction(h.history, h.store);
+    const redoAction = createRedoAction(h.history);
     act(() => h.app.actionManager.executeAction(redoAction));
 
     // with explicit redo (as removal) we again restore the element from the snapshot!

+ 1 - 1
packages/excalidraw/emitter.ts → packages/common/src/emitter.ts

@@ -1,4 +1,4 @@
-import type { UnsubscribeCallback } from "./types";
+import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
 
 type Subscriber<T extends any[]> = (...payload: T) => void;
 

+ 1 - 0
packages/common/src/index.ts

@@ -9,3 +9,4 @@ export * from "./promise-pool";
 export * from "./random";
 export * from "./url";
 export * from "./utils";
+export * from "./emitter";

+ 5 - 0
packages/common/src/utility-types.ts

@@ -68,3 +68,8 @@ export type MaybePromise<T> = T | Promise<T>;
 
 // get union of all keys from the union of types
 export type AllPossibleKeys<T> = T extends any ? keyof T : never;
+
+/** Strip all the methods or functions from a type */
+export type DTO<T> = {
+  [K in keyof T as T[K] extends Function ? never : K]: T[K];
+};

+ 19 - 0
packages/common/src/utils.ts

@@ -735,6 +735,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
     return acc;
   }, [] as Node<T>[]);
 
+/**
+ * Converts a readonly array or map into an iterable.
+ * Useful for avoiding entry allocations when iterating object / map on each iteration.
+ */
+export const toIterable = <T>(
+  values: readonly T[] | ReadonlyMap<string, T>,
+): Iterable<T> => {
+  return Array.isArray(values) ? values : values.values();
+};
+
+/**
+ * Converts a readonly array or map into an array.
+ */
+export const toArray = <T>(
+  values: readonly T[] | ReadonlyMap<string, T>,
+): T[] => {
+  return Array.isArray(values) ? values : Array.from(toIterable(values));
+};
+
 export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
 
 export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;

+ 5 - 12
packages/element/src/Scene.ts

@@ -6,14 +6,13 @@ import {
   toBrandedType,
   isDevEnv,
   isTestEnv,
-  isReadonlyArray,
+  toArray,
 } from "@excalidraw/common";
 import { isNonDeletedElement } from "@excalidraw/element";
 import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
 import { getElementsInGroup } from "@excalidraw/element/groups";
 
 import {
-  orderByFractionalIndex,
   syncInvalidIndices,
   syncMovedIndices,
   validateFractionalIndices,
@@ -268,19 +267,13 @@ class Scene {
   }
 
   replaceAllElements(nextElements: ElementsMapOrArray) {
-    // ts doesn't like `Array.isArray` of `instanceof Map`
-    if (!isReadonlyArray(nextElements)) {
-      // need to order by fractional indices to get the correct order
-      nextElements = orderByFractionalIndex(
-        Array.from(nextElements.values()) as OrderedExcalidrawElement[],
-      );
-    }
-
+    // we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
+    const _nextElements = toArray(nextElements);
     const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
 
-    validateIndicesThrottled(nextElements);
+    validateIndicesThrottled(_nextElements);
 
-    this.elements = syncInvalidIndices(nextElements);
+    this.elements = syncInvalidIndices(_nextElements);
     this.elementsMap.clear();
     this.elements.forEach((element) => {
       if (isFrameLikeElement(element)) {

+ 257 - 215
packages/excalidraw/change.ts → packages/element/src/delta.ts

@@ -5,43 +5,7 @@ import {
   isDevEnv,
   isShallowEqual,
   isTestEnv,
-  toBrandedType,
 } from "@excalidraw/common";
-import {
-  BoundElement,
-  BindableElement,
-  bindingProperties,
-  updateBoundElements,
-} from "@excalidraw/element/binding";
-import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
-import {
-  mutateElement,
-  newElementWith,
-} from "@excalidraw/element/mutateElement";
-import {
-  getBoundTextElementId,
-  redrawTextBoundingBox,
-} from "@excalidraw/element/textElement";
-import {
-  hasBoundTextElement,
-  isBindableElement,
-  isBoundToContainer,
-  isImageElement,
-  isTextElement,
-} from "@excalidraw/element/typeChecks";
-
-import { getNonDeletedGroupIds } from "@excalidraw/element/groups";
-
-import {
-  orderByFractionalIndex,
-  syncMovedIndices,
-} from "@excalidraw/element/fractionalIndex";
-
-import Scene from "@excalidraw/element/Scene";
-
-import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
-
-import type { ElementUpdate } from "@excalidraw/element/mutateElement";
 
 import type {
   ExcalidrawElement,
@@ -54,16 +18,42 @@ import type {
   SceneElementsMap,
 } from "@excalidraw/element/types";
 
-import type { SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
-
-import { getObservedAppState } from "./store";
+import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
 
 import type {
   AppState,
   ObservedAppState,
   ObservedElementsAppState,
   ObservedStandaloneAppState,
-} from "./types";
+} from "@excalidraw/excalidraw/types";
+
+import { getObservedAppState } from "./store";
+
+import {
+  BoundElement,
+  BindableElement,
+  bindingProperties,
+  updateBoundElements,
+} from "./binding";
+import { LinearElementEditor } from "./linearElementEditor";
+import { mutateElement, newElementWith } from "./mutateElement";
+import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
+import {
+  hasBoundTextElement,
+  isBindableElement,
+  isBoundToContainer,
+  isTextElement,
+} from "./typeChecks";
+
+import { getNonDeletedGroupIds } from "./groups";
+
+import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
+
+import Scene from "./Scene";
+
+import type { BindableProp, BindingProp } from "./binding";
+
+import type { ElementUpdate } from "./mutateElement";
 
 /**
  * Represents the difference between two objects of the same type.
@@ -74,7 +64,7 @@ import type {
  *
  * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load.
  */
-class Delta<T> {
+export class Delta<T> {
   private constructor(
     public readonly deleted: Partial<T>,
     public readonly inserted: Partial<T>,
@@ -326,7 +316,7 @@ class Delta<T> {
   }
 
   /**
-   * Returns all the object1 keys that have distinct values.
+   * Returns sorted object1 keys that have distinct values.
    */
   public static getLeftDifferences<T extends {}>(
     object1: T,
@@ -335,11 +325,11 @@ class Delta<T> {
   ) {
     return Array.from(
       this.distinctKeysIterator("left", object1, object2, skipShallowCompare),
-    );
+    ).sort();
   }
 
   /**
-   * Returns all the object2 keys that have distinct values.
+   * Returns sorted object2 keys that have distinct values.
    */
   public static getRightDifferences<T extends {}>(
     object1: T,
@@ -348,7 +338,7 @@ class Delta<T> {
   ) {
     return Array.from(
       this.distinctKeysIterator("right", object1, object2, skipShallowCompare),
-    );
+    ).sort();
   }
 
   /**
@@ -409,51 +399,57 @@ class Delta<T> {
 }
 
 /**
- * Encapsulates the modifications captured as `Delta`/s.
+ * Encapsulates a set of application-level `Delta`s.
  */
-interface Change<T> {
+export interface DeltaContainer<T> {
   /**
-   * Inverses the `Delta`s inside while creating a new `Change`.
+   * Inverses the `Delta`s while creating a new `DeltaContainer` instance.
    */
-  inverse(): Change<T>;
+  inverse(): DeltaContainer<T>;
 
   /**
-   * Applies the `Change` to the previous object.
+   * Applies the `Delta`s to the previous object.
    *
-   * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change.
+   * @returns a tuple of the next object `T` with applied `Delta`s, and `boolean`, indicating whether the applied deltas resulted in a visible change.
    */
   applyTo(previous: T, ...options: unknown[]): [T, boolean];
 
   /**
-   * Checks whether there are actually `Delta`s.
+   * Checks whether all `Delta`s are empty.
    */
   isEmpty(): boolean;
 }
 
-export class AppStateChange implements Change<AppState> {
-  private constructor(private readonly delta: Delta<ObservedAppState>) {}
+export class AppStateDelta implements DeltaContainer<AppState> {
+  private constructor(public readonly delta: Delta<ObservedAppState>) {}
 
   public static calculate<T extends ObservedAppState>(
     prevAppState: T,
     nextAppState: T,
-  ): AppStateChange {
+  ): AppStateDelta {
     const delta = Delta.calculate(
       prevAppState,
       nextAppState,
-      undefined,
-      AppStateChange.postProcess,
+      // making the order of keys in deltas stable for hashing purposes
+      AppStateDelta.orderAppStateKeys,
+      AppStateDelta.postProcess,
     );
 
-    return new AppStateChange(delta);
+    return new AppStateDelta(delta);
+  }
+
+  public static restore(appStateDeltaDTO: DTO<AppStateDelta>): AppStateDelta {
+    const { delta } = appStateDeltaDTO;
+    return new AppStateDelta(delta);
   }
 
   public static empty() {
-    return new AppStateChange(Delta.create({}, {}));
+    return new AppStateDelta(Delta.create({}, {}));
   }
 
-  public inverse(): AppStateChange {
+  public inverse(): AppStateDelta {
     const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted);
-    return new AppStateChange(inversedDelta);
+    return new AppStateDelta(inversedDelta);
   }
 
   public applyTo(
@@ -544,40 +540,6 @@ export class AppStateChange implements Change<AppState> {
     return Delta.isEmpty(this.delta);
   }
 
-  /**
-   * It is necessary to post process the partials in case of reference values,
-   * for which we need to calculate the real diff between `deleted` and `inserted`.
-   */
-  private static postProcess<T extends ObservedAppState>(
-    deleted: Partial<T>,
-    inserted: Partial<T>,
-  ): [Partial<T>, Partial<T>] {
-    try {
-      Delta.diffObjects(
-        deleted,
-        inserted,
-        "selectedElementIds",
-        // ts language server has a bit trouble resolving this, so we are giving it a little push
-        (_) => true as ValueOf<T["selectedElementIds"]>,
-      );
-      Delta.diffObjects(
-        deleted,
-        inserted,
-        "selectedGroupIds",
-        (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
-      );
-    } catch (e) {
-      // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
-      console.error(`Couldn't postprocess appstate change deltas.`);
-
-      if (isTestEnv() || isDevEnv()) {
-        throw e;
-      }
-    } finally {
-      return [deleted, inserted];
-    }
-  }
-
   /**
    * Mutates `nextAppState` be filtering out state related to deleted elements.
    *
@@ -594,13 +556,13 @@ export class AppStateChange implements Change<AppState> {
     const nextObservedAppState = getObservedAppState(nextAppState);
 
     const containsStandaloneDifference = Delta.isRightDifferent(
-      AppStateChange.stripElementsProps(prevObservedAppState),
-      AppStateChange.stripElementsProps(nextObservedAppState),
+      AppStateDelta.stripElementsProps(prevObservedAppState),
+      AppStateDelta.stripElementsProps(nextObservedAppState),
     );
 
     const containsElementsDifference = Delta.isRightDifferent(
-      AppStateChange.stripStandaloneProps(prevObservedAppState),
-      AppStateChange.stripStandaloneProps(nextObservedAppState),
+      AppStateDelta.stripStandaloneProps(prevObservedAppState),
+      AppStateDelta.stripStandaloneProps(nextObservedAppState),
     );
 
     if (!containsStandaloneDifference && !containsElementsDifference) {
@@ -615,8 +577,8 @@ export class AppStateChange implements Change<AppState> {
     if (containsElementsDifference) {
       // filter invisible changes on each iteration
       const changedElementsProps = Delta.getRightDifferences(
-        AppStateChange.stripStandaloneProps(prevObservedAppState),
-        AppStateChange.stripStandaloneProps(nextObservedAppState),
+        AppStateDelta.stripStandaloneProps(prevObservedAppState),
+        AppStateDelta.stripStandaloneProps(nextObservedAppState),
       ) as Array<keyof ObservedElementsAppState>;
 
       let nonDeletedGroupIds = new Set<string>();
@@ -633,7 +595,7 @@ export class AppStateChange implements Change<AppState> {
       for (const key of changedElementsProps) {
         switch (key) {
           case "selectedElementIds":
-            nextAppState[key] = AppStateChange.filterSelectedElements(
+            nextAppState[key] = AppStateDelta.filterSelectedElements(
               nextAppState[key],
               nextElements,
               visibleDifferenceFlag,
@@ -641,7 +603,7 @@ export class AppStateChange implements Change<AppState> {
 
             break;
           case "selectedGroupIds":
-            nextAppState[key] = AppStateChange.filterSelectedGroups(
+            nextAppState[key] = AppStateDelta.filterSelectedGroups(
               nextAppState[key],
               nonDeletedGroupIds,
               visibleDifferenceFlag,
@@ -677,7 +639,7 @@ export class AppStateChange implements Change<AppState> {
             break;
           case "selectedLinearElementId":
           case "editingLinearElementId":
-            const appStateKey = AppStateChange.convertToAppStateKey(key);
+            const appStateKey = AppStateDelta.convertToAppStateKey(key);
             const linearElement = nextAppState[appStateKey];
 
             if (!linearElement) {
@@ -812,6 +774,51 @@ export class AppStateChange implements Change<AppState> {
       ObservedElementsAppState
     >;
   }
+
+  /**
+   * It is necessary to post process the partials in case of reference values,
+   * for which we need to calculate the real diff between `deleted` and `inserted`.
+   */
+  private static postProcess<T extends ObservedAppState>(
+    deleted: Partial<T>,
+    inserted: Partial<T>,
+  ): [Partial<T>, Partial<T>] {
+    try {
+      Delta.diffObjects(
+        deleted,
+        inserted,
+        "selectedElementIds",
+        // ts language server has a bit trouble resolving this, so we are giving it a little push
+        (_) => true as ValueOf<T["selectedElementIds"]>,
+      );
+      Delta.diffObjects(
+        deleted,
+        inserted,
+        "selectedGroupIds",
+        (prevValue) => (prevValue ?? false) as ValueOf<T["selectedGroupIds"]>,
+      );
+    } catch (e) {
+      // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
+      console.error(`Couldn't postprocess appstate change deltas.`);
+
+      if (isTestEnv() || isDevEnv()) {
+        throw e;
+      }
+    } finally {
+      return [deleted, inserted];
+    }
+  }
+
+  private static orderAppStateKeys(partial: Partial<ObservedAppState>) {
+    const orderedPartial: { [key: string]: unknown } = {};
+
+    for (const key of Object.keys(partial).sort()) {
+      // relying on insertion order
+      orderedPartial[key] = partial[key as keyof ObservedAppState];
+    }
+
+    return orderedPartial as Partial<ObservedAppState>;
+  }
 }
 
 type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
@@ -823,50 +830,63 @@ type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
  * Elements change is a low level primitive to capture a change between two sets of elements.
  * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions.
  */
-export class ElementsChange implements Change<SceneElementsMap> {
+export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private constructor(
-    private readonly added: Map<string, Delta<ElementPartial>>,
-    private readonly removed: Map<string, Delta<ElementPartial>>,
-    private readonly updated: Map<string, Delta<ElementPartial>>,
+    public readonly added: Record<string, Delta<ElementPartial>>,
+    public readonly removed: Record<string, Delta<ElementPartial>>,
+    public readonly updated: Record<string, Delta<ElementPartial>>,
   ) {}
 
   public static create(
-    added: Map<string, Delta<ElementPartial>>,
-    removed: Map<string, Delta<ElementPartial>>,
-    updated: Map<string, Delta<ElementPartial>>,
-    options = { shouldRedistribute: false },
+    added: Record<string, Delta<ElementPartial>>,
+    removed: Record<string, Delta<ElementPartial>>,
+    updated: Record<string, Delta<ElementPartial>>,
+    options: {
+      shouldRedistribute: boolean;
+    } = {
+      shouldRedistribute: false,
+    },
   ) {
-    let change: ElementsChange;
+    let delta: ElementsDelta;
 
     if (options.shouldRedistribute) {
-      const nextAdded = new Map<string, Delta<ElementPartial>>();
-      const nextRemoved = new Map<string, Delta<ElementPartial>>();
-      const nextUpdated = new Map<string, Delta<ElementPartial>>();
+      const nextAdded: Record<string, Delta<ElementPartial>> = {};
+      const nextRemoved: Record<string, Delta<ElementPartial>> = {};
+      const nextUpdated: Record<string, Delta<ElementPartial>> = {};
 
-      const deltas = [...added, ...removed, ...updated];
+      const deltas = [
+        ...Object.entries(added),
+        ...Object.entries(removed),
+        ...Object.entries(updated),
+      ];
 
       for (const [id, delta] of deltas) {
         if (this.satisfiesAddition(delta)) {
-          nextAdded.set(id, delta);
+          nextAdded[id] = delta;
         } else if (this.satisfiesRemoval(delta)) {
-          nextRemoved.set(id, delta);
+          nextRemoved[id] = delta;
         } else {
-          nextUpdated.set(id, delta);
+          nextUpdated[id] = delta;
         }
       }
 
-      change = new ElementsChange(nextAdded, nextRemoved, nextUpdated);
+      delta = new ElementsDelta(nextAdded, nextRemoved, nextUpdated);
     } else {
-      change = new ElementsChange(added, removed, updated);
+      delta = new ElementsDelta(added, removed, updated);
     }
 
     if (isTestEnv() || isDevEnv()) {
-      ElementsChange.validate(change, "added", this.satisfiesAddition);
-      ElementsChange.validate(change, "removed", this.satisfiesRemoval);
-      ElementsChange.validate(change, "updated", this.satisfiesUpdate);
+      ElementsDelta.validate(delta, "added", this.satisfiesAddition);
+      ElementsDelta.validate(delta, "removed", this.satisfiesRemoval);
+      ElementsDelta.validate(delta, "updated", this.satisfiesUpdate);
     }
 
-    return change;
+    return delta;
+  }
+
+  public static restore(elementsDeltaDTO: DTO<ElementsDelta>): ElementsDelta {
+    const { added, removed, updated } = elementsDeltaDTO;
+    return ElementsDelta.create(added, removed, updated);
   }
 
   private static satisfiesAddition = ({
@@ -888,17 +908,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
   }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
 
   private static validate(
-    change: ElementsChange,
+    elementsDelta: ElementsDelta,
     type: "added" | "removed" | "updated",
     satifies: (delta: Delta<ElementPartial>) => boolean,
   ) {
-    for (const [id, delta] of change[type].entries()) {
+    for (const [id, delta] of Object.entries(elementsDelta[type])) {
       if (!satifies(delta)) {
         console.error(
           `Broken invariant for "${type}" delta, element "${id}", delta:`,
           delta,
         );
-        throw new Error(`ElementsChange invariant broken for element "${id}".`);
+        throw new Error(`ElementsDelta invariant broken for element "${id}".`);
       }
     }
   }
@@ -909,19 +929,19 @@ export class ElementsChange implements Change<SceneElementsMap> {
    * @param prevElements - Map representing the previous state of elements.
    * @param nextElements - Map representing the next state of elements.
    *
-   * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
+   * @returns `ElementsDelta` instance representing the `Delta` changes between the two sets of elements.
    */
   public static calculate<T extends OrderedExcalidrawElement>(
     prevElements: Map<string, T>,
     nextElements: Map<string, T>,
-  ): ElementsChange {
+  ): ElementsDelta {
     if (prevElements === nextElements) {
-      return ElementsChange.empty();
+      return ElementsDelta.empty();
     }
 
-    const added = new Map<string, Delta<ElementPartial>>();
-    const removed = new Map<string, Delta<ElementPartial>>();
-    const updated = new Map<string, Delta<ElementPartial>>();
+    const added: Record<string, Delta<ElementPartial>> = {};
+    const removed: Record<string, Delta<ElementPartial>> = {};
+    const updated: Record<string, Delta<ElementPartial>> = {};
 
     // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements
     for (const prevElement of prevElements.values()) {
@@ -934,10 +954,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
         const delta = Delta.create(
           deleted,
           inserted,
-          ElementsChange.stripIrrelevantProps,
+          ElementsDelta.stripIrrelevantProps,
         );
 
-        removed.set(prevElement.id, delta);
+        removed[prevElement.id] = delta;
       }
     }
 
@@ -954,10 +974,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
         const delta = Delta.create(
           deleted,
           inserted,
-          ElementsChange.stripIrrelevantProps,
+          ElementsDelta.stripIrrelevantProps,
         );
 
-        added.set(nextElement.id, delta);
+        added[nextElement.id] = delta;
 
         continue;
       }
@@ -966,8 +986,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
         const delta = Delta.calculate<ElementPartial>(
           prevElement,
           nextElement,
-          ElementsChange.stripIrrelevantProps,
-          ElementsChange.postProcess,
+          ElementsDelta.stripIrrelevantProps,
+          ElementsDelta.postProcess,
         );
 
         if (
@@ -978,9 +998,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
         ) {
           // notice that other props could have been updated as well
           if (prevElement.isDeleted && !nextElement.isDeleted) {
-            added.set(nextElement.id, delta);
+            added[nextElement.id] = delta;
           } else {
-            removed.set(nextElement.id, delta);
+            removed[nextElement.id] = delta;
           }
 
           continue;
@@ -988,24 +1008,24 @@ export class ElementsChange implements Change<SceneElementsMap> {
 
         // making sure there are at least some changes
         if (!Delta.isEmpty(delta)) {
-          updated.set(nextElement.id, delta);
+          updated[nextElement.id] = delta;
         }
       }
     }
 
-    return ElementsChange.create(added, removed, updated);
+    return ElementsDelta.create(added, removed, updated);
   }
 
   public static empty() {
-    return ElementsChange.create(new Map(), new Map(), new Map());
+    return ElementsDelta.create({}, {}, {});
   }
 
-  public inverse(): ElementsChange {
-    const inverseInternal = (deltas: Map<string, Delta<ElementPartial>>) => {
-      const inversedDeltas = new Map<string, Delta<ElementPartial>>();
+  public inverse(): ElementsDelta {
+    const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
+      const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
 
-      for (const [id, delta] of deltas.entries()) {
-        inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted));
+      for (const [id, delta] of Object.entries(deltas)) {
+        inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
       }
 
       return inversedDeltas;
@@ -1016,14 +1036,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
     const updated = inverseInternal(this.updated);
 
     // notice we inverse removed with added not to break the invariants
-    return ElementsChange.create(removed, added, updated);
+    return ElementsDelta.create(removed, added, updated);
   }
 
   public isEmpty(): boolean {
     return (
-      this.added.size === 0 &&
-      this.removed.size === 0 &&
-      this.updated.size === 0
+      Object.keys(this.added).length === 0 &&
+      Object.keys(this.removed).length === 0 &&
+      Object.keys(this.updated).length === 0
     );
   }
 
@@ -1034,7 +1054,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
    * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
    * @returns new instance with modified delta/s
    */
-  public applyLatestChanges(elements: SceneElementsMap): ElementsChange {
+  public applyLatestChanges(
+    elements: SceneElementsMap,
+    modifierOptions: "deleted" | "inserted",
+  ): ElementsDelta {
     const modifier =
       (element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
         const latestPartial: { [key: string]: unknown } = {};
@@ -1055,11 +1078,11 @@ export class ElementsChange implements Change<SceneElementsMap> {
       };
 
     const applyLatestChangesInternal = (
-      deltas: Map<string, Delta<ElementPartial>>,
+      deltas: Record<string, Delta<ElementPartial>>,
     ) => {
-      const modifiedDeltas = new Map<string, Delta<ElementPartial>>();
+      const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
 
-      for (const [id, delta] of deltas.entries()) {
+      for (const [id, delta] of Object.entries(deltas)) {
         const existingElement = elements.get(id);
 
         if (existingElement) {
@@ -1067,12 +1090,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
             delta.deleted,
             delta.inserted,
             modifier(existingElement),
-            "inserted",
+            modifierOptions,
           );
 
-          modifiedDeltas.set(id, modifiedDelta);
+          modifiedDeltas[id] = modifiedDelta;
         } else {
-          modifiedDeltas.set(id, delta);
+          modifiedDeltas[id] = delta;
         }
       }
 
@@ -1083,16 +1106,16 @@ export class ElementsChange implements Change<SceneElementsMap> {
     const removed = applyLatestChangesInternal(this.removed);
     const updated = applyLatestChangesInternal(this.updated);
 
-    return ElementsChange.create(added, removed, updated, {
+    return ElementsDelta.create(added, removed, updated, {
       shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated
     });
   }
 
   public applyTo(
     elements: SceneElementsMap,
-    snapshot: Map<string, OrderedExcalidrawElement>,
+    elementsSnapshot: Map<string, OrderedExcalidrawElement>,
   ): [SceneElementsMap, boolean] {
-    let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
+    let nextElements = new Map(elements) as SceneElementsMap;
     let changedElements: Map<string, OrderedExcalidrawElement>;
 
     const flags = {
@@ -1102,15 +1125,15 @@ export class ElementsChange implements Change<SceneElementsMap> {
 
     // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
     try {
-      const applyDeltas = ElementsChange.createApplier(
+      const applyDeltas = ElementsDelta.createApplier(
         nextElements,
-        snapshot,
+        elementsSnapshot,
         flags,
       );
 
-      const addedElements = applyDeltas(this.added);
-      const removedElements = applyDeltas(this.removed);
-      const updatedElements = applyDeltas(this.updated);
+      const addedElements = applyDeltas("added", this.added);
+      const removedElements = applyDeltas("removed", this.removed);
+      const updatedElements = applyDeltas("updated", this.updated);
 
       const affectedElements = this.resolveConflicts(elements, nextElements);
 
@@ -1122,7 +1145,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
         ...affectedElements,
       ]);
     } catch (e) {
-      console.error(`Couldn't apply elements change`, e);
+      console.error(`Couldn't apply elements delta`, e);
 
       if (isTestEnv() || isDevEnv()) {
         throw e;
@@ -1138,7 +1161,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
     try {
       // the following reorder performs also mutations, but only on new instances of changed elements
       // (unless something goes really bad and it fallbacks to fixing all invalid indices)
-      nextElements = ElementsChange.reorderElements(
+      nextElements = ElementsDelta.reorderElements(
         nextElements,
         changedElements,
         flags,
@@ -1149,9 +1172,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
       // so we are creating a temp scene just to query and mutate elements
       const tempScene = new Scene(nextElements);
 
-      ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
+      ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
       // Need ordered nextElements to avoid z-index binding issues
-      ElementsChange.redrawBoundArrows(tempScene, changedElements);
+      ElementsDelta.redrawBoundArrows(tempScene, changedElements);
     } catch (e) {
       console.error(
         `Couldn't mutate elements after applying elements change`,
@@ -1166,36 +1189,42 @@ export class ElementsChange implements Change<SceneElementsMap> {
     }
   }
 
-  private static createApplier = (
-    nextElements: SceneElementsMap,
-    snapshot: Map<string, OrderedExcalidrawElement>,
-    flags: {
-      containsVisibleDifference: boolean;
-      containsZindexDifference: boolean;
-    },
-  ) => {
-    const getElement = ElementsChange.createGetter(
-      nextElements,
-      snapshot,
-      flags,
-    );
+  private static createApplier =
+    (
+      nextElements: SceneElementsMap,
+      snapshot: Map<string, OrderedExcalidrawElement>,
+      flags: {
+        containsVisibleDifference: boolean;
+        containsZindexDifference: boolean;
+      },
+    ) =>
+    (
+      type: "added" | "removed" | "updated",
+      deltas: Record<string, Delta<ElementPartial>>,
+    ) => {
+      const getElement = ElementsDelta.createGetter(
+        type,
+        nextElements,
+        snapshot,
+        flags,
+      );
 
-    return (deltas: Map<string, Delta<ElementPartial>>) =>
-      Array.from(deltas.entries()).reduce((acc, [id, delta]) => {
+      return Object.entries(deltas).reduce((acc, [id, delta]) => {
         const element = getElement(id, delta.inserted);
 
         if (element) {
-          const newElement = ElementsChange.applyDelta(element, delta, flags);
+          const newElement = ElementsDelta.applyDelta(element, delta, flags);
           nextElements.set(newElement.id, newElement);
           acc.set(newElement.id, newElement);
         }
 
         return acc;
       }, new Map<string, OrderedExcalidrawElement>());
-  };
+    };
 
   private static createGetter =
     (
+      type: "added" | "removed" | "updated",
       elements: SceneElementsMap,
       snapshot: Map<string, OrderedExcalidrawElement>,
       flags: {
@@ -1221,6 +1250,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
           ) {
             flags.containsVisibleDifference = true;
           }
+        } else {
+          // not in elements, not in snapshot? element might have been added remotely!
+          element = newElementWith(
+            { id, version: 1 } as OrderedExcalidrawElement,
+            {
+              ...partial,
+            },
+          );
         }
       }
 
@@ -1257,7 +1294,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
       });
     }
 
-    if (isImageElement(element)) {
+    // TODO: this looks wrong, shouldn't be here
+    if (element.type === "image") {
       const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
       // we want to override `crop` only if modified so that we don't reset
       // when undoing/redoing unrelated change
@@ -1270,10 +1308,12 @@ export class ElementsChange implements Change<SceneElementsMap> {
     }
 
     if (!flags.containsVisibleDifference) {
-      // strip away fractional as even if it would be different, it doesn't have to result in visible change
+      // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
       const { index, ...rest } = directlyApplicablePartial;
-      const containsVisibleDifference =
-        ElementsChange.checkForVisibleDifference(element, rest);
+      const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
+        element,
+        rest,
+      );
 
       flags.containsVisibleDifference = containsVisibleDifference;
     }
@@ -1316,6 +1356,8 @@ export class ElementsChange implements Change<SceneElementsMap> {
    * Resolves conflicts for all previously added, removed and updated elements.
    * Updates the previous deltas with all the changes after conflict resolution.
    *
+   * // TODO: revisit since some bound arrows seem to be often redrawn incorrectly
+   *
    * @returns all elements affected by the conflict resolution
    */
   private resolveConflicts(
@@ -1346,7 +1388,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
           nextElement,
           nextElements,
           updates as ElementUpdate<OrderedExcalidrawElement>,
-        ) as OrderedExcalidrawElement;
+        );
       }
 
       nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1354,20 +1396,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
     };
 
     // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound
-    for (const [id] of this.removed) {
-      ElementsChange.unbindAffected(prevElements, nextElements, id, updater);
+    for (const id of Object.keys(this.removed)) {
+      ElementsDelta.unbindAffected(prevElements, nextElements, id, updater);
     }
 
     // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound
-    for (const [id] of this.added) {
-      ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
+    for (const id of Object.keys(this.added)) {
+      ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
     }
 
     // updated delta is affecting the binding only in case it contains changed binding or bindable property
-    for (const [id] of Array.from(this.updated).filter(([_, delta]) =>
-      Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
-        bindingProperties.has(prop as BindingProp | BindableProp),
-      ),
+    for (const [id] of Array.from(Object.entries(this.updated)).filter(
+      ([_, delta]) =>
+        Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) =>
+          bindingProperties.has(prop as BindingProp | BindableProp),
+        ),
     )) {
       const updatedElement = nextElements.get(id);
       if (!updatedElement || updatedElement.isDeleted) {
@@ -1375,7 +1418,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
         continue;
       }
 
-      ElementsChange.rebindAffected(prevElements, nextElements, id, updater);
+      ElementsDelta.rebindAffected(prevElements, nextElements, id, updater);
     }
 
     // filter only previous elements, which were now affected
@@ -1385,21 +1428,21 @@ export class ElementsChange implements Change<SceneElementsMap> {
 
     // calculate complete deltas for affected elements, and assign them back to all the deltas
     // technically we could do better here if perf. would become an issue
-    const { added, removed, updated } = ElementsChange.calculate(
+    const { added, removed, updated } = ElementsDelta.calculate(
       prevAffectedElements,
       nextAffectedElements,
     );
 
-    for (const [id, delta] of added) {
-      this.added.set(id, delta);
+    for (const [id, delta] of Object.entries(added)) {
+      this.added[id] = delta;
     }
 
-    for (const [id, delta] of removed) {
-      this.removed.set(id, delta);
+    for (const [id, delta] of Object.entries(removed)) {
+      this.removed[id] = delta;
     }
 
-    for (const [id, delta] of updated) {
-      this.updated.set(id, delta);
+    for (const [id, delta] of Object.entries(updated)) {
+      this.updated[id] = delta;
     }
 
     return nextAffectedElements;
@@ -1572,7 +1615,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
       Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
     } catch (e) {
       // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
-      console.error(`Couldn't postprocess elements change deltas.`);
+      console.error(`Couldn't postprocess elements delta.`);
 
       if (isTestEnv() || isDevEnv()) {
         throw e;
@@ -1585,8 +1628,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
   private static stripIrrelevantProps(
     partial: Partial<OrderedExcalidrawElement>,
   ): ElementPartial {
-    const { id, updated, version, versionNonce, seed, ...strippedPartial } =
-      partial;
+    const { id, updated, version, versionNonce, ...strippedPartial } = partial;
 
     return strippedPartial;
   }

+ 6 - 5
packages/element/src/index.ts

@@ -1,3 +1,5 @@
+import { toIterable } from "@excalidraw/common";
+
 import { isInvisiblySmallElement } from "./sizeHelpers";
 import { isLinearElementType } from "./typeChecks";
 
@@ -5,6 +7,7 @@ import type {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeleted,
+  ElementsMapOrArray,
 } from "./types";
 
 /**
@@ -16,12 +19,10 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
 /**
  * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
  */
-export const hashElementsVersion = (
-  elements: readonly ExcalidrawElement[],
-): number => {
+export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
   let hash = 5381;
-  for (let i = 0; i < elements.length; i++) {
-    hash = (hash << 5) + hash + elements[i].versionNonce;
+  for (const element of toIterable(elements)) {
+    hash = (hash << 5) + hash + element.versionNonce;
   }
   return hash >>> 0; // Ensure unsigned 32-bit integer
 };

+ 2 - 2
packages/element/src/linearElementEditor.ts

@@ -20,7 +20,7 @@ import {
   tupleToCoors,
 } from "@excalidraw/common";
 
-import type { Store } from "@excalidraw/excalidraw/store";
+import type { Store } from "@excalidraw/element/store";
 
 import type { Radians } from "@excalidraw/math";
 
@@ -807,7 +807,7 @@ export class LinearElementEditor {
         });
         ret.didAddPoint = true;
       }
-      store.shouldCaptureIncrement();
+      store.scheduleCapture();
       ret.linearElementEditor = {
         ...linearElementEditor,
         pointerDownState: {

+ 968 - 0
packages/element/src/store.ts

@@ -0,0 +1,968 @@
+import {
+  assertNever,
+  COLOR_PALETTE,
+  isDevEnv,
+  isTestEnv,
+  randomId,
+  Emitter,
+  toIterable,
+} from "@excalidraw/common";
+
+import type App from "@excalidraw/excalidraw/components/App";
+
+import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
+
+import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
+
+import { deepCopyElement } from "./duplicate";
+import { newElementWith } from "./mutateElement";
+
+import { ElementsDelta, AppStateDelta, Delta } from "./delta";
+
+import { hashElementsVersion, hashString } from "./index";
+
+import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
+
+export const CaptureUpdateAction = {
+  /**
+   * Immediately undoable.
+   *
+   * Use for updates which should be captured.
+   * Should be used for most of the local updates, except ephemerals such as dragging or resizing.
+   *
+   * These updates will _immediately_ make it to the local undo / redo stacks.
+   */
+  IMMEDIATELY: "IMMEDIATELY",
+  /**
+   * Never undoable.
+   *
+   * Use for updates which should never be recorded, such as remote updates
+   * or scene initialization.
+   *
+   * These updates will _never_ make it to the local undo / redo stacks.
+   */
+  NEVER: "NEVER",
+  /**
+   * Eventually undoable.
+   *
+   * Use for updates which should not be captured immediately - likely
+   * exceptions which are part of some async multi-step process. Otherwise, all
+   * such updates would end up being captured with the next
+   * `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
+   * or internally by the editor.
+   *
+   * These updates will _eventually_ make it to the local undo / redo stacks.
+   */
+  EVENTUALLY: "EVENTUALLY",
+} as const;
+
+export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
+
+type MicroActionsQueue = (() => void)[];
+
+/**
+ * Store which captures the observed changes and emits them as `StoreIncrement` events.
+ */
+export class Store {
+  // internally used by history
+  public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
+  public readonly onStoreIncrementEmitter = new Emitter<
+    [DurableIncrement | EphemeralIncrement]
+  >();
+
+  private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
+  private scheduledMicroActions: MicroActionsQueue = [];
+
+  private _snapshot = StoreSnapshot.empty();
+
+  public get snapshot() {
+    return this._snapshot;
+  }
+
+  public set snapshot(snapshot: StoreSnapshot) {
+    this._snapshot = snapshot;
+  }
+
+  constructor(private readonly app: App) {}
+
+  public scheduleAction(action: CaptureUpdateActionType) {
+    this.scheduledMacroActions.add(action);
+    this.satisfiesScheduledActionsInvariant();
+  }
+
+  /**
+   * Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
+   */
+  // TODO: Suspicious that this is called so many places. Seems error-prone.
+  public scheduleCapture() {
+    this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
+  }
+
+  /**
+   * Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
+   */
+  public scheduleMicroAction(
+    params:
+      | {
+          action: CaptureUpdateActionType;
+          elements: SceneElementsMap | undefined;
+          appState: AppState | ObservedAppState | undefined;
+        }
+      | {
+          action: typeof CaptureUpdateAction.IMMEDIATELY;
+          change: StoreChange;
+          delta: StoreDelta;
+        }
+      | {
+          action:
+            | typeof CaptureUpdateAction.NEVER
+            | typeof CaptureUpdateAction.EVENTUALLY;
+          change: StoreChange;
+        },
+  ) {
+    const { action } = params;
+
+    let change: StoreChange;
+
+    if ("change" in params) {
+      change = params.change;
+    } else {
+      // immediately create an immutable change of the scheduled updates,
+      // compared to the current state, so that they won't mutate later on during batching
+      const currentSnapshot = StoreSnapshot.create(
+        this.app.scene.getElementsMapIncludingDeleted(),
+        this.app.state,
+      );
+      const scheduledSnapshot = currentSnapshot.maybeClone(
+        action,
+        params.elements,
+        params.appState,
+      );
+
+      change = StoreChange.create(currentSnapshot, scheduledSnapshot);
+    }
+
+    const delta = "delta" in params ? params.delta : undefined;
+
+    this.scheduledMicroActions.push(() =>
+      this.processAction({
+        action,
+        change,
+        delta,
+      }),
+    );
+  }
+
+  /**
+   * Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
+   * Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
+   *
+   * @emits StoreIncrement
+   */
+  public commit(
+    elements: SceneElementsMap | undefined,
+    appState: AppState | ObservedAppState | undefined,
+  ): void {
+    // execute all scheduled micro actions first
+    // similar to microTasks, there can be many
+    this.flushMicroActions();
+
+    try {
+      // execute a single scheduled "macro" function
+      // similar to macro tasks, there can be only one within a single commit (loop)
+      const action = this.getScheduledMacroAction();
+      this.processAction({ action, elements, appState });
+    } finally {
+      this.satisfiesScheduledActionsInvariant();
+      // defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
+      this.scheduledMacroActions = new Set();
+    }
+  }
+
+  /**
+   * Clears the store instance.
+   */
+  public clear(): void {
+    this.snapshot = StoreSnapshot.empty();
+    this.scheduledMacroActions = new Set();
+  }
+
+  /**
+   * Performs delta & change calculation and emits a durable increment.
+   *
+   * @emits StoreIncrement.
+   */
+  private emitDurableIncrement(
+    snapshot: StoreSnapshot,
+    change: StoreChange | undefined = undefined,
+    delta: StoreDelta | undefined = undefined,
+  ) {
+    const prevSnapshot = this.snapshot;
+
+    let storeChange: StoreChange;
+    let storeDelta: StoreDelta;
+
+    if (change) {
+      storeChange = change;
+    } else {
+      storeChange = StoreChange.create(prevSnapshot, snapshot);
+    }
+
+    if (delta) {
+      // we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
+      // using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
+      storeDelta = delta;
+    } else {
+      // calculate the deltas based on the previous and next snapshot
+      const elementsDelta = snapshot.metadata.didElementsChange
+        ? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
+        : ElementsDelta.empty();
+
+      const appStateDelta = snapshot.metadata.didAppStateChange
+        ? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
+        : AppStateDelta.empty();
+
+      storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
+    }
+
+    if (!storeDelta.isEmpty()) {
+      const increment = new DurableIncrement(storeChange, storeDelta);
+
+      // Notify listeners with the increment
+      this.onDurableIncrementEmitter.trigger(increment);
+      this.onStoreIncrementEmitter.trigger(increment);
+    }
+  }
+
+  /**
+   * Performs change calculation and emits an ephemeral increment.
+   *
+   * @emits EphemeralStoreIncrement
+   */
+  private emitEphemeralIncrement(
+    snapshot: StoreSnapshot,
+    change: StoreChange | undefined = undefined,
+  ) {
+    let storeChange: StoreChange;
+
+    if (change) {
+      storeChange = change;
+    } else {
+      const prevSnapshot = this.snapshot;
+      storeChange = StoreChange.create(prevSnapshot, snapshot);
+    }
+
+    const increment = new EphemeralIncrement(storeChange);
+
+    // Notify listeners with the increment
+    this.onStoreIncrementEmitter.trigger(increment);
+  }
+
+  private applyChangeToSnapshot(change: StoreChange) {
+    const prevSnapshot = this.snapshot;
+    const nextSnapshot = this.snapshot.applyChange(change);
+
+    if (prevSnapshot === nextSnapshot) {
+      return null;
+    }
+
+    return nextSnapshot;
+  }
+
+  /**
+   * Clones the snapshot if there are changes detected.
+   */
+  private maybeCloneSnapshot(
+    action: CaptureUpdateActionType,
+    elements: SceneElementsMap | undefined,
+    appState: AppState | ObservedAppState | undefined,
+  ) {
+    if (!elements && !appState) {
+      return null;
+    }
+
+    const prevSnapshot = this.snapshot;
+    const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
+
+    if (prevSnapshot === nextSnapshot) {
+      return null;
+    }
+
+    return nextSnapshot;
+  }
+
+  private flushMicroActions() {
+    for (const microAction of this.scheduledMicroActions) {
+      try {
+        microAction();
+      } catch (error) {
+        console.error(`Failed to execute scheduled micro action`, error);
+      }
+    }
+
+    this.scheduledMicroActions = [];
+  }
+
+  private processAction(
+    params:
+      | {
+          action: CaptureUpdateActionType;
+          elements: SceneElementsMap | undefined;
+          appState: AppState | ObservedAppState | undefined;
+        }
+      | {
+          action: CaptureUpdateActionType;
+          change: StoreChange;
+          delta: StoreDelta | undefined;
+        },
+  ) {
+    const { action } = params;
+
+    // perf. optimisation, since "EVENTUALLY" does not update the snapshot,
+    // so if nobody is listening for increments, we don't need to even clone the snapshot
+    // as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
+    if (
+      action === CaptureUpdateAction.EVENTUALLY &&
+      !this.onStoreIncrementEmitter.subscribers.length
+    ) {
+      return;
+    }
+
+    let nextSnapshot: StoreSnapshot | null;
+
+    if ("change" in params) {
+      nextSnapshot = this.applyChangeToSnapshot(params.change);
+    } else {
+      nextSnapshot = this.maybeCloneSnapshot(
+        action,
+        params.elements,
+        params.appState,
+      );
+    }
+
+    if (!nextSnapshot) {
+      // don't continue if there is not change detected
+      return;
+    }
+
+    const change = "change" in params ? params.change : undefined;
+    const delta = "delta" in params ? params.delta : undefined;
+
+    try {
+      switch (action) {
+        // only immediately emits a durable increment
+        case CaptureUpdateAction.IMMEDIATELY:
+          this.emitDurableIncrement(nextSnapshot, change, delta);
+          break;
+        // both never and eventually emit an ephemeral increment
+        case CaptureUpdateAction.NEVER:
+        case CaptureUpdateAction.EVENTUALLY:
+          this.emitEphemeralIncrement(nextSnapshot, change);
+          break;
+        default:
+          assertNever(action, `Unknown store action`);
+      }
+    } finally {
+      // update the snapshot no-matter what, as it would mess up with the next action
+      switch (action) {
+        // both immediately and never update the snapshot, unlike eventually
+        case CaptureUpdateAction.IMMEDIATELY:
+        case CaptureUpdateAction.NEVER:
+          this.snapshot = nextSnapshot;
+          break;
+      }
+    }
+  }
+
+  /**
+   * Returns the scheduled macro action.
+   */
+  private getScheduledMacroAction() {
+    let scheduledAction: CaptureUpdateActionType;
+
+    if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
+      // Capture has a precedence over update, since it also performs snapshot update
+      scheduledAction = CaptureUpdateAction.IMMEDIATELY;
+    } else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
+      // Update has a precedence over none, since it also emits an (ephemeral) increment
+      scheduledAction = CaptureUpdateAction.NEVER;
+    } else {
+      // Default is to emit ephemeral increment and don't update the snapshot
+      scheduledAction = CaptureUpdateAction.EVENTUALLY;
+    }
+
+    return scheduledAction;
+  }
+
+  /**
+   * Ensures that the scheduled actions invariant is satisfied.
+   */
+  private satisfiesScheduledActionsInvariant() {
+    if (
+      !(
+        this.scheduledMacroActions.size >= 0 &&
+        this.scheduledMacroActions.size <=
+          Object.keys(CaptureUpdateAction).length
+      )
+    ) {
+      const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
+      console.error(message, this.scheduledMacroActions.values());
+
+      if (isTestEnv() || isDevEnv()) {
+        throw new Error(message);
+      }
+    }
+  }
+}
+
+/**
+ * Repsents a change to the store containing changed elements and appState.
+ */
+export class StoreChange {
+  // so figuring out what has changed should ideally be just quick reference checks
+  // TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
+  private constructor(
+    public readonly elements: Record<string, OrderedExcalidrawElement>,
+    public readonly appState: Partial<ObservedAppState>,
+  ) {}
+
+  public static create(
+    prevSnapshot: StoreSnapshot,
+    nextSnapshot: StoreSnapshot,
+  ) {
+    const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
+    const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
+
+    return new StoreChange(changedElements, changedAppState);
+  }
+}
+
+/**
+ * Encpasulates any change to the store (durable or ephemeral).
+ */
+export abstract class StoreIncrement {
+  protected constructor(
+    public readonly type: "durable" | "ephemeral",
+    public readonly change: StoreChange,
+  ) {}
+
+  public static isDurable(
+    increment: StoreIncrement,
+  ): increment is DurableIncrement {
+    return increment.type === "durable";
+  }
+
+  public static isEphemeral(
+    increment: StoreIncrement,
+  ): increment is EphemeralIncrement {
+    return increment.type === "ephemeral";
+  }
+}
+
+/**
+ * Represents a durable change to the store.
+ */
+export class DurableIncrement extends StoreIncrement {
+  constructor(
+    public readonly change: StoreChange,
+    public readonly delta: StoreDelta,
+  ) {
+    super("durable", change);
+  }
+}
+
+/**
+ * Represents an ephemeral change to the store.
+ */
+export class EphemeralIncrement extends StoreIncrement {
+  constructor(public readonly change: StoreChange) {
+    super("ephemeral", change);
+  }
+}
+
+/**
+ * Represents a captured delta by the Store.
+ */
+export class StoreDelta {
+  protected constructor(
+    public readonly id: string,
+    public readonly elements: ElementsDelta,
+    public readonly appState: AppStateDelta,
+  ) {}
+
+  /**
+   * Create a new instance of `StoreDelta`.
+   */
+  public static create(
+    elements: ElementsDelta,
+    appState: AppStateDelta,
+    opts: {
+      id: string;
+    } = {
+      id: randomId(),
+    },
+  ) {
+    return new this(opts.id, elements, appState);
+  }
+
+  /**
+   * Restore a store delta instance from a DTO.
+   */
+  public static restore(storeDeltaDTO: DTO<StoreDelta>) {
+    const { id, elements, appState } = storeDeltaDTO;
+    return new this(
+      id,
+      ElementsDelta.restore(elements),
+      AppStateDelta.restore(appState),
+    );
+  }
+
+  /**
+   * Parse and load the delta from the remote payload.
+   */
+  public static load({
+    id,
+    elements: { added, removed, updated },
+  }: DTO<StoreDelta>) {
+    const elements = ElementsDelta.create(added, removed, updated, {
+      shouldRedistribute: false,
+    });
+
+    return new this(id, elements, AppStateDelta.empty());
+  }
+
+  /**
+   * Inverse store delta, creates new instance of `StoreDelta`.
+   */
+  public static inverse(delta: StoreDelta): StoreDelta {
+    return this.create(delta.elements.inverse(), delta.appState.inverse());
+  }
+
+  /**
+   * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
+   */
+  public static applyLatestChanges(
+    delta: StoreDelta,
+    elements: SceneElementsMap,
+    modifierOptions: "deleted" | "inserted",
+  ): StoreDelta {
+    return this.create(
+      delta.elements.applyLatestChanges(elements, modifierOptions),
+      delta.appState,
+      {
+        id: delta.id,
+      },
+    );
+  }
+
+  /**
+   * Apply the delta to the passed elements and appState, does not modify the snapshot.
+   */
+  public static applyTo(
+    delta: StoreDelta,
+    elements: SceneElementsMap,
+    appState: AppState,
+    prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
+  ): [SceneElementsMap, AppState, boolean] {
+    const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
+      elements,
+      prevSnapshot.elements,
+    );
+
+    const [nextAppState, appStateContainsVisibleChange] =
+      delta.appState.applyTo(appState, nextElements);
+
+    const appliedVisibleChanges =
+      elementsContainVisibleChange || appStateContainsVisibleChange;
+
+    return [nextElements, nextAppState, appliedVisibleChanges];
+  }
+
+  public isEmpty() {
+    return this.elements.isEmpty() && this.appState.isEmpty();
+  }
+}
+
+/**
+ * Represents a snapshot of the captured or updated changes in the store,
+ * used for producing deltas and emitting `DurableStoreIncrement`s.
+ */
+export class StoreSnapshot {
+  private _lastChangedElementsHash: number = 0;
+  private _lastChangedAppStateHash: number = 0;
+
+  private constructor(
+    public readonly elements: SceneElementsMap,
+    public readonly appState: ObservedAppState,
+    public readonly metadata: {
+      didElementsChange: boolean;
+      didAppStateChange: boolean;
+      isEmpty?: boolean;
+    } = {
+      didElementsChange: false,
+      didAppStateChange: false,
+      isEmpty: false,
+    },
+  ) {}
+
+  public static create(
+    elements: SceneElementsMap,
+    appState: AppState | ObservedAppState,
+    metadata: {
+      didElementsChange: boolean;
+      didAppStateChange: boolean;
+    } = {
+      didElementsChange: false,
+      didAppStateChange: false,
+    },
+  ) {
+    return new StoreSnapshot(
+      elements,
+      isObservedAppState(appState) ? appState : getObservedAppState(appState),
+      metadata,
+    );
+  }
+
+  public static empty() {
+    return new StoreSnapshot(
+      new Map() as SceneElementsMap,
+      getDefaultObservedAppState(),
+      {
+        didElementsChange: false,
+        didAppStateChange: false,
+        isEmpty: true,
+      },
+    );
+  }
+
+  public getChangedElements(prevSnapshot: StoreSnapshot) {
+    const changedElements: Record<string, OrderedExcalidrawElement> = {};
+
+    for (const prevElement of toIterable(prevSnapshot.elements)) {
+      const nextElement = this.elements.get(prevElement.id);
+
+      if (!nextElement) {
+        changedElements[prevElement.id] = newElementWith(prevElement, {
+          isDeleted: true,
+        });
+      }
+    }
+
+    for (const nextElement of toIterable(this.elements)) {
+      // Due to the structural clone inside `maybeClone`, we can perform just these reference checks
+      if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
+        changedElements[nextElement.id] = nextElement;
+      }
+    }
+
+    return changedElements;
+  }
+
+  public getChangedAppState(
+    prevSnapshot: StoreSnapshot,
+  ): Partial<ObservedAppState> {
+    return Delta.getRightDifferences(
+      prevSnapshot.appState,
+      this.appState,
+    ).reduce(
+      (acc, key) =>
+        Object.assign(acc, {
+          [key]: this.appState[key as keyof ObservedAppState],
+        }),
+      {} as Partial<ObservedAppState>,
+    );
+  }
+
+  public isEmpty() {
+    return this.metadata.isEmpty;
+  }
+
+  /**
+   * Apply the change and return a new snapshot instance.
+   */
+  public applyChange(change: StoreChange): StoreSnapshot {
+    const nextElements = new Map(this.elements) as SceneElementsMap;
+
+    for (const [id, changedElement] of Object.entries(change.elements)) {
+      nextElements.set(id, changedElement);
+    }
+
+    const nextAppState = Object.assign(
+      {},
+      this.appState,
+      change.appState,
+    ) as ObservedAppState;
+
+    return StoreSnapshot.create(nextElements, nextAppState, {
+      // by default we assume that change is different from what we have in the snapshot
+      // so that we trigger the delta calculation and if it isn't different, delta will be empty
+      didElementsChange: Object.keys(change.elements).length > 0,
+      didAppStateChange: Object.keys(change.appState).length > 0,
+    });
+  }
+
+  /**
+   * Efficiently clone the existing snapshot, only if we detected changes.
+   *
+   * @returns same instance if there are no changes detected, new instance otherwise.
+   */
+  public maybeClone(
+    action: CaptureUpdateActionType,
+    elements: SceneElementsMap | undefined,
+    appState: AppState | ObservedAppState | undefined,
+  ) {
+    const options = {
+      shouldCompareHashes: false,
+    };
+
+    if (action === CaptureUpdateAction.EVENTUALLY) {
+      // actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
+      // as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
+      // instead of just the first time the elements or appState actually changed
+      options.shouldCompareHashes = true;
+    }
+
+    const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
+      elements,
+      options,
+    );
+    const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
+      appState,
+      options,
+    );
+
+    let didElementsChange = false;
+    let didAppStateChange = false;
+
+    if (this.elements !== nextElementsSnapshot) {
+      didElementsChange = true;
+    }
+
+    if (this.appState !== nextAppStateSnapshot) {
+      didAppStateChange = true;
+    }
+
+    if (!didElementsChange && !didAppStateChange) {
+      return this;
+    }
+
+    const snapshot = new StoreSnapshot(
+      nextElementsSnapshot,
+      nextAppStateSnapshot,
+      {
+        didElementsChange,
+        didAppStateChange,
+      },
+    );
+
+    return snapshot;
+  }
+
+  private maybeCreateAppStateSnapshot(
+    appState: AppState | ObservedAppState | undefined,
+    options: {
+      shouldCompareHashes: boolean;
+    } = {
+      shouldCompareHashes: false,
+    },
+  ): ObservedAppState {
+    if (!appState) {
+      return this.appState;
+    }
+
+    // Not watching over everything from the app state, just the relevant props
+    const nextAppStateSnapshot = !isObservedAppState(appState)
+      ? getObservedAppState(appState)
+      : appState;
+
+    const didAppStateChange = this.detectChangedAppState(
+      nextAppStateSnapshot,
+      options,
+    );
+
+    if (!didAppStateChange) {
+      return this.appState;
+    }
+
+    return nextAppStateSnapshot;
+  }
+
+  private maybeCreateElementsSnapshot(
+    elements: SceneElementsMap | undefined,
+    options: {
+      shouldCompareHashes: boolean;
+    } = {
+      shouldCompareHashes: false,
+    },
+  ): SceneElementsMap {
+    if (!elements) {
+      return this.elements;
+    }
+
+    const changedElements = this.detectChangedElements(elements, options);
+
+    if (!changedElements?.size) {
+      return this.elements;
+    }
+
+    const elementsSnapshot = this.createElementsSnapshot(changedElements);
+    return elementsSnapshot;
+  }
+
+  private detectChangedAppState(
+    nextObservedAppState: ObservedAppState,
+    options: {
+      shouldCompareHashes: boolean;
+    } = {
+      shouldCompareHashes: false,
+    },
+  ): boolean | undefined {
+    if (this.appState === nextObservedAppState) {
+      return;
+    }
+
+    const didAppStateChange = Delta.isRightDifferent(
+      this.appState,
+      nextObservedAppState,
+    );
+
+    if (!didAppStateChange) {
+      return;
+    }
+
+    const changedAppStateHash = hashString(
+      JSON.stringify(nextObservedAppState),
+    );
+
+    if (
+      options.shouldCompareHashes &&
+      this._lastChangedAppStateHash === changedAppStateHash
+    ) {
+      return;
+    }
+
+    this._lastChangedAppStateHash = changedAppStateHash;
+
+    return didAppStateChange;
+  }
+
+  /**
+   * Detect if there any changed elements.
+   */
+  private detectChangedElements(
+    nextElements: SceneElementsMap,
+    options: {
+      shouldCompareHashes: boolean;
+    } = {
+      shouldCompareHashes: false,
+    },
+  ): SceneElementsMap | undefined {
+    if (this.elements === nextElements) {
+      return;
+    }
+
+    const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
+
+    for (const prevElement of toIterable(this.elements)) {
+      const nextElement = nextElements.get(prevElement.id);
+
+      if (!nextElement) {
+        // element was deleted
+        changedElements.set(
+          prevElement.id,
+          newElementWith(prevElement, { isDeleted: true }),
+        );
+      }
+    }
+
+    for (const nextElement of toIterable(nextElements)) {
+      const prevElement = this.elements.get(nextElement.id);
+
+      if (
+        !prevElement || // element was added
+        prevElement.version < nextElement.version // element was updated
+      ) {
+        changedElements.set(nextElement.id, nextElement);
+      }
+    }
+
+    if (!changedElements.size) {
+      return;
+    }
+
+    const changedElementsHash = hashElementsVersion(changedElements);
+
+    if (
+      options.shouldCompareHashes &&
+      this._lastChangedElementsHash === changedElementsHash
+    ) {
+      return;
+    }
+
+    this._lastChangedElementsHash = changedElementsHash;
+
+    return changedElements;
+  }
+
+  /**
+   * Perform structural clone, deep cloning only elements that changed.
+   */
+  private createElementsSnapshot(changedElements: SceneElementsMap) {
+    const clonedElements = new Map() as SceneElementsMap;
+
+    for (const prevElement of toIterable(this.elements)) {
+      // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
+      // i.e. during collab, persist or whenenever isDeleted elements get cleared
+      clonedElements.set(prevElement.id, prevElement);
+    }
+
+    for (const changedElement of toIterable(changedElements)) {
+      // TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
+      // TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
+      clonedElements.set(changedElement.id, deepCopyElement(changedElement));
+    }
+
+    return clonedElements;
+  }
+}
+
+// hidden non-enumerable property for runtime checks
+const hiddenObservedAppStateProp = "__observedAppState";
+
+const getDefaultObservedAppState = (): ObservedAppState => {
+  return {
+    name: null,
+    editingGroupId: null,
+    viewBackgroundColor: COLOR_PALETTE.white,
+    selectedElementIds: {},
+    selectedGroupIds: {},
+    editingLinearElementId: null,
+    selectedLinearElementId: null,
+    croppingElementId: null,
+  };
+};
+
+export const getObservedAppState = (appState: AppState): ObservedAppState => {
+  const observedAppState = {
+    name: appState.name,
+    editingGroupId: appState.editingGroupId,
+    viewBackgroundColor: appState.viewBackgroundColor,
+    selectedElementIds: appState.selectedElementIds,
+    selectedGroupIds: appState.selectedGroupIds,
+    editingLinearElementId: appState.editingLinearElement?.elementId || null,
+    selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
+    croppingElementId: appState.croppingElementId,
+  };
+
+  Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+    value: true,
+    enumerable: false,
+  });
+
+  return observedAppState;
+};
+
+const isObservedAppState = (
+  appState: AppState | ObservedAppState,
+): appState is ObservedAppState =>
+  !!Reflect.get(appState, hiddenObservedAppStateProp);

+ 143 - 0
packages/element/tests/delta.test.tsx

@@ -0,0 +1,143 @@
+import type { ObservedAppState } from "@excalidraw/excalidraw/types";
+import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
+
+import { AppStateDelta } from "../src/delta";
+
+describe("AppStateDelta", () => {
+  describe("ensure stable delta properties order", () => {
+    it("should maintain stable order for root properties", () => {
+      const name = "untitled scene";
+      const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
+
+      const commonAppState = {
+        viewBackgroundColor: "#ffffff",
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        editingGroupId: null,
+        croppingElementId: null,
+        editingLinearElementId: null,
+      };
+
+      const prevAppState1: ObservedAppState = {
+        ...commonAppState,
+        name: "",
+        selectedLinearElementId: null,
+      };
+
+      const nextAppState1: ObservedAppState = {
+        ...commonAppState,
+        name,
+        selectedLinearElementId,
+      };
+
+      const prevAppState2: ObservedAppState = {
+        selectedLinearElementId: null,
+        name: "",
+        ...commonAppState,
+      };
+
+      const nextAppState2: ObservedAppState = {
+        selectedLinearElementId,
+        name,
+        ...commonAppState,
+      };
+
+      const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
+      const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
+
+      expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
+    });
+
+    it("should maintain stable order for selectedElementIds", () => {
+      const commonAppState = {
+        name: "",
+        viewBackgroundColor: "#ffffff",
+        selectedGroupIds: {},
+        editingGroupId: null,
+        croppingElementId: null,
+        selectedLinearElementId: null,
+        editingLinearElementId: null,
+      };
+
+      const prevAppState1: ObservedAppState = {
+        ...commonAppState,
+        selectedElementIds: { id5: true, id2: true, id4: true },
+      };
+
+      const nextAppState1: ObservedAppState = {
+        ...commonAppState,
+        selectedElementIds: {
+          id1: true,
+          id2: true,
+          id3: true,
+        },
+      };
+
+      const prevAppState2: ObservedAppState = {
+        ...commonAppState,
+        selectedElementIds: { id4: true, id2: true, id5: true },
+      };
+
+      const nextAppState2: ObservedAppState = {
+        ...commonAppState,
+        selectedElementIds: {
+          id3: true,
+          id2: true,
+          id1: true,
+        },
+      };
+
+      const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
+      const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
+
+      expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
+    });
+
+    it("should maintain stable order for selectedGroupIds", () => {
+      const commonAppState = {
+        name: "",
+        viewBackgroundColor: "#ffffff",
+        selectedElementIds: {},
+        editingGroupId: null,
+        croppingElementId: null,
+        selectedLinearElementId: null,
+        editingLinearElementId: null,
+      };
+
+      const prevAppState1: ObservedAppState = {
+        ...commonAppState,
+        selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
+      };
+
+      const nextAppState1: ObservedAppState = {
+        ...commonAppState,
+        selectedGroupIds: {
+          id0: true,
+          id1: true,
+          id2: false,
+          id3: true,
+        },
+      };
+
+      const prevAppState2: ObservedAppState = {
+        ...commonAppState,
+        selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
+      };
+
+      const nextAppState2: ObservedAppState = {
+        ...commonAppState,
+        selectedGroupIds: {
+          id3: true,
+          id2: false,
+          id1: true,
+          id0: true,
+        },
+      };
+
+      const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
+      const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
+
+      expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
+    });
+  });
+});

+ 2 - 1
packages/excalidraw/actions/actionAddToLibrary.ts

@@ -1,8 +1,9 @@
 import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
 import { deepCopyElement } from "@excalidraw/element/duplicate";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionAlign.tsx

@@ -8,6 +8,8 @@ import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
 
 import { alignElements } from "@excalidraw/element/align";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import type { Alignment } from "@excalidraw/element/align";
@@ -25,7 +27,6 @@ import {
 import { t } from "../i18n";
 
 import { isSomeElementSelected } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 2
packages/excalidraw/actions/actionBoundText.tsx

@@ -33,6 +33,8 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
 
 import { newElement } from "@excalidraw/element/newElement";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -44,8 +46,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
 
 import type { Radians } from "@excalidraw/math";
 
-import { CaptureUpdateAction } from "../store";
-
 import { register } from "./register";
 
 import type { AppState } from "../types";

+ 2 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -17,6 +17,8 @@ import { getNonDeletedElements } from "@excalidraw/element";
 import { newElementWith } from "@excalidraw/element/mutateElement";
 import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import {
@@ -44,7 +46,6 @@ import { t } from "../i18n";
 import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 2
packages/excalidraw/actions/actionClipboard.tsx

@@ -3,6 +3,8 @@ import { getTextFromElements } from "@excalidraw/element/textElement";
 
 import { CODES, KEYS, isFirefox } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import {
   copyTextToSystemClipboard,
   copyToClipboard,
@@ -15,8 +17,6 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
 import { exportCanvas, prepareElementsForExport } from "../data/index";
 import { t } from "../i18n";
 
-import { CaptureUpdateAction } from "../store";
-
 import { actionDeleteSelected } from "./actionDeleteSelected";
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionCropEditor.tsx

@@ -1,11 +1,12 @@
 import { isImageElement } from "@excalidraw/element/typeChecks";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawImageElement } from "@excalidraw/element/types";
 
 import { ToolButton } from "../components/ToolButton";
 import { cropIcon } from "../components/icons";
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -17,11 +17,12 @@ import {
   selectGroupsForSelectedElements,
 } from "@excalidraw/element/groups";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import { t } from "../i18n";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { CaptureUpdateAction } from "../store";
 import { TrashIcon } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 

+ 2 - 1
packages/excalidraw/actions/actionDistribute.tsx

@@ -8,6 +8,8 @@ import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/fra
 
 import { distributeElements } from "@excalidraw/element/distribute";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import type { Distribution } from "@excalidraw/element/distribute";
@@ -21,7 +23,6 @@ import {
 import { t } from "../i18n";
 
 import { isSomeElementSelected } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -18,12 +18,13 @@ import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
 
 import { duplicateElements } from "@excalidraw/element/duplicate";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { ToolButton } from "../components/ToolButton";
 import { DuplicateIcon } from "../components/icons";
 
 import { t } from "../i18n";
 import { isSomeElementSelected } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionElementLink.ts

@@ -4,11 +4,12 @@ import {
   getLinkIdAndTypeFromSelection,
 } from "@excalidraw/element/elementLink";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { copyTextToSystemClipboard } from "../clipboard";
 import { copyIcon, elementLinkIcon } from "../components/icons";
 import { t } from "../i18n";
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionElementLock.ts

@@ -4,12 +4,13 @@ import { newElementWith } from "@excalidraw/element/mutateElement";
 
 import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import { LockedIcon, UnlockedIcon } from "../components/icons";
 
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionEmbeddable.ts

@@ -1,7 +1,8 @@
 import { updateActiveTool } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { setCursorForShape } from "../cursor";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionExport.tsx

@@ -7,6 +7,8 @@ import {
 
 import { getNonDeletedElements } from "@excalidraw/element";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { Theme } from "@excalidraw/element/types";
 
 import { useDevice } from "../components/App";
@@ -24,7 +26,6 @@ import { resaveAsImageWithScene } from "../data/resave";
 import { t } from "../i18n";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { getExportSize } from "../scene/export";
-import { CaptureUpdateAction } from "../store";
 
 import "../components/ToolIcon.scss";
 

+ 2 - 1
packages/excalidraw/actions/actionFinalize.tsx

@@ -16,11 +16,12 @@ import { isPathALoop } from "@excalidraw/element/shapes";
 
 import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { t } from "../i18n";
 import { resetCursor } from "../cursor";
 import { done } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionFlip.ts

@@ -15,6 +15,8 @@ import {
 import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
 import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type {
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
@@ -24,7 +26,6 @@ import type {
 } from "@excalidraw/element/types";
 
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { flipHorizontal, flipVertical } from "../components/icons";
 

+ 2 - 1
packages/excalidraw/actions/actionFrame.ts

@@ -14,12 +14,13 @@ import { getElementsInGroup } from "@excalidraw/element/groups";
 
 import { getCommonBounds } from "@excalidraw/element/bounds";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import { setCursorForShape } from "../cursor";
 import { frameToolIcon } from "../components/icons";
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionGroup.tsx

@@ -28,6 +28,8 @@ import {
 
 import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type {
   ExcalidrawElement,
   ExcalidrawTextElement,
@@ -40,7 +42,6 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
 import { t } from "../i18n";
 
 import { isSomeElementSelected } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 15 - 17
packages/excalidraw/actions/actionHistory.tsx

@@ -1,5 +1,9 @@
 import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
+import { orderByFractionalIndex } from "@excalidraw/element/fractionalIndex";
+
 import type { SceneElementsMap } from "@excalidraw/element/types";
 
 import { ToolButton } from "../components/ToolButton";
@@ -7,10 +11,8 @@ import { UndoIcon, RedoIcon } from "../components/icons";
 import { HistoryChangedEvent } from "../history";
 import { useEmitter } from "../hooks/useEmitter";
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import type { History } from "../history";
-import type { Store } from "../store";
 import type { AppClassProperties, AppState } from "../types";
 import type { Action, ActionResult } from "./types";
 
@@ -35,7 +37,11 @@ const executeHistoryAction = (
     }
 
     const [nextElementsMap, nextAppState] = result;
-    const nextElements = Array.from(nextElementsMap.values());
+
+    // order by fractional indices in case the map was accidently modified in the meantime
+    const nextElements = orderByFractionalIndex(
+      Array.from(nextElementsMap.values()),
+    );
 
     return {
       appState: nextAppState,
@@ -47,9 +53,9 @@ const executeHistoryAction = (
   return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
 };
 
-type ActionCreator = (history: History, store: Store) => Action;
+type ActionCreator = (history: History) => Action;
 
-export const createUndoAction: ActionCreator = (history, store) => ({
+export const createUndoAction: ActionCreator = (history) => ({
   name: "undo",
   label: "buttons.undo",
   icon: UndoIcon,
@@ -57,11 +63,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   viewMode: false,
   perform: (elements, appState, value, app) =>
     executeHistoryAction(app, appState, () =>
-      history.undo(
-        arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
-        appState,
-        store.snapshot,
-      ),
+      history.undo(arrayToMap(elements) as SceneElementsMap, appState),
     ),
   keyTest: (event) =>
     event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
@@ -88,19 +90,15 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   },
 });
 
-export const createRedoAction: ActionCreator = (history, store) => ({
+export const createRedoAction: ActionCreator = (history) => ({
   name: "redo",
   label: "buttons.redo",
   icon: RedoIcon,
   trackEvent: { category: "history" },
   viewMode: false,
-  perform: (elements, appState, _, app) =>
+  perform: (elements, appState, __, app) =>
     executeHistoryAction(app, appState, () =>
-      history.redo(
-        arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
-        appState,
-        store.snapshot,
-      ),
+      history.redo(arrayToMap(elements) as SceneElementsMap, appState),
     ),
   keyTest: (event) =>
     (event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||

+ 2 - 1
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -4,6 +4,8 @@ import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
 
 import { arrayToMap } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
 
 import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@@ -11,7 +13,6 @@ import { ToolButton } from "../components/ToolButton";
 import { lineEditorIcon } from "../components/icons";
 
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionLink.tsx

@@ -2,13 +2,14 @@ import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
 
 import { KEYS, getShortcutKey } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { ToolButton } from "../components/ToolButton";
 import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
 import { LinkIcon } from "../components/icons";
 import { t } from "../i18n";
 
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 2
packages/excalidraw/actions/actionMenu.tsx

@@ -4,12 +4,12 @@ import { getNonDeletedElements } from "@excalidraw/element";
 
 import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { ToolButton } from "../components/ToolButton";
 import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
 import { t } from "../i18n";
 
-import { CaptureUpdateAction } from "../store";
-
 import { register } from "./register";
 
 export const actionToggleCanvasMenu = register({

+ 2 - 1
packages/excalidraw/actions/actionNavigate.tsx

@@ -1,5 +1,7 @@
 import clsx from "clsx";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { getClientColor } from "../clients";
 import { Avatar } from "../components/Avatar";
 import {
@@ -8,7 +10,6 @@ import {
   microphoneMutedIcon,
 } from "../components/icons";
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 4 - 2
packages/excalidraw/actions/actionProperties.tsx

@@ -54,6 +54,8 @@ import { hasStrokeColor } from "@excalidraw/element/comparisons";
 
 import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { LocalPoint } from "@excalidraw/math";
 
 import type {
@@ -70,6 +72,8 @@ import type {
 
 import type Scene from "@excalidraw/element/Scene";
 
+import type { CaptureUpdateActionType } from "@excalidraw/element/store";
+
 import { trackEvent } from "../analytics";
 import { ButtonIconSelect } from "../components/ButtonIconSelect";
 import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@@ -131,11 +135,9 @@ import {
   getTargetElements,
   isSomeElementSelected,
 } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 
-import type { CaptureUpdateActionType } from "../store";
 import type { AppClassProperties, AppState, Primitive } from "../types";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;

+ 2 - 2
packages/excalidraw/actions/actionSelectAll.ts

@@ -6,9 +6,9 @@ import { arrayToMap, KEYS } from "@excalidraw/common";
 
 import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
 
-import type { ExcalidrawElement } from "@excalidraw/element/types";
+import { CaptureUpdateAction } from "@excalidraw/element/store";
 
-import { CaptureUpdateAction } from "../store";
+import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import { selectAllIcon } from "../components/icons";
 

+ 2 - 1
packages/excalidraw/actions/actionStyles.ts

@@ -24,13 +24,14 @@ import {
   redrawTextBoundingBox,
 } from "@excalidraw/element/textElement";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawTextElement } from "@excalidraw/element/types";
 
 import { paintIcon } from "../components/icons";
 
 import { t } from "../i18n";
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionTextAutoResize.ts

@@ -5,8 +5,9 @@ import { measureText } from "@excalidraw/element/textMeasurements";
 
 import { isTextElement } from "@excalidraw/element/typeChecks";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { getSelectedElements } from "../scene";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleGridMode.tsx

@@ -1,7 +1,8 @@
 import { CODES, KEYS } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { gridIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx

@@ -1,7 +1,8 @@
 import { CODES, KEYS } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { magnetIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleSearchMenu.ts

@@ -5,8 +5,9 @@ import {
   DEFAULT_SIDEBAR,
 } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { searchIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleShapeSwitch.tsx

@@ -1,3 +1,5 @@
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import {
@@ -5,7 +7,6 @@ import {
   convertElementTypePopupAtom,
 } from "../components/ConvertElementTypePopup";
 import { editorJotaiStore } from "../editor-jotai";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleStats.tsx

@@ -1,7 +1,8 @@
 import { CODES, KEYS } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { abacusIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleViewMode.tsx

@@ -1,7 +1,8 @@
 import { CODES, KEYS } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { eyeIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionToggleZenMode.tsx

@@ -1,7 +1,8 @@
 import { CODES, KEYS } from "@excalidraw/common";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import { coffeeIcon } from "../components/icons";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/actionZindex.tsx

@@ -7,6 +7,8 @@ import {
   moveAllRight,
 } from "@excalidraw/element/zindex";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import {
   BringForwardIcon,
   BringToFrontIcon,
@@ -14,7 +16,6 @@ import {
   SendToBackIcon,
 } from "../components/icons";
 import { t } from "../i18n";
-import { CaptureUpdateAction } from "../store";
 
 import { register } from "./register";
 

+ 2 - 1
packages/excalidraw/actions/types.ts

@@ -3,7 +3,8 @@ import type {
   OrderedExcalidrawElement,
 } from "@excalidraw/element/types";
 
-import type { CaptureUpdateActionType } from "../store";
+import type { CaptureUpdateActionType } from "@excalidraw/element/store";
+
 import type {
   AppClassProperties,
   AppState,

+ 72 - 75
packages/excalidraw/components/App.tsx

@@ -101,6 +101,7 @@ import {
   type EXPORT_IMAGE_TYPES,
   randomInteger,
   CLASSES,
+  Emitter,
 } from "@excalidraw/common";
 
 import {
@@ -303,6 +304,8 @@ import { isNonDeletedElement } from "@excalidraw/element";
 
 import Scene from "@excalidraw/element/Scene";
 
+import { Store, CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ElementUpdate } from "@excalidraw/element/mutateElement";
 
 import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -331,6 +334,7 @@ import type {
   ExcalidrawNonSelectionElement,
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
+  SceneElementsMap,
 } from "@excalidraw/element/types";
 
 import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -454,9 +458,7 @@ import {
   resetCursor,
   setCursorForShape,
 } from "../cursor";
-import { Emitter } from "../emitter";
 import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
-import { Store, CaptureUpdateAction } from "../store";
 import { LaserTrails } from "../laser-trails";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { textWysiwyg } from "../wysiwyg/textWysiwyg";
@@ -761,8 +763,8 @@ class App extends React.Component<AppProps, AppState> {
     this.renderer = new Renderer(this.scene);
     this.visibleElements = [];
 
-    this.store = new Store();
-    this.history = new History();
+    this.store = new Store(this);
+    this.history = new History(this.store);
 
     if (excalidrawAPI) {
       const api: ExcalidrawImperativeAPI = {
@@ -792,6 +794,7 @@ class App extends React.Component<AppProps, AppState> {
         updateFrameRendering: this.updateFrameRendering,
         toggleSidebar: this.toggleSidebar,
         onChange: (cb) => this.onChangeEmitter.on(cb),
+        onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
         onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
         onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
         onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
@@ -810,15 +813,11 @@ class App extends React.Component<AppProps, AppState> {
     };
 
     this.fonts = new Fonts(this.scene);
-    this.history = new History();
+    this.history = new History(this.store);
 
     this.actionManager.registerAll(actions);
-    this.actionManager.registerAction(
-      createUndoAction(this.history, this.store),
-    );
-    this.actionManager.registerAction(
-      createRedoAction(this.history, this.store),
-    );
+    this.actionManager.registerAction(createUndoAction(this.history));
+    this.actionManager.registerAction(createRedoAction(this.history));
   }
 
   updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -1899,6 +1898,10 @@ class App extends React.Component<AppProps, AppState> {
     return this.scene.getElementsIncludingDeleted();
   };
 
+  public getSceneElementsMapIncludingDeleted = () => {
+    return this.scene.getElementsMapIncludingDeleted();
+  };
+
   public getSceneElements = () => {
     return this.scene.getNonDeletedElements();
   };
@@ -2215,11 +2218,7 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
-    if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
-      this.store.shouldUpdateSnapshot();
-    } else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
-      this.store.shouldCaptureIncrement();
-    }
+    this.store.scheduleAction(actionResult.captureUpdate);
 
     let didUpdate = false;
 
@@ -2292,10 +2291,7 @@ class App extends React.Component<AppProps, AppState> {
       didUpdate = true;
     }
 
-    if (
-      !didUpdate &&
-      actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
-    ) {
+    if (!didUpdate) {
       this.scene.triggerUpdate();
     }
   });
@@ -2547,10 +2543,19 @@ class App extends React.Component<AppProps, AppState> {
       });
     }
 
-    this.store.onStoreIncrementEmitter.on((increment) => {
-      this.history.record(increment.elementsChange, increment.appStateChange);
+    this.store.onDurableIncrementEmitter.on((increment) => {
+      this.history.record(increment.delta);
     });
 
+    const { onIncrement } = this.props;
+
+    // per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
+    if (onIncrement) {
+      this.store.onStoreIncrementEmitter.on((increment) => {
+        onIncrement(increment);
+      });
+    }
+
     this.scene.onUpdate(this.triggerRender);
     this.addEventListeners();
 
@@ -2610,6 +2615,7 @@ class App extends React.Component<AppProps, AppState> {
     this.eraserTrail.stop();
     this.onChangeEmitter.clear();
     this.store.onStoreIncrementEmitter.clear();
+    this.store.onDurableIncrementEmitter.clear();
     ShapeCache.destroy();
     SnapCache.destroy();
     clearTimeout(touchTimeout);
@@ -2903,7 +2909,7 @@ class App extends React.Component<AppProps, AppState> {
       this.state.editingLinearElement &&
       !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
     ) {
-      // defer so that the shouldCaptureIncrement flag isn't reset via current update
+      // defer so that the scheduleCapture flag isn't reset via current update
       setTimeout(() => {
         // execute only if the condition still holds when the deferred callback
         // executes (it can be scheduled multiple times depending on how
@@ -3358,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
       this.addMissingFiles(opts.files);
     }
 
-    this.store.shouldCaptureIncrement();
+    this.store.scheduleCapture();
 
     const nextElementsToSelect =
       excludeElementsInFramesFromSelection(duplicatedElements);
@@ -3619,7 +3625,7 @@ class App extends React.Component<AppProps, AppState> {
       PLAIN_PASTE_TOAST_SHOWN = true;
     }
 
-    this.store.shouldCaptureIncrement();
+    this.store.scheduleCapture();
   }
 
   setAppState: React.Component<any, AppState>["setState"] = (
@@ -3975,51 +3981,37 @@ class App extends React.Component<AppProps, AppState> {
        */
       captureUpdate?: SceneData["captureUpdate"];
     }) => {
-      const nextElements = syncInvalidIndices(sceneData.elements ?? []);
+      const { elements, appState, collaborators, captureUpdate } = sceneData;
 
-      if (
-        sceneData.captureUpdate &&
-        sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
-      ) {
-        const prevCommittedAppState = this.store.snapshot.appState;
-        const prevCommittedElements = this.store.snapshot.elements;
+      const nextElements = elements ? syncInvalidIndices(elements) : undefined;
 
-        const nextCommittedAppState = sceneData.appState
-          ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
-          : prevCommittedAppState;
+      if (captureUpdate) {
+        const nextElementsMap = elements
+          ? (arrayToMap(nextElements ?? []) as SceneElementsMap)
+          : undefined;
 
-        const nextCommittedElements = sceneData.elements
-          ? this.store.filterUncomittedElements(
-              this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
-              arrayToMap(nextElements), // We expect all (already reconciled) elements
-            )
-          : prevCommittedElements;
-
-        // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
-        // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
-        if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
-          this.store.captureIncrement(
-            nextCommittedElements,
-            nextCommittedAppState,
-          );
-        } else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
-          this.store.updateSnapshot(
-            nextCommittedElements,
-            nextCommittedAppState,
-          );
-        }
+        const nextAppState = appState
+          ? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
+            Object.assign({}, this.store.snapshot.appState, appState)
+          : undefined;
+
+        this.store.scheduleMicroAction({
+          action: captureUpdate,
+          elements: nextElementsMap,
+          appState: nextAppState,
+        });
       }
 
-      if (sceneData.appState) {
-        this.setState(sceneData.appState);
+      if (appState) {
+        this.setState(appState);
       }
 
-      if (sceneData.elements) {
+      if (nextElements) {
         this.scene.replaceAllElements(nextElements);
       }
 
-      if (sceneData.collaborators) {
-        this.setState({ collaborators: sceneData.collaborators });
+      if (collaborators) {
+        this.setState({ collaborators });
       }
     },
   );
@@ -4202,7 +4194,7 @@ class App extends React.Component<AppProps, AppState> {
                 direction: event.shiftKey ? "left" : "right",
               })
             ) {
-              this.store.shouldCaptureIncrement();
+              this.store.scheduleCapture();
             }
           }
           if (conversionType) {
@@ -4519,7 +4511,7 @@ class App extends React.Component<AppProps, AppState> {
                 this.state.editingLinearElement.elementId !==
                   selectedElements[0].id
               ) {
-                this.store.shouldCaptureIncrement();
+                this.store.scheduleCapture();
                 if (!isElbowArrow(selectedElement)) {
                   this.setState({
                     editingLinearElement: new LinearElementEditor(
@@ -4845,7 +4837,7 @@ class App extends React.Component<AppProps, AppState> {
       } as const;
 
       if (nextActiveTool.type === "freedraw") {
-        this.store.shouldCaptureIncrement();
+        this.store.scheduleCapture();
       }
 
       if (nextActiveTool.type === "lasso") {
@@ -5062,7 +5054,7 @@ class App extends React.Component<AppProps, AppState> {
           ]);
         }
         if (!isDeleted || isExistingElement) {
-          this.store.shouldCaptureIncrement();
+          this.store.scheduleCapture();
         }
 
         flushSync(() => {
@@ -5475,7 +5467,7 @@ class App extends React.Component<AppProps, AppState> {
   };
 
   private startImageCropping = (image: ExcalidrawImageElement) => {
-    this.store.shouldCaptureIncrement();
+    this.store.scheduleCapture();
     this.setState({
       croppingElementId: image.id,
     });
@@ -5483,7 +5475,7 @@ class App extends React.Component<AppProps, AppState> {
 
   private finishImageCropping = () => {
     if (this.state.croppingElementId) {
-      this.store.shouldCaptureIncrement();
+      this.store.scheduleCapture();
       this.setState({
         croppingElementId: null,
       });
@@ -5518,7 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
             selectedElements[0].id) &&
         !isElbowArrow(selectedElements[0])
       ) {
-        this.store.shouldCaptureIncrement();
+        this.store.scheduleCapture();
         this.setState({
           editingLinearElement: new LinearElementEditor(
             selectedElements[0],
@@ -5546,7 +5538,7 @@ class App extends React.Component<AppProps, AppState> {
           : -1;
 
         if (midPoint && midPoint > -1) {
-          this.store.shouldCaptureIncrement();
+          this.store.scheduleCapture();
           LinearElementEditor.deleteFixedSegment(
             selectedElements[0],
             this.scene,
@@ -5608,7 +5600,7 @@ class App extends React.Component<AppProps, AppState> {
         getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
 
       if (selectedGroupId) {
-        this.store.shouldCaptureIncrement();
+        this.store.scheduleCapture();
         this.setState((prevState) => ({
           ...prevState,
           ...selectGroupsForSelectedElements(
@@ -9131,7 +9123,7 @@ class App extends React.Component<AppProps, AppState> {
 
       if (isLinearElement(newElement)) {
         if (newElement!.points.length > 1) {
-          this.store.shouldCaptureIncrement();
+          this.store.scheduleCapture();
         }
         const pointerCoords = viewportCoordsToSceneCoords(
           childEvent,
@@ -9404,7 +9396,7 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (resizingElement) {
-        this.store.shouldCaptureIncrement();
+        this.store.scheduleCapture();
       }
 
       if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@@ -9744,7 +9736,7 @@ class App extends React.Component<AppProps, AppState> {
           this.state.selectedElementIds,
         )
       ) {
-        this.store.shouldCaptureIncrement();
+        this.store.scheduleCapture();
       }
 
       if (
@@ -9837,7 +9829,7 @@ class App extends React.Component<AppProps, AppState> {
     this.elementsPendingErasure = new Set();
 
     if (didChange) {
-      this.store.shouldCaptureIncrement();
+      this.store.scheduleCapture();
       this.scene.replaceAllElements(elements);
     }
   };
@@ -10517,8 +10509,13 @@ class App extends React.Component<AppProps, AppState> {
         // restore the fractional indices by mutating elements
         syncInvalidIndices(elements.concat(ret.data.elements));
 
-        // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
-        this.store.updateSnapshot(arrayToMap(elements), this.state);
+        // don't capture and only update the store snapshot for old elements,
+        // otherwise we would end up with duplicated fractional indices on undo
+        this.store.scheduleMicroAction({
+          action: CaptureUpdateAction.NEVER,
+          elements: arrayToMap(elements) as SceneElementsMap,
+          appState: undefined,
+        });
 
         this.setState({ isLoading: true });
         this.syncActionResult({

+ 2 - 1
packages/excalidraw/components/Stats/DragInput.tsx

@@ -5,11 +5,12 @@ import { EVENT, KEYS, cloneJSON } from "@excalidraw/common";
 
 import { deepCopyElement } from "@excalidraw/element/duplicate";
 
+import { CaptureUpdateAction } from "@excalidraw/element/store";
+
 import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
 
 import type Scene from "@excalidraw/element/Scene";
 
-import { CaptureUpdateAction } from "../../store";
 import { useApp } from "../App";
 import { InlineIcon } from "../InlineIcon";
 

+ 1 - 1
packages/excalidraw/data/library.ts

@@ -14,6 +14,7 @@ import {
   resolvablePromise,
   toValidURL,
   Queue,
+  Emitter,
 } from "@excalidraw/common";
 
 import { hashElementsVersion, hashString } from "@excalidraw/element";
@@ -26,7 +27,6 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
 
 import { atom, editorJotaiStore } from "../editor-jotai";
 
-import { Emitter } from "../emitter";
 import { AbortError } from "../errors";
 import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
 import { t } from "../i18n";

+ 68 - 94
packages/excalidraw/history.ts

@@ -1,12 +1,17 @@
-import type { SceneElementsMap } from "@excalidraw/element/types";
+import { Emitter } from "@excalidraw/common";
+
+import {
+  CaptureUpdateAction,
+  StoreChange,
+  StoreDelta,
+  type Store,
+} from "@excalidraw/element/store";
 
-import { Emitter } from "./emitter";
+import type { SceneElementsMap } from "@excalidraw/element/types";
 
-import type { AppStateChange, ElementsChange } from "./change";
-import type { Snapshot } from "./store";
 import type { AppState } from "./types";
 
-type HistoryStack = HistoryEntry[];
+class HistoryEntry extends StoreDelta {}
 
 export class HistoryChangedEvent {
   constructor(
@@ -20,8 +25,8 @@ export class History {
     [HistoryChangedEvent]
   >();
 
-  private readonly undoStack: HistoryStack = [];
-  private readonly redoStack: HistoryStack = [];
+  public readonly undoStack: HistoryEntry[] = [];
+  public readonly redoStack: HistoryEntry[] = [];
 
   public get isUndoStackEmpty() {
     return this.undoStack.length === 0;
@@ -31,60 +36,52 @@ export class History {
     return this.redoStack.length === 0;
   }
 
+  constructor(private readonly store: Store) {}
+
   public clear() {
     this.undoStack.length = 0;
     this.redoStack.length = 0;
   }
 
   /**
-   * Record a local change which will go into the history
+   * Record a non-empty local durable increment, which will go into the undo stack..
+   * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
    */
-  public record(
-    elementsChange: ElementsChange,
-    appStateChange: AppStateChange,
-  ) {
-    const entry = HistoryEntry.create(appStateChange, elementsChange);
+  public record(delta: StoreDelta) {
+    if (delta.isEmpty() || delta instanceof HistoryEntry) {
+      return;
+    }
 
-    if (!entry.isEmpty()) {
-      // we have the latest changes, no need to `applyLatest`, which is done within `History.push`
-      this.undoStack.push(entry.inverse());
+    // construct history entry, so once it's emitted, it's not recorded again
+    const entry = HistoryEntry.inverse(delta);
 
-      if (!entry.elementsChange.isEmpty()) {
-        // don't reset redo stack on local appState changes,
-        // as a simple click (unselect) could lead to losing all the redo entries
-        // only reset on non empty elements changes!
-        this.redoStack.length = 0;
-      }
+    this.undoStack.push(entry);
 
-      this.onHistoryChangedEmitter.trigger(
-        new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
-      );
+    if (!entry.elements.isEmpty()) {
+      // don't reset redo stack on local appState changes,
+      // as a simple click (unselect) could lead to losing all the redo entries
+      // only reset on non empty elements changes!
+      this.redoStack.length = 0;
     }
+
+    this.onHistoryChangedEmitter.trigger(
+      new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
+    );
   }
 
-  public undo(
-    elements: SceneElementsMap,
-    appState: AppState,
-    snapshot: Readonly<Snapshot>,
-  ) {
+  public undo(elements: SceneElementsMap, appState: AppState) {
     return this.perform(
       elements,
       appState,
-      snapshot,
       () => History.pop(this.undoStack),
       (entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
     );
   }
 
-  public redo(
-    elements: SceneElementsMap,
-    appState: AppState,
-    snapshot: Readonly<Snapshot>,
-  ) {
+  public redo(elements: SceneElementsMap, appState: AppState) {
     return this.perform(
       elements,
       appState,
-      snapshot,
       () => History.pop(this.redoStack),
       (entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
     );
@@ -93,7 +90,6 @@ export class History {
   private perform(
     elements: SceneElementsMap,
     appState: AppState,
-    snapshot: Readonly<Snapshot>,
     pop: () => HistoryEntry | null,
     push: (entry: HistoryEntry) => void,
   ): [SceneElementsMap, AppState] | void {
@@ -104,6 +100,10 @@ export class History {
         return;
       }
 
+      const action = CaptureUpdateAction.IMMEDIATELY;
+
+      let prevSnapshot = this.store.snapshot;
+
       let nextElements = elements;
       let nextAppState = appState;
       let containsVisibleChange = false;
@@ -112,9 +112,29 @@ export class History {
       while (historyEntry) {
         try {
           [nextElements, nextAppState, containsVisibleChange] =
-            historyEntry.applyTo(nextElements, nextAppState, snapshot);
+            StoreDelta.applyTo(
+              historyEntry,
+              nextElements,
+              nextAppState,
+              prevSnapshot,
+            );
+
+          const nextSnapshot = prevSnapshot.maybeClone(
+            action,
+            nextElements,
+            nextAppState,
+          );
+
+          // schedule immediate capture, so that it's emitted for the sync purposes
+          this.store.scheduleMicroAction({
+            action,
+            change: StoreChange.create(prevSnapshot, nextSnapshot),
+            delta: historyEntry,
+          });
+
+          prevSnapshot = nextSnapshot;
         } finally {
-          // make sure to always push / pop, even if the increment is corrupted
+          // make sure to always push, even if the delta is corrupted
           push(historyEntry);
         }
 
@@ -135,7 +155,7 @@ export class History {
     }
   }
 
-  private static pop(stack: HistoryStack): HistoryEntry | null {
+  private static pop(stack: HistoryEntry[]): HistoryEntry | null {
     if (!stack.length) {
       return null;
     }
@@ -150,63 +170,17 @@ export class History {
   }
 
   private static push(
-    stack: HistoryStack,
+    stack: HistoryEntry[],
     entry: HistoryEntry,
     prevElements: SceneElementsMap,
   ) {
-    const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
-    return stack.push(updatedEntry);
-  }
-}
-
-export class HistoryEntry {
-  private constructor(
-    public readonly appStateChange: AppStateChange,
-    public readonly elementsChange: ElementsChange,
-  ) {}
-
-  public static create(
-    appStateChange: AppStateChange,
-    elementsChange: ElementsChange,
-  ) {
-    return new HistoryEntry(appStateChange, elementsChange);
-  }
-
-  public inverse(): HistoryEntry {
-    return new HistoryEntry(
-      this.appStateChange.inverse(),
-      this.elementsChange.inverse(),
+    const inversedEntry = HistoryEntry.inverse(entry);
+    const updatedEntry = HistoryEntry.applyLatestChanges(
+      inversedEntry,
+      prevElements,
+      "inserted",
     );
-  }
-
-  public applyTo(
-    elements: SceneElementsMap,
-    appState: AppState,
-    snapshot: Readonly<Snapshot>,
-  ): [SceneElementsMap, AppState, boolean] {
-    const [nextElements, elementsContainVisibleChange] =
-      this.elementsChange.applyTo(elements, snapshot.elements);
 
-    const [nextAppState, appStateContainsVisibleChange] =
-      this.appStateChange.applyTo(appState, nextElements);
-
-    const appliedVisibleChanges =
-      elementsContainVisibleChange || appStateContainsVisibleChange;
-
-    return [nextElements, nextAppState, appliedVisibleChanges];
-  }
-
-  /**
-   * Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
-   */
-  public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
-    const updatedElementsChange =
-      this.elementsChange.applyLatestChanges(elements);
-
-    return HistoryEntry.create(this.appStateChange, updatedElementsChange);
-  }
-
-  public isEmpty(): boolean {
-    return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
+    return stack.push(updatedEntry);
   }
 }

+ 1 - 1
packages/excalidraw/hooks/useEmitter.ts

@@ -1,6 +1,6 @@
 import { useEffect, useState } from "react";
 
-import type { Emitter } from "../emitter";
+import type { Emitter } from "@excalidraw/common";
 
 export const useEmitter = <TEvent extends unknown>(
   emitter: Emitter<[TEvent]>,

+ 3 - 1
packages/excalidraw/index.tsx

@@ -23,6 +23,7 @@ polyfill();
 const ExcalidrawBase = (props: ExcalidrawProps) => {
   const {
     onChange,
+    onIncrement,
     initialData,
     excalidrawAPI,
     isCollaborating = false,
@@ -114,6 +115,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
       <InitializeApp langCode={langCode} theme={theme}>
         <App
           onChange={onChange}
+          onIncrement={onIncrement}
           initialData={initialData}
           excalidrawAPI={excalidrawAPI}
           isCollaborating={isCollaborating}
@@ -266,7 +268,7 @@ export {
   bumpVersion,
 } from "@excalidraw/element/mutateElement";
 
-export { CaptureUpdateAction } from "./store";
+export { CaptureUpdateAction } from "@excalidraw/element/store";
 
 export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
 

+ 0 - 449
packages/excalidraw/store.ts

@@ -1,449 +0,0 @@
-import { isDevEnv, isShallowEqual, isTestEnv } from "@excalidraw/common";
-
-import { deepCopyElement } from "@excalidraw/element/duplicate";
-
-import { newElementWith } from "@excalidraw/element/mutateElement";
-
-import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
-
-import type { ValueOf } from "@excalidraw/common/utility-types";
-
-import { getDefaultAppState } from "./appState";
-import { AppStateChange, ElementsChange } from "./change";
-
-import { Emitter } from "./emitter";
-
-import type { AppState, ObservedAppState } from "./types";
-
-// hidden non-enumerable property for runtime checks
-const hiddenObservedAppStateProp = "__observedAppState";
-
-export const getObservedAppState = (appState: AppState): ObservedAppState => {
-  const observedAppState = {
-    name: appState.name,
-    editingGroupId: appState.editingGroupId,
-    viewBackgroundColor: appState.viewBackgroundColor,
-    selectedElementIds: appState.selectedElementIds,
-    selectedGroupIds: appState.selectedGroupIds,
-    editingLinearElementId: appState.editingLinearElement?.elementId || null,
-    selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
-    croppingElementId: appState.croppingElementId,
-  };
-
-  Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
-    value: true,
-    enumerable: false,
-  });
-
-  return observedAppState;
-};
-
-const isObservedAppState = (
-  appState: AppState | ObservedAppState,
-): appState is ObservedAppState =>
-  !!Reflect.get(appState, hiddenObservedAppStateProp);
-
-export const CaptureUpdateAction = {
-  /**
-   * Immediately undoable.
-   *
-   * Use for updates which should be captured.
-   * Should be used for most of the local updates.
-   *
-   * These updates will _immediately_ make it to the local undo / redo stacks.
-   */
-  IMMEDIATELY: "IMMEDIATELY",
-  /**
-   * Never undoable.
-   *
-   * Use for updates which should never be recorded, such as remote updates
-   * or scene initialization.
-   *
-   * These updates will _never_ make it to the local undo / redo stacks.
-   */
-  NEVER: "NEVER",
-  /**
-   * Eventually undoable.
-   *
-   * Use for updates which should not be captured immediately - likely
-   * exceptions which are part of some async multi-step process. Otherwise, all
-   * such updates would end up being captured with the next
-   * `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
-   * or internally by the editor.
-   *
-   * These updates will _eventually_ make it to the local undo / redo stacks.
-   */
-  EVENTUALLY: "EVENTUALLY",
-} as const;
-
-export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
-
-/**
- * Represent an increment to the Store.
- */
-class StoreIncrementEvent {
-  constructor(
-    public readonly elementsChange: ElementsChange,
-    public readonly appStateChange: AppStateChange,
-  ) {}
-}
-
-/**
- * Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
- *
- * @experimental this interface is experimental and subject to change.
- */
-export interface IStore {
-  onStoreIncrementEmitter: Emitter<[StoreIncrementEvent]>;
-  get snapshot(): Snapshot;
-  set snapshot(snapshot: Snapshot);
-
-  /**
-   * Use to schedule update of the snapshot, useful on updates for which we don't need to calculate increments (i.e. remote updates).
-   */
-  shouldUpdateSnapshot(): void;
-
-  /**
-   * Use to schedule calculation of a store increment.
-   */
-  shouldCaptureIncrement(): void;
-
-  /**
-   * Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
-   *
-   * @emits StoreIncrementEvent when increment is calculated.
-   */
-  commit(
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-    appState: AppState | ObservedAppState | undefined,
-  ): void;
-
-  /**
-   * Clears the store instance.
-   */
-  clear(): void;
-
-  /**
-   * Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
-   *
-   * This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
-   */
-  filterUncomittedElements(
-    prevElements: Map<string, OrderedExcalidrawElement>,
-    nextElements: Map<string, OrderedExcalidrawElement>,
-  ): Map<string, OrderedExcalidrawElement>;
-}
-
-export class Store implements IStore {
-  public readonly onStoreIncrementEmitter = new Emitter<
-    [StoreIncrementEvent]
-  >();
-
-  private scheduledActions: Set<CaptureUpdateActionType> = new Set();
-  private _snapshot = Snapshot.empty();
-
-  public get snapshot() {
-    return this._snapshot;
-  }
-
-  public set snapshot(snapshot: Snapshot) {
-    this._snapshot = snapshot;
-  }
-
-  // TODO: Suspicious that this is called so many places. Seems error-prone.
-  public shouldCaptureIncrement = () => {
-    this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
-  };
-
-  public shouldUpdateSnapshot = () => {
-    this.scheduleAction(CaptureUpdateAction.NEVER);
-  };
-
-  private scheduleAction = (action: CaptureUpdateActionType) => {
-    this.scheduledActions.add(action);
-    this.satisfiesScheduledActionsInvariant();
-  };
-
-  public commit = (
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-    appState: AppState | ObservedAppState | undefined,
-  ): void => {
-    try {
-      // Capture has precedence since it also performs update
-      if (this.scheduledActions.has(CaptureUpdateAction.IMMEDIATELY)) {
-        this.captureIncrement(elements, appState);
-      } else if (this.scheduledActions.has(CaptureUpdateAction.NEVER)) {
-        this.updateSnapshot(elements, appState);
-      }
-    } finally {
-      this.satisfiesScheduledActionsInvariant();
-      // Defensively reset all scheduled actions, potentially cleans up other runtime garbage
-      this.scheduledActions = new Set();
-    }
-  };
-
-  public captureIncrement = (
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-    appState: AppState | ObservedAppState | undefined,
-  ) => {
-    const prevSnapshot = this.snapshot;
-    const nextSnapshot = this.snapshot.maybeClone(elements, appState);
-
-    // Optimisation, don't continue if nothing has changed
-    if (prevSnapshot !== nextSnapshot) {
-      // Calculate and record the changes based on the previous and next snapshot
-      const elementsChange = nextSnapshot.meta.didElementsChange
-        ? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
-        : ElementsChange.empty();
-
-      const appStateChange = nextSnapshot.meta.didAppStateChange
-        ? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
-        : AppStateChange.empty();
-
-      if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
-        // Notify listeners with the increment
-        this.onStoreIncrementEmitter.trigger(
-          new StoreIncrementEvent(elementsChange, appStateChange),
-        );
-      }
-
-      // Update snapshot
-      this.snapshot = nextSnapshot;
-    }
-  };
-
-  public updateSnapshot = (
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-    appState: AppState | ObservedAppState | undefined,
-  ) => {
-    const nextSnapshot = this.snapshot.maybeClone(elements, appState);
-
-    if (this.snapshot !== nextSnapshot) {
-      // Update snapshot
-      this.snapshot = nextSnapshot;
-    }
-  };
-
-  public filterUncomittedElements = (
-    prevElements: Map<string, OrderedExcalidrawElement>,
-    nextElements: Map<string, OrderedExcalidrawElement>,
-  ) => {
-    for (const [id, prevElement] of prevElements.entries()) {
-      const nextElement = nextElements.get(id);
-
-      if (!nextElement) {
-        // Nothing to care about here, elements were forcefully deleted
-        continue;
-      }
-
-      const elementSnapshot = this.snapshot.elements.get(id);
-
-      // Checks for in progress async user action
-      if (!elementSnapshot) {
-        // Detected yet uncomitted local element
-        nextElements.delete(id);
-      } else if (elementSnapshot.version < prevElement.version) {
-        // Element was already commited, but the snapshot version is lower than current current local version
-        nextElements.set(id, elementSnapshot);
-      }
-    }
-
-    return nextElements;
-  };
-
-  public clear = (): void => {
-    this.snapshot = Snapshot.empty();
-    this.scheduledActions = new Set();
-  };
-
-  private satisfiesScheduledActionsInvariant = () => {
-    if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
-      const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
-      console.error(message, this.scheduledActions.values());
-
-      if (isTestEnv() || isDevEnv()) {
-        throw new Error(message);
-      }
-    }
-  };
-}
-
-export class Snapshot {
-  private constructor(
-    public readonly elements: Map<string, OrderedExcalidrawElement>,
-    public readonly appState: ObservedAppState,
-    public readonly meta: {
-      didElementsChange: boolean;
-      didAppStateChange: boolean;
-      isEmpty?: boolean;
-    } = {
-      didElementsChange: false,
-      didAppStateChange: false,
-      isEmpty: false,
-    },
-  ) {}
-
-  public static empty() {
-    return new Snapshot(
-      new Map(),
-      getObservedAppState(getDefaultAppState() as AppState),
-      { didElementsChange: false, didAppStateChange: false, isEmpty: true },
-    );
-  }
-
-  public isEmpty() {
-    return this.meta.isEmpty;
-  }
-
-  /**
-   * Efficiently clone the existing snapshot, only if we detected changes.
-   *
-   * @returns same instance if there are no changes detected, new instance otherwise.
-   */
-  public maybeClone(
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-    appState: AppState | ObservedAppState | undefined,
-  ) {
-    const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
-    const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
-
-    let didElementsChange = false;
-    let didAppStateChange = false;
-
-    if (this.elements !== nextElementsSnapshot) {
-      didElementsChange = true;
-    }
-
-    if (this.appState !== nextAppStateSnapshot) {
-      didAppStateChange = true;
-    }
-
-    if (!didElementsChange && !didAppStateChange) {
-      return this;
-    }
-
-    const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
-      didElementsChange,
-      didAppStateChange,
-    });
-
-    return snapshot;
-  }
-
-  private maybeCreateAppStateSnapshot(
-    appState: AppState | ObservedAppState | undefined,
-  ) {
-    if (!appState) {
-      return this.appState;
-    }
-
-    // Not watching over everything from the app state, just the relevant props
-    const nextAppStateSnapshot = !isObservedAppState(appState)
-      ? getObservedAppState(appState)
-      : appState;
-
-    const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
-
-    if (!didAppStateChange) {
-      return this.appState;
-    }
-
-    return nextAppStateSnapshot;
-  }
-
-  private detectChangedAppState(nextObservedAppState: ObservedAppState) {
-    return !isShallowEqual(this.appState, nextObservedAppState, {
-      selectedElementIds: isShallowEqual,
-      selectedGroupIds: isShallowEqual,
-    });
-  }
-
-  private maybeCreateElementsSnapshot(
-    elements: Map<string, OrderedExcalidrawElement> | undefined,
-  ) {
-    if (!elements) {
-      return this.elements;
-    }
-
-    const didElementsChange = this.detectChangedElements(elements);
-
-    if (!didElementsChange) {
-      return this.elements;
-    }
-
-    const elementsSnapshot = this.createElementsSnapshot(elements);
-    return elementsSnapshot;
-  }
-
-  /**
-   * Detect if there any changed elements.
-   *
-   * NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates.
-   */
-  private detectChangedElements(
-    nextElements: Map<string, OrderedExcalidrawElement>,
-  ) {
-    if (this.elements === nextElements) {
-      return false;
-    }
-
-    if (this.elements.size !== nextElements.size) {
-      return true;
-    }
-
-    // loop from right to left as changes are likelier to happen on new elements
-    const keys = Array.from(nextElements.keys());
-
-    for (let i = keys.length - 1; i >= 0; i--) {
-      const prev = this.elements.get(keys[i]);
-      const next = nextElements.get(keys[i]);
-      if (
-        !prev ||
-        !next ||
-        prev.id !== next.id ||
-        prev.versionNonce !== next.versionNonce
-      ) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-  /**
-   * Perform structural clone, cloning only elements that changed.
-   */
-  private createElementsSnapshot(
-    nextElements: Map<string, OrderedExcalidrawElement>,
-  ) {
-    const clonedElements = new Map();
-
-    for (const [id, prevElement] of this.elements.entries()) {
-      // Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
-      // i.e. during collab, persist or whenenever isDeleted elements get cleared
-      if (!nextElements.get(id)) {
-        // When we cannot find the prev element in the next elements, we mark it as deleted
-        clonedElements.set(
-          id,
-          newElementWith(prevElement, { isDeleted: true }),
-        );
-      } else {
-        clonedElements.set(id, prevElement);
-      }
-    }
-
-    for (const [id, nextElement] of nextElements.entries()) {
-      const prevElement = clonedElements.get(id);
-
-      // At this point our elements are reconcilled already, meaning the next element is always newer
-      if (
-        !prevElement || // element was added
-        (prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
-      ) {
-        clonedElements.set(id, deepCopyElement(nextElement));
-      }
-    }
-
-    return clonedElements;
-  }
-}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 434 - 468
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 543 - 545
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


+ 6 - 6
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap

@@ -44,7 +44,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
   "frameId": null,
   "groupIds": [],
   "height": 50,
-  "id": "id2",
+  "id": "id4",
   "index": "a1",
   "isDeleted": false,
   "link": null,
@@ -108,7 +108,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id2",
+      "id": "id6",
       "type": "arrow",
     },
   ],
@@ -147,7 +147,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id2",
+      "id": "id6",
       "type": "arrow",
     },
   ],
@@ -156,7 +156,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "frameId": null,
   "groupIds": [],
   "height": 300,
-  "id": "id1",
+  "id": "id3",
   "index": "a1",
   "isDeleted": false,
   "link": null,
@@ -189,7 +189,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
-    "elementId": "id1",
+    "elementId": "id3",
     "focus": "-0.46667",
     "gap": 10,
   },
@@ -197,7 +197,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "frameId": null,
   "groupIds": [],
   "height": "87.29887",
-  "id": "id2",
+  "id": "id6",
   "index": "a2",
   "isDeleted": false,
   "lastCommittedPoint": null,

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 477 - 470
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap


+ 3 - 1
packages/excalidraw/tests/contextmenu.test.tsx

@@ -23,6 +23,7 @@ import {
   waitFor,
   togglePopover,
   unmountComponent,
+  checkpointHistory,
 } from "./test-utils";
 
 import type { ShortcutName } from "../actions/shortcuts";
@@ -33,11 +34,12 @@ const checkpoint = (name: string) => {
     `[${name}] number of renders`,
   );
   expect(h.state).toMatchSnapshot(`[${name}] appState`);
-  expect(h.history).toMatchSnapshot(`[${name}] history`);
   expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
   h.elements.forEach((element, i) =>
     expect(element).toMatchSnapshot(`[${name}] element ${i}`),
   );
+
+  checkpointHistory(h.history, name);
 };
 
 const mouse = new Pointer("mouse");

+ 19 - 17
packages/excalidraw/tests/history.test.tsx

@@ -23,6 +23,10 @@ import {
 
 import "@excalidraw/utils/test-utils";
 
+import { ElementsDelta, AppStateDelta } from "@excalidraw/element/delta";
+
+import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element/store";
+
 import type { LocalPoint, Radians } from "@excalidraw/math";
 
 import type {
@@ -46,11 +50,8 @@ import {
 import { createUndoAction, createRedoAction } from "../actions/actionHistory";
 import { actionToggleViewMode } from "../actions/actionToggleViewMode";
 import { getDefaultAppState } from "../appState";
-import { HistoryEntry } from "../history";
 import { Excalidraw } from "../index";
 import * as StaticScene from "../renderer/staticScene";
-import { Snapshot, CaptureUpdateAction } from "../store";
-import { AppStateChange, ElementsChange } from "../change";
 
 import { API } from "./helpers/api";
 import { Keyboard, Pointer, UI } from "./helpers/ui";
@@ -61,6 +62,7 @@ import {
   render,
   togglePopover,
   getCloneByOrigId,
+  checkpointHistory,
 } from "./test-utils";
 
 import type { AppState } from "../types";
@@ -82,13 +84,15 @@ const checkpoint = (name: string) => {
     ...strippedAppState
   } = h.state;
   expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
-  expect(h.history).toMatchSnapshot(`[${name}] history`);
   expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
+
   h.elements
     .map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
     .forEach((element, i) =>
       expect(element).toMatchSnapshot(`[${name}] element ${i}`),
     );
+
+  checkpointHistory(h.history, name);
 };
 
 const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
@@ -116,12 +120,12 @@ describe("history", () => {
 
       API.setElements([rect]);
 
-      const corrupedEntry = HistoryEntry.create(
-        AppStateChange.empty(),
-        ElementsChange.empty(),
+      const corrupedEntry = StoreDelta.create(
+        ElementsDelta.empty(),
+        AppStateDelta.empty(),
       );
 
-      vi.spyOn(corrupedEntry, "applyTo").mockImplementation(() => {
+      vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
         throw new Error("Oh no, I am corrupted!");
       });
 
@@ -136,7 +140,6 @@ describe("history", () => {
             h.history.undo(
               arrayToMap(h.elements) as SceneElementsMap,
               appState,
-              Snapshot.empty(),
             ) as any,
         );
       } catch (e) {
@@ -157,7 +160,6 @@ describe("history", () => {
             h.history.redo(
               arrayToMap(h.elements) as SceneElementsMap,
               appState,
-              Snapshot.empty(),
             ) as any,
         );
       } catch (e) {
@@ -454,8 +456,8 @@ describe("history", () => {
         expect(h.history.isUndoStackEmpty).toBeTruthy();
       });
 
-      const undoAction = createUndoAction(h.history, h.store);
-      const redoAction = createRedoAction(h.history, h.store);
+      const undoAction = createUndoAction(h.history);
+      const redoAction = createRedoAction(h.history);
       // noop
       API.executeAction(undoAction);
       expect(h.elements).toEqual([
@@ -531,8 +533,8 @@ describe("history", () => {
         expect.objectContaining({ id: "B", isDeleted: false }),
       ]);
 
-      const undoAction = createUndoAction(h.history, h.store);
-      const redoAction = createRedoAction(h.history, h.store);
+      const undoAction = createUndoAction(h.history);
+      const redoAction = createRedoAction(h.history);
       API.executeAction(undoAction);
 
       expect(API.getSnapshot()).toEqual([
@@ -1713,8 +1715,8 @@ describe("history", () => {
         />,
       );
 
-      const undoAction = createUndoAction(h.history, h.store);
-      const redoAction = createRedoAction(h.history, h.store);
+      const undoAction = createUndoAction(h.history);
+      const redoAction = createRedoAction(h.history);
 
       await waitFor(() => {
         expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
@@ -1763,7 +1765,7 @@ describe("history", () => {
         />,
       );
 
-      const undoAction = createUndoAction(h.history, h.store);
+      const undoAction = createUndoAction(h.history);
 
       await waitFor(() => {
         expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

+ 3 - 1
packages/excalidraw/tests/regressionTests.test.tsx

@@ -14,6 +14,7 @@ import { API } from "./helpers/api";
 import { Keyboard, Pointer, UI } from "./helpers/ui";
 import {
   assertSelectedElements,
+  checkpointHistory,
   fireEvent,
   render,
   screen,
@@ -39,11 +40,12 @@ const checkpoint = (name: string) => {
     `[${name}] number of renders`,
   );
   expect(h.state).toMatchSnapshot(`[${name}] appState`);
-  expect(h.history).toMatchSnapshot(`[${name}] history`);
   expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
   h.elements.forEach((element, i) =>
     expect(element).toMatchSnapshot(`[${name}] element ${i}`),
   );
+
+  checkpointHistory(h.history, name);
 };
 beforeEach(async () => {
   unmountComponent();

+ 44 - 0
packages/excalidraw/tests/test-utils.ts

@@ -22,6 +22,8 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
 import { Pointer, UI } from "./helpers/ui";
 import * as toolQueries from "./queries/toolQueries";
 
+import type { History } from "../history";
+
 import type { RenderResult, RenderOptions } from "@testing-library/react";
 
 import type { ImportedDataState } from "../data/types";
@@ -432,3 +434,45 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
 
   expect(h.state.selectedElementIds).toEqual(selectedElementIds);
 };
+
+const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
+  Object.entries(deltas).reduce((acc, curr) => {
+    const { inserted, deleted, ...rest } = curr[1];
+
+    delete inserted.seed;
+    delete deleted.seed;
+
+    acc[curr[0]] = {
+      inserted,
+      deleted,
+      ...rest,
+    };
+
+    return acc;
+  }, {} as Record<string, any>);
+
+export const checkpointHistory = (history: History, name: string) => {
+  expect(
+    history.undoStack.map((x) => ({
+      ...x,
+      elements: {
+        ...x.elements,
+        added: stripSeed(x.elements.added),
+        removed: stripSeed(x.elements.removed),
+        updated: stripSeed(x.elements.updated),
+      },
+    })),
+  ).toMatchSnapshot(`[${name}] undo stack`);
+
+  expect(
+    history.redoStack.map((x) => ({
+      ...x,
+      elements: {
+        ...x.elements,
+        added: stripSeed(x.elements.added),
+        removed: stripSeed(x.elements.removed),
+        updated: stripSeed(x.elements.updated),
+      },
+    })),
+  ).toMatchSnapshot(`[${name}] redo stack`);
+};

+ 10 - 1
packages/excalidraw/types.ts

@@ -43,6 +43,12 @@ import type {
   MakeBrand,
 } from "@excalidraw/common/utility-types";
 
+import type {
+  CaptureUpdateActionType,
+  DurableIncrement,
+  EphemeralIncrement,
+} from "@excalidraw/element/store";
+
 import type { Action } from "./actions/types";
 import type { Spreadsheet } from "./charts";
 import type { ClipboardData } from "./clipboard";
@@ -51,7 +57,6 @@ import type Library from "./data/library";
 import type { FileSystemHandle } from "./data/filesystem";
 import type { ContextMenuItems } from "./components/ContextMenu";
 import type { SnapLine } from "./snapping";
-import type { CaptureUpdateActionType } from "./store";
 import type { ImportedDataState } from "./data/types";
 
 import type { Language } from "./i18n";
@@ -518,6 +523,7 @@ export interface ExcalidrawProps {
     appState: AppState,
     files: BinaryFiles,
   ) => void;
+  onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
   initialData?:
     | (() => MaybePromise<ExcalidrawInitialDataState | null>)
     | MaybePromise<ExcalidrawInitialDataState | null>;
@@ -821,6 +827,9 @@ export interface ExcalidrawImperativeAPI {
       files: BinaryFiles,
     ) => void,
   ) => UnsubscribeCallback;
+  onIncrement: (
+    callback: (event: DurableIncrement | EphemeralIncrement) => void,
+  ) => UnsubscribeCallback;
   onPointerDown: (
     callback: (
       activeTool: AppState["activeTool"],

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác