浏览代码

feat: apply deltas API (#9869)

Marcel Mraz 3 周之前
父节点
当前提交
2535d73054

+ 16 - 4
packages/element/src/Scene.ts

@@ -164,9 +164,14 @@ export class Scene {
     return this.frames;
   }
 
-  constructor(elements: ElementsMapOrArray | null = null) {
+  constructor(
+    elements: ElementsMapOrArray | null = null,
+    options?: {
+      skipValidation?: true;
+    },
+  ) {
     if (elements) {
-      this.replaceAllElements(elements);
+      this.replaceAllElements(elements, options);
     }
   }
 
@@ -263,12 +268,19 @@ export class Scene {
     return didChange;
   }
 
-  replaceAllElements(nextElements: ElementsMapOrArray) {
+  replaceAllElements(
+    nextElements: ElementsMapOrArray,
+    options?: {
+      skipValidation?: true;
+    },
+  ) {
     // 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);
+    if (!options?.skipValidation) {
+      validateIndicesThrottled(_nextElements);
+    }
 
     this.elements = syncInvalidIndices(_nextElements);
     this.elementsMap.clear();

+ 254 - 50
packages/element/src/delta.ts

@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
 
 import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
 
-import { Scene } from "./Scene";
-
 import { StoreSnapshot } from "./store";
 
+import { Scene } from "./Scene";
+
 import type { BindableProp, BindingProp } from "./binding";
 
 import type { ElementUpdate } from "./mutateElement";
@@ -153,10 +153,14 @@ export class Delta<T> {
   /**
    * Merges two deltas into a new one.
    */
-  public static merge<T>(delta1: Delta<T>, delta2: Delta<T>) {
+  public static merge<T>(
+    delta1: Delta<T>,
+    delta2: Delta<T>,
+    delta3: Delta<T> = Delta.empty(),
+  ) {
     return Delta.create(
-      { ...delta1.deleted, ...delta2.deleted },
-      { ...delta1.inserted, ...delta2.inserted },
+      { ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
+      { ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
     );
   }
 
@@ -166,7 +170,7 @@ export class Delta<T> {
   public static mergeObjects<T extends { [key: string]: unknown }>(
     prev: T,
     added: T,
-    removed: T,
+    removed: T = {} as T,
   ) {
     const cloned = { ...prev };
 
@@ -520,6 +524,10 @@ export interface DeltaContainer<T> {
 export class AppStateDelta implements DeltaContainer<AppState> {
   private constructor(public delta: Delta<ObservedAppState>) {}
 
+  public static create(delta: Delta<ObservedAppState>): AppStateDelta {
+    return new AppStateDelta(delta);
+  }
+
   public static calculate<T extends ObservedAppState>(
     prevAppState: T,
     nextAppState: T,
@@ -550,7 +558,74 @@ export class AppStateDelta implements DeltaContainer<AppState> {
   }
 
   public squash(delta: AppStateDelta): this {
-    this.delta = Delta.merge(this.delta, delta.delta);
+    if (delta.isEmpty()) {
+      return this;
+    }
+
+    const mergedDeletedSelectedElementIds = Delta.mergeObjects(
+      this.delta.deleted.selectedElementIds ?? {},
+      delta.delta.deleted.selectedElementIds ?? {},
+    );
+
+    const mergedInsertedSelectedElementIds = Delta.mergeObjects(
+      this.delta.inserted.selectedElementIds ?? {},
+      delta.delta.inserted.selectedElementIds ?? {},
+    );
+
+    const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
+      this.delta.deleted.selectedGroupIds ?? {},
+      delta.delta.deleted.selectedGroupIds ?? {},
+    );
+
+    const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
+      this.delta.inserted.selectedGroupIds ?? {},
+      delta.delta.inserted.selectedGroupIds ?? {},
+    );
+
+    const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
+      this.delta.deleted.lockedMultiSelections ?? {},
+      delta.delta.deleted.lockedMultiSelections ?? {},
+    );
+
+    const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
+      this.delta.inserted.lockedMultiSelections ?? {},
+      delta.delta.inserted.lockedMultiSelections ?? {},
+    );
+
+    const mergedInserted: Partial<ObservedAppState> = {};
+    const mergedDeleted: Partial<ObservedAppState> = {};
+
+    if (
+      Object.keys(mergedDeletedSelectedElementIds).length ||
+      Object.keys(mergedInsertedSelectedElementIds).length
+    ) {
+      mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
+      mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
+    }
+
+    if (
+      Object.keys(mergedDeletedSelectedGroupIds).length ||
+      Object.keys(mergedInsertedSelectedGroupIds).length
+    ) {
+      mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
+      mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
+    }
+
+    if (
+      Object.keys(mergedDeletedLockedMultiSelections).length ||
+      Object.keys(mergedInsertedLockedMultiSelections).length
+    ) {
+      mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
+      mergedInserted.lockedMultiSelections =
+        mergedInsertedLockedMultiSelections;
+    }
+
+    this.delta = Delta.merge(
+      this.delta,
+      delta.delta,
+      Delta.create(mergedDeleted, mergedInserted),
+    );
+
     return this;
   }
 
@@ -562,11 +637,13 @@ export class AppStateDelta implements DeltaContainer<AppState> {
       const {
         selectedElementIds: deletedSelectedElementIds = {},
         selectedGroupIds: deletedSelectedGroupIds = {},
+        lockedMultiSelections: deletedLockedMultiSelections = {},
       } = this.delta.deleted;
 
       const {
         selectedElementIds: insertedSelectedElementIds = {},
         selectedGroupIds: insertedSelectedGroupIds = {},
+        lockedMultiSelections: insertedLockedMultiSelections = {},
         selectedLinearElement: insertedSelectedLinearElement,
         ...directlyApplicablePartial
       } = this.delta.inserted;
@@ -583,6 +660,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
         deletedSelectedGroupIds,
       );
 
+      const mergedLockedMultiSelections = Delta.mergeObjects(
+        appState.lockedMultiSelections,
+        insertedLockedMultiSelections,
+        deletedLockedMultiSelections,
+      );
+
       const selectedLinearElement =
         insertedSelectedLinearElement &&
         nextElements.has(insertedSelectedLinearElement.elementId)
@@ -600,6 +683,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
         ...directlyApplicablePartial,
         selectedElementIds: mergedSelectedElementIds,
         selectedGroupIds: mergedSelectedGroupIds,
+        lockedMultiSelections: mergedLockedMultiSelections,
         selectedLinearElement:
           typeof insertedSelectedLinearElement !== "undefined"
             ? selectedLinearElement
@@ -904,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
         "lockedMultiSelections",
         (prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
       );
-      Delta.diffObjects(
-        deleted,
-        inserted,
-        "activeLockedId",
-        (prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
-      );
     } 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.`);
@@ -938,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
   Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
 
 export type ApplyToOptions = {
-  excludedProperties: Set<keyof ElementPartial>;
+  excludedProperties?: Set<keyof ElementPartial>;
 };
 
 type ApplyToFlags = {
   containsVisibleDifference: boolean;
   containsZindexDifference: boolean;
+  applyDirection: "forward" | "backward" | undefined;
 };
 
 /**
@@ -1044,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
       deleted.version !== inserted.version
     );
 
+  private static satisfiesUniqueInvariants = (
+    elementsDelta: ElementsDelta,
+    id: string,
+  ) => {
+    const { added, removed, updated } = elementsDelta;
+    // it's required that there is only one unique delta type per element
+    return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
+  };
+
   private static validate(
     elementsDelta: ElementsDelta,
     type: "added" | "removed" | "updated",
@@ -1052,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
     for (const [id, delta] of Object.entries(elementsDelta[type])) {
       if (
         !this.satisfiesCommmonInvariants(delta) ||
+        !this.satisfiesUniqueInvariants(elementsDelta, id) ||
         !satifiesSpecialInvariants(delta)
       ) {
         console.error(
@@ -1311,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   public applyTo(
     elements: SceneElementsMap,
     snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
-    options: ApplyToOptions = {
-      excludedProperties: new Set(),
-    },
+    options?: ApplyToOptions,
   ): [SceneElementsMap, boolean] {
     let nextElements = new Map(elements) as SceneElementsMap;
     let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1321,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
     const flags: ApplyToFlags = {
       containsVisibleDifference: false,
       containsZindexDifference: false,
+      applyDirection: undefined,
     };
 
     // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
     try {
       const applyDeltas = ElementsDelta.createApplier(
+        elements,
         nextElements,
         snapshot,
-        options,
         flags,
+        options,
       );
 
       const addedElements = applyDeltas(this.added);
       const removedElements = applyDeltas(this.removed);
       const updatedElements = applyDeltas(this.updated);
 
-      const affectedElements = this.resolveConflicts(elements, nextElements);
+      const affectedElements = this.resolveConflicts(
+        elements,
+        nextElements,
+        flags.applyDirection,
+      );
 
       // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
       changedElements = new Map([
@@ -1360,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<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)
+      // the following reorder performs mutations, but only on new instances of changed elements,
+      // unless something goes really bad and it fallbacks to fixing all invalid indices
       nextElements = ElementsDelta.reorderElements(
         nextElements,
         changedElements,
         flags,
       );
 
-      // we don't have an up-to-date scene, as we can be just in the middle of applying history entry
-      // we also don't have a scene on the server
-      // so we are creating a temp scene just to query and mutate elements
-      const tempScene = new Scene(nextElements);
-
-      ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
-      // Need ordered nextElements to avoid z-index binding issues
-      ElementsDelta.redrawBoundArrows(tempScene, changedElements);
+      ElementsDelta.redrawElements(nextElements, changedElements);
     } catch (e) {
       console.error(
         `Couldn't mutate elements after applying elements change`,
@@ -1391,47 +1477,112 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   }
 
   public squash(delta: ElementsDelta): this {
+    if (delta.isEmpty()) {
+      return this;
+    }
+
     const { added, removed, updated } = delta;
 
+    const mergeBoundElements = (
+      prevDelta: Delta<ElementPartial>,
+      nextDelta: Delta<ElementPartial>,
+    ) => {
+      const mergedDeletedBoundElements =
+        Delta.mergeArrays(
+          prevDelta.deleted.boundElements ?? [],
+          nextDelta.deleted.boundElements ?? [],
+          undefined,
+          (x) => x.id,
+        ) ?? [];
+
+      const mergedInsertedBoundElements =
+        Delta.mergeArrays(
+          prevDelta.inserted.boundElements ?? [],
+          nextDelta.inserted.boundElements ?? [],
+          undefined,
+          (x) => x.id,
+        ) ?? [];
+
+      if (
+        !mergedDeletedBoundElements.length &&
+        !mergedInsertedBoundElements.length
+      ) {
+        return;
+      }
+
+      return Delta.create(
+        {
+          boundElements: mergedDeletedBoundElements,
+        },
+        {
+          boundElements: mergedInsertedBoundElements,
+        },
+      );
+    };
+
     for (const [id, nextDelta] of Object.entries(added)) {
-      const prevDelta = this.added[id];
+      const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
 
       if (!prevDelta) {
         this.added[id] = nextDelta;
       } else {
-        this.added[id] = Delta.merge(prevDelta, nextDelta);
+        const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
+        delete this.removed[id];
+        delete this.updated[id];
+
+        this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
       }
     }
 
     for (const [id, nextDelta] of Object.entries(removed)) {
-      const prevDelta = this.removed[id];
+      const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
 
       if (!prevDelta) {
         this.removed[id] = nextDelta;
       } else {
-        this.removed[id] = Delta.merge(prevDelta, nextDelta);
+        const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
+        delete this.added[id];
+        delete this.updated[id];
+
+        this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
       }
     }
 
     for (const [id, nextDelta] of Object.entries(updated)) {
-      const prevDelta = this.updated[id];
+      const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
 
       if (!prevDelta) {
         this.updated[id] = nextDelta;
       } else {
-        this.updated[id] = Delta.merge(prevDelta, nextDelta);
+        const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
+        const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
+
+        if (prevDelta === this.added[id]) {
+          this.added[id] = updatedDelta;
+        } else if (prevDelta === this.removed[id]) {
+          this.removed[id] = updatedDelta;
+        } else {
+          this.updated[id] = updatedDelta;
+        }
       }
     }
 
+    if (isTestEnv() || isDevEnv()) {
+      ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
+      ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
+      ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
+    }
+
     return this;
   }
 
   private static createApplier =
     (
+      prevElements: SceneElementsMap,
       nextElements: SceneElementsMap,
       snapshot: StoreSnapshot["elements"],
-      options: ApplyToOptions,
       flags: ApplyToFlags,
+      options?: ApplyToOptions,
     ) =>
     (deltas: Record<string, Delta<ElementPartial>>) => {
       const getElement = ElementsDelta.createGetter(
@@ -1444,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
         const element = getElement(id, delta.inserted);
 
         if (element) {
-          const newElement = ElementsDelta.applyDelta(
+          const nextElement = ElementsDelta.applyDelta(
             element,
             delta,
-            options,
             flags,
+            options,
           );
 
-          nextElements.set(newElement.id, newElement);
-          acc.set(newElement.id, newElement);
+          nextElements.set(nextElement.id, nextElement);
+          acc.set(nextElement.id, nextElement);
+
+          if (!flags.applyDirection) {
+            const prevElement = prevElements.get(id);
+
+            if (prevElement) {
+              flags.applyDirection =
+                prevElement.version > nextElement.version
+                  ? "backward"
+                  : "forward";
+            }
+          }
         }
 
         return acc;
@@ -1497,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private static applyDelta(
     element: OrderedExcalidrawElement,
     delta: Delta<ElementPartial>,
-    options: ApplyToOptions,
     flags: ApplyToFlags,
+    options?: ApplyToOptions,
   ) {
     const directlyApplicablePartial: Mutable<ElementPartial> = {};
 
@@ -1512,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
         continue;
       }
 
-      if (options.excludedProperties.has(key)) {
+      if (options?.excludedProperties?.has(key)) {
         continue;
       }
 
@@ -1552,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
         delta.deleted.index !== delta.inserted.index;
     }
 
-    return newElementWith(element, directlyApplicablePartial);
+    return newElementWith(element, directlyApplicablePartial, true);
   }
 
   /**
@@ -1592,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   private resolveConflicts(
     prevElements: SceneElementsMap,
     nextElements: SceneElementsMap,
+    applyDirection: "forward" | "backward" = "forward",
   ) {
     const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
     const updater = (
@@ -1603,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
         return;
       }
 
+      const prevElement = prevElements.get(element.id);
+      const nextVersion =
+        applyDirection === "forward"
+          ? nextElement.version + 1
+          : nextElement.version - 1;
+
+      const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
+
       let affectedElement: OrderedExcalidrawElement;
 
-      if (prevElements.get(element.id) === nextElement) {
+      if (prevElement === nextElement) {
         // create the new element instance in case we didn't modify the element yet
         // so that we won't end up in an incosistent state in case we would fail in the middle of mutations
         affectedElement = newElementWith(
           nextElement,
-          updates as ElementUpdate<OrderedExcalidrawElement>,
+          {
+            ...elementUpdates,
+            version: nextVersion,
+          },
+          true,
         );
       } else {
-        affectedElement = mutateElement(
-          nextElement,
-          nextElements,
-          updates as ElementUpdate<OrderedExcalidrawElement>,
-        );
+        affectedElement = mutateElement(nextElement, nextElements, {
+          ...elementUpdates,
+          // don't modify the version further, if it's already different
+          version:
+            prevElement?.version !== nextElement.version
+              ? nextElement.version
+              : nextVersion,
+        });
       }
 
       nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1722,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
     BindableElement.rebindAffected(nextElements, nextElement(), updater);
   }
 
+  public static redrawElements(
+    nextElements: SceneElementsMap,
+    changedElements: Map<string, OrderedExcalidrawElement>,
+  ) {
+    try {
+      // we don't have an up-to-date scene, as we can be just in the middle of applying history entry
+      // we also don't have a scene on the server
+      // so we are creating a temp scene just to query and mutate elements
+      const tempScene = new Scene(nextElements, { skipValidation: true });
+
+      ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
+
+      // needs ordered nextElements to avoid z-index binding issues
+      ElementsDelta.redrawBoundArrows(tempScene, changedElements);
+    } catch (e) {
+      console.error(`Couldn't redraw elements`, e);
+
+      if (isTestEnv() || isDevEnv()) {
+        throw e;
+      }
+    } finally {
+      return nextElements;
+    }
+  }
+
   private static redrawTextBoundingBoxes(
     scene: Scene,
     changed: Map<string, OrderedExcalidrawElement>,
@@ -1776,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
   ) {
     for (const element of changed.values()) {
       if (!element.isDeleted && isBindableElement(element)) {
+        // TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
         updateBoundElements(element, scene, {
           changedElements: changed,
         });

+ 22 - 4
packages/element/src/store.ts

@@ -552,10 +552,26 @@ export class StoreDelta {
   public static load({
     id,
     elements: { added, removed, updated },
+    appState: { delta: appStateDelta },
   }: DTO<StoreDelta>) {
     const elements = ElementsDelta.create(added, removed, updated);
+    const appState = AppStateDelta.create(appStateDelta);
 
-    return new this(id, elements, AppStateDelta.empty());
+    return new this(id, elements, appState);
+  }
+
+  /**
+   * Squash the passed deltas into the aggregated delta instance.
+   */
+  public static squash(...deltas: StoreDelta[]) {
+    const aggregatedDelta = StoreDelta.empty();
+
+    for (const delta of deltas) {
+      aggregatedDelta.elements.squash(delta.elements);
+      aggregatedDelta.appState.squash(delta.appState);
+    }
+
+    return aggregatedDelta;
   }
 
   /**
@@ -572,9 +588,7 @@ export class StoreDelta {
     delta: StoreDelta,
     elements: SceneElementsMap,
     appState: AppState,
-    options: ApplyToOptions = {
-      excludedProperties: new Set(),
-    },
+    options?: ApplyToOptions,
   ): [SceneElementsMap, AppState, boolean] {
     const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
       elements,
@@ -613,6 +627,10 @@ export class StoreDelta {
     );
   }
 
+  public static empty() {
+    return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
+  }
+
   public isEmpty() {
     return this.elements.isEmpty() && this.appState.isEmpty();
   }

+ 339 - 1
packages/element/tests/delta.test.tsx

@@ -4,7 +4,7 @@ import type { ObservedAppState } from "@excalidraw/excalidraw/types";
 import type { LinearElementEditor } from "@excalidraw/element";
 import type { SceneElementsMap } from "@excalidraw/element/types";
 
-import { AppStateDelta, ElementsDelta } from "../src/delta";
+import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
 
 describe("ElementsDelta", () => {
   describe("elements delta calculation", () => {
@@ -68,6 +68,251 @@ describe("ElementsDelta", () => {
       expect(delta.isEmpty()).toBeTruthy();
     });
   });
+
+  describe("squash", () => {
+    it("should not squash when second delta is empty", () => {
+      const updatedDelta = Delta.create(
+        { x: 100, version: 1, versionNonce: 1 },
+        { x: 200, version: 2, versionNonce: 2 },
+      );
+
+      const elementsDelta1 = ElementsDelta.create(
+        {},
+        {},
+        { id1: updatedDelta },
+      );
+      const elementsDelta2 = ElementsDelta.empty();
+      const elementsDelta = elementsDelta1.squash(elementsDelta2);
+
+      expect(elementsDelta.isEmpty()).toBeFalsy();
+      expect(elementsDelta).toBe(elementsDelta1);
+      expect(elementsDelta.updated.id1).toBe(updatedDelta);
+    });
+
+    it("should squash mutually exclusive delta types", () => {
+      const addedDelta = Delta.create(
+        { x: 100, version: 1, versionNonce: 1, isDeleted: true },
+        { x: 200, version: 2, versionNonce: 2, isDeleted: false },
+      );
+
+      const removedDelta = Delta.create(
+        { x: 100, version: 1, versionNonce: 1, isDeleted: false },
+        { x: 200, version: 2, versionNonce: 2, isDeleted: true },
+      );
+
+      const updatedDelta = Delta.create(
+        { x: 100, version: 1, versionNonce: 1 },
+        { x: 200, version: 2, versionNonce: 2 },
+      );
+
+      const elementsDelta1 = ElementsDelta.create(
+        { id1: addedDelta },
+        { id2: removedDelta },
+        {},
+      );
+
+      const elementsDelta2 = ElementsDelta.create(
+        {},
+        {},
+        { id3: updatedDelta },
+      );
+
+      const elementsDelta = elementsDelta1.squash(elementsDelta2);
+
+      expect(elementsDelta.isEmpty()).toBeFalsy();
+      expect(elementsDelta).toBe(elementsDelta1);
+      expect(elementsDelta.added.id1).toBe(addedDelta);
+      expect(elementsDelta.removed.id2).toBe(removedDelta);
+      expect(elementsDelta.updated.id3).toBe(updatedDelta);
+    });
+
+    it("should squash the same delta types", () => {
+      const elementsDelta1 = ElementsDelta.create(
+        {
+          id1: Delta.create(
+            { x: 100, version: 1, versionNonce: 1, isDeleted: true },
+            { x: 200, version: 2, versionNonce: 2, isDeleted: false },
+          ),
+        },
+        {
+          id2: Delta.create(
+            { x: 100, version: 1, versionNonce: 1, isDeleted: false },
+            { x: 200, version: 2, versionNonce: 2, isDeleted: true },
+          ),
+        },
+        {
+          id3: Delta.create(
+            { x: 100, version: 1, versionNonce: 1 },
+            { x: 200, version: 2, versionNonce: 2 },
+          ),
+        },
+      );
+
+      const elementsDelta2 = ElementsDelta.create(
+        {
+          id1: Delta.create(
+            { y: 100, version: 2, versionNonce: 2, isDeleted: true },
+            { y: 200, version: 3, versionNonce: 3, isDeleted: false },
+          ),
+        },
+        {
+          id2: Delta.create(
+            { y: 100, version: 2, versionNonce: 2, isDeleted: false },
+            { y: 200, version: 3, versionNonce: 3, isDeleted: true },
+          ),
+        },
+        {
+          id3: Delta.create(
+            { y: 100, version: 2, versionNonce: 2 },
+            { y: 200, version: 3, versionNonce: 3 },
+          ),
+        },
+      );
+
+      const elementsDelta = elementsDelta1.squash(elementsDelta2);
+
+      expect(elementsDelta.isEmpty()).toBeFalsy();
+      expect(elementsDelta).toBe(elementsDelta1);
+      expect(elementsDelta.added.id1).toEqual(
+        Delta.create(
+          { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
+          { x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
+        ),
+      );
+      expect(elementsDelta.removed.id2).toEqual(
+        Delta.create(
+          { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
+          { x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
+        ),
+      );
+      expect(elementsDelta.updated.id3).toEqual(
+        Delta.create(
+          { x: 100, y: 100, version: 2, versionNonce: 2 },
+          { x: 200, y: 200, version: 3, versionNonce: 3 },
+        ),
+      );
+    });
+
+    it("should squash different delta types ", () => {
+      // id1: added   -> updated => added
+      // id2: removed -> added   => added
+      // id3: updated -> removed => removed
+      const elementsDelta1 = ElementsDelta.create(
+        {
+          id1: Delta.create(
+            { x: 100, version: 1, versionNonce: 1, isDeleted: true },
+            { x: 101, version: 2, versionNonce: 2, isDeleted: false },
+          ),
+        },
+        {
+          id2: Delta.create(
+            { x: 200, version: 1, versionNonce: 1, isDeleted: false },
+            { x: 201, version: 2, versionNonce: 2, isDeleted: true },
+          ),
+        },
+        {
+          id3: Delta.create(
+            { x: 300, version: 1, versionNonce: 1 },
+            { x: 301, version: 2, versionNonce: 2 },
+          ),
+        },
+      );
+
+      const elementsDelta2 = ElementsDelta.create(
+        {
+          id2: Delta.create(
+            { y: 200, version: 2, versionNonce: 2, isDeleted: true },
+            { y: 201, version: 3, versionNonce: 3, isDeleted: false },
+          ),
+        },
+        {
+          id3: Delta.create(
+            { y: 300, version: 2, versionNonce: 2, isDeleted: false },
+            { y: 301, version: 3, versionNonce: 3, isDeleted: true },
+          ),
+        },
+        {
+          id1: Delta.create(
+            { y: 100, version: 2, versionNonce: 2 },
+            { y: 101, version: 3, versionNonce: 3 },
+          ),
+        },
+      );
+
+      const elementsDelta = elementsDelta1.squash(elementsDelta2);
+
+      expect(elementsDelta.isEmpty()).toBeFalsy();
+      expect(elementsDelta).toBe(elementsDelta1);
+      expect(elementsDelta.added).toEqual({
+        id1: Delta.create(
+          { x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
+          { x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
+        ),
+        id2: Delta.create(
+          { x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
+          { x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
+        ),
+      });
+      expect(elementsDelta.removed).toEqual({
+        id3: Delta.create(
+          { x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
+          { x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
+        ),
+      });
+      expect(elementsDelta.updated).toEqual({});
+    });
+
+    it("should squash bound elements", () => {
+      const elementsDelta1 = ElementsDelta.create(
+        {},
+        {},
+        {
+          id1: Delta.create(
+            {
+              version: 1,
+              versionNonce: 1,
+              boundElements: [{ id: "t1", type: "text" }],
+            },
+            {
+              version: 2,
+              versionNonce: 2,
+              boundElements: [{ id: "t2", type: "text" }],
+            },
+          ),
+        },
+      );
+
+      const elementsDelta2 = ElementsDelta.create(
+        {},
+        {},
+        {
+          id1: Delta.create(
+            {
+              version: 2,
+              versionNonce: 2,
+              boundElements: [{ id: "a1", type: "arrow" }],
+            },
+            {
+              version: 3,
+              versionNonce: 3,
+              boundElements: [{ id: "a2", type: "arrow" }],
+            },
+          ),
+        },
+      );
+
+      const elementsDelta = elementsDelta1.squash(elementsDelta2);
+
+      expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
+        { id: "t1", type: "text" },
+        { id: "a1", type: "arrow" },
+      ]);
+      expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
+        { id: "t2", type: "text" },
+        { id: "a2", type: "arrow" },
+      ]);
+    });
+  });
 });
 
 describe("AppStateDelta", () => {
@@ -215,4 +460,97 @@ describe("AppStateDelta", () => {
       expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
     });
   });
+
+  describe("squash", () => {
+    it("should not squash when second delta is empty", () => {
+      const delta = Delta.create(
+        { name: "untitled scene" },
+        { name: "titled scene" },
+      );
+
+      const appStateDelta1 = AppStateDelta.create(delta);
+      const appStateDelta2 = AppStateDelta.empty();
+      const appStateDelta = appStateDelta1.squash(appStateDelta2);
+
+      expect(appStateDelta.isEmpty()).toBeFalsy();
+      expect(appStateDelta).toBe(appStateDelta1);
+      expect(appStateDelta.delta).toBe(delta);
+    });
+
+    it("should squash exclusive properties", () => {
+      const delta1 = Delta.create(
+        { name: "untitled scene" },
+        { name: "titled scene" },
+      );
+      const delta2 = Delta.create(
+        { viewBackgroundColor: "#ffffff" },
+        { viewBackgroundColor: "#000000" },
+      );
+
+      const appStateDelta1 = AppStateDelta.create(delta1);
+      const appStateDelta2 = AppStateDelta.create(delta2);
+      const appStateDelta = appStateDelta1.squash(appStateDelta2);
+
+      expect(appStateDelta.isEmpty()).toBeFalsy();
+      expect(appStateDelta).toBe(appStateDelta1);
+      expect(appStateDelta.delta).toEqual(
+        Delta.create(
+          { name: "untitled scene", viewBackgroundColor: "#ffffff" },
+          { name: "titled scene", viewBackgroundColor: "#000000" },
+        ),
+      );
+    });
+
+    it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
+      const delta1 = Delta.create<Partial<ObservedAppState>>(
+        {
+          name: "untitled scene",
+          selectedElementIds: { id1: true },
+          selectedGroupIds: {},
+          lockedMultiSelections: { g1: true },
+        },
+        {
+          name: "titled scene",
+          selectedElementIds: { id2: true },
+          selectedGroupIds: { g1: true },
+          lockedMultiSelections: {},
+        },
+      );
+      const delta2 = Delta.create<Partial<ObservedAppState>>(
+        {
+          selectedElementIds: { id3: true },
+          selectedGroupIds: { g1: true },
+          lockedMultiSelections: {},
+        },
+        {
+          selectedElementIds: { id2: true },
+          selectedGroupIds: { g2: true, g3: true },
+          lockedMultiSelections: { g3: true },
+        },
+      );
+
+      const appStateDelta1 = AppStateDelta.create(delta1);
+      const appStateDelta2 = AppStateDelta.create(delta2);
+      const appStateDelta = appStateDelta1.squash(appStateDelta2);
+
+      expect(appStateDelta.isEmpty()).toBeFalsy();
+      expect(appStateDelta).toBe(appStateDelta1);
+      expect(appStateDelta.delta).toEqual(
+        Delta.create<Partial<ObservedAppState>>(
+          {
+            name: "untitled scene",
+            selectedElementIds: { id1: true, id3: true },
+            selectedGroupIds: { g1: true },
+            lockedMultiSelections: { g1: true },
+          },
+          {
+            name: "titled scene",
+            selectedElementIds: { id2: true },
+            selectedGroupIds: { g1: true, g2: true, g3: true },
+            lockedMultiSelections: { g3: true },
+          },
+        ),
+      );
+    });
+  });
 });

+ 25 - 0
packages/excalidraw/components/App.tsx

@@ -233,6 +233,8 @@ import {
   hitElementBoundingBox,
   isLineElement,
   isSimpleArrow,
+  StoreDelta,
+  type ApplyToOptions,
 } from "@excalidraw/element";
 
 import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -259,6 +261,7 @@ import type {
   MagicGenerationData,
   ExcalidrawArrowElement,
   ExcalidrawElbowArrowElement,
+  SceneElementsMap,
 } from "@excalidraw/element/types";
 
 import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -697,6 +700,7 @@ class App extends React.Component<AppProps, AppState> {
     if (excalidrawAPI) {
       const api: ExcalidrawImperativeAPI = {
         updateScene: this.updateScene,
+        applyDeltas: this.applyDeltas,
         mutateElement: this.mutateElement,
         updateLibrary: this.library.updateLibrary,
         addFiles: this.addFiles,
@@ -3938,6 +3942,27 @@ class App extends React.Component<AppProps, AppState> {
     },
   );
 
+  public applyDeltas = (
+    deltas: StoreDelta[],
+    options?: ApplyToOptions,
+  ): [SceneElementsMap, AppState, boolean] => {
+    // squash all deltas together, starting with a fresh new delta instance
+    const aggregatedDelta = StoreDelta.squash(...deltas);
+
+    // create new instance of elements map & appState, so we don't accidentaly mutate existing ones
+    const nextAppState = { ...this.state };
+    const nextElements = new Map(
+      this.scene.getElementsMapIncludingDeleted(),
+    ) as SceneElementsMap;
+
+    return StoreDelta.applyTo(
+      aggregatedDelta,
+      nextElements,
+      nextAppState,
+      options,
+    );
+  };
+
   public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
     element: TElement,
     updates: ElementUpdate<TElement>,

+ 1 - 1
packages/excalidraw/history.ts

@@ -175,7 +175,7 @@ export class History {
       let nextAppState = appState;
       let containsVisibleChange = false;
 
-      // iterate through the history entries in case ;they result in no visible changes
+      // iterate through the history entries in case they result in no visible changes
       while (historyDelta) {
         try {
           [nextElements, nextAppState, containsVisibleChange] =

+ 92 - 92
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -137,7 +137,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 18,
+  "version": 13,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -258,7 +258,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 14,
+  "version": 10,
   "width": 50,
   "x": 100,
   "y": 100,
@@ -305,11 +305,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 12,
+            "version": 9,
           },
           "inserted": {
             "boundElements": [],
-            "version": 11,
+            "version": 8,
           },
         },
         "id4": {
@@ -384,7 +384,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
         "id0": {
           "deleted": {
             "boundElements": [],
-            "version": 18,
+            "version": 13,
           },
           "inserted": {
             "boundElements": [
@@ -393,7 +393,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 17,
+            "version": 12,
           },
         },
         "id4": {
@@ -735,7 +735,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 19,
+  "version": 14,
   "width": 100,
   "x": 150,
   "y": -50,
@@ -884,7 +884,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
         "id0": {
           "deleted": {
             "boundElements": [],
-            "version": 19,
+            "version": 14,
           },
           "inserted": {
             "boundElements": [
@@ -893,7 +893,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 18,
+            "version": 13,
           },
         },
         "id4": {
@@ -1242,7 +1242,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 11,
+  "version": 10,
   "width": 98,
   "x": 1,
   "y": 0,
@@ -1421,12 +1421,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
               "focus": 0,
               "gap": 1,
             },
-            "version": 11,
+            "version": 10,
           },
           "inserted": {
             "endBinding": null,
             "startBinding": null,
-            "version": 8,
+            "version": 7,
           },
         },
       },
@@ -1639,7 +1639,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 12,
+  "version": 8,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -1674,7 +1674,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 11,
+  "version": 7,
   "width": 100,
   "x": 100,
   "y": -50,
@@ -1772,11 +1772,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 12,
+            "version": 8,
           },
           "inserted": {
             "boundElements": [],
-            "version": 9,
+            "version": 7,
           },
         },
         "id1": {
@@ -1787,11 +1787,11 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 11,
+            "version": 7,
           },
           "inserted": {
             "boundElements": [],
-            "version": 8,
+            "version": 6,
           },
         },
       },
@@ -2202,7 +2202,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 5,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -2237,7 +2237,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "width": 100,
   "x": 500,
   "y": -500,
@@ -2473,7 +2473,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 7,
+            "version": 5,
           },
           "inserted": {
             "boundElements": [],
@@ -2488,7 +2488,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
                 "type": "arrow",
               },
             ],
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "boundElements": [],
@@ -2720,7 +2720,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 10,
+  "version": 7,
   "verticalAlign": "top",
   "width": 30,
   "x": 15,
@@ -2780,11 +2780,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
         "id5": {
           "deleted": {
             "containerId": null,
-            "version": 10,
+            "version": 7,
           },
           "inserted": {
             "containerId": "id0",
-            "version": 9,
+            "version": 6,
           },
         },
       },
@@ -2937,7 +2937,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 11,
+  "version": 9,
   "width": 100,
   "x": 10,
   "y": 10,
@@ -2975,7 +2975,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 9,
+  "version": 8,
   "verticalAlign": "top",
   "width": 100,
   "x": 15,
@@ -3014,7 +3014,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 11,
+  "version": 7,
   "verticalAlign": "top",
   "width": 30,
   "x": 15,
@@ -3041,7 +3041,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
           "deleted": {
             "containerId": "id0",
             "isDeleted": true,
-            "version": 9,
+            "version": 8,
           },
           "inserted": {
             "angle": 0,
@@ -3071,7 +3071,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
             "text": "que pasa",
             "textAlign": "left",
             "type": "text",
-            "version": 8,
+            "version": 7,
             "verticalAlign": "top",
             "width": 100,
             "x": 15,
@@ -3084,7 +3084,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
         "id0": {
           "deleted": {
             "boundElements": [],
-            "version": 11,
+            "version": 9,
           },
           "inserted": {
             "boundElements": [
@@ -3093,7 +3093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
                 "type": "text",
               },
             ],
-            "version": 10,
+            "version": 8,
           },
         },
       },
@@ -3246,7 +3246,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 10,
+  "version": 9,
   "width": 100,
   "x": 10,
   "y": 10,
@@ -3356,7 +3356,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
                 "type": "text",
               },
             ],
-            "version": 10,
+            "version": 9,
           },
           "inserted": {
             "boundElements": [
@@ -3365,7 +3365,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
                 "type": "text",
               },
             ],
-            "version": 9,
+            "version": 8,
           },
         },
         "id1": {
@@ -4093,7 +4093,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 12,
+  "version": 8,
   "verticalAlign": "top",
   "width": 80,
   "x": 15,
@@ -4155,11 +4155,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
         "id1": {
           "deleted": {
             "containerId": "id0",
-            "version": 12,
+            "version": 8,
           },
           "inserted": {
             "containerId": null,
-            "version": 9,
+            "version": 7,
           },
         },
       },
@@ -4310,7 +4310,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 11,
+  "version": 7,
   "width": 100,
   "x": 10,
   "y": 10,
@@ -4424,11 +4424,11 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
                 "type": "text",
               },
             ],
-            "version": 11,
+            "version": 7,
           },
           "inserted": {
             "boundElements": [],
-            "version": 8,
+            "version": 6,
           },
         },
       },
@@ -4617,7 +4617,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "verticalAlign": "top",
   "width": 80,
   "x": 15,
@@ -5028,7 +5028,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 7,
   "width": 100,
   "x": 10,
   "y": 10,
@@ -5113,7 +5113,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
-            "version": 8,
+            "version": 7,
             "width": 100,
             "x": 10,
             "y": 10,
@@ -5126,7 +5126,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
               },
             ],
             "isDeleted": true,
-            "version": 7,
+            "version": 6,
           },
         },
       },
@@ -5316,7 +5316,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "textAlign": "left",
   "type": "text",
   "updated": 1,
-  "version": 8,
+  "version": 7,
   "verticalAlign": "top",
   "width": 100,
   "x": 15,
@@ -5371,7 +5371,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
             "text": "que pasa",
             "textAlign": "left",
             "type": "text",
-            "version": 8,
+            "version": 7,
             "verticalAlign": "top",
             "width": 100,
             "x": 15,
@@ -5380,7 +5380,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
           "inserted": {
             "containerId": "id0",
             "isDeleted": true,
-            "version": 7,
+            "version": 6,
           },
         },
       },
@@ -5527,7 +5527,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 10,
+  "version": 9,
   "width": 100,
   "x": 10,
   "y": 10,
@@ -5784,7 +5784,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 4,
+  "version": 5,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -5816,7 +5816,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 3,
+  "version": 4,
   "width": 100,
   "x": 100,
   "y": 100,
@@ -6072,7 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "width": 10,
   "x": 20,
   "y": 0,
@@ -6102,7 +6102,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "width": 10,
   "x": 50,
   "y": 50,
@@ -6205,11 +6205,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
         "id3": {
           "deleted": {
             "backgroundColor": "#ffc9c9",
-            "version": 7,
+            "version": 8,
           },
           "inserted": {
             "backgroundColor": "transparent",
-            "version": 6,
+            "version": 7,
           },
         },
       },
@@ -6251,12 +6251,12 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
       "updated": {
         "id8": {
           "deleted": {
-            "version": 7,
+            "version": 8,
             "x": 50,
             "y": 50,
           },
           "inserted": {
-            "version": 6,
+            "version": 7,
             "x": 30,
             "y": 30,
           },
@@ -7104,7 +7104,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "width": 10,
   "x": 0,
   "y": 0,
@@ -7344,7 +7344,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "width": 10,
   "x": 10,
   "y": 0,
@@ -7393,11 +7393,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
         "id0": {
           "deleted": {
             "backgroundColor": "#ffec99",
-            "version": 7,
+            "version": 8,
           },
           "inserted": {
             "backgroundColor": "transparent",
-            "version": 6,
+            "version": 7,
           },
         },
       },
@@ -10326,7 +10326,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 8,
   "width": 10,
   "x": 10,
   "y": 0,
@@ -10378,14 +10378,14 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
             "strokeStyle": "solid",
             "strokeWidth": 2,
             "type": "rectangle",
-            "version": 7,
+            "version": 8,
             "width": 10,
             "x": 10,
             "y": 0,
           },
           "inserted": {
             "isDeleted": true,
-            "version": 6,
+            "version": 7,
           },
         },
       },
@@ -15584,7 +15584,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -15622,7 +15622,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 6,
+  "version": 5,
   "verticalAlign": "middle",
   "width": 30,
   "x": -65,
@@ -15658,7 +15658,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 5,
   "width": 100,
   "x": 100,
   "y": -50,
@@ -15768,7 +15768,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "boundElements": [],
@@ -15783,7 +15783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 7,
+            "version": 5,
           },
           "inserted": {
             "boundElements": [],
@@ -16279,7 +16279,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -16317,7 +16317,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "verticalAlign": "middle",
   "width": 30,
   "x": -65,
@@ -16353,7 +16353,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 7,
+  "version": 5,
   "width": 100,
   "x": 100,
   "y": -50,
@@ -16729,7 +16729,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "boundElements": [],
@@ -16744,7 +16744,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 7,
+            "version": 5,
           },
           "inserted": {
             "boundElements": [],
@@ -16904,7 +16904,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 12,
+  "version": 10,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -16942,7 +16942,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 12,
+  "version": 10,
   "verticalAlign": "middle",
   "width": 30,
   "x": -65,
@@ -16978,7 +16978,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 9,
+  "version": 7,
   "width": 100,
   "x": 100,
   "y": -50,
@@ -17119,7 +17119,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "text": "ola",
             "textAlign": "left",
             "type": "text",
-            "version": 9,
+            "version": 8,
             "verticalAlign": "top",
             "width": 100,
             "x": -200,
@@ -17127,7 +17127,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
           },
           "inserted": {
             "isDeleted": true,
-            "version": 8,
+            "version": 7,
           },
         },
         "id2": {
@@ -17243,7 +17243,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "containerId": "id0",
             "height": 25,
             "textAlign": "center",
-            "version": 10,
+            "version": 9,
             "verticalAlign": "middle",
             "width": 30,
             "x": -65,
@@ -17253,7 +17253,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "containerId": null,
             "height": 100,
             "textAlign": "left",
-            "version": 9,
+            "version": 8,
             "verticalAlign": "top",
             "width": 100,
             "x": -200,
@@ -17354,7 +17354,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 12,
+            "version": 10,
           },
           "inserted": {
             "boundElements": [],
@@ -17369,7 +17369,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
                 "type": "arrow",
               },
             ],
-            "version": 9,
+            "version": 7,
           },
           "inserted": {
             "boundElements": [],
@@ -17527,7 +17527,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -17565,7 +17565,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "verticalAlign": "middle",
   "width": 30,
   "x": -65,
@@ -17601,7 +17601,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 5,
+  "version": 4,
   "width": 100,
   "x": 100,
   "y": -50,
@@ -17689,7 +17689,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
         "id0": {
           "deleted": {
             "isDeleted": false,
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "isDeleted": true,
@@ -17699,7 +17699,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
         "id1": {
           "deleted": {
             "isDeleted": false,
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "isDeleted": true,
@@ -18239,7 +18239,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "width": 100,
   "x": -100,
   "y": -50,
@@ -18277,7 +18277,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "textAlign": "center",
   "type": "text",
   "updated": 1,
-  "version": 8,
+  "version": 6,
   "verticalAlign": "middle",
   "width": 30,
   "x": -65,
@@ -18402,7 +18402,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
         "id0": {
           "deleted": {
             "isDeleted": false,
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "isDeleted": true,
@@ -18412,7 +18412,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
         "id1": {
           "deleted": {
             "isDeleted": false,
-            "version": 8,
+            "version": 6,
           },
           "inserted": {
             "isDeleted": true,

+ 1 - 0
packages/excalidraw/types.ts

@@ -801,6 +801,7 @@ export type UnsubscribeCallback = () => void;
 
 export interface ExcalidrawImperativeAPI {
   updateScene: InstanceType<typeof App>["updateScene"];
+  applyDeltas: InstanceType<typeof App>["applyDeltas"];
   mutateElement: InstanceType<typeof App>["mutateElement"];
   updateLibrary: InstanceType<typeof Library>["updateLibrary"];
   resetScene: InstanceType<typeof App>["resetScene"];