Compare commits

..

1 Commits

Author SHA1 Message Date
5639bb8e87 Updates to point to excalidraw-storage-backend 2025-05-25 22:22:50 -04:00
22 changed files with 3269 additions and 2707 deletions

View File

@ -1,5 +1,3 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@ -1,7 +1,5 @@
MODE="production" VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries

View File

@ -34,9 +34,6 @@
<a href="https://discord.gg/UexuTaE"> <a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/> <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a> </a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a> </a>

View File

@ -19,7 +19,7 @@ services:
- ./:/opt/node_app/app:delegated - ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json - ./package.json:/opt/node_app/package.json
- ./yarn.lock:/opt/node_app/yarn.lock - ./yarn.lock:/opt/node_app/yarn.lock
- notused:/opt/node_app/app/node_modules # - notused:/opt/node_app/app/node_modules
volumes: # volumes:
notused: # notused:

View File

@ -926,16 +926,21 @@ const ExcalidrawWrapper = () => {
<ShareDialog <ShareDialog
collabAPI={collabAPI} collabAPI={collabAPI}
onExportToBackend={async () => { onExportToBackend={async () => {
if (excalidrawAPI) { if (!excalidrawAPI) {
try { return;
await onExportToBackend( }
excalidrawAPI.getSceneElements(), try {
excalidrawAPI.getAppState(), const { url, errorMessage } = await exportToBackend(
excalidrawAPI.getFiles(), excalidrawAPI.getSceneElements(),
); excalidrawAPI.getAppState(),
} catch (error: any) { excalidrawAPI.getFiles(),
setErrorMessage(error.message); );
if (errorMessage) {
throw new Error(errorMessage);
} }
setLatestShareableLink(url);
} catch (error: any) {
setErrorMessage(error.message);
} }
}} }}
/> />

View File

@ -41,8 +41,8 @@
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build", "build:app:docker": "vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build", "build:app": "vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",

View File

@ -205,7 +205,6 @@ 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 }),
@ -237,7 +236,7 @@ describe("collaboration", () => {
// with explicit redo (as removal) we again restore the element from the snapshot! // with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([ expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props), expect.objectContaining(rect1Props),
@ -249,11 +248,13 @@ describe("collaboration", () => {
]); ]);
}); });
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update // simulate local update
API.updateScene({ API.updateScene({
elements: syncInvalidIndices([ elements: syncInvalidIndices([
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { x: 100, isDeleted: false }), newElementWith(h.elements[1], { x: 100 }),
]), ]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
@ -270,5 +271,55 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), 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

@ -712,8 +712,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, idx) => { array.reduce((acc, value) => {
acc[groupBy ? groupBy(value) : idx] = value; acc[groupBy ? groupBy(value) : String(value)] = value;
return acc; return acc;
}, {} as { [key: string]: T }); }, {} as { [key: string]: T });

View File

@ -9,7 +9,7 @@ import {
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
@ -18,12 +18,7 @@ import type {
SceneElementsMap, SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types";
DTO,
Mutable,
SubtypeOf,
ValueOf,
} from "@excalidraw/common/utility-types";
import type { import type {
AppState, AppState,
@ -56,8 +51,6 @@ 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";
@ -263,14 +256,12 @@ 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 (
@ -867,17 +858,10 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
} }
type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> = type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">; ElementUpdate<Ordered<T>>,
"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.
@ -1166,15 +1150,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements, elementsSnapshot: Map<string, OrderedExcalidrawElement>,
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: ApplyToFlags = { const flags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
}; };
@ -1183,14 +1164,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
snapshot, elementsSnapshot,
options,
flags, flags,
); );
const addedElements = applyDeltas(this.added); const addedElements = applyDeltas("added", this.added);
const removedElements = applyDeltas(this.removed); const removedElements = applyDeltas("removed", this.removed);
const updatedElements = applyDeltas(this.updated); const updatedElements = applyDeltas("updated", this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(elements, nextElements);
@ -1249,12 +1229,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createApplier = private static createApplier =
( (
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: Map<string, OrderedExcalidrawElement>,
options: ApplyToOptions, flags: {
flags: ApplyToFlags, containsVisibleDifference: boolean;
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,
@ -1264,13 +1250,7 @@ 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 newElement = ElementsDelta.applyDelta(element, delta, flags);
element,
delta,
options,
flags,
);
nextElements.set(newElement.id, newElement); nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement); acc.set(newElement.id, newElement);
} }
@ -1281,9 +1261,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createGetter = private static createGetter =
( (
type: "added" | "removed" | "updated",
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"], snapshot: Map<string, OrderedExcalidrawElement>,
flags: ApplyToFlags, flags: {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) => ) =>
(id: string, partial: ElementPartial) => { (id: string, partial: ElementPartial) => {
let element = elements.get(id); let element = elements.get(id);
@ -1297,7 +1281,10 @@ 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 (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) { if (
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
} else { } else {
@ -1317,28 +1304,16 @@ 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: {
flags: ApplyToFlags, containsVisibleDifference: boolean;
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) { ) {
const directlyApplicablePartial: Mutable<ElementPartial> = {}; const { boundElements, ...directlyApplicablePartial } = delta.inserted;
// 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 ||
@ -1356,6 +1331,19 @@ 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;
@ -1662,32 +1650,6 @@ 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.isLeftDifferent(deletedPoints, insertedPoints) &&
!Delta.isRightDifferent(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.`);
@ -1703,7 +1665,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static stripIrrelevantProps( private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>, partial: Partial<OrderedExcalidrawElement>,
): ElementPartial { ): ElementPartial {
const { id, updated, ...strippedPartial } = partial; const { id, updated, version, versionNonce, ...strippedPartial } = partial;
return strippedPartial; return strippedPartial;
} }

View File

@ -974,25 +974,6 @@ export const updateElbowArrowPoints = (
), ),
"Elbow arrow segments must be either horizontal or vertical", "Elbow arrow segments must be either horizontal or vertical",
); );
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
} }
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];

View File

@ -2,9 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types"; 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";
@ -163,15 +161,9 @@ 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) =>
const elementUpdates = elementsUpdates.get(x); elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : 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(
@ -185,8 +177,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, { index }] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
mutateElement(element, elementsMap, { index }); mutateElement(element, elementsMap, update);
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@ -197,33 +189,19 @@ export const syncMovedIndices = (
}; };
/** /**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements array. * Synchronizes all invalid fractional indices with the array order by mutating passed elements.
*
* When `shouldCreateNewInstances` is true, it creates new instances of the elements, instead of mutating the existing ones.
* *
* 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.
*/ */
export const syncInvalidIndices = ( export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
{
shouldCreateNewInstances = false,
}: {
shouldCreateNewInstances?: boolean;
} = {},
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index, arrayIndex }] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
if (shouldCreateNewInstances) { mutateElement(element, elementsMap, update);
const updatedElement = newElementWith(element, { index });
// mutate the element in the array with the new updated instance
(elements as Mutable<typeof elements>)[arrayIndex] = updatedElement;
} else {
const elementsMap = arrayToMap(elements);
mutateElement(element, elementsMap, { index });
}
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
@ -402,7 +380,7 @@ const generateIndices = (
) => { ) => {
const elementsUpdates = new Map< const elementsUpdates = new Map<
ExcalidrawElement, ExcalidrawElement,
{ index: FractionalIndex; arrayIndex: number } { index: FractionalIndex }
>(); >();
for (const indices of indicesGroups) { for (const indices of indicesGroups) {
@ -420,7 +398,6 @@ const generateIndices = (
elementsUpdates.set(element, { elementsUpdates.set(element, {
index: fractionalIndices[i], index: fractionalIndices[i],
arrayIndex: indices[i],
}); });
} }
} }

View File

@ -213,7 +213,16 @@ 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 {
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot); // calculate the deltas based on the previous and next snapshot
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
} }
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
@ -496,24 +505,6 @@ 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.
*/ */
@ -533,7 +524,9 @@ 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());
} }
@ -541,10 +534,27 @@ export class StoreDelta {
/** /**
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta) { public static inverse(delta: StoreDelta): 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.
*/ */
@ -552,9 +562,12 @@ 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] = const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
delta.elements.applyTo(elements); elements,
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@ -674,10 +687,11 @@ export class StoreSnapshot {
nextElements.set(id, changedElement); nextElements.set(id, changedElement);
} }
const nextAppState = getObservedAppState({ const nextAppState = Object.assign(
...this.appState, {},
...change.appState, this.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
@ -930,26 +944,18 @@ const getDefaultObservedAppState = (): ObservedAppState => {
}; };
}; };
export const getObservedAppState = ( export const getObservedAppState = (appState: AppState): ObservedAppState => {
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,6 +505,8 @@ 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

@ -1483,13 +1483,13 @@ const getArrowheadOptions = (flip: boolean) => {
value: "crowfoot_one", value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"), text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />, icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "x", keyBinding: "c",
}, },
{ {
value: "crowfoot_many", value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"), text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />, icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "c", keyBinding: "x",
}, },
{ {
value: "crowfoot_one_or_many", value: "crowfoot_one_or_many",

View File

@ -104,11 +104,7 @@ import {
Emitter, Emitter,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
getCommonBounds,
getElementAbsoluteCoords,
getObservedAppState,
} from "@excalidraw/element";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
@ -3997,30 +3993,22 @@ class App extends React.Component<AppProps, AppState> {
}) => { }) => {
const { elements, appState, collaborators, captureUpdate } = sceneData; const { elements, appState, collaborators, captureUpdate } = sceneData;
const nextElements = elements const nextElements = elements ? syncInvalidIndices(elements) : undefined;
? syncInvalidIndices(elements, {
// we have to create new instances here, otherwise scheduled micro action below won't be able to
// detect the fractional index change and won't update the store snapshot
shouldCreateNewInstances: true,
})
: undefined;
if (captureUpdate) { if (captureUpdate) {
const nextElementsMap = elements const nextElementsMap = elements
? (arrayToMap(nextElements ?? []) as SceneElementsMap) ? (arrayToMap(nextElements ?? []) as SceneElementsMap)
: undefined; : undefined;
const nextObservedAppState = appState const nextAppState = appState
? getObservedAppState({ ? // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
...this.store.snapshot.appState, Object.assign({}, this.store.snapshot.appState, appState)
...appState,
})
: undefined; : undefined;
this.store.scheduleMicroAction({ this.store.scheduleMicroAction({
action: captureUpdate, action: captureUpdate,
elements: nextElementsMap, elements: nextElementsMap,
appState: nextObservedAppState, appState: nextAppState,
}); });
} }

View File

@ -564,7 +564,7 @@ export const convertElementTypes = (
continue; continue;
} }
const fixedSegments: FixedSegment[] = []; const fixedSegments: FixedSegment[] = [];
for (let i = 1; i < nextPoints.length - 2; i++) { for (let i = 0; i < nextPoints.length - 1; i++) {
fixedSegments.push({ fixedSegments.push({
start: nextPoints[i], start: nextPoints[i],
end: nextPoints[i + 1], end: nextPoints[i + 1],
@ -581,7 +581,6 @@ export const convertElementTypes = (
); );
mutateElement(element, app.scene.getNonDeletedElementsMap(), { mutateElement(element, app.scene.getNonDeletedElementsMap(), {
...updates, ...updates,
endArrowhead: "arrow",
}); });
} else { } else {
// if we're converting to non-elbow linear element, check if // if we're converting to non-elbow linear element, check if

View File

@ -7,62 +7,11 @@ import {
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { StoreSnapshot } 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";
export class HistoryDelta extends StoreDelta { class HistoryEntry 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 elements in the scene (force deleted)
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 operation
{
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;
}
}
export class HistoryChangedEvent { export class HistoryChangedEvent {
constructor( constructor(
@ -76,8 +25,8 @@ export class History {
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
public readonly undoStack: HistoryDelta[] = []; public readonly undoStack: HistoryEntry[] = [];
public readonly redoStack: HistoryDelta[] = []; public readonly redoStack: HistoryEntry[] = [];
public get isUndoStackEmpty() { public get isUndoStackEmpty() {
return this.undoStack.length === 0; return this.undoStack.length === 0;
@ -99,16 +48,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 HistoryDelta) { if (delta.isEmpty() || delta instanceof HistoryEntry) {
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 historyDelta = HistoryDelta.inverse(delta); const entry = HistoryEntry.inverse(delta);
this.undoStack.push(historyDelta); this.undoStack.push(entry);
if (!historyDelta.elements.isEmpty()) { if (!entry.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!
@ -125,7 +74,7 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryDelta) => History.push(this.redoStack, entry), (entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
); );
} }
@ -134,20 +83,20 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryDelta) => History.push(this.undoStack, entry), (entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
); );
} }
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
pop: () => HistoryDelta | null, pop: () => HistoryEntry | null,
push: (entry: HistoryDelta) => void, push: (entry: HistoryEntry) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
try { try {
let historyDelta = pop(); let historyEntry = pop();
if (historyDelta === null) { if (historyEntry === null) {
return; return;
} }
@ -160,10 +109,15 @@ export class History {
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 (historyEntry) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
historyDelta.applyTo(nextElements, nextAppState, prevSnapshot); StoreDelta.applyTo(
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
action, action,
@ -171,31 +125,24 @@ export class History {
nextAppState, nextAppState,
); );
const change = StoreChange.create(prevSnapshot, nextSnapshot);
// update the history entry, so that it's the a new history entry instance
// and so that it contains the latest changes in both inserted and deleted partials
// including version and versionNonce or properties which were in the meantime updated by a remote client
historyDelta = HistoryDelta.calculate(prevSnapshot, nextSnapshot);
// schedule immediate capture, so that it's emitted for the sync purposes // schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({ this.store.scheduleMicroAction({
action, action,
change, change: StoreChange.create(prevSnapshot, nextSnapshot),
delta: historyDelta, delta: historyEntry,
}); });
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
// make sure to always push, even if the delta is corrupted // make sure to always push, even if the delta is corrupted
push(historyDelta); push(historyEntry);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
break; break;
} }
historyDelta = pop(); historyEntry = pop();
} }
return [nextElements, nextAppState]; return [nextElements, nextAppState];
@ -208,7 +155,7 @@ export class History {
} }
} }
private static pop(stack: HistoryDelta[]): HistoryDelta | null { private static pop(stack: HistoryEntry[]): HistoryEntry | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
} }
@ -222,12 +169,18 @@ export class History {
return null; return null;
} }
private static push(stack: HistoryDelta[], entry: HistoryDelta) { private static push(
if (entry.isEmpty()) { stack: HistoryEntry[],
return; entry: HistoryEntry,
} prevElements: SceneElementsMap,
) {
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
const inversedEntry = HistoryDelta.inverse(entry); return stack.push(updatedEntry);
return stack.push(inversedEntry);
} }
} }

View File

@ -1277,7 +1277,6 @@ 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": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@ -1526,7 +1525,6 @@ 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,
@ -1581,7 +1579,6 @@ 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,
@ -1617,11 +1614,9 @@ 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,
}, },
}, },
}, },
@ -1863,7 +1858,6 @@ 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,
@ -1918,7 +1912,6 @@ 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,
@ -1954,11 +1947,9 @@ 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,
}, },
}, },
}, },
@ -2168,7 +2159,6 @@ 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": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@ -2381,7 +2371,6 @@ 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": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@ -2413,11 +2402,9 @@ 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,
}, },
}, },
}, },
@ -2661,7 +2648,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"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,
@ -2716,7 +2702,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"version": 5,
"width": 20, "width": 20,
"x": 0, "x": 0,
"y": 10, "y": 10,
@ -2974,7 +2959,6 @@ 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,
@ -3029,7 +3013,6 @@ 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,
@ -3085,11 +3068,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"groupIds": [ "groupIds": [
"id9", "id9",
], ],
"version": 4,
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"version": 3,
}, },
}, },
"id3": { "id3": {
@ -3097,11 +3078,9 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"groupIds": [ "groupIds": [
"id9", "id9",
], ],
"version": 4,
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"version": 3,
}, },
}, },
}, },
@ -3345,7 +3324,6 @@ 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,
@ -3400,7 +3378,6 @@ 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,
@ -3428,11 +3405,9 @@ 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,
}, },
}, },
}, },
@ -3453,11 +3428,9 @@ 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,
}, },
}, },
}, },
@ -3478,11 +3451,9 @@ 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,
}, },
}, },
}, },
@ -3503,11 +3474,9 @@ 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,
}, },
}, },
}, },
@ -3528,11 +3497,9 @@ 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,
}, },
}, },
}, },
@ -3553,11 +3520,9 @@ 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,
}, },
}, },
}, },
@ -3591,7 +3556,6 @@ 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",
@ -3600,7 +3564,6 @@ 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,
}, },
}, },
}, },
@ -3842,7 +3805,6 @@ 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,
@ -3897,7 +3859,6 @@ 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,
@ -3925,11 +3886,9 @@ 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,
}, },
}, },
}, },
@ -4171,7 +4130,6 @@ 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,
@ -4226,7 +4184,6 @@ 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,
@ -4254,11 +4211,9 @@ 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,
}, },
}, },
}, },
@ -4503,7 +4458,6 @@ 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,
@ -4558,7 +4512,6 @@ 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,
@ -4614,11 +4567,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"groupIds": [ "groupIds": [
"id9", "id9",
], ],
"version": 4,
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"version": 3,
}, },
}, },
"id3": { "id3": {
@ -4626,11 +4577,9 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"groupIds": [ "groupIds": [
"id9", "id9",
], ],
"version": 4,
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"version": 3,
}, },
}, },
}, },
@ -4657,25 +4606,21 @@ 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,
}, },
}, },
}, },
@ -5794,7 +5739,6 @@ 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,
@ -5849,7 +5793,6 @@ 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,
@ -7023,7 +6966,6 @@ 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,
@ -7078,7 +7020,6 @@ 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,
@ -7156,11 +7097,9 @@ 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": {
@ -7168,11 +7107,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"groupIds": [ "groupIds": [
"id12", "id12",
], ],
"version": 4,
}, },
"inserted": { "inserted": {
"groupIds": [], "groupIds": [],
"version": 3,
}, },
}, },
}, },
@ -9989,7 +9926,6 @@ 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": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@ import "@excalidraw/utils/test-utils";
import { ElementsDelta, AppStateDelta } from "@excalidraw/element"; import { ElementsDelta, AppStateDelta } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -34,7 +34,6 @@ import type {
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawRectangleElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding, FixedPointBinding,
FractionalIndex, FractionalIndex,
@ -54,8 +53,6 @@ import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene"; import * as StaticScene from "../renderer/staticScene";
import { HistoryDelta } from "../history";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
@ -123,19 +120,9 @@ describe("history", () => {
API.setElements([rect]); API.setElements([rect]);
const corrupedEntry = HistoryDelta.create( const corrupedEntry = StoreDelta.create(
ElementsDelta.empty(), ElementsDelta.empty(),
// delta can't be empty, otherwise it won't be pushed into the undo stack AppStateDelta.empty(),
AppStateDelta.restore({
delta: {
inserted: {
selectedElementIds: {},
},
deleted: {
selectedElementIds: { [rect.id]: true },
},
},
}),
); );
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => { vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
@ -402,7 +389,7 @@ describe("history", () => {
]); ]);
}); });
it("should not push into redo stack when selection changes dooes not produce a visible change", async () => { it("should iterate through the history when selection changes do not produce visible change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect = UI.createElement("rectangle", { x: 10 }); const rect = UI.createElement("rectangle", { x: 10 });
@ -433,18 +420,18 @@ describe("history", () => {
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
Keyboard.redo(); // acceptable empty redo Keyboard.redo(); // acceptable empty redo
expect(API.getUndoStack().length).toBe(2); // empty change, nothing goes into undo stack expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect); assertSelectedElements(rect);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0); // now we iterated through the same undos!
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(3);
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: true }), expect.objectContaining({ id: rect.id, isDeleted: true }),
@ -1326,10 +1313,6 @@ describe("history", () => {
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
rect1 = h.elements[0] as ExcalidrawRectangleElement;
text = h.elements[1] as ExcalidrawTextElement;
rect2 = h.elements[2] as ExcalidrawRectangleElement;
// bind text1 to rect1 // bind text1 to rect1
mouse.select([rect1, text]); mouse.select([rect1, text]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas); fireEvent.contextMenu(GlobalTestState.interactiveCanvas);
@ -2379,10 +2362,10 @@ describe("history", () => {
}), }),
]); ]);
// We reached the bottom, again we iterate through invisible changes and reach the top
Keyboard.redo(); Keyboard.redo();
assertSelectedElements(); assertSelectedElements();
// We reached the bottom, now there is only one non-empty history delta, which will be pushed to the undo stack expect(API.getUndoStack().length).toBe(2);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -2447,31 +2430,7 @@ describe("history", () => {
]); ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(3);
// visible change detected, so we don't iterate up again
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
isDeleted: true,
backgroundColor: transparent,
}),
expect.objectContaining({
id: rect3.id,
isDeleted: true,
x: 30,
y: 30,
}),
]);
Keyboard.redo();
// we iterate all the way up, as there are no visible changes
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements()).toEqual([]); expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -2530,8 +2489,8 @@ describe("history", () => {
]); ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(0);
// do not expect any selectedElementIds, as all relate to deleted elements // do not expect any selectedElementIds, as all relate to deleted elements
expect(API.getSelectedElements()).toEqual([]); expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
@ -2547,6 +2506,7 @@ describe("history", () => {
expect.objectContaining({ id: rect1.id, isDeleted: false }), expect.objectContaining({ id: rect1.id, isDeleted: false }),
]); ]);
// Simulate remote update
API.updateScene({ API.updateScene({
elements: [ elements: [
h.elements[0], h.elements[0],
@ -2563,13 +2523,14 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
// redo entry was calculated again with the latest undo, which goes back to nothing being selected expect(API.getSelectedElements()).toEqual([
expect(API.getSelectedElements()).toEqual([]); expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
// now we again expect these as selected, as they got restored remotely and this redo entry was calculated in the beginning // now we again expect these as selected, as they got restored remotely
expect(API.getSelectedElements()).toEqual([ expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id }), expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }), expect.objectContaining({ id: rect3.id }),
@ -2638,14 +2599,16 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1); // iterated two steps back and reduce two empty entries into one! expect(API.getRedoStack().length).toBe(2); // iterated two steps back!
expect(h.state.selectedGroupIds).toEqual({}); expect(h.state.selectedGroupIds).toEqual({});
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(0); // no changes applied, so there is no new entry expect(API.getUndoStack().length).toBe(2); // iterated two steps forward!
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedGroupIds).toEqual({}); expect(h.state.selectedGroupIds).toEqual({});
Keyboard.undo();
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [ elements: [
@ -2658,6 +2621,22 @@ describe("history", () => {
], ],
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.selectedGroupIds).toEqual({ A: true });
// Simulate remote update
API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedGroupIds).toEqual({ A: true, B: true });
}); });
it("should iterate through the history when editing group contains only remotely deleted elements", async () => { it("should iterate through the history when editing group contains only remotely deleted elements", async () => {
@ -2702,7 +2681,33 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(0); // all changes relate to remotely deleted elements, there is no change and thus nothing to redo expect(API.getRedoStack().length).toBe(3);
expect(h.state.editingGroupId).toBeNull();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBeNull();
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
isDeleted: false,
}),
h.elements[1],
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.editingGroupId).toBe("A");
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBeNull(); expect(h.state.editingGroupId).toBeNull();
}); });
@ -2739,13 +2744,13 @@ describe("history", () => {
}); });
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); // iterated few entries back expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1); // added just one non-empty entry into redo stack expect(API.getRedoStack().length).toBe(3);
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingLinearElement).toBeNull(); expect(h.state.editingLinearElement).toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
@ -2833,7 +2838,7 @@ describe("history", () => {
// We iterated two steps as there was no change in order! // We iterated two steps as there was no change in order!
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
assertSelectedElements([]); expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }), // a "Zx" expect.objectContaining({ id: rect1.id }), // a "Zx"
expect.objectContaining({ id: rect3.id }), // c "Zy" expect.objectContaining({ id: rect3.id }), // c "Zy"
@ -2841,7 +2846,7 @@ describe("history", () => {
]); ]);
}); });
it("should tolerate remote z-index changes with incorrect fractional indices", async () => { it("should iterate through the history when z-index changes do not produce visible change and we synced all indices", async () => {
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 }); const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 }); const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 }); const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
@ -2894,32 +2899,22 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [ elements: [
h.elements[1], // rect2
h.elements[0], // rect3 h.elements[0], // rect3
h.elements[2], // rect1 h.elements[2], // rect1
h.elements[1], // rect2
], ],
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(2); // now we iterated two steps back!
assertSelectedElements([rect2]); assertSelectedElements([]);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }), expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }), expect.objectContaining({ id: rect3.id }),
expect.objectContaining({ id: rect1.id }), expect.objectContaining({ id: rect1.id }),
]); ]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect3.id }),
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
]);
}); });
it("should not let remote changes to interfere with in progress freedraw", async () => { it("should not let remote changes to interfere with in progress freedraw", async () => {
@ -3729,52 +3724,54 @@ describe("history", () => {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
Keyboard.redo(); runTwice(() => {
expect(API.getUndoStack().length).toBe(1); Keyboard.redo();
expect(API.getRedoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(API.getRedoStack().length).toBe(0);
expect.objectContaining({ expect(h.elements).toEqual([
id: container.id, expect.objectContaining({
// previously bound text is preserved id: container.id,
// text bindings are not duplicated // previously bound text is preserved
boundElements: [{ id: remoteText.id, type: "text" }], // text bindings are not duplicated
isDeleted: false, boundElements: [{ id: remoteText.id, type: "text" }],
}), isDeleted: false,
expect.objectContaining({ }),
id: text.id, expect.objectContaining({
// unbound id: text.id,
containerId: null, // unbound
isDeleted: false, containerId: null,
}), isDeleted: false,
expect.objectContaining({ }),
id: remoteText.id, expect.objectContaining({
// preserved existing binding! id: remoteText.id,
containerId: container.id, // preserved existing binding!
isDeleted: false, containerId: container.id,
}), isDeleted: false,
]); }),
]);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: container.id, id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }], boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, // isDeleted got remotely updated to false isDeleted: false, // isDeleted got remotely updated to false
}), }),
expect.objectContaining({ expect.objectContaining({
id: text.id, id: text.id,
containerId: null, containerId: null,
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
id: remoteText.id, id: remoteText.id,
// unbound // unbound
containerId: container.id, containerId: container.id,
isDeleted: false, isDeleted: false,
}), }),
]); ]);
});
}); });
it("should preserve latest remotely added binding and unbind previous one when the text is added through history", async () => { it("should preserve latest remotely added binding and unbind previous one when the text is added through history", async () => {

View File

@ -435,17 +435,12 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
expect(h.state.selectedElementIds).toEqual(selectedElementIds); expect(h.state.selectedElementIds).toEqual(selectedElementIds);
}; };
const stripProps = ( const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) =>
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];
for (const prop of props) { delete inserted.seed;
delete inserted[prop]; delete deleted.seed;
delete deleted[prop];
}
acc[curr[0]] = { acc[curr[0]] = {
inserted, inserted,
@ -462,9 +457,9 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]), added: stripSeed(x.elements.added),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]), removed: stripSeed(x.elements.removed),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]), updated: stripSeed(x.elements.updated),
}, },
})), })),
).toMatchSnapshot(`[${name}] undo stack`); ).toMatchSnapshot(`[${name}] undo stack`);
@ -474,9 +469,9 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripProps(x.elements.added, ["seed", "versionNonce"]), added: stripSeed(x.elements.added),
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]), removed: stripSeed(x.elements.removed),
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]), updated: stripSeed(x.elements.updated),
}, },
})), })),
).toMatchSnapshot(`[${name}] redo stack`); ).toMatchSnapshot(`[${name}] redo stack`);