feat: various delta improvements (#9571)

This commit is contained in:
Marcel Mraz
2025-06-09 09:55:35 +02:00
committed by GitHub
parent d4e85a9480
commit d108053351
16 changed files with 1423 additions and 687 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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