Selaa lähdekoodia

feat: various delta improvements (#9571)

Marcel Mraz 3 kuukautta sitten
vanhempi
commit
d108053351

+ 1 - 74
excalidraw-app/tests/collab.test.tsx

@@ -205,6 +205,7 @@ describe("collaboration", () => {
     // with explicit undo (as addition) we expect our item to be restored from the snapshot!
     await waitFor(() => {
       expect(API.getUndoStack().length).toBe(1);
+      expect(API.getRedoStack().length).toBe(1);
       expect(API.getSnapshot()).toEqual([
         expect.objectContaining(rect1Props),
         expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -247,79 +248,5 @@ describe("collaboration", () => {
         expect.objectContaining({ ...rect2Props, isDeleted: true }),
       ]);
     });
-
-    act(() => h.app.actionManager.executeAction(undoAction));
-
-    // simulate local update
-    API.updateScene({
-      elements: syncInvalidIndices([
-        h.elements[0],
-        newElementWith(h.elements[1], { x: 100 }),
-      ]),
-      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
-    });
-
-    await waitFor(() => {
-      expect(API.getUndoStack().length).toBe(2);
-      expect(API.getRedoStack().length).toBe(0);
-      expect(API.getSnapshot()).toEqual([
-        expect.objectContaining(rect1Props),
-        expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
-      ]);
-      expect(h.elements).toEqual([
-        expect.objectContaining(rect1Props),
-        expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
-      ]);
-    });
-
-    act(() => h.app.actionManager.executeAction(undoAction));
-
-    // we expect to iterate the stack to the first visible change
-    await waitFor(() => {
-      expect(API.getUndoStack().length).toBe(1);
-      expect(API.getRedoStack().length).toBe(1);
-      expect(API.getSnapshot()).toEqual([
-        expect.objectContaining(rect1Props),
-        expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
-      ]);
-      expect(h.elements).toEqual([
-        expect.objectContaining(rect1Props),
-        expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
-      ]);
-    });
-
-    // simulate force deleting the element remotely
-    API.updateScene({
-      elements: syncInvalidIndices([rect1]),
-      captureUpdate: CaptureUpdateAction.NEVER,
-    });
-
-    // snapshot was correctly updated and marked the element as deleted
-    await waitFor(() => {
-      expect(API.getUndoStack().length).toBe(1);
-      expect(API.getRedoStack().length).toBe(1);
-      expect(API.getSnapshot()).toEqual([
-        expect.objectContaining(rect1Props),
-        expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
-      ]);
-      expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
-    });
-
-    act(() => h.app.actionManager.executeAction(redoAction));
-
-    // with explicit redo (as update) we again restored the element from the snapshot!
-    await waitFor(() => {
-      expect(API.getUndoStack().length).toBe(2);
-      expect(API.getRedoStack().length).toBe(0);
-      expect(API.getSnapshot()).toEqual([
-        expect.objectContaining({ id: "A", isDeleted: false }),
-        expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
-      ]);
-      expect(h.history.isRedoStackEmpty).toBeTruthy();
-      expect(h.elements).toEqual([
-        expect.objectContaining({ id: "A", isDeleted: false }),
-        expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
-      ]);
-    });
   });
 });

+ 2 - 2
packages/common/src/utils.ts

@@ -714,8 +714,8 @@ export const arrayToObject = <T>(
   array: readonly T[],
   groupBy?: (value: T) => string | number,
 ) =>
-  array.reduce((acc, value) => {
-    acc[groupBy ? groupBy(value) : String(value)] = value;
+  array.reduce((acc, value, idx) => {
+    acc[groupBy ? groupBy(value) : idx] = value;
     return acc;
   }, {} as { [key: string]: T });
 

+ 252 - 89
packages/element/src/delta.ts

@@ -5,11 +5,12 @@ import {
   isDevEnv,
   isShallowEqual,
   isTestEnv,
+  randomInteger,
 } from "@excalidraw/common";
 
 import type {
   ExcalidrawElement,
-  ExcalidrawImageElement,
+  ExcalidrawFreeDrawElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
   NonDeleted,
@@ -18,7 +19,12 @@ import type {
   SceneElementsMap,
 } from "@excalidraw/element/types";
 
-import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
+import type {
+  DTO,
+  Mutable,
+  SubtypeOf,
+  ValueOf,
+} from "@excalidraw/common/utility-types";
 
 import type {
   AppState,
@@ -51,6 +57,8 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
 
 import { Scene } from "./Scene";
 
+import { StoreSnapshot } from "./store";
+
 import type { BindableProp, BindingProp } from "./binding";
 
 import type { ElementUpdate } from "./mutateElement";
@@ -73,13 +81,20 @@ export class Delta<T> {
   public static create<T>(
     deleted: Partial<T>,
     inserted: Partial<T>,
-    modifier?: (delta: Partial<T>) => Partial<T>,
-    modifierOptions?: "deleted" | "inserted",
+    modifier?: (
+      delta: Partial<T>,
+      partialType: "deleted" | "inserted",
+    ) => Partial<T>,
+    modifierOptions?: "deleted" | "inserted" | "both",
   ) {
     const modifiedDeleted =
-      modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted;
+      modifier && modifierOptions !== "inserted"
+        ? modifier(deleted, "deleted")
+        : deleted;
     const modifiedInserted =
-      modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted;
+      modifier && modifierOptions !== "deleted"
+        ? modifier(inserted, "inserted")
+        : inserted;
 
     return new Delta(modifiedDeleted, modifiedInserted);
   }
@@ -113,11 +128,7 @@ export class Delta<T> {
     // - we do this only on previously detected changed elements
     // - we do shallow compare only on the first level of properties (not going any deeper)
     // - # of properties is reasonably small
-    for (const key of this.distinctKeysIterator(
-      "full",
-      prevObject,
-      nextObject,
-    )) {
+    for (const key of this.getDifferences(prevObject, nextObject)) {
       deleted[key as keyof T] = prevObject[key];
       inserted[key as keyof T] = nextObject[key];
     }
@@ -256,12 +267,14 @@ export class Delta<T> {
           arrayToObject(deletedArray, groupBy),
           arrayToObject(insertedArray, groupBy),
         ),
+        (x) => x,
       );
       const insertedDifferences = arrayToObject(
         Delta.getRightDifferences(
           arrayToObject(deletedArray, groupBy),
           arrayToObject(insertedArray, groupBy),
         ),
+        (x) => x,
       );
 
       if (
@@ -320,6 +333,42 @@ export class Delta<T> {
     return !!anyDistinctKey;
   }
 
+  /**
+   * Compares if shared properties of object1 and object2 contain any different value (aka inner join).
+   */
+  public static isInnerDifferent<T extends {}>(
+    object1: T,
+    object2: T,
+    skipShallowCompare = false,
+  ): boolean {
+    const anyDistinctKey = !!this.distinctKeysIterator(
+      "inner",
+      object1,
+      object2,
+      skipShallowCompare,
+    ).next().value;
+
+    return !!anyDistinctKey;
+  }
+
+  /**
+   * Compares if any properties of object1 and object2 contain any different value (aka full join).
+   */
+  public static isDifferent<T extends {}>(
+    object1: T,
+    object2: T,
+    skipShallowCompare = false,
+  ): boolean {
+    const anyDistinctKey = !!this.distinctKeysIterator(
+      "full",
+      object1,
+      object2,
+      skipShallowCompare,
+    ).next().value;
+
+    return !!anyDistinctKey;
+  }
+
   /**
    * Returns sorted object1 keys that have distinct values.
    */
@@ -346,6 +395,32 @@ export class Delta<T> {
     ).sort();
   }
 
+  /**
+   * Returns sorted keys of shared object1 and object2 properties that have distinct values (aka inner join).
+   */
+  public static getInnerDifferences<T extends {}>(
+    object1: T,
+    object2: T,
+    skipShallowCompare = false,
+  ) {
+    return Array.from(
+      this.distinctKeysIterator("inner", object1, object2, skipShallowCompare),
+    ).sort();
+  }
+
+  /**
+   * Returns sorted keys that have distinct values between object1 and object2 (aka full join).
+   */
+  public static getDifferences<T extends {}>(
+    object1: T,
+    object2: T,
+    skipShallowCompare = false,
+  ) {
+    return Array.from(
+      this.distinctKeysIterator("full", object1, object2, skipShallowCompare),
+    ).sort();
+  }
+
   /**
    * Iterator comparing values of object properties based on the passed joining strategy.
    *
@@ -354,7 +429,7 @@ export class Delta<T> {
    * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
    */
   private static *distinctKeysIterator<T extends {}>(
-    join: "left" | "right" | "full",
+    join: "left" | "right" | "inner" | "full",
     object1: T,
     object2: T,
     skipShallowCompare = false,
@@ -369,6 +444,8 @@ export class Delta<T> {
       keys = Object.keys(object1);
     } else if (join === "right") {
       keys = Object.keys(object2);
+    } else if (join === "inner") {
+      keys = Object.keys(object1).filter((key) => key in object2);
     } else if (join === "full") {
       keys = Array.from(
         new Set([...Object.keys(object1), ...Object.keys(object2)]),
@@ -382,17 +459,17 @@ export class Delta<T> {
     }
 
     for (const key of keys) {
-      const object1Value = object1[key as keyof T];
-      const object2Value = object2[key as keyof T];
+      const value1 = object1[key as keyof T];
+      const value2 = object2[key as keyof T];
 
-      if (object1Value !== object2Value) {
+      if (value1 !== value2) {
         if (
           !skipShallowCompare &&
-          typeof object1Value === "object" &&
-          typeof object2Value === "object" &&
-          object1Value !== null &&
-          object2Value !== null &&
-          isShallowEqual(object1Value, object2Value)
+          typeof value1 === "object" &&
+          typeof value2 === "object" &&
+          value1 !== null &&
+          value2 !== null &&
+          isShallowEqual(value1, value2)
         ) {
           continue;
         }
@@ -858,10 +935,17 @@ export class AppStateDelta implements DeltaContainer<AppState> {
   }
 }
 
-type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
-  ElementUpdate<Ordered<T>>,
-  "seed"
->;
+type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
+  Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
+
+export type ApplyToOptions = {
+  excludedProperties: Set<keyof ElementPartial>;
+};
+
+type ApplyToFlags = {
+  containsVisibleDifference: boolean;
+  containsZindexDifference: boolean;
+};
 
 /**
  * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -944,13 +1028,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
     inserted,
   }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
 
+  private static satisfiesCommmonInvariants = ({
+    deleted,
+    inserted,
+  }: Delta<ElementPartial>) =>
+    !!(
+      deleted.version &&
+      inserted.version &&
+      // versions are required integers
+      Number.isInteger(deleted.version) &&
+      Number.isInteger(inserted.version) &&
+      // versions should be positive, zero included
+      deleted.version >= 0 &&
+      inserted.version >= 0 &&
+      // versions should never be the same
+      deleted.version !== inserted.version
+    );
+
   private static validate(
     elementsDelta: ElementsDelta,
     type: "added" | "removed" | "updated",
-    satifies: (delta: Delta<ElementPartial>) => boolean,
+    satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
   ) {
     for (const [id, delta] of Object.entries(elementsDelta[type])) {
-      if (!satifies(delta)) {
+      if (
+        !this.satisfiesCommmonInvariants(delta) ||
+        !satifiesSpecialInvariants(delta)
+      ) {
         console.error(
           `Broken invariant for "${type}" delta, element "${id}", delta:`,
           delta,
@@ -986,7 +1090,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
 
       if (!nextElement) {
         const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
-        const inserted = { isDeleted: true } as ElementPartial;
+
+        const inserted = {
+          isDeleted: true,
+          version: prevElement.version + 1,
+          versionNonce: randomInteger(),
+        } as ElementPartial;
 
         const delta = Delta.create(
           deleted,
@@ -1002,7 +1111,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
       const prevElement = prevElements.get(nextElement.id);
 
       if (!prevElement) {
-        const deleted = { isDeleted: true } as ElementPartial;
+        const deleted = {
+          isDeleted: true,
+          version: nextElement.version - 1,
+          versionNonce: randomInteger(),
+        } as ElementPartial;
+
         const inserted = {
           ...nextElement,
           isDeleted: false,
@@ -1087,16 +1201,40 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   /**
    * Update delta/s based on the existing elements.
    *
-   * @param elements current elements
+   * @param nextElements current elements
    * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
    * @returns new instance with modified delta/s
    */
   public applyLatestChanges(
-    elements: SceneElementsMap,
-    modifierOptions: "deleted" | "inserted",
+    prevElements: SceneElementsMap,
+    nextElements: SceneElementsMap,
+    modifierOptions?: "deleted" | "inserted",
   ): ElementsDelta {
     const modifier =
-      (element: OrderedExcalidrawElement) => (partial: ElementPartial) => {
+      (
+        prevElement: OrderedExcalidrawElement | undefined,
+        nextElement: OrderedExcalidrawElement | undefined,
+      ) =>
+      (partial: ElementPartial, partialType: "deleted" | "inserted") => {
+        let element: OrderedExcalidrawElement | undefined;
+
+        switch (partialType) {
+          case "deleted":
+            element = prevElement;
+            break;
+          case "inserted":
+            element = nextElement;
+            break;
+        }
+
+        // the element wasn't found -> don't update the partial
+        if (!element) {
+          console.error(
+            `Element not found when trying to apply latest changes`,
+          );
+          return partial;
+        }
+
         const latestPartial: { [key: string]: unknown } = {};
 
         for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
@@ -1120,19 +1258,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
       const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
 
       for (const [id, delta] of Object.entries(deltas)) {
-        const existingElement = elements.get(id);
+        const prevElement = prevElements.get(id);
+        const nextElement = nextElements.get(id);
+
+        let latestDelta: Delta<ElementPartial> | null = null;
 
-        if (existingElement) {
-          const modifiedDelta = Delta.create(
+        if (prevElement || nextElement) {
+          latestDelta = Delta.create(
             delta.deleted,
             delta.inserted,
-            modifier(existingElement),
+            modifier(prevElement, nextElement),
             modifierOptions,
           );
-
-          modifiedDeltas[id] = modifiedDelta;
         } else {
-          modifiedDeltas[id] = delta;
+          latestDelta = delta;
+        }
+
+        // it might happen that after applying latest changes the delta itself does not contain any changes
+        if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
+          modifiedDeltas[id] = latestDelta;
         }
       }
 
@@ -1150,12 +1294,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
 
   public applyTo(
     elements: SceneElementsMap,
-    elementsSnapshot: Map<string, OrderedExcalidrawElement>,
+    snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
+    options: ApplyToOptions = {
+      excludedProperties: new Set(),
+    },
   ): [SceneElementsMap, boolean] {
     let nextElements = new Map(elements) as SceneElementsMap;
     let changedElements: Map<string, OrderedExcalidrawElement>;
 
-    const flags = {
+    const flags: ApplyToFlags = {
       containsVisibleDifference: false,
       containsZindexDifference: false,
     };
@@ -1164,13 +1311,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
     try {
       const applyDeltas = ElementsDelta.createApplier(
         nextElements,
-        elementsSnapshot,
+        snapshot,
+        options,
         flags,
       );
 
-      const addedElements = applyDeltas("added", this.added);
-      const removedElements = applyDeltas("removed", this.removed);
-      const updatedElements = applyDeltas("updated", this.updated);
+      const addedElements = applyDeltas(this.added);
+      const removedElements = applyDeltas(this.removed);
+      const updatedElements = applyDeltas(this.updated);
 
       const affectedElements = this.resolveConflicts(elements, nextElements);
 
@@ -1229,18 +1377,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private static createApplier =
     (
       nextElements: SceneElementsMap,
-      snapshot: Map<string, OrderedExcalidrawElement>,
-      flags: {
-        containsVisibleDifference: boolean;
-        containsZindexDifference: boolean;
-      },
+      snapshot: StoreSnapshot["elements"],
+      options: ApplyToOptions,
+      flags: ApplyToFlags,
     ) =>
-    (
-      type: "added" | "removed" | "updated",
-      deltas: Record<string, Delta<ElementPartial>>,
-    ) => {
+    (deltas: Record<string, Delta<ElementPartial>>) => {
       const getElement = ElementsDelta.createGetter(
-        type,
         nextElements,
         snapshot,
         flags,
@@ -1250,7 +1392,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
         const element = getElement(id, delta.inserted);
 
         if (element) {
-          const newElement = ElementsDelta.applyDelta(element, delta, flags);
+          const newElement = ElementsDelta.applyDelta(
+            element,
+            delta,
+            options,
+            flags,
+          );
+
           nextElements.set(newElement.id, newElement);
           acc.set(newElement.id, newElement);
         }
@@ -1261,13 +1409,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
 
   private static createGetter =
     (
-      type: "added" | "removed" | "updated",
       elements: SceneElementsMap,
-      snapshot: Map<string, OrderedExcalidrawElement>,
-      flags: {
-        containsVisibleDifference: boolean;
-        containsZindexDifference: boolean;
-      },
+      snapshot: StoreSnapshot["elements"],
+      flags: ApplyToFlags,
     ) =>
     (id: string, partial: ElementPartial) => {
       let element = elements.get(id);
@@ -1281,10 +1425,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
           flags.containsZindexDifference = true;
 
           // as the element was force deleted, we need to check if adding it back results in a visible change
-          if (
-            partial.isDeleted === false ||
-            (partial.isDeleted !== true && element.isDeleted === false)
-          ) {
+          if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
             flags.containsVisibleDifference = true;
           }
         } else {
@@ -1304,16 +1445,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private static applyDelta(
     element: OrderedExcalidrawElement,
     delta: Delta<ElementPartial>,
-    flags: {
-      containsVisibleDifference: boolean;
-      containsZindexDifference: boolean;
-    } = {
-      // by default we don't care about about the flags
-      containsVisibleDifference: true,
-      containsZindexDifference: true,
-    },
+    options: ApplyToOptions,
+    flags: ApplyToFlags,
   ) {
-    const { boundElements, ...directlyApplicablePartial } = delta.inserted;
+    const directlyApplicablePartial: Mutable<ElementPartial> = {};
+
+    // some properties are not directly applicable, such as:
+    // - boundElements which contains only diff)
+    // - version & versionNonce, if we don't want to return to previous versions
+    for (const key of Object.keys(delta.inserted) as Array<
+      keyof typeof delta.inserted
+    >) {
+      if (key === "boundElements") {
+        continue;
+      }
+
+      if (options.excludedProperties.has(key)) {
+        continue;
+      }
+
+      const value = delta.inserted[key];
+      Reflect.set(directlyApplicablePartial, key, value);
+    }
 
     if (
       delta.deleted.boundElements?.length ||
@@ -1331,19 +1484,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
       });
     }
 
-    // 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
-      if (_delta.deleted.crop || _delta.inserted.crop) {
-        Object.assign(directlyApplicablePartial, {
-          // apply change verbatim
-          crop: _delta.inserted.crop ?? null,
-        });
-      }
-    }
-
     if (!flags.containsVisibleDifference) {
       // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
       const { index, ...rest } = directlyApplicablePartial;
@@ -1650,6 +1790,29 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   ): [ElementPartial, ElementPartial] {
     try {
       Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
+
+      // don't diff the points as:
+      // - we can't ensure the multiplayer order consistency without fractional index on each point
+      // - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
+      const deletedPoints =
+        (
+          deleted as ElementPartial<
+            ExcalidrawFreeDrawElement | ExcalidrawLinearElement
+          >
+        ).points ?? [];
+
+      const insertedPoints =
+        (
+          inserted as ElementPartial<
+            ExcalidrawFreeDrawElement | ExcalidrawLinearElement
+          >
+        ).points ?? [];
+
+      if (!Delta.isDifferent(deletedPoints, insertedPoints)) {
+        // delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
+        Reflect.deleteProperty(deleted, "points");
+        Reflect.deleteProperty(inserted, "points");
+      }
     } 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 delta.`);
@@ -1665,7 +1828,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private static stripIrrelevantProps(
     partial: Partial<OrderedExcalidrawElement>,
   ): ElementPartial {
-    const { id, updated, version, versionNonce, ...strippedPartial } = partial;
+    const { id, updated, ...strippedPartial } = partial;
 
     return strippedPartial;
   }

+ 35 - 9
packages/element/src/fractionalIndex.ts

@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
 
 import { arrayToMap } from "@excalidraw/common";
 
-import { mutateElement } from "./mutateElement";
+import { mutateElement, newElementWith } from "./mutateElement";
 import { getBoundTextElement } from "./textElement";
 import { hasBoundTextElement } from "./typeChecks";
 
@@ -11,6 +11,7 @@ import type {
   ExcalidrawElement,
   FractionalIndex,
   OrderedExcalidrawElement,
+  SceneElementsMap,
 } from "./types";
 
 export class InvalidFractionalIndexError extends Error {
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
 
     // try generatating indices, throws on invalid movedElements
     const elementsUpdates = generateIndices(elements, indicesGroups);
-    const elementsCandidates = elements.map((x) =>
-      elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
-    );
+    const elementsCandidates = elements.map((x) => {
+      const elementUpdates = elementsUpdates.get(x);
+
+      if (elementUpdates) {
+        return { ...x, index: elementUpdates.index };
+      }
+
+      return x;
+    });
 
     // ensure next indices are valid before mutation, throws on invalid ones
     validateFractionalIndices(
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
     );
 
     // split mutation so we don't end up in an incosistent state
-    for (const [element, update] of elementsUpdates) {
-      mutateElement(element, elementsMap, update);
+    for (const [element, { index }] of elementsUpdates) {
+      mutateElement(element, elementsMap, { index });
     }
   } catch (e) {
     // fallback to default sync
@@ -189,7 +196,7 @@ export const syncMovedIndices = (
 };
 
 /**
- * Synchronizes all invalid fractional indices with the array order by mutating passed elements.
+ * Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
  *
  * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
  */
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
   const indicesGroups = getInvalidIndicesGroups(elements);
   const elementsUpdates = generateIndices(elements, indicesGroups);
 
-  for (const [element, update] of elementsUpdates) {
-    mutateElement(element, elementsMap, update);
+  for (const [element, { index }] of elementsUpdates) {
+    mutateElement(element, elementsMap, { index });
   }
 
   return elements as OrderedExcalidrawElement[];
 };
 
+/**
+ * Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
+ *
+ * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
+ */
+export const syncInvalidIndicesImmutable = (
+  elements: readonly ExcalidrawElement[],
+): SceneElementsMap | undefined => {
+  const syncedElements = arrayToMap(elements);
+  const indicesGroups = getInvalidIndicesGroups(elements);
+  const elementsUpdates = generateIndices(elements, indicesGroups);
+
+  for (const [element, { index }] of elementsUpdates) {
+    syncedElements.set(element.id, newElementWith(element, { index }));
+  }
+
+  return syncedElements as SceneElementsMap;
+};
+
 /**
  * Get contiguous groups of indices of passed moved elements.
  *

+ 5 - 5
packages/element/src/mutateElement.ts

@@ -23,7 +23,7 @@ import type {
 
 export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
   Partial<TElement>,
-  "id" | "version" | "versionNonce" | "updated"
+  "id" | "updated"
 >;
 
 /**
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
     ShapeCache.delete(element);
   }
 
-  element.version++;
-  element.versionNonce = randomInteger();
+  element.version = updates.version ?? element.version + 1;
+  element.versionNonce = updates.versionNonce ?? randomInteger();
   element.updated = getUpdatedTimestamp();
 
   return element;
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
   return {
     ...element,
     ...updates,
+    version: updates.version ?? element.version + 1,
+    versionNonce: updates.versionNonce ?? randomInteger(),
     updated: getUpdatedTimestamp(),
-    version: element.version + 1,
-    versionNonce: randomInteger(),
   };
 };
 

+ 78 - 48
packages/element/src/store.ts

@@ -19,9 +19,17 @@ import { newElementWith } from "./mutateElement";
 
 import { ElementsDelta, AppStateDelta, Delta } from "./delta";
 
-import { hashElementsVersion, hashString } from "./index";
+import {
+  syncInvalidIndicesImmutable,
+  hashElementsVersion,
+  hashString,
+} from "./index";
 
-import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
+import type {
+  ExcalidrawElement,
+  OrderedExcalidrawElement,
+  SceneElementsMap,
+} from "./types";
 
 export const CaptureUpdateAction = {
   /**
@@ -105,7 +113,7 @@ export class Store {
     params:
       | {
           action: CaptureUpdateActionType;
-          elements: SceneElementsMap | undefined;
+          elements: readonly ExcalidrawElement[] | undefined;
           appState: AppState | ObservedAppState | undefined;
         }
       | {
@@ -133,9 +141,15 @@ export class Store {
         this.app.scene.getElementsMapIncludingDeleted(),
         this.app.state,
       );
+
       const scheduledSnapshot = currentSnapshot.maybeClone(
         action,
-        params.elements,
+        // let's sync invalid indices first, so that we could detect this change
+        // also have the synced elements immutable, so that we don't mutate elements,
+        // that are already in the scene, otherwise we wouldn't see any change
+        params.elements
+          ? syncInvalidIndicesImmutable(params.elements)
+          : undefined,
         params.appState,
       );
 
@@ -213,16 +227,7 @@ export class Store {
       // 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);
+      storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
     }
 
     if (!storeDelta.isEmpty()) {
@@ -505,6 +510,24 @@ export class StoreDelta {
     return new this(opts.id, elements, appState);
   }
 
+  /**
+   * Calculate the delta between the previous and next snapshot.
+   */
+  public static calculate(
+    prevSnapshot: StoreSnapshot,
+    nextSnapshot: StoreSnapshot,
+  ) {
+    const elementsDelta = nextSnapshot.metadata.didElementsChange
+      ? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
+      : ElementsDelta.empty();
+
+    const appStateDelta = nextSnapshot.metadata.didAppStateChange
+      ? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
+      : AppStateDelta.empty();
+
+    return this.create(elementsDelta, appStateDelta);
+  }
+
   /**
    * Restore a store delta instance from a DTO.
    */
@@ -524,9 +547,7 @@ export class StoreDelta {
     id,
     elements: { added, removed, updated },
   }: DTO<StoreDelta>) {
-    const elements = ElementsDelta.create(added, removed, updated, {
-      shouldRedistribute: false,
-    });
+    const elements = ElementsDelta.create(added, removed, updated);
 
     return new this(id, elements, AppStateDelta.empty());
   }
@@ -534,27 +555,10 @@ export class StoreDelta {
   /**
    * Inverse store delta, creates new instance of `StoreDelta`.
    */
-  public static inverse(delta: StoreDelta): StoreDelta {
+  public static inverse(delta: 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.
    */
@@ -562,12 +566,9 @@ export class StoreDelta {
     delta: StoreDelta,
     elements: SceneElementsMap,
     appState: AppState,
-    prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
   ): [SceneElementsMap, AppState, boolean] {
-    const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
-      elements,
-      prevSnapshot.elements,
-    );
+    const [nextElements, elementsContainVisibleChange] =
+      delta.elements.applyTo(elements);
 
     const [nextAppState, appStateContainsVisibleChange] =
       delta.appState.applyTo(appState, nextElements);
@@ -578,6 +579,28 @@ export class StoreDelta {
     return [nextElements, nextAppState, appliedVisibleChanges];
   }
 
+  /**
+   * Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
+   */
+  public static applyLatestChanges(
+    delta: StoreDelta,
+    prevElements: SceneElementsMap,
+    nextElements: SceneElementsMap,
+    modifierOptions?: "deleted" | "inserted",
+  ): StoreDelta {
+    return this.create(
+      delta.elements.applyLatestChanges(
+        prevElements,
+        nextElements,
+        modifierOptions,
+      ),
+      delta.appState,
+      {
+        id: delta.id,
+      },
+    );
+  }
+
   public isEmpty() {
     return this.elements.isEmpty() && this.appState.isEmpty();
   }
@@ -687,11 +710,10 @@ export class StoreSnapshot {
       nextElements.set(id, changedElement);
     }
 
-    const nextAppState = Object.assign(
-      {},
-      this.appState,
-      change.appState,
-    ) as ObservedAppState;
+    const nextAppState = getObservedAppState({
+      ...this.appState,
+      ...change.appState,
+    });
 
     return StoreSnapshot.create(nextElements, nextAppState, {
       // by default we assume that change is different from what we have in the snapshot
@@ -944,18 +966,26 @@ const getDefaultObservedAppState = (): ObservedAppState => {
   };
 };
 
-export const getObservedAppState = (appState: AppState): ObservedAppState => {
+export const getObservedAppState = (
+  appState: AppState | ObservedAppState,
+): 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,
     activeLockedId: appState.activeLockedId,
     lockedMultiSelections: appState.lockedMultiSelections,
+    editingLinearElementId:
+      (appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
+      (appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
+      null,
+    selectedLinearElementId:
+      (appState as AppState).selectedLinearElement?.elementId ??
+      (appState as ObservedAppState).selectedLinearElementId ??
+      null,
   };
 
   Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

+ 0 - 2
packages/element/tests/duplicate.test.tsx

@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
       mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
     });
 
-    // console.log(h.elements);
-
     assertElements(h.elements, [
       { id: frame.id },
       { id: rectangle1.id, frameId: frame.id },

+ 13 - 15
packages/excalidraw/components/App.tsx

@@ -103,6 +103,7 @@ import {
 } from "@excalidraw/common";
 
 import {
+  getObservedAppState,
   getCommonBounds,
   getElementAbsoluteCoords,
   bindOrUnbindLinearElements,
@@ -260,7 +261,6 @@ import type {
   ExcalidrawNonSelectionElement,
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
-  SceneElementsMap,
 } from "@excalidraw/element/types";
 
 import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -702,6 +702,8 @@ class App extends React.Component<AppProps, AppState> {
         addFiles: this.addFiles,
         resetScene: this.resetScene,
         getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
+        getSceneElementsMapIncludingDeleted:
+          this.getSceneElementsMapIncludingDeleted,
         history: {
           clear: this.resetHistory,
         },
@@ -3909,22 +3911,18 @@ class App extends React.Component<AppProps, AppState> {
     }) => {
       const { elements, appState, collaborators, captureUpdate } = sceneData;
 
-      const nextElements = elements ? syncInvalidIndices(elements) : undefined;
-
       if (captureUpdate) {
-        const nextElementsMap = elements
-          ? (arrayToMap(nextElements ?? []) as SceneElementsMap)
-          : undefined;
-
-        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)
+        const observedAppState = appState
+          ? getObservedAppState({
+              ...this.store.snapshot.appState,
+              ...appState,
+            })
           : undefined;
 
         this.store.scheduleMicroAction({
           action: captureUpdate,
-          elements: nextElementsMap,
-          appState: nextAppState,
+          elements: elements ?? [],
+          appState: observedAppState,
         });
       }
 
@@ -3932,8 +3930,8 @@ class App extends React.Component<AppProps, AppState> {
         this.setState(appState);
       }
 
-      if (nextElements) {
-        this.scene.replaceAllElements(nextElements);
+      if (elements) {
+        this.scene.replaceAllElements(elements);
       }
 
       if (collaborators) {
@@ -10550,7 +10548,7 @@ class App extends React.Component<AppProps, AppState> {
         // otherwise we would end up with duplicated fractional indices on undo
         this.store.scheduleMicroAction({
           action: CaptureUpdateAction.NEVER,
-          elements: arrayToMap(elements) as SceneElementsMap,
+          elements,
           appState: undefined,
         });
 

+ 108 - 45
packages/excalidraw/history.ts

@@ -4,14 +4,81 @@ import {
   CaptureUpdateAction,
   StoreChange,
   StoreDelta,
-  type Store,
 } from "@excalidraw/element";
 
+import type { StoreSnapshot, Store } from "@excalidraw/element";
+
 import type { SceneElementsMap } from "@excalidraw/element/types";
 
 import type { AppState } from "./types";
 
-class HistoryEntry extends StoreDelta {}
+export class HistoryDelta extends StoreDelta {
+  /**
+   * Apply the delta to the passed elements and appState, does not modify the snapshot.
+   */
+  public applyTo(
+    elements: SceneElementsMap,
+    appState: AppState,
+    snapshot: StoreSnapshot,
+  ): [SceneElementsMap, AppState, boolean] {
+    const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
+      elements,
+      // used to fallback into local snapshot in case we couldn't apply the delta
+      // due to a missing (force deleted) elements in the scene
+      snapshot.elements,
+      // we don't want to apply the `version` and `versionNonce` properties for history
+      // as we always need to end up with a new version due to collaboration,
+      // approaching each undo / redo as a new user action
+      {
+        excludedProperties: new Set(["version", "versionNonce"]),
+      },
+    );
+
+    const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
+      appState,
+      nextElements,
+    );
+
+    const appliedVisibleChanges =
+      elementsContainVisibleChange || appStateContainsVisibleChange;
+
+    return [nextElements, nextAppState, appliedVisibleChanges];
+  }
+
+  /**
+   * Overriding once to avoid type casting everywhere.
+   */
+  public static override calculate(
+    prevSnapshot: StoreSnapshot,
+    nextSnapshot: StoreSnapshot,
+  ) {
+    return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
+  }
+
+  /**
+   * Overriding once to avoid type casting everywhere.
+   */
+  public static override inverse(delta: StoreDelta): HistoryDelta {
+    return super.inverse(delta) as HistoryDelta;
+  }
+
+  /**
+   * Overriding once to avoid type casting everywhere.
+   */
+  public static override applyLatestChanges(
+    delta: StoreDelta,
+    prevElements: SceneElementsMap,
+    nextElements: SceneElementsMap,
+    modifierOptions?: "deleted" | "inserted",
+  ) {
+    return super.applyLatestChanges(
+      delta,
+      prevElements,
+      nextElements,
+      modifierOptions,
+    ) as HistoryDelta;
+  }
+}
 
 export class HistoryChangedEvent {
   constructor(
@@ -25,8 +92,8 @@ export class History {
     [HistoryChangedEvent]
   >();
 
-  public readonly undoStack: HistoryEntry[] = [];
-  public readonly redoStack: HistoryEntry[] = [];
+  public readonly undoStack: HistoryDelta[] = [];
+  public readonly redoStack: HistoryDelta[] = [];
 
   public get isUndoStackEmpty() {
     return this.undoStack.length === 0;
@@ -48,16 +115,16 @@ export class History {
    * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
    */
   public record(delta: StoreDelta) {
-    if (delta.isEmpty() || delta instanceof HistoryEntry) {
+    if (delta.isEmpty() || delta instanceof HistoryDelta) {
       return;
     }
 
     // construct history entry, so once it's emitted, it's not recorded again
-    const entry = HistoryEntry.inverse(delta);
+    const historyDelta = HistoryDelta.inverse(delta);
 
-    this.undoStack.push(entry);
+    this.undoStack.push(historyDelta);
 
-    if (!entry.elements.isEmpty()) {
+    if (!historyDelta.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!
@@ -74,7 +141,7 @@ export class History {
       elements,
       appState,
       () => History.pop(this.undoStack),
-      (entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
+      (entry: HistoryDelta) => History.push(this.redoStack, entry),
     );
   }
 
@@ -83,20 +150,20 @@ export class History {
       elements,
       appState,
       () => History.pop(this.redoStack),
-      (entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
+      (entry: HistoryDelta) => History.push(this.undoStack, entry),
     );
   }
 
   private perform(
     elements: SceneElementsMap,
     appState: AppState,
-    pop: () => HistoryEntry | null,
-    push: (entry: HistoryEntry) => void,
+    pop: () => HistoryDelta | null,
+    push: (entry: HistoryDelta) => void,
   ): [SceneElementsMap, AppState] | void {
     try {
-      let historyEntry = pop();
+      let historyDelta = pop();
 
-      if (historyEntry === null) {
+      if (historyDelta === null) {
         return;
       }
 
@@ -108,41 +175,47 @@ export class History {
       let nextAppState = appState;
       let containsVisibleChange = false;
 
-      // iterate through the history entries in case they result in no visible changes
-      while (historyEntry) {
+      // iterate through the history entries in case ;they result in no visible changes
+      while (historyDelta) {
         try {
           [nextElements, nextAppState, containsVisibleChange] =
-            StoreDelta.applyTo(
-              historyEntry,
-              nextElements,
-              nextAppState,
-              prevSnapshot,
-            );
+            historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
 
+          const prevElements = prevSnapshot.elements;
           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,
-          });
+          const change = StoreChange.create(prevSnapshot, nextSnapshot);
+          const delta = HistoryDelta.applyLatestChanges(
+            historyDelta,
+            prevElements,
+            nextElements,
+          );
+
+          if (!delta.isEmpty()) {
+            // schedule immediate capture, so that it's emitted for the sync purposes
+            this.store.scheduleMicroAction({
+              action,
+              change,
+              delta,
+            });
+
+            historyDelta = delta;
+          }
 
           prevSnapshot = nextSnapshot;
         } finally {
-          // make sure to always push, even if the delta is corrupted
-          push(historyEntry);
+          push(historyDelta);
         }
 
         if (containsVisibleChange) {
           break;
         }
 
-        historyEntry = pop();
+        historyDelta = pop();
       }
 
       return [nextElements, nextAppState];
@@ -155,7 +228,7 @@ export class History {
     }
   }
 
-  private static pop(stack: HistoryEntry[]): HistoryEntry | null {
+  private static pop(stack: HistoryDelta[]): HistoryDelta | null {
     if (!stack.length) {
       return null;
     }
@@ -169,18 +242,8 @@ export class History {
     return null;
   }
 
-  private static push(
-    stack: HistoryEntry[],
-    entry: HistoryEntry,
-    prevElements: SceneElementsMap,
-  ) {
-    const inversedEntry = HistoryEntry.inverse(entry);
-    const updatedEntry = HistoryEntry.applyLatestChanges(
-      inversedEntry,
-      prevElements,
-      "inserted",
-    );
-
-    return stack.push(updatedEntry);
+  private static push(stack: HistoryDelta[], entry: HistoryDelta) {
+    const inversedEntry = HistoryDelta.inverse(entry);
+    return stack.push(inversedEntry);
   }
 }

+ 115 - 27
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -1269,12 +1269,14 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -20,
             "y": -10,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -1420,14 +1422,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 1604849351,
+  "versionNonce": 1505387817,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -1459,7 +1461,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 23633383,
+  "versionNonce": 915032327,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -1511,12 +1513,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -1563,12 +1567,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -1598,9 +1604,11 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
         "id0": {
           "deleted": {
             "index": "a2",
+            "version": 4,
           },
           "inserted": {
             "index": "a0",
+            "version": 3,
           },
         },
       },
@@ -1745,14 +1753,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 1604849351,
+  "versionNonce": 1505387817,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -1784,7 +1792,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 23633383,
+  "versionNonce": 915032327,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -1836,12 +1844,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -1888,12 +1898,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -1923,9 +1935,11 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
         "id0": {
           "deleted": {
             "index": "a2",
+            "version": 4,
           },
           "inserted": {
             "index": "a0",
+            "version": 3,
           },
         },
       },
@@ -2131,12 +2145,14 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -20,
             "y": -10,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -2287,7 +2303,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1116226695,
+  "versionNonce": 1014066025,
   "width": 10,
   "x": -20,
   "y": -10,
@@ -2339,12 +2355,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -20,
             "y": -10,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -2370,9 +2388,11 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
         "id0": {
           "deleted": {
             "isDeleted": true,
+            "version": 4,
           },
           "inserted": {
             "isDeleted": false,
+            "version": 3,
           },
         },
       },
@@ -2551,14 +2571,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 5,
-  "versionNonce": 400692809,
+  "versionNonce": 1604849351,
   "width": 10,
   "x": -10,
   "y": 0,
@@ -2610,12 +2630,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -20,
             "y": -10,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -2662,12 +2684,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 5,
             "width": 10,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 4,
           },
         },
       },
@@ -2827,7 +2851,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 493213705,
+  "versionNonce": 81784553,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -2854,14 +2878,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 915032327,
+  "versionNonce": 747212839,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -2913,12 +2937,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -2965,12 +2991,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -3020,9 +3048,11 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
         "id3": {
@@ -3030,9 +3060,11 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
       },
@@ -3186,7 +3218,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 908564423,
+  "versionNonce": 1359939303,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -3211,14 +3243,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "opacity": 60,
   "roughness": 2,
   "roundness": null,
-  "seed": 1315507081,
+  "seed": 640725609,
   "strokeColor": "#e03131",
   "strokeStyle": "dotted",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 9,
-  "versionNonce": 406373543,
+  "versionNonce": 908564423,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -3270,12 +3302,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -3322,12 +3356,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -3349,9 +3385,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "strokeColor": "#e03131",
+            "version": 4,
           },
           "inserted": {
             "strokeColor": "#1e1e1e",
+            "version": 3,
           },
         },
       },
@@ -3372,9 +3410,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "backgroundColor": "#a5d8ff",
+            "version": 5,
           },
           "inserted": {
             "backgroundColor": "transparent",
+            "version": 4,
           },
         },
       },
@@ -3395,9 +3435,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "fillStyle": "cross-hatch",
+            "version": 6,
           },
           "inserted": {
             "fillStyle": "solid",
+            "version": 5,
           },
         },
       },
@@ -3418,9 +3460,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "strokeStyle": "dotted",
+            "version": 7,
           },
           "inserted": {
             "strokeStyle": "solid",
+            "version": 6,
           },
         },
       },
@@ -3441,9 +3485,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "roughness": 2,
+            "version": 8,
           },
           "inserted": {
             "roughness": 1,
+            "version": 7,
           },
         },
       },
@@ -3464,9 +3510,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
         "id3": {
           "deleted": {
             "opacity": 60,
+            "version": 9,
           },
           "inserted": {
             "opacity": 100,
+            "version": 8,
           },
         },
       },
@@ -3500,6 +3548,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
             "roughness": 2,
             "strokeColor": "#e03131",
             "strokeStyle": "dotted",
+            "version": 4,
           },
           "inserted": {
             "backgroundColor": "transparent",
@@ -3508,6 +3557,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
             "roughness": 1,
             "strokeColor": "#1e1e1e",
             "strokeStyle": "solid",
+            "version": 3,
           },
         },
       },
@@ -3652,14 +3702,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1150084233,
+  "seed": 1116226695,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1604849351,
+  "versionNonce": 23633383,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -3743,12 +3793,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -3795,12 +3847,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -3822,9 +3876,11 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
         "id3": {
           "deleted": {
             "index": "Zz",
+            "version": 4,
           },
           "inserted": {
             "index": "a1",
+            "version": 3,
           },
         },
       },
@@ -3969,14 +4025,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1014066025,
+  "seed": 238820263,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 23633383,
+  "versionNonce": 915032327,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -4060,12 +4116,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -4112,12 +4170,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -4139,9 +4199,11 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
         "id3": {
           "deleted": {
             "index": "Zz",
+            "version": 4,
           },
           "inserted": {
             "index": "a1",
+            "version": 3,
           },
         },
       },
@@ -4296,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "type": "rectangle",
   "updated": 1,
   "version": 5,
-  "versionNonce": 1723083209,
+  "versionNonce": 1006504105,
   "width": 20,
   "x": -10,
   "y": 0,
@@ -4321,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 238820263,
+  "seed": 400692809,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 5,
-  "versionNonce": 760410951,
+  "versionNonce": 289600103,
   "width": 20,
   "x": 20,
   "y": 30,
@@ -4380,12 +4442,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -4432,12 +4496,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 20,
             "x": 20,
             "y": 30,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -4487,9 +4553,11 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
         "id3": {
@@ -4497,9 +4565,11 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
       },
@@ -4526,21 +4596,25 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
         "id0": {
           "deleted": {
             "groupIds": [],
+            "version": 5,
           },
           "inserted": {
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
         },
         "id3": {
           "deleted": {
             "groupIds": [],
+            "version": 5,
           },
           "inserted": {
             "groupIds": [
               "id9",
             ],
+            "version": 4,
           },
         },
       },
@@ -5594,14 +5668,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1604849351,
+  "seed": 1505387817,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 3,
-  "versionNonce": 493213705,
+  "versionNonce": 915032327,
   "width": 10,
   "x": 12,
   "y": 0,
@@ -5653,12 +5727,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -5705,12 +5781,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": 12,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -6786,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 81784553,
+  "versionNonce": 1723083209,
   "width": 10,
   "x": -10,
   "y": 0,
@@ -6813,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 238820263,
+  "seed": 400692809,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 747212839,
+  "versionNonce": 760410951,
   "width": 10,
   "x": 12,
   "y": 0,
@@ -6872,12 +6950,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -6924,12 +7004,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": 12,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },
@@ -7001,9 +7083,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
             "groupIds": [
               "id12",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
         "id3": {
@@ -7011,9 +7095,11 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
             "groupIds": [
               "id12",
             ],
+            "version": 4,
           },
           "inserted": {
             "groupIds": [],
+            "version": 3,
           },
         },
       },
@@ -9822,12 +9908,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] un
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
+            "version": 3,
             "width": 10,
             "x": -20,
             "y": -10,
           },
           "inserted": {
             "isDeleted": true,
+            "version": 2,
           },
         },
       },

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 141 - 171
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


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

@@ -25,7 +25,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
   "type": "rectangle",
   "updated": 1,
   "version": 5,
-  "versionNonce": 1505387817,
+  "versionNonce": 23633383,
   "width": 30,
   "x": 30,
   "y": 20,
@@ -50,14 +50,14 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1604849351,
+  "seed": 1505387817,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 7,
-  "versionNonce": 915032327,
+  "versionNonce": 81784553,
   "width": 30,
   "x": -10,
   "y": 60,
@@ -89,7 +89,7 @@ exports[`move element > rectangle 5`] = `
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1116226695,
+  "versionNonce": 1014066025,
   "width": 30,
   "x": 0,
   "y": 40,
@@ -126,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
   "type": "rectangle",
   "updated": 1,
   "version": 4,
-  "versionNonce": 1723083209,
+  "versionNonce": 1006504105,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -156,14 +156,14 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "opacity": 100,
   "roughness": 1,
   "roundness": null,
-  "seed": 1150084233,
+  "seed": 1116226695,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 7,
-  "versionNonce": 1051383431,
+  "versionNonce": 1984422985,
   "width": 300,
   "x": 201,
   "y": 2,
@@ -208,7 +208,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "roundness": {
     "type": 2,
   },
-  "seed": 1604849351,
+  "seed": 23633383,
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id0",
@@ -221,7 +221,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "type": "arrow",
   "updated": 1,
   "version": 11,
-  "versionNonce": 1996028265,
+  "versionNonce": 1573789895,
   "width": "81.00000",
   "x": "110.00000",
   "y": 50,

+ 2 - 2
packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
   "type": "arrow",
   "updated": 1,
   "version": 8,
-  "versionNonce": 400692809,
+  "versionNonce": 1604849351,
   "width": 70,
   "x": 30,
   "y": 30,
@@ -105,7 +105,7 @@ exports[`multi point mode in linear elements > line 3`] = `
   "type": "line",
   "updated": 1,
   "version": 8,
-  "versionNonce": 400692809,
+  "versionNonce": 1604849351,
   "width": 70,
   "x": 30,
   "y": 30,

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 151 - 4
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap


+ 14 - 9
packages/excalidraw/tests/test-utils.ts

@@ -431,12 +431,17 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
   expect(h.state.selectedElementIds).toEqual(selectedElementIds);
 };
 
-const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
+const stripProps = (
+  deltas: Record<string, { deleted: any; inserted: any }>,
+  props: string[],
+) =>
   Object.entries(deltas).reduce((acc, curr) => {
     const { inserted, deleted, ...rest } = curr[1];
 
-    delete inserted.seed;
-    delete deleted.seed;
+    for (const prop of props) {
+      delete inserted[prop];
+      delete deleted[prop];
+    }
 
     acc[curr[0]] = {
       inserted,
@@ -453,9 +458,9 @@ export const checkpointHistory = (history: History, name: string) => {
       ...x,
       elements: {
         ...x.elements,
-        added: stripSeed(x.elements.added),
-        removed: stripSeed(x.elements.removed),
-        updated: stripSeed(x.elements.updated),
+        added: stripProps(x.elements.added, ["seed", "versionNonce"]),
+        removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
+        updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
       },
     })),
   ).toMatchSnapshot(`[${name}] undo stack`);
@@ -465,9 +470,9 @@ export const checkpointHistory = (history: History, name: string) => {
       ...x,
       elements: {
         ...x.elements,
-        added: stripSeed(x.elements.added),
-        removed: stripSeed(x.elements.removed),
-        updated: stripSeed(x.elements.updated),
+        added: stripProps(x.elements.added, ["seed", "versionNonce"]),
+        removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
+        updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
       },
     })),
   ).toMatchSnapshot(`[${name}] redo stack`);

+ 3 - 0
packages/excalidraw/types.ts

@@ -813,6 +813,9 @@ export interface ExcalidrawImperativeAPI {
   getSceneElementsIncludingDeleted: InstanceType<
     typeof App
   >["getSceneElementsIncludingDeleted"];
+  getSceneElementsMapIncludingDeleted: InstanceType<
+    typeof App
+  >["getSceneElementsMapIncludingDeleted"];
   history: {
     clear: InstanceType<typeof App>["resetHistory"];
   };

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä