excalidraw/packages/excalidraw/tests/history.test.tsx
Mark Tolmacs 4438137a57
Fixed point binding for simple arrows
Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point at finalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix type errors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

New arrow binding rules

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix cyclical dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix all tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

chore: Trigger build

Remove binding gap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix delayed bind mode for multiElement arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix multi-point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes for arrow dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug
2025-09-01 21:56:20 +02:00

5248 lines
165 KiB
TypeScript

import React from "react";
import {
queryByText,
fireEvent,
queryByTestId,
waitFor,
} from "@testing-library/react";
import { vi } from "vitest";
import { pointFrom } from "@excalidraw/math";
import { newElementWith } from "@excalidraw/element";
import {
EXPORT_DATA_TYPES,
MIME_TYPES,
ORIG_ID,
KEYS,
arrayToMap,
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
randomId,
} from "@excalidraw/common";
import "@excalidraw/utils/test-utils";
import { ElementsDelta, AppStateDelta } from "@excalidraw/element";
import { CaptureUpdateAction, StoreDelta } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
ExcalidrawElbowArrowElement,
ExcalidrawFrameElement,
ExcalidrawGenericElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FileId,
FixedPointBinding,
FractionalIndex,
SceneElementsMap,
} from "@excalidraw/element/types";
import "../global.d.ts";
import {
actionSendBackward,
actionBringForward,
actionSendToBack,
} from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index";
import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import {
DEER_IMAGE_DIMENSIONS,
SMILEY_IMAGE_DIMENSIONS,
} from "./fixtures/constants";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { INITIALIZED_IMAGE_PROPS } from "./helpers/constants";
import {
GlobalTestState,
act,
assertSelectedElements,
render,
togglePopover,
getCloneByOrigId,
checkpointHistory,
unmountComponent,
} from "./test-utils";
import { setupImageTest as _setupImageTest } from "./image.test";
import type { AppState } from "../types";
const { h } = window;
const mouse = new Pointer("mouse");
const checkpoint = (name: string) => {
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
// `scrolledOutside` does not appear to be stable between test runs
// `selectedLinearElemnt` includes `startBindingElement` containing seed and versionNonce
const {
name: _,
scrolledOutside,
selectedLinearElement,
...strippedAppState
} = h.state;
expect(strippedAppState).toMatchSnapshot(`[${name}] appState`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements
.map(({ seed, versionNonce, ...strippedElement }) => strippedElement)
.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
checkpointHistory(h.history, name);
};
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const transparent = COLOR_PALETTE.transparent;
const black = COLOR_PALETTE.black;
const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => {
beforeEach(() => {
unmountComponent();
renderStaticScene.mockClear();
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() =>
Promise.resolve(randomId() as FileId),
);
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
});
afterEach(() => {
checkpoint("end of test");
});
describe("singleplayer undo/redo", () => {
it("should not collapse when applying corrupted history entry", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect = API.createElement({ type: "rectangle" });
API.setElements([rect]);
const corrupedEntry = StoreDelta.create(
ElementsDelta.empty(),
AppStateDelta.empty(),
);
vi.spyOn(corrupedEntry.elements, "applyTo").mockImplementation(() => {
throw new Error("Oh no, I am corrupted!");
});
(h.history as any).undoStack.push(corrupedEntry);
const appState = getDefaultAppState() as AppState;
try {
// due to this we unfortunately we couldn't do simple .toThrow()
act(
() =>
h.history.undo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
) as any,
);
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
// we popped the entry, even though it is corrupted, so the user could perform subsequent undo/redo and would not be stuck on this entry forever
expect(API.getUndoStack().length).toBe(0);
// we pushed the entr, as we don't want just lose it and throw it away - it might be perfectly valid on subsequent redo
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: false }), // no changes detected
]);
try {
// due to this we unfortunately we couldn't do simple .toThrow()
act(
() =>
h.history.redo(
arrayToMap(h.elements) as SceneElementsMap,
appState,
) as any,
);
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
expect(API.getUndoStack().length).toBe(1); // vice versa for redo
expect(API.getRedoStack().length).toBe(0); // vice versa for undo
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: false }),
]);
});
it("should not end up with history entry when there are no appstate changes", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = API.createElement({ type: "rectangle", groupIds: ["A"] });
const rect2 = API.createElement({ type: "rectangle", groupIds: ["A"] });
API.setElements([rect1, rect2]);
mouse.select(rect1);
assertSelectedElements([rect1, rect2]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
mouse.select(rect2);
assertSelectedElements([rect1, rect2]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
expect(API.getUndoStack().length).toBe(1); // no new entry was created
expect(API.getRedoStack().length).toBe(0);
});
it("should not end up with history entry when there are no elements changes", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = API.createElement({ type: "rectangle" });
const rect2 = API.createElement({ type: "rectangle" });
API.updateScene({
elements: [rect1, rect2],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
API.updateScene({
elements: [rect1, rect2],
captureUpdate: CaptureUpdateAction.IMMEDIATELY, // even though the flag is on, same elements are passed, nothing to commit
});
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
});
it("should not modify anything on unrelated appstate change", async () => {
const rect = API.createElement({ type: "rectangle" });
await render(
<Excalidraw
handleKeyboardGlobally={true}
initialData={{
elements: [rect],
}}
/>,
);
API.updateScene({
appState: {
viewModeEnabled: true,
},
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect(h.state.viewModeEnabled).toBe(true);
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: false }),
]);
expect(h.store.snapshot.elements.get(rect.id)).toEqual(
expect.objectContaining({ id: rect.id, isDeleted: false }),
);
});
});
it("should not clear the redo stack on standalone appstate change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20 });
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
]);
mouse.clickAt(-10, -10);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1); // we still have a possibility to redo!
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
]);
mouse.downAt(0, 0);
mouse.moveTo(50, 50);
mouse.upAt(50, 50);
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1); // even after re-select!
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
});
it("should not override appstate changes when redo stack is not cleared", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect = UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
UI.clickOnTestId("color-blue");
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: blue }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: red }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
assertSelectedElements(rect);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: transparent }),
]);
mouse.clickAt(-10, -10);
expect(API.getUndoStack().length).toBe(2); // pushed appstate change,
expect(API.getRedoStack().length).toBe(2); // redo stack is not cleared
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: transparent }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements().length).toBe(0); // previously the item was selected, not it is not
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: red }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements().length).toBe(0); // previously the item was selected, not it is not
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: blue }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements().length).toBe(0); // previously the item was selected, not it is not
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: red }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: transparent }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(3);
assertSelectedElements(rect); // get's reselected with out pushed entry!
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, backgroundColor: transparent }),
]);
});
it("should clear the redo stack on elements change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = UI.createElement("rectangle", { x: 10 });
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
]);
const rect2 = UI.createElement("rectangle", { x: 20 });
assertSelectedElements(rect2);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0); // redo stack got cleared
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
});
it("should iterate through the history when selection changes do not produce visible change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect = UI.createElement("rectangle", { x: 10 });
mouse.clickAt(-10, -10);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements().length).toBe(0);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect);
mouse.clickAt(-10, -10);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements().length).toBe(0);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2); // now we have two same redos
assertSelectedElements(rect);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1); // didn't iterate through completely, as first redo already results in a visible change
expect(API.getSelectedElements().length).toBe(0);
Keyboard.redo(); // acceptable empty redo
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements().length).toBe(0);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); // now we iterated through the same undos!
expect(API.getRedoStack().length).toBe(3);
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect.id, isDeleted: true }),
]);
});
it("should end up with no history entry after initializing scene", async () => {
await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
zenModeEnabled: true,
},
}}
/>,
);
await waitFor(() => {
expect(h.state.zenModeEnabled).toBe(true);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
});
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
// noop
API.executeAction(undoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
]);
const rectangle = UI.createElement("rectangle");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: rectangle.id }),
]);
API.executeAction(undoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
]);
// noop
API.executeAction(undoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: true }),
]);
expect(API.getUndoStack().length).toBe(0);
API.executeAction(redoAction);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: rectangle.id, isDeleted: false }),
]);
expect(API.getUndoStack().length).toBe(1);
});
it("should create new history entry on scene import via drag&drop", async () => {
await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
appState: {
viewBackgroundColor: "#FFF",
},
}}
/>,
);
await waitFor(() => expect(h.state.viewBackgroundColor).toBe("#FFF"));
await waitFor(() =>
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
);
await API.drop(
new Blob(
[
JSON.stringify({
type: EXPORT_DATA_TYPES.excalidraw,
appState: {
...getDefaultAppState(),
viewBackgroundColor: "#000",
},
elements: [API.createElement({ type: "rectangle", id: "B" })],
}),
],
{ type: MIME_TYPES.json },
),
);
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
expect(h.state.viewBackgroundColor).toBe("#000");
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining({ id: "B", isDeleted: false }),
]);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
API.executeAction(undoAction);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(h.state.viewBackgroundColor).toBe("#FFF");
API.executeAction(redoAction);
expect(h.state.viewBackgroundColor).toBe("#000");
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: true }),
expect.objectContaining({ id: "B", isDeleted: false }),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop(
new Blob([link], {
type: MIME_TYPES.text,
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
const setupImageTest = () =>
_setupImageTest([DEER_IMAGE_DIMENSIONS, SMILEY_IMAGE_DIMENSIONS]);
const assertImageTest = async () => {
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
// need to check that delta actually contains initialized image elements (with fileId & natural dimensions)
expect(
Object.values(h.history.undoStack[0].elements.removed).map(
(val) => val.deleted,
),
).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: true,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...DEER_IMAGE_DIMENSIONS,
}),
expect.objectContaining({
...INITIALIZED_IMAGE_PROPS,
isDeleted: false,
...SMILEY_IMAGE_DIMENSIONS,
}),
]);
};
it("should create new history entry on image drag&drop", async () => {
await setupImageTest();
await API.drop(
await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
);
await assertImageTest();
});
it("should create new history entry on image paste", async () => {
await setupImageTest();
document.dispatchEvent(
createPasteEvent({
files: await Promise.all([
API.loadFile("./fixtures/deer.png"),
API.loadFile("./fixtures/smiley.png"),
]),
}),
);
await assertImageTest();
});
it("should create new history entry on embeddable link paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
document.dispatchEvent(
createPasteEvent({
types: {
"text/plain": link,
},
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should support appstate name or viewBackgroundColor change", async () => {
await render(
<Excalidraw
handleKeyboardGlobally={true}
initialData={{
appState: {
name: "Old name",
viewBackgroundColor: "#FFF",
},
}}
/>,
);
expect(h.state.isLoading).toBe(false);
expect(h.state.name).toBe("Old name");
API.updateScene({
appState: {
name: "New name",
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.name).toBe("New name");
API.updateScene({
appState: {
viewBackgroundColor: "#000",
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.name).toBe("New name");
expect(h.state.viewBackgroundColor).toBe("#000");
// just to double check that same change is not recorded
API.updateScene({
appState: {
name: "New name",
viewBackgroundColor: "#000",
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.name).toBe("New name");
expect(h.state.viewBackgroundColor).toBe("#000");
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.name).toBe("New name");
expect(h.state.viewBackgroundColor).toBe("#FFF");
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2);
expect(h.state.name).toBe("Old name");
expect(h.state.viewBackgroundColor).toBe("#FFF");
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.name).toBe("New name");
expect(h.state.viewBackgroundColor).toBe("#FFF");
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.name).toBe("New name");
expect(h.state.viewBackgroundColor).toBe("#000");
});
it("should support element creation, deletion and appstate element selection change", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20, y: 20 });
const rect3 = UI.createElement("rectangle", { x: 40, y: 40 });
mouse.select([rect2, rect3]);
Keyboard.keyDown(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(6);
Keyboard.undo();
assertSelectedElements(rect2, rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: rect3.id, isDeleted: false }),
]);
Keyboard.undo();
assertSelectedElements(rect2);
Keyboard.undo();
assertSelectedElements(rect3);
Keyboard.undo();
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.undo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
// no-op
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: true }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect2);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.redo();
assertSelectedElements(rect3);
Keyboard.redo();
assertSelectedElements(rect2);
Keyboard.redo();
assertSelectedElements(rect2, rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: rect3.id, isDeleted: false }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
// no-op
Keyboard.redo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
});
it("should support linear element creation and points manipulation through the editor", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// create three point arrow
UI.clickTool("arrow");
mouse.click(0, 0);
mouse.click(10, 10);
mouse.click(10, -10);
// actionFinalize
Keyboard.keyPress(KEYS.ENTER);
// open editor
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
// move point
mouse.downAt(20, 0);
mouse.moveTo(20, 20);
mouse.up();
// leave editor
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 20],
],
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 20],
],
}),
]);
// making sure clicking on points in the editor does not generate new history entries!
mouse.clickAt(0, 0);
mouse.clickAt(10, 10);
mouse.clickAt(20, 20);
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(1);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 0],
],
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false); // undo `open editor`
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 0],
],
}),
]);
// Keyboard.undo();
// expect(API.getUndoStack().length).toBe(2);
// expect(API.getRedoStack().length).toBe(4);
// expect(assertSelectedElements(h.elements[0]));
// expect(h.state.selectedLinearElement?.isEditing).toBe(false);
// expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
// expect(h.elements).toEqual([
// expect.objectContaining({
// isDeleted: false,
// points: [
// [0, 0],
// [10, 10],
// [20, 0],
// ],
// }),
// ]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
],
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(5);
expect(API.getSelectedElements().length).toBe(0);
expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: true,
points: [
[0, 0],
[10, 10],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
],
}),
]);
// Keyboard.redo();
// expect(API.getUndoStack().length).toBe(2);
// expect(API.getRedoStack().length).toBe(3);
// expect(assertSelectedElements(h.elements[0]));
// expect(h.state.selectedLinearElement?.isEditing).toBe(false);
// expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
// expect(h.elements).toEqual([
// expect.objectContaining({
// isDeleted: false,
// points: [
// [0, 0],
// [10, 10],
// [20, 0],
// ],
// }),
// ]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 0],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 0],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 20],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 20],
],
}),
]);
});
it("should create entry when selecting freedraw", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.clickTool("rectangle");
mouse.down(-10, -10);
mouse.up(10, 10);
UI.clickTool("freedraw");
mouse.down(40, -20);
mouse.up(50, 10);
const rectangle = h.elements[0];
const freedraw1 = h.elements[1];
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rectangle.id }),
expect.objectContaining({ id: freedraw1.id, strokeColor: black }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rectangle.id }),
expect.objectContaining({
id: freedraw1.id,
strokeColor: black,
isDeleted: true,
}),
]);
togglePopover("Stroke");
UI.clickOnTestId("color-red");
mouse.down(40, -20);
mouse.up(50, 10);
const freedraw2 = h.elements[2];
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rectangle.id }),
expect.objectContaining({
id: freedraw1.id,
strokeColor: black,
isDeleted: true,
}),
expect.objectContaining({
id: freedraw2.id,
strokeColor: COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
}),
]);
// ensure we don't end up with duplicated entries
UI.clickTool("freedraw");
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
});
it("should support duplication of groups, appstate group selection and editing group", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A"],
x: 0,
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A"],
x: 100,
});
API.setElements([rect1, rect2]);
mouse.select(rect1);
assertSelectedElements([rect1, rect2]);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true });
// inside the editing group
mouse.doubleClickOn(rect2);
assertSelectedElements([rect2]);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
mouse.clickOn(rect1);
assertSelectedElements([rect1]);
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
Keyboard.undo();
assertSelectedElements([rect2]);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
Keyboard.undo();
assertSelectedElements([rect1, rect2]);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true });
Keyboard.redo();
assertSelectedElements([rect2]);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
Keyboard.redo();
assertSelectedElements([rect1]);
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
Keyboard.undo();
assertSelectedElements([rect2]);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.editingGroupId).toBe("A");
expect(h.state.selectedGroupIds).not.toEqual({ A: true });
Keyboard.undo();
assertSelectedElements([rect1, rect2]);
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true });
// outside the editing group, testing duplication
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("d");
});
assertSelectedElements([h.elements[2], h.elements[3]]);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements.length).toBe(4);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements.length).toBe(4);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true });
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements.length).toBe(4);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
// undo again, and duplicate once more
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress("z");
Keyboard.keyPress("d");
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements.length).toBe(6);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
expect.objectContaining({
[ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
isDeleted: false,
}),
expect.objectContaining({
[ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
isDeleted: false,
}),
]),
);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual(
expect.objectContaining({ A: true }),
);
});
it("should support changes in elements' order", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20, y: 20 });
const rect3 = UI.createElement("rectangle", { x: 40, y: 40 });
API.executeAction(actionSendBackward);
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect3);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements(rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect3);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect3.id }),
expect.objectContaining({ id: rect2.id }),
]);
mouse.select([rect1, rect3]);
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect1, rect3]);
API.executeAction(actionBringForward);
expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect1, rect3]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements([rect1, rect3]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect3.id }),
expect.objectContaining({ id: rect2.id }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect1, rect3]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect3.id }),
]);
});
describe("should support bidirectional bindings", async () => {
let rect1: ExcalidrawGenericElement;
let rect2: ExcalidrawGenericElement;
let text: ExcalidrawTextElement;
let arrow: ExcalidrawLinearElement;
const rect1Props = {
type: "rectangle",
height: 100,
width: 100,
x: -100,
y: -50,
} as const;
const rect2Props = {
type: "rectangle",
height: 100,
width: 100,
x: 100,
y: -50,
} as const;
const textProps = {
type: "text",
x: -200,
text: "ola",
} as const;
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
rect1 = API.createElement({ ...rect1Props });
text = API.createElement({ ...textProps });
rect2 = API.createElement({ ...rect2Props });
API.updateScene({
elements: [rect1, text, rect2],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// bind text1 to rect1
mouse.select([rect1, text]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas);
fireEvent.click(
queryByText(
document.querySelector(".context-menu") as HTMLElement,
"Bind text to the container",
)!,
);
expect(API.getUndoStack().length).toBe(4);
expect(text.containerId).toBe(rect1.id);
expect(rect1.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
// bind arrow to rect1 and rect2
UI.clickTool("arrow");
mouse.down(0, 0);
mouse.up(100, 0);
arrow = h.elements[3] as ExcalidrawLinearElement;
expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
mode: "orbit",
});
expect(rect1.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
]);
expect(rect2.boundElements).toStrictEqual([
{ id: arrow.id, type: "arrow" },
]);
});
it("should unbind arrow from non deleted bindable elements on undo and rebind on redo", async () => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
mode: "orbit",
});
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: text.id, type: "text" }],
}),
expect.objectContaining({ id: text.id }),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ id: arrow.id, isDeleted: true }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
mode: "orbit",
});
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
],
}),
expect.objectContaining({ id: text.id }),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
expect.objectContaining({ id: arrow.id, isDeleted: false }),
]);
});
it("should unbind arrow from non deleted bindable elements on deletion and rebind on undo", async () => {
Keyboard.keyDown(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
mode: "orbit",
});
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: text.id, type: "text" }],
}),
expect.objectContaining({ id: text.id }),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ id: arrow.id, isDeleted: true }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
mode: "orbit",
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
mode: "orbit",
});
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
],
}),
expect.objectContaining({ id: text.id }),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
expect.objectContaining({ id: arrow.id, isDeleted: false }),
]);
});
it("should unbind everything from non deleted elements when iterating through the whole undo stack and vice versa rebind everything on redo", async () => {
Keyboard.undo();
Keyboard.undo();
Keyboard.undo();
Keyboard.undo();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(5);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [],
isDeleted: true,
}),
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: true,
}),
]),
);
Keyboard.redo();
Keyboard.redo();
Keyboard.redo();
Keyboard.redo();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: expect.arrayContaining([
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
]),
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: rect1.id,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: false,
}),
]),
);
});
it("should unbind rectangle from arrow on deletion and rebind on undo", async () => {
mouse.select(rect1);
Keyboard.keyPress(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
containerId: rect1.id,
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: arrow.id,
startBinding: null,
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: false,
}),
]),
);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: expect.arrayContaining([
{ id: arrow.id, type: "arrow" },
{ id: text.id, type: "text" }, // order has now changed!
]),
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: rect1.id,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: false,
}),
]),
);
});
it("should unbind rectangles from arrow on deletion and rebind on undo", async () => {
mouse.select([rect1, rect2]);
Keyboard.keyPress(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(8);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [
{ id: text.id, type: "text" },
{ id: arrow.id, type: "arrow" },
],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
containerId: rect1.id,
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: true,
}),
expect.objectContaining({
id: arrow.id,
startBinding: null,
endBinding: null,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(7);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: expect.arrayContaining([
{ id: arrow.id, type: "arrow" },
{ id: text.id, type: "text" }, // order has now changed!
]),
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: rect1.id,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: false,
}),
]),
);
});
});
it("should disable undo/redo buttons when stacks empty", async () => {
const { container } = await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
const undoAction = createUndoAction(h.history);
const redoAction = createRedoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
});
const undoButton = queryByTestId(container, "button-undo");
const redoButton = queryByTestId(container, "button-redo");
expect(undoButton).toBeDisabled();
expect(redoButton).toBeDisabled();
const rectangle = UI.createElement("rectangle");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: rectangle.id }),
]);
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(undoButton).not.toBeDisabled();
expect(redoButton).toBeDisabled();
API.executeAction(undoAction);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(undoButton).toBeDisabled();
expect(redoButton).not.toBeDisabled();
API.executeAction(redoAction);
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(undoButton).not.toBeDisabled();
expect(redoButton).toBeDisabled();
});
it("remounting undo/redo buttons should initialize undo/redo state correctly", async () => {
const { container } = await render(
<Excalidraw
initialData={{
elements: [API.createElement({ type: "rectangle", id: "A" })],
}}
/>,
);
const undoAction = createUndoAction(h.history);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
});
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
// testing undo button
// -----------------------------------------------------------------------
const rectangle = UI.createElement("rectangle");
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: rectangle.id }),
]);
expect(h.history.isUndoStackEmpty).toBeFalsy();
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
API.executeAction(actionToggleViewMode);
expect(h.state.viewModeEnabled).toBe(true);
expect(queryByTestId(container, "button-undo")).toBeNull();
expect(queryByTestId(container, "button-redo")).toBeNull();
API.executeAction(actionToggleViewMode);
expect(h.state.viewModeEnabled).toBe(false);
await waitFor(() => {
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
expect(queryByTestId(container, "button-redo")).toBeDisabled();
});
// testing redo button
// -----------------------------------------------------------------------
API.executeAction(undoAction);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
API.executeAction(actionToggleViewMode);
expect(h.state.viewModeEnabled).toBe(true);
expect(queryByTestId(container, "button-undo")).toBeNull();
expect(queryByTestId(container, "button-redo")).toBeNull();
API.executeAction(actionToggleViewMode);
expect(h.state.viewModeEnabled).toBe(false);
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.history.isRedoStackEmpty).toBeFalsy();
expect(queryByTestId(container, "button-undo")).toBeDisabled();
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
});
});
describe("multiplayer undo/redo", () => {
// Util to check that we end up in the same state after series of undo / redo
function runTwice(callback: () => void) {
for (let i = 0; i < 2; i++) {
callback();
}
}
beforeEach(async () => {
await render(
<Excalidraw handleKeyboardGlobally={true} isCollaborating={true} />,
);
});
it("should not override remote changes on different elements", async () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
expect(API.getUndoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
// Simulate remote update
API.updateScene({
elements: [
...h.elements,
API.createElement({
type: "rectangle",
strokeColor: blue,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
expect.objectContaining({ strokeColor: blue }),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
expect.objectContaining({ strokeColor: blue }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
expect.objectContaining({ strokeColor: blue }),
]);
});
it("should not override remote changes on different properties", async () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
expect(API.getUndoStack().length).toBe(2);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
strokeColor: yellow,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: transparent,
strokeColor: yellow,
}),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: red,
strokeColor: yellow,
}),
]);
});
// https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#implementing-undo
// This is due to the fact that deltas are updated in `applyLatestChanges`.
it("should update history entries after remote changes on the same properties", async () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
UI.clickOnTestId("color-blue");
// At this point we have all the history entries created, no new entries will be created, only existing entries will get inversed and updated
expect(API.getUndoStack().length).toBe(3);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: blue }),
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: yellow,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: violet,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
Keyboard.redo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: yellow }),
]);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: violet }),
]);
Keyboard.undo();
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: transparent }),
]);
});
it("should redraw arrows on undo", () => {
const rect = API.createElement({
type: "rectangle",
id: "KPrBI4g_v9qUB1XxYLgSz",
x: 873,
y: 212,
width: 157,
height: 126,
});
const diamond = API.createElement({
id: "u2JGnnmoJ0VATV4vCNJE5",
type: "diamond",
x: 1152,
y: 516,
width: 124,
height: 129,
});
const arrow = API.createElement({
type: "arrow",
id: "6Rm4g567UQM4WjLwej2Vc",
elbowed: true,
});
API.updateScene({
elements: [rect, diamond],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Connect the arrow
API.updateScene({
elements: [
{
...rect,
boundElements: [
{
id: "6Rm4g567UQM4WjLwej2Vc",
type: "arrow",
},
],
},
{
...diamond,
boundElements: [
{
id: "6Rm4g567UQM4WjLwej2Vc",
type: "arrow",
},
],
},
{
...arrow,
x: 1035,
y: 274.9,
width: 178.9000000000001,
height: 236.10000000000002,
points: [
pointFrom(0, 0),
pointFrom(178.9000000000001, 0),
pointFrom(178.9000000000001, 236.10000000000002),
],
startBinding: {
elementId: "KPrBI4g_v9qUB1XxYLgSz",
fixedPoint: [1.0318471337579618, 0.49920634920634904],
mode: "orbit",
} as FixedPointBinding,
endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5",
fixedPoint: [0.4991935483870975, -0.03875193720914723],
mode: "orbit",
} as FixedPointBinding,
},
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
API.updateScene({
elements: h.elements.map((el) =>
el.id === "KPrBI4g_v9qUB1XxYLgSz"
? {
...el,
x: 600,
y: 0,
}
: el,
),
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
const modifiedArrow = h.elements.filter(
(el) => el.type === "arrow",
)[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toCloselyEqualPoints([
[0, 0],
[178.9, 0],
[178.9, 236.1],
]);
});
// TODO: #7348 ideally we should not override, but since the order of groupIds matters, right now we cannot ensure that with postprocssed groupIds the order will be consistent after series or undos/redos, we don't postprocess them at all
// in other words, if we would postprocess groupIds, the groupIds order on "redo" below would be ["B", "A"] instead of ["A", "B"]
it("should override remotely added groups on undo, but restore them on redo", async () => {
const rect1 = API.createElement({ type: "rectangle" });
const rect2 = API.createElement({ type: "rectangle" });
// Initialize scene
API.updateScene({
elements: [rect1, rect2],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], { groupIds: ["A"] }),
newElementWith(h.elements[1], { groupIds: ["A"] }),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
const rect4 = API.createElement({ type: "rectangle", groupIds: ["B"] });
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], { groupIds: ["A", "B"] }),
newElementWith(h.elements[1], { groupIds: ["A", "B"] }),
rect3,
rect4,
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, groupIds: [] }),
expect.objectContaining({ id: rect2.id, groupIds: [] }),
expect.objectContaining({ id: rect3.id, groupIds: ["B"] }),
expect.objectContaining({ id: rect4.id, groupIds: ["B"] }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, groupIds: ["A", "B"] }),
expect.objectContaining({ id: rect2.id, groupIds: ["A", "B"] }),
expect.objectContaining({ id: rect3.id, groupIds: ["B"] }),
expect.objectContaining({ id: rect4.id, groupIds: ["B"] }),
]);
});
it("should override remotely added points on undo, but restore them on redo", async () => {
UI.clickTool("arrow");
mouse.click(0, 0);
mouse.click(10, 10);
mouse.click(20, 20);
// actionFinalize
Keyboard.keyPress(KEYS.ENTER);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0] as ExcalidrawLinearElement, {
points: [
pointFrom(0, 0),
pointFrom(5, 5),
pointFrom(10, 10),
pointFrom(15, 15),
pointFrom(20, 20),
] as LocalPoint[],
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
points: [
[0, 0],
// overriding all the remote points as they are not being postprocessed (as we cannot ensure the order consistency similar to groupIds)
// but in this case it might not make even sense to combine the points, as in some cases the linear element might lead unexpected results
[10, 10],
],
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: true,
points: [
[0, 0],
[10, 10],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
points: [
[0, 0],
[5, 5],
[10, 10],
[15, 15],
[20, 20],
],
}),
]);
});
it("should redistribute deltas when element gets removed locally but is restored remotely", async () => {
UI.createElement("rectangle", { x: 10 });
Keyboard.keyDown(KEYS.DELETE);
expect(API.getUndoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: transparent,
isDeleted: true,
}),
]);
// Simulate remote update & restore
API.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: yellow,
isDeleted: false, // undeletion might happen due to concurrency between clients
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: false,
}),
]);
// inserted.isDeleted: true is updated with the latest changes to false
// deleted.isDeleted and inserted.isDeleted are the same and therefore removed delta becomes an updated delta
Keyboard.undo();
expect(assertSelectedElements(h.elements[0]));
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getSelectedElements()).toEqual([]);
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(assertSelectedElements(h.elements[0]));
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(assertSelectedElements([]));
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: false, // isDeleted got updated
}),
]);
});
it("should iterate through the history when when element change relates to remotely deleted element", async () => {
UI.createElement("rectangle", { x: 10 });
togglePopover("Background");
UI.clickOnTestId("color-red");
expect(API.getUndoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({ backgroundColor: red }),
]);
// Simulate remote update & deletion
API.updateScene({
elements: [
newElementWith(h.elements[0], {
backgroundColor: yellow,
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow,
isDeleted: true,
}),
]);
// Will iterate through undo stack since applying the change
// results in no visible change on a deleted element
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: transparent,
isDeleted: true,
}),
]);
// We reached the bottom, again we iterate through invisible changes and reach the top
Keyboard.redo();
assertSelectedElements();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
backgroundColor: yellow, // the color still get's updated
isDeleted: true, // but the element remains deleted
}),
]);
});
it("should iterate through the history when element changes relate only to remotely deleted elements", async () => {
const rect1 = UI.createElement("rectangle", { x: 10 });
const rect2 = UI.createElement("rectangle", { x: 20 });
togglePopover("Background");
UI.clickOnTestId("color-red");
const rect3 = UI.createElement("rectangle", { x: 30, y: 30 });
// move rect3
mouse.downAt(35, 35);
mouse.moveTo(55, 55);
mouse.upAt(55, 55);
expect(API.getUndoStack().length).toBe(5);
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
newElementWith(h.elements[2], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id }),
]);
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();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
isDeleted: true,
backgroundColor: red,
}),
expect.objectContaining({
id: rect3.id,
isDeleted: true,
x: 50,
y: 50,
}),
]);
});
it("should iterate through the history when selected elements relate only to remotely deleted elements", async () => {
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 });
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
API.setElements([rect1, rect2, rect3]);
mouse.select(rect1);
mouse.select([rect2, rect3]);
expect(API.getUndoStack().length).toBe(3);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
]);
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
newElementWith(h.elements[2], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
// do not expect any selectedElementIds, as all relate to deleted elements
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: true }),
expect.objectContaining({ id: rect3.id, isDeleted: true }),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
]);
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: false,
}),
newElementWith(h.elements[2], {
isDeleted: false,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id, isDeleted: false }),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
// now we again expect these as selected, as they got restored remotely
expect(API.getSelectedElements()).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: rect3.id, isDeleted: false }),
]);
});
it("should iterate through the history when selected groups contain only remotely deleted elements", async () => {
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A"],
});
const rect3 = API.createElement({
type: "rectangle",
groupIds: ["B"],
});
const rect4 = API.createElement({
type: "rectangle",
groupIds: ["B"],
});
// Simulate remote update
API.updateScene({
elements: [rect1, rect2],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.A);
});
// Simulate remote update
API.updateScene({
elements: [h.elements[0], h.elements[1], rect3, rect4],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.A);
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedGroupIds).toEqual({ A: true, B: true });
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
isDeleted: true,
}),
newElementWith(h.elements[1], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2); // iterated two steps back!
expect(h.state.selectedGroupIds).toEqual({});
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); // iterated two steps forward!
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedGroupIds).toEqual({});
Keyboard.undo();
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
isDeleted: false,
}),
newElementWith(h.elements[1], {
isDeleted: false,
}),
],
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 () => {
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A"],
x: 0,
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A"],
x: 100,
});
API.setElements([rect1, rect2]);
mouse.select(rect1);
// inside the editing group
mouse.doubleClickOn(rect2);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.editingGroupId).toBe("A");
mouse.clickAt(-10, -10);
expect(API.getSelectedElements().length).toBe(0);
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: true,
}),
newElementWith(h.elements[1], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
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();
});
it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
// create three point arrow
UI.clickTool("arrow");
mouse.click(0, 0);
mouse.click(10, 10);
// actionFinalize
Keyboard.keyPress(KEYS.ENTER);
// open editor
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
// leave editor
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(3);
expect(h.state.selectedLinearElement).toBeNull();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
});
it("should iterate through the history when z-index changes do not produce visible change and we synced changed indices", async () => {
const rect1 = API.createElement({ type: "rectangle", x: 10, y: 10 }); // a "a0"
const rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 }); // b "a1"
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 }); // c "a2"
API.setElements([rect1, rect2, rect3]);
mouse.select(rect2);
API.executeAction(actionSendToBack);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }), // b "Zz"
expect.objectContaining({ id: rect1.id }), // a "a0"
expect.objectContaining({ id: rect3.id }), // c "a2"
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[2], { index: "Zy" as FractionalIndex }),
h.elements[0],
h.elements[1],
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect3.id }), // c "Zy"
expect.objectContaining({ id: rect2.id }), // b "Zz"
expect.objectContaining({ id: rect1.id }), // a "a0"
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect3.id }), // c "Zy"
expect.objectContaining({ id: rect1.id }), // a "a0"
expect.objectContaining({ id: rect2.id }), // b "a1"
]);
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 }), // c "Zy"
expect.objectContaining({ id: rect2.id }), // b "Zz"
expect.objectContaining({ id: rect1.id }), // a "a0"
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[2], { index: "Zx" as FractionalIndex }),
h.elements[0],
h.elements[1],
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }), // a "Zx"
expect.objectContaining({ id: rect3.id }), // c "Zy"
expect.objectContaining({ id: rect2.id }), // b "Zz"
]);
Keyboard.undo();
// We iterated two steps as there was no change in order!
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2);
expect(API.getSelectedElements().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id }), // a "Zx"
expect.objectContaining({ id: rect3.id }), // c "Zy"
expect.objectContaining({ id: rect2.id }), // b "a1"
]);
});
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 rect2 = API.createElement({ type: "rectangle", x: 20, y: 20 });
const rect3 = API.createElement({ type: "rectangle", x: 30, y: 30 });
API.setElements([rect1, rect2, rect3]);
mouse.select(rect2);
API.executeAction(actionSendToBack);
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect1.id }),
expect.objectContaining({ id: rect3.id }),
]);
// Simulate remote update (fixes all invalid z-indices)
API.updateScene({
elements: [
h.elements[2], // rect3
h.elements[0], // rect2
h.elements[1], // rect1
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
assertSelectedElements([rect2]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.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: rect2.id }),
expect.objectContaining({ id: rect1.id }),
]);
// Simulate remote update
API.updateScene({
elements: [
h.elements[1], // rect2
h.elements[0], // rect3
h.elements[2], // rect1
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2); // now we iterated two steps back!
assertSelectedElements([]);
expect(h.elements).toEqual([
expect.objectContaining({ id: rect2.id }),
expect.objectContaining({ id: rect3.id }),
expect.objectContaining({ id: rect1.id }),
]);
});
it("should not let remote changes to interfere with in progress freedraw", async () => {
UI.clickTool("freedraw");
mouse.down(10, 10);
mouse.moveTo(30, 30);
const rectProps = {
type: "rectangle",
strokeColor: blue,
} as const;
// Simulate remote update
const rect = API.createElement({ ...rectProps });
// Simulate remote update
API.updateScene({
elements: [...h.elements, rect],
captureUpdate: CaptureUpdateAction.NEVER,
});
mouse.moveTo(60, 60);
mouse.up();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: h.elements[0].id,
type: "freedraw",
isDeleted: true,
}),
expect.objectContaining(rectProps),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: h.elements[0].id,
type: "freedraw",
isDeleted: false,
}),
expect.objectContaining(rectProps),
]);
});
it("should not let remote changes to interfere with in progress resizing", async () => {
const props1 = { x: 10, y: 10, width: 10, height: 10 };
const rect1 = UI.createElement("rectangle", { ...props1 });
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
assertSelectedElements(rect1);
expect(API.getUndoStack().length).toBe(1);
const rect3Props = {
type: "rectangle",
strokeColor: blue,
} as const;
const rect3 = API.createElement({ ...rect3Props });
// // Simulate remote update
API.updateScene({
elements: [...h.elements, rect3],
captureUpdate: CaptureUpdateAction.NEVER,
});
mouse.moveTo(100, 100);
mouse.up();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
...props1,
isDeleted: false,
width: 90,
height: 90,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.undo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
...props1,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.undo();
expect(API.getSelectedElements()).toEqual([]);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
...props1,
isDeleted: true,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.redo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
...props1,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
...props1,
isDeleted: false,
width: 90,
height: 90,
}),
expect.objectContaining(rect3Props),
]);
});
it("should not let remote changes to interfere with in progress dragging", async () => {
const rect1 = UI.createElement("rectangle", { x: 10, y: 10 });
const rect2 = UI.createElement("rectangle", { x: 30, y: 30 });
mouse.select([rect1, rect2]);
mouse.downAt(20, 20);
mouse.moveTo(50, 50);
assertSelectedElements(rect1, rect2);
expect(API.getUndoStack().length).toBe(4);
const rect3Props = {
type: "rectangle",
strokeColor: blue,
} as const;
const rect3 = API.createElement({ ...rect3Props });
// Simulate remote update
API.updateScene({
elements: [...h.elements, rect3],
captureUpdate: CaptureUpdateAction.NEVER,
});
mouse.moveTo(100, 100);
mouse.up();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 90,
y: 90,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 110,
y: 110,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.undo();
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.undo();
assertSelectedElements(rect1);
Keyboard.undo();
assertSelectedElements(rect2);
Keyboard.undo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.undo();
assertSelectedElements();
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.redo();
assertSelectedElements(rect1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: true,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.redo();
assertSelectedElements(rect2);
Keyboard.redo();
assertSelectedElements(rect1);
Keyboard.redo();
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 10,
y: 10,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 30,
y: 30,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
assertSelectedElements(rect1, rect2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
x: 90,
y: 90,
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
x: 110,
y: 110,
isDeleted: false,
}),
expect.objectContaining(rect3Props),
]);
});
describe("conflicts in bound text elements and their containers", () => {
let container: ExcalidrawGenericElement;
let text: ExcalidrawTextElement;
const containerProps = {
type: "rectangle",
width: 100,
x: 10,
y: 10,
angle: 0,
} as const;
const textProps = {
type: "text",
text: "que pasa",
x: 15,
y: 15,
angle: 0,
} as const;
beforeEach(() => {
container = API.createElement({ ...containerProps });
text = API.createElement({ ...textProps });
});
it("should rebind bindings when both are updated through the history and there no conflicting updates in the meantime", async () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[1] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
// no conflicting updates
x: h.elements[1].x + 20,
}),
newElementWith(h.elements[1] as ExcalidrawTextElement, {
// no conflicting updates
x: h.elements[1].x + 10,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
]);
});
});
// TODO: #7348 we do rebind now, when we have bi-directional binding in history, to eliminate potential data-integrity issues, but we should consider not rebinding in the future
it("should rebind bindings when both are updated through the history and the container got bound to a different text in the meantime", async () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[1] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
]);
const remoteText = API.createElement({
type: "text",
text: "ola",
containerId: container.id,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: remoteText.id, type: "text" }],
}),
remoteText,
h.elements[1],
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// last added was `text.id`, removing `remoteText.id`
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: remoteText.id,
// unbound as `remoteText.id` was removed
containerId: null,
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
// rebound!
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: remoteText.id,
containerId: container.id,
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
]);
});
});
// TODO: #7348 we do rebind now, when we have bi-directional binding in history, to eliminate potential data-integrity issues, but we should consider not rebinding in the future
it("should rebind bindings when both are updated through the history and the text got bound to a different container in the meantime", async () => {
// Initialize the scene
API.updateScene({
elements: [container, text],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[1] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
]);
const remoteContainer = API.createElement({
type: "rectangle",
width: 50,
x: 100,
boundElements: [{ id: text.id, type: "text" }],
});
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(remoteContainer, {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[1] as ExcalidrawTextElement, {
containerId: remoteContainer.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// rebound the text as we captured the full bidirectional binding in history!
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: remoteContainer.id,
// previous binding got unbound, as text is no longer bound to this element
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
// rebound!
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// deleted binding (already during applyDelta)
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: remoteContainer.id,
// #2 due to restored binding in #1, we could rebind the remote container!
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
// #1 due to applying latest changes to the history entries, we could restore this binding
containerId: remoteContainer.id,
isDeleted: false,
}),
]);
});
});
it("should rebind remotely added bound text when it's container is added through the history", async () => {
// Simulate local update
API.updateScene({
elements: [container],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(text, { containerId: container.id }),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
// binding from deleted to non deleted is correct!
// so that we could restore the bindings on history actions (subsequent redo in this case)
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
expect.objectContaining({
...textProps,
id: text.id,
// we trigger unbind - binding from non deleted to deleted cannot exist!
containerId: null,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
id: text.id,
// we triggered rebind!
containerId: container.id,
isDeleted: false,
}),
]);
});
});
it("should rebind remotely added container when it's bound text is added through the history", async () => {
// Simulate local update
API.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(container, {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(text, { containerId: container.id }),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
// we trigged unbind - bindings from non deleted to deleted cannot exist!
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
// binding from deleted to non deleted is correct, so that we could restore the bindings on history actions
containerId: container.id,
id: text.id,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
// we triggered rebind!
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
containerId: container.id,
id: text.id,
isDeleted: false,
}),
]);
});
});
it("should preserve latest remotely added binding and unbind previous one when the container is added through the history", async () => {
// Simulate local update
API.updateScene({
elements: [container],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(text, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
// unbound!
containerId: null,
isDeleted: false,
}),
]);
const remoteText = API.createElement({
type: "text",
text: "ola",
containerId: container.id,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, // purposefully undeleting, mimicing concurrenct update
}),
h.elements[1],
// rebinding the container with a new text element!
remoteText,
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// previously bound text is preserved
// text bindings are not duplicated
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
// unbound
containerId: null,
isDeleted: false,
}),
expect.objectContaining({
id: remoteText.id,
// preserved existing binding!
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: null,
isDeleted: false,
}),
expect.objectContaining({
id: remoteText.id,
containerId: container.id,
isDeleted: false,
}),
]);
});
});
it("should preserve latest remotely added binding and unbind previous one when the text is added through history", async () => {
// Simulate local update
API.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(container, {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[0] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// unbind affected bindable element
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: true,
}),
]);
const remoteText = API.createElement({
type: "text",
text: "ola",
containerId: container.id,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: remoteText.id, type: "text" }],
}),
h.elements[1],
newElementWith(remoteText as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// previously bound text is preserved
// text bindings are not duplicated
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
// unbound from container!
containerId: null,
isDeleted: false,
}),
expect.objectContaining({
id: remoteText.id,
// preserved existing binding!
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: true,
}),
expect.objectContaining({
id: remoteText.id,
containerId: container.id,
isDeleted: false,
}),
]);
});
});
it("should unbind remotely deleted bound text from container when the container is added through the history", async () => {
// Simulate local update
API.updateScene({
elements: [container],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(text, {
containerId: container.id,
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
// unbound!
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: true,
}),
]);
});
});
it("should unbind remotely deleted container from bound text when the text is added through the history", async () => {
// Simulate local update
API.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(container, {
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
newElementWith(h.elements[0] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
containerId: container.id,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
// unbound!
containerId: null,
isDeleted: false,
}),
]);
});
});
it("should redraw remotely added bound text when it's container is updated through the history", async () => {
// Initialize the scene
API.updateScene({
elements: [container],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
x: 200,
y: 200,
angle: 90 as Radians,
}),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(text, { containerId: container.id }),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
x: 200,
y: 200,
angle: 90,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
// text element got redrawn!
x: 241.295259647664,
y: 247.59240920619527,
angle: 90,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
// both elements got redrawn!
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
// both elements got redrawn!
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
x: 200,
y: 200,
angle: 90,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
x: 241.295259647664,
y: 247.59240920619527,
angle: 90,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
});
// TODO: #7348 this leads to empty undo/redo and could be confusing - instead we might consider redrawing container based on the text dimensions
it("should redraw bound text to match container dimensions when the bound text is updated through the history", async () => {
// Initialize the scene
API.updateScene({
elements: [text],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
x: 205,
y: 205,
angle: 90 as Radians,
}),
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
// Simulate remote update
API.updateScene({
elements: [
newElementWith(container, {
boundElements: [{ id: text.id, type: "text" }],
}),
newElementWith(h.elements[0] as ExcalidrawTextElement, {
containerId: container.id,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
// bound text got redrawn, as redraw is triggered based on container positon!
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
// both elements got redrawn!
expect(h.elements).toEqual([
expect.objectContaining({
...containerProps,
id: container.id,
boundElements: [{ id: text.id, type: "text" }],
isDeleted: false,
}),
expect.objectContaining({
...textProps,
id: text.id,
containerId: container.id,
isDeleted: false,
}),
]);
});
});
describe("conflicts in arrows and their bindable elements", () => {
let rect1: ExcalidrawGenericElement;
let rect2: ExcalidrawGenericElement;
const rect1Props = {
type: "rectangle",
height: 100,
width: 100,
x: -100,
y: -50,
} as const;
const rect2Props = {
type: "rectangle",
height: 100,
width: 100,
x: 100,
y: -50,
} as const;
function roundToNearestHundred(number: number) {
return Math.round(number / 100) * 100;
}
beforeEach(() => {
rect1 = API.createElement({ ...rect1Props });
rect2 = API.createElement({ ...rect2Props });
// Simulate local update
API.updateScene({
elements: [rect1, rect2],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
mouse.reset();
});
it("should rebind bindings when both are updated through the history and there are no conflicting updates in the meantime", async () => {
// create arrow without bindings
Keyboard.withModifierKeys({ ctrl: true }, () => {
UI.clickTool("arrow");
mouse.down(0, 0);
mouse.up(100, 0);
});
const arrowId = h.elements[2].id;
// create start binding
mouse.downAt(0, 0);
mouse.moveTo(0, 10);
mouse.moveTo(0, 10);
mouse.up();
// create end binding
mouse.downAt(100, 0);
mouse.moveTo(100, 10);
mouse.moveTo(100, 10);
mouse.up();
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding
?.fixedPoint,
).not.toEqual([1, 0.5001]);
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode,
).toBe("orbit");
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).endBinding,
).not.toEqual([1, 0.5001]);
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).endBinding?.mode,
).toBe("orbit");
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
}),
]),
);
Keyboard.undo(); // undo start binding
Keyboard.undo(); // undo end binding
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null,
}),
]);
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
// no conflicting updates
x: h.elements[1].x + 50,
}),
newElementWith(h.elements[1], {
// no conflicting updates
x: h.elements[1].x + 50,
}),
newElementWith(h.elements[2], {
// no conflicting updates
x: h.elements[1].x + 50,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: expect.arrayContaining([
{ id: arrowId, type: "arrow" },
]),
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.6],
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [0, 0.6],
mode: "orbit",
}),
}),
]),
);
Keyboard.undo();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null,
}),
]);
});
});
it("should rebind bindings when both are updated through the history and the arrow got bound to a different element in the meantime", async () => {
// create arrow without bindings
Keyboard.withModifierKeys({ ctrl: true }, () => {
UI.clickTool("arrow");
mouse.down(0, 0);
mouse.up(100, 0);
});
const arrowId = h.elements[2].id;
// create start binding
mouse.downAt(0, 0);
mouse.moveTo(0, 10);
mouse.upAt(0, 10);
// create end binding
mouse.downAt(100, 0);
mouse.moveTo(100, 10);
mouse.upAt(100, 10);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
}),
]),
);
Keyboard.undo();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect1.id,
boundElements: [
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null,
}),
]);
const remoteContainer = API.createElement({
type: "rectangle",
width: 50,
x: 100,
boundElements: [{ id: arrowId, type: "arrow" }],
});
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], { boundElements: [] }),
newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, {
endBinding: {
elementId: remoteContainer.id,
fixedPoint: [0.5, 1],
mode: "orbit",
},
}),
remoteContainer,
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.redo();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.6],
mode: "orbit",
}),
// rebound with previous rectangle
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [0, 0.6],
mode: "orbit",
}),
}),
expect.objectContaining({
id: remoteContainer.id,
boundElements: [],
}),
]),
);
Keyboard.undo();
Keyboard.undo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}),
expect.objectContaining({
id: rect2.id,
boundElements: [],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: expect.objectContaining({
// now we are back in the previous state!
elementId: remoteContainer.id,
fixedPoint: [0.5, 1],
mode: "orbit",
}),
}),
expect.objectContaining({
id: remoteContainer.id,
// leaving as bound until we can rebind arrows!
boundElements: [{ id: arrowId, type: "arrow" }],
}),
]),
);
});
});
it("should rebind remotely added arrow when it's bindable elements are added through the history", async () => {
const arrow = API.createElement({
type: "arrow",
startBinding: {
elementId: rect1.id,
fixedPoint: [1, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
// Simulate remote update
API.updateScene({
elements: [
arrow,
newElementWith(h.elements[0], {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
newElementWith(h.elements[1], {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: arrow.id,
startBinding: null,
endBinding: null,
}),
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: true,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
// now we are back in the previous state!
elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
}),
endBinding: expect.objectContaining({
// now we are back in the previous state!
elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
}),
}),
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
]),
);
});
});
it("should rebind remotely added bindable elements when it's arrow is added through the history", async () => {
Keyboard.undo();
const arrow = API.createElement({
type: "arrow",
});
// Simulate local update
API.updateScene({
elements: [arrow],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate remote update
API.updateScene({
elements: [
newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, {
startBinding: {
elementId: rect1.id,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
fixedPoint: [1, 0.5],
mode: "orbit",
},
}),
newElementWith(rect1, {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
newElementWith(rect2, {
boundElements: [{ id: arrow.id, type: "arrow" }],
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
runTwice(() => {
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: arrow.id,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
}),
isDeleted: true,
}),
expect.objectContaining({
id: rect1.id,
boundElements: [],
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [],
isDeleted: false,
}),
]),
);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: arrow.id,
startBinding: {
elementId: rect1.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
},
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: [
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
mode: "orbit",
}),
isDeleted: false,
}),
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrow.id, type: "arrow" }],
isDeleted: false,
}),
]),
);
});
});
it("should unbind remotely deleted bindable elements from arrow when the arrow is added through the history", async () => {});
it("should update bound element points when rectangle was remotely moved and arrow is added back through the history", async () => {
// bind arrow to rect1 and rect2
UI.clickTool("arrow");
mouse.down(0, 0);
mouse.up(100, 0);
const arrowId = h.elements[2].id;
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [],
}),
expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]),
}),
isDeleted: true,
}),
]),
);
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], { x: 500, y: -500 }),
h.elements[2],
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
{
// no need to be strict about points, hence the rounding
const points = (h.elements[2] as ExcalidrawLinearElement).points[1];
expect([
roundToNearestHundred(points[0]),
roundToNearestHundred(points[1]),
]).toEqual([500, -400]);
}
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: rect2.id,
boundElements: [{ id: arrowId, type: "arrow" }],
}),
expect.objectContaining({
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
fixedPoint: expect.arrayContaining([
expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}),
isDeleted: false,
}),
]),
);
});
});
describe("conflicts in frames and their children", () => {
let frame: ExcalidrawFrameElement;
let rect: ExcalidrawGenericElement;
const frameProps = {
type: "frame",
x: 0,
width: 500,
} as const;
const rectProps = {
type: "rectangle",
width: 100,
x: 10,
y: 10,
angle: 0,
} as const;
beforeEach(() => {
frame = API.createElement({ ...frameProps });
rect = API.createElement({ ...rectProps });
});
it("should not rebind frame child with frame when frame was remotely deleted and frame child is added back through the history ", async () => {
// Initialize the scene
API.updateScene({
elements: [frame],
captureUpdate: CaptureUpdateAction.NEVER,
});
// Simulate local update
API.updateScene({
elements: [rect, h.elements[0]],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Simulate local update
API.updateScene({
elements: [
newElementWith(h.elements[0], {
frameId: frame.id,
}),
h.elements[1],
],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect.id,
frameId: null,
isDeleted: false,
}),
expect.objectContaining({
id: frame.id,
isDeleted: false,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect.id,
frameId: frame.id, // double check that the element is rebound
isDeleted: false,
}),
expect.objectContaining({
id: frame.id,
isDeleted: false,
}),
]);
Keyboard.undo();
Keyboard.undo();
// Simulate remote update
API.updateScene({
elements: [
h.elements[0],
newElementWith(h.elements[1], {
isDeleted: true,
}),
],
captureUpdate: CaptureUpdateAction.NEVER,
});
Keyboard.redo();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
id: rect.id,
frameId: null, // element is not unbound from
isDeleted: false,
}),
expect.objectContaining({
id: frame.id,
isDeleted: true,
}),
]);
});
});
});
});