feat: apply deltas API (#9869)

This commit is contained in:
Marcel Mraz 2025-08-15 15:25:56 +02:00 committed by GitHub
parent dda3affcb0
commit 2535d73054
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 750 additions and 152 deletions

View File

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

View File

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

View File

@ -552,10 +552,26 @@ export class StoreDelta {
public static load({ public static load({
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated); 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, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
options: ApplyToOptions = { options?: ApplyToOptions,
excludedProperties: new Set(),
},
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements, elements,
@ -613,6 +627,10 @@ export class StoreDelta {
); );
} }
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }

View File

@ -4,7 +4,7 @@ import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta, ElementsDelta } from "../src/delta"; import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => { describe("ElementsDelta", () => {
describe("elements delta calculation", () => { describe("elements delta calculation", () => {
@ -68,6 +68,251 @@ describe("ElementsDelta", () => {
expect(delta.isEmpty()).toBeTruthy(); 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", () => { describe("AppStateDelta", () => {
@ -215,4 +460,97 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2)); 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 },
},
),
);
});
});
}); });

View File

@ -233,6 +233,8 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
StoreDelta,
type ApplyToOptions,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -259,6 +261,7 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@ -697,6 +700,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) { if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = { const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene, updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement, mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary, updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles, 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>>( public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,

View File

@ -175,7 +175,7 @@ export class History {
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; 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) { while (historyDelta) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =

View File

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

View File

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