From 2535d7305485a032b5a860a4538870a0c2d09c5a Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Fri, 15 Aug 2025 15:25:56 +0200 Subject: [PATCH] feat: apply deltas API (#9869) --- packages/element/src/Scene.ts | 20 +- packages/element/src/delta.ts | 304 +++++++++++++--- packages/element/src/store.ts | 26 +- packages/element/tests/delta.test.tsx | 340 +++++++++++++++++- packages/excalidraw/components/App.tsx | 25 ++ packages/excalidraw/history.ts | 2 +- .../tests/__snapshots__/history.test.tsx.snap | 184 +++++----- packages/excalidraw/types.ts | 1 + 8 files changed, 750 insertions(+), 152 deletions(-) diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index df0fd3e2d..eaef25796 100644 --- a/packages/element/src/Scene.ts +++ b/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(); diff --git a/packages/element/src/delta.ts b/packages/element/src/delta.ts index 38839fa84..d7b242d60 100644 --- a/packages/element/src/delta.ts +++ b/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 { /** * Merges two deltas into a new one. */ - public static merge(delta1: Delta, delta2: Delta) { + public static merge( + delta1: Delta, + delta2: Delta, + delta3: Delta = 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 { public static mergeObjects( prev: T, added: T, - removed: T, + removed: T = {} as T, ) { const cloned = { ...prev }; @@ -520,6 +524,10 @@ export interface DeltaContainer { export class AppStateDelta implements DeltaContainer { private constructor(public delta: Delta) {} + public static create(delta: Delta): AppStateDelta { + return new AppStateDelta(delta); + } + public static calculate( prevAppState: T, nextAppState: T, @@ -550,7 +558,74 @@ export class AppStateDelta implements DeltaContainer { } 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 = {}; + const mergedDeleted: Partial = {}; + + 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 { 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 { 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 { ...directlyApplicablePartial, selectedElementIds: mergedSelectedElementIds, selectedGroupIds: mergedSelectedGroupIds, + lockedMultiSelections: mergedLockedMultiSelections, selectedLinearElement: typeof insertedSelectedLinearElement !== "undefined" ? selectedLinearElement @@ -904,12 +988,6 @@ export class AppStateDelta implements DeltaContainer { "lockedMultiSelections", (prevValue) => (prevValue ?? {}) as ValueOf, ); - Delta.diffObjects( - deleted, - inserted, - "activeLockedId", - (prevValue) => (prevValue ?? null) as ValueOf, - ); } 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 = Omit>, "id" | "updated" | "seed">; export type ApplyToOptions = { - excludedProperties: Set; + excludedProperties?: Set; }; type ApplyToFlags = { containsVisibleDifference: boolean; containsZindexDifference: boolean; + applyDirection: "forward" | "backward" | undefined; }; /** @@ -1044,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer { 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 { 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 { 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; @@ -1321,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer { 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 { } 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 { } public squash(delta: ElementsDelta): this { + if (delta.isEmpty()) { + return this; + } + const { added, removed, updated } = delta; + const mergeBoundElements = ( + prevDelta: Delta, + nextDelta: Delta, + ) => { + 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>) => { const getElement = ElementsDelta.createGetter( @@ -1444,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer { 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 { private static applyDelta( element: OrderedExcalidrawElement, delta: Delta, - options: ApplyToOptions, flags: ApplyToFlags, + options?: ApplyToOptions, ) { const directlyApplicablePartial: Mutable = {}; @@ -1512,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer { continue; } - if (options.excludedProperties.has(key)) { + if (options?.excludedProperties?.has(key)) { continue; } @@ -1552,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer { delta.deleted.index !== delta.inserted.index; } - return newElementWith(element, directlyApplicablePartial); + return newElementWith(element, directlyApplicablePartial, true); } /** @@ -1592,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer { private resolveConflicts( prevElements: SceneElementsMap, nextElements: SceneElementsMap, + applyDirection: "forward" | "backward" = "forward", ) { const nextAffectedElements = new Map(); const updater = ( @@ -1603,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer { return; } + const prevElement = prevElements.get(element.id); + const nextVersion = + applyDirection === "forward" + ? nextElement.version + 1 + : nextElement.version - 1; + + const elementUpdates = updates as ElementUpdate; + 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, + { + ...elementUpdates, + version: nextVersion, + }, + true, ); } else { - affectedElement = mutateElement( - nextElement, - nextElements, - updates as ElementUpdate, - ); + 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 { BindableElement.rebindAffected(nextElements, nextElement(), updater); } + public static redrawElements( + nextElements: SceneElementsMap, + changedElements: Map, + ) { + 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, @@ -1776,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer { ) { 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, }); diff --git a/packages/element/src/store.ts b/packages/element/src/store.ts index e304486ff..38235e752 100644 --- a/packages/element/src/store.ts +++ b/packages/element/src/store.ts @@ -552,10 +552,26 @@ export class StoreDelta { public static load({ id, elements: { added, removed, updated }, + appState: { delta: appStateDelta }, }: DTO) { 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(); } diff --git a/packages/element/tests/delta.test.tsx b/packages/element/tests/delta.test.tsx index 81c9d4591..e9a19d850 100644 --- a/packages/element/tests/delta.test.tsx +++ b/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>( + { + name: "untitled scene", + selectedElementIds: { id1: true }, + selectedGroupIds: {}, + lockedMultiSelections: { g1: true }, + }, + { + name: "titled scene", + selectedElementIds: { id2: true }, + selectedGroupIds: { g1: true }, + lockedMultiSelections: {}, + }, + ); + const delta2 = Delta.create>( + { + 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>( + { + 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 }, + }, + ), + ); + }); + }); }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5d0ce49e2..65635cd60 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { 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 { }, ); + 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 = >( element: TElement, updates: ElementUpdate, diff --git a/packages/excalidraw/history.ts b/packages/excalidraw/history.ts index 7250dd600..482065be4 100644 --- a/packages/excalidraw/history.ts +++ b/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] = diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c9a299283..60d7e5ed6 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/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, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e321b34cb..6694c8810 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -801,6 +801,7 @@ export type UnsubscribeCallback = () => void; export interface ExcalidrawImperativeAPI { updateScene: InstanceType["updateScene"]; + applyDeltas: InstanceType["applyDeltas"]; mutateElement: InstanceType["mutateElement"]; updateLibrary: InstanceType["updateLibrary"]; resetScene: InstanceType["resetScene"];