b324a85ab1
* fix(editor): elements duplicated when moving frame children * fix(editor): accumulate reorders across frames in bring-to-front/back * add invalid-order tests * fix: make sure moved indices are within range * add length/duplicate elements guards
1699 lines
48 KiB
TypeScript
1699 lines
48 KiB
TypeScript
import { reseed } from "@excalidraw/common";
|
|
|
|
import {
|
|
actionSendBackward,
|
|
actionBringForward,
|
|
actionBringToFront,
|
|
actionSendToBack,
|
|
actionDuplicateSelection,
|
|
} from "@excalidraw/excalidraw/actions";
|
|
|
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
|
|
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
import {
|
|
act,
|
|
getCloneByOrigId,
|
|
render,
|
|
unmountComponent,
|
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
|
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
|
|
import { selectGroupsForSelectedElements } from "../src/groups";
|
|
|
|
import type {
|
|
ExcalidrawElement,
|
|
ExcalidrawFrameElement,
|
|
ExcalidrawSelectionElement,
|
|
} from "../src/types";
|
|
|
|
unmountComponent();
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
reseed(7);
|
|
});
|
|
|
|
const { h } = window;
|
|
|
|
type ExcalidrawElementType = Exclude<
|
|
ExcalidrawElement,
|
|
ExcalidrawSelectionElement
|
|
>["type"];
|
|
|
|
const populateElements = (
|
|
elements: {
|
|
id: string;
|
|
type?: ExcalidrawElementType;
|
|
isDeleted?: boolean;
|
|
isSelected?: boolean;
|
|
groupIds?: string[];
|
|
y?: number;
|
|
x?: number;
|
|
width?: number;
|
|
height?: number;
|
|
containerId?: string;
|
|
frameId?: ExcalidrawFrameElement["id"];
|
|
index?: ExcalidrawElement["index"];
|
|
}[],
|
|
appState?: Partial<AppState>,
|
|
) => {
|
|
const selectedElementIds: any = {};
|
|
|
|
const newElements = elements.map(
|
|
({
|
|
id,
|
|
isDeleted = false,
|
|
isSelected = false,
|
|
groupIds = [],
|
|
y = 100,
|
|
x = 100,
|
|
width = 100,
|
|
height = 100,
|
|
containerId = null,
|
|
frameId = null,
|
|
type,
|
|
}) => {
|
|
const element = API.createElement({
|
|
type: type ?? (containerId ? "text" : "rectangle"),
|
|
id,
|
|
isDeleted,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
groupIds,
|
|
containerId,
|
|
frameId: frameId || null,
|
|
});
|
|
if (isSelected) {
|
|
selectedElementIds[element.id] = true;
|
|
}
|
|
return element;
|
|
},
|
|
);
|
|
|
|
// initialize `boundElements` on containers, if applicable
|
|
API.setElements(
|
|
newElements.map((element, index, elements) => {
|
|
const nextElement = elements[index + 1];
|
|
if (
|
|
nextElement &&
|
|
"containerId" in nextElement &&
|
|
element.id === nextElement.containerId
|
|
) {
|
|
return {
|
|
...element,
|
|
boundElements: [{ type: "text", id: nextElement.id }],
|
|
};
|
|
}
|
|
return element;
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
h.setState({
|
|
...selectGroupsForSelectedElements(
|
|
{ ...h.state, ...appState, selectedElementIds },
|
|
h.elements,
|
|
h.state,
|
|
null,
|
|
),
|
|
...appState,
|
|
selectedElementIds,
|
|
} as AppState);
|
|
});
|
|
|
|
return selectedElementIds;
|
|
};
|
|
|
|
type Actions =
|
|
| typeof actionBringForward
|
|
| typeof actionSendBackward
|
|
| typeof actionBringToFront
|
|
| typeof actionSendToBack;
|
|
|
|
const assertZindex = ({
|
|
elements,
|
|
appState,
|
|
operations,
|
|
}: {
|
|
elements: {
|
|
id: string;
|
|
isDeleted?: true;
|
|
isSelected?: true;
|
|
groupIds?: string[];
|
|
containerId?: string;
|
|
frameId?: ExcalidrawFrameElement["id"];
|
|
type?: ExcalidrawElementType;
|
|
}[];
|
|
appState?: Partial<AppState>;
|
|
operations: [Actions, string[]][];
|
|
}) => {
|
|
const selectedElementIds = populateElements(elements, appState);
|
|
operations.forEach(([action, expected]) => {
|
|
API.executeAction(action);
|
|
expect(h.elements.map((element) => element.id)).toEqual(expected);
|
|
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
|
});
|
|
};
|
|
|
|
describe("z-index manipulation", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw />);
|
|
});
|
|
|
|
it("send back", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["D", "A", "B", "C"]],
|
|
// noop
|
|
[actionSendBackward, ["D", "A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionSendBackward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isDeleted: true },
|
|
{ id: "B" },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", isSelected: true },
|
|
],
|
|
operations: [[actionSendBackward, ["A", "D", "B", "C"]]],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", isSelected: true },
|
|
{ id: "E", isSelected: true },
|
|
{ id: "F" },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["D", "E", "A", "B", "C", "F"]],
|
|
// noop
|
|
[actionSendBackward, ["D", "E", "A", "B", "C", "F"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B" },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", isDeleted: true },
|
|
{ id: "E", isSelected: true },
|
|
{ id: "F" },
|
|
{ id: "G", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "E", "B", "C", "D", "G", "F"]],
|
|
[actionSendBackward, ["E", "A", "G", "B", "C", "D", "F"]],
|
|
[actionSendBackward, ["E", "G", "A", "B", "C", "D", "F"]],
|
|
// noop
|
|
[actionSendBackward, ["E", "G", "A", "B", "C", "D", "F"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B" },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", isSelected: true },
|
|
{ id: "E", isDeleted: true },
|
|
{ id: "F", isSelected: true },
|
|
{ id: "G" },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "D", "E", "F", "B", "C", "G"]],
|
|
[actionSendBackward, ["D", "E", "F", "A", "B", "C", "G"]],
|
|
// noop
|
|
[actionSendBackward, ["D", "E", "F", "A", "B", "C", "G"]],
|
|
],
|
|
});
|
|
|
|
// elements should not duplicate
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", containerId: "C" },
|
|
{ id: "B" },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "C", "B"]],
|
|
// noop
|
|
[actionSendBackward, ["A", "C", "B"]],
|
|
],
|
|
});
|
|
|
|
// grouped elements should be atomic
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", isDeleted: true },
|
|
{ id: "E", isDeleted: true },
|
|
{ id: "F", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "F", "B", "C", "D", "E"]],
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
// noop
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g2", "g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
{ id: "E", isDeleted: true },
|
|
{ id: "F", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "F", "B", "C", "D", "E"]],
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
// noop
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"] },
|
|
{ id: "E", isDeleted: true },
|
|
{ id: "F", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["A", "F", "B", "C", "D", "E"]],
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
// noop
|
|
[actionSendBackward, ["F", "A", "B", "C", "D", "E"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B1", groupIds: ["g1"] },
|
|
{ id: "C1", groupIds: ["g1"] },
|
|
{ id: "D2", groupIds: ["g2"], isSelected: true },
|
|
{ id: "E2", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: null,
|
|
},
|
|
operations: [[actionSendBackward, ["A", "D2", "E2", "B1", "C1"]]],
|
|
});
|
|
|
|
// in-group siblings
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "B", "D", "C"]],
|
|
// noop (prevented)
|
|
[actionSendBackward, ["A", "B", "D", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g2", "g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g1"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "D", "B", "C"]],
|
|
// noop (prevented)
|
|
[actionSendBackward, ["A", "D", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"], isSelected: true },
|
|
{ id: "D", groupIds: ["g2", "g1"], isDeleted: true },
|
|
{ id: "E", groupIds: ["g2", "g1"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "C", "D", "E", "B"]],
|
|
// noop (prevented)
|
|
[actionSendBackward, ["A", "C", "D", "E", "B"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"] },
|
|
{ id: "E", groupIds: ["g3", "g1"], isSelected: true },
|
|
{ id: "F", groupIds: ["g3", "g1"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "B", "E", "F", "C", "D"]],
|
|
[actionSendBackward, ["A", "E", "F", "B", "C", "D"]],
|
|
// noop (prevented)
|
|
[actionSendBackward, ["A", "E", "F", "B", "C", "D"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B", groupIds: ["g2"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", groupIds: ["g2"], isSelected: true },
|
|
{ id: "E", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "D", "E", "B", "C"]],
|
|
// noop
|
|
[actionSendBackward, ["A", "D", "E", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B", groupIds: ["g2"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", groupIds: ["g2"], isSelected: true },
|
|
{ id: "F" },
|
|
{ id: "G", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["A", "D", "G", "B", "C", "F"]],
|
|
// noop
|
|
[actionSendBackward, ["A", "D", "G", "B", "C", "F"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("bring forward", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
{ id: "D", isDeleted: true },
|
|
{ id: "E" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["A", "D", "E", "B", "C"]],
|
|
// noop
|
|
[actionBringForward, ["A", "D", "E", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionBringForward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D" },
|
|
{ id: "E", isSelected: true },
|
|
{ id: "F", isDeleted: true },
|
|
{ id: "G" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["B", "C", "D", "A", "F", "G", "E"]],
|
|
[actionBringForward, ["B", "C", "D", "F", "G", "A", "E"]],
|
|
// noop
|
|
[actionBringForward, ["B", "C", "D", "F", "G", "A", "E"]],
|
|
],
|
|
});
|
|
|
|
// grouped elements should be atomic
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
{ id: "E", groupIds: ["g1"] },
|
|
{ id: "F" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["B", "C", "D", "E", "A", "F"]],
|
|
[actionBringForward, ["B", "C", "D", "E", "F", "A"]],
|
|
// noop
|
|
[actionBringForward, ["B", "C", "D", "E", "F", "A"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"] },
|
|
{ id: "E", groupIds: ["g1"] },
|
|
{ id: "F" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["A", "C", "D", "E", "B", "F"]],
|
|
[actionBringForward, ["A", "C", "D", "E", "F", "B"]],
|
|
// noop
|
|
[actionBringForward, ["A", "C", "D", "E", "F", "B"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"] },
|
|
{ id: "E", groupIds: ["g2", "g1"] },
|
|
{ id: "F" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["A", "C", "D", "E", "B", "F"]],
|
|
[actionBringForward, ["A", "C", "D", "E", "F", "B"]],
|
|
// noop
|
|
[actionBringForward, ["A", "C", "D", "E", "F", "B"]],
|
|
],
|
|
});
|
|
|
|
// in-group siblings
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g2", "g1"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["A", "C", "B", "D"]],
|
|
// noop (prevented)
|
|
[actionBringForward, ["A", "C", "B", "D"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g2", "g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D" },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["B", "C", "A", "D"]],
|
|
// noop (prevented)
|
|
[actionBringForward, ["B", "C", "A", "D"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g2", "g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g2", "g1"], isSelected: true },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D" },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["C", "A", "B", "D"]],
|
|
// noop (prevented)
|
|
[actionBringForward, ["C", "A", "B", "D"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", groupIds: ["g2"] },
|
|
{ id: "E", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["C", "D", "A", "B", "E"]],
|
|
// noop
|
|
[actionBringForward, ["C", "D", "A", "B", "E"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g2"], isSelected: true },
|
|
{ id: "B" },
|
|
{ id: "C", groupIds: ["g2"], isSelected: true },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
{ id: "E", groupIds: ["g2"] },
|
|
{ id: "F", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["B", "D", "E", "A", "C", "F"]],
|
|
// noop
|
|
[actionBringForward, ["B", "D", "E", "A", "C", "F"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("bring to front", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "0" },
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C", isDeleted: true },
|
|
{ id: "D" },
|
|
{ id: "E", isSelected: true },
|
|
{ id: "F", isDeleted: true },
|
|
{ id: "G" },
|
|
],
|
|
operations: [
|
|
[actionBringToFront, ["0", "B", "C", "D", "F", "G", "A", "E"]],
|
|
// noop
|
|
[actionBringToFront, ["0", "B", "C", "D", "F", "G", "A", "E"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionBringToFront, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionBringToFront, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C" },
|
|
],
|
|
operations: [
|
|
[actionBringToFront, ["C", "A", "B"]],
|
|
// noop
|
|
[actionBringToFront, ["C", "A", "B"]],
|
|
],
|
|
});
|
|
|
|
// in-group sorting
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
{ id: "E", groupIds: ["g1"], isSelected: true },
|
|
{ id: "F", groupIds: ["g2", "g1"] },
|
|
{ id: "G", groupIds: ["g2", "g1"] },
|
|
{ id: "H", groupIds: ["g3", "g1"] },
|
|
{ id: "I", groupIds: ["g3", "g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionBringToFront, ["A", "B", "D", "F", "G", "H", "I", "C", "E"]],
|
|
// noop (prevented)
|
|
[actionBringToFront, ["A", "B", "D", "F", "G", "H", "I", "C", "E"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g2", "g1"], isSelected: true },
|
|
{ id: "D", groupIds: ["g2", "g1"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringToFront, ["A", "D", "B", "C"]],
|
|
// noop (prevented)
|
|
[actionBringToFront, ["A", "D", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g2", "g3"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g3"] },
|
|
{ id: "C", groupIds: ["g2", "g3"] },
|
|
{ id: "D", groupIds: ["g1", "g3"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringToFront, ["B", "C", "A", "D"]],
|
|
// noop
|
|
[actionBringToFront, ["B", "C", "A", "D"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2"] },
|
|
{ id: "D", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionBringToFront, ["B", "C", "A", "D"]],
|
|
// noop
|
|
[actionBringToFront, ["B", "C", "A", "D"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("send to back", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isDeleted: true },
|
|
{ id: "C" },
|
|
{ id: "D", isDeleted: true },
|
|
{ id: "E", isSelected: true },
|
|
{ id: "F", isDeleted: true },
|
|
{ id: "G" },
|
|
{ id: "H", isSelected: true },
|
|
{ id: "I" },
|
|
],
|
|
operations: [
|
|
[actionSendToBack, ["E", "H", "A", "B", "C", "D", "F", "G", "I"]],
|
|
// noop
|
|
[actionSendToBack, ["E", "H", "A", "B", "C", "D", "F", "G", "I"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionSendToBack, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C" },
|
|
],
|
|
operations: [
|
|
// noop
|
|
[actionSendToBack, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
],
|
|
operations: [
|
|
[actionSendToBack, ["B", "C", "A"]],
|
|
// noop
|
|
[actionSendToBack, ["B", "C", "A"]],
|
|
],
|
|
});
|
|
|
|
// in-group sorting
|
|
// -------------------------------------------------------------------------
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g2", "g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g3", "g1"] },
|
|
{ id: "E", groupIds: ["g3", "g1"] },
|
|
{ id: "F", groupIds: ["g1"], isSelected: true },
|
|
{ id: "G", groupIds: ["g1"] },
|
|
{ id: "H", groupIds: ["g1"], isSelected: true },
|
|
{ id: "I", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionSendToBack, ["A", "F", "H", "B", "C", "D", "E", "G", "I"]],
|
|
// noop (prevented)
|
|
[actionSendToBack, ["A", "F", "H", "B", "C", "D", "E", "G", "I"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g2", "g1"] },
|
|
{ id: "D", groupIds: ["g2", "g1"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendToBack, ["A", "B", "D", "C"]],
|
|
// noop (prevented)
|
|
[actionSendToBack, ["A", "B", "D", "C"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1", "g3"] },
|
|
{ id: "B", groupIds: ["g2", "g3"] },
|
|
{ id: "C", groupIds: ["g1", "g3"] },
|
|
{ id: "D", groupIds: ["g2", "g3"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendToBack, ["A", "D", "B", "C"]],
|
|
// noop
|
|
[actionSendToBack, ["A", "D", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
// invalid z-indexes across groups (legacy) → allow to sort to next sibling
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B", groupIds: ["g2"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "D", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g2",
|
|
},
|
|
operations: [
|
|
[actionSendToBack, ["A", "D", "B", "C"]],
|
|
// noop
|
|
[actionSendToBack, ["A", "D", "B", "C"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("duplicating elements should retain zindex integrity", () => {
|
|
populateElements([
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B", isSelected: true },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements).toMatchObject([
|
|
{ id: "A" },
|
|
{ id: getCloneByOrigId("A").id },
|
|
{ id: "B" },
|
|
{ id: getCloneByOrigId("B").id },
|
|
]);
|
|
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements).toMatchObject([
|
|
{ id: "A" },
|
|
{ id: "B" },
|
|
{
|
|
id: getCloneByOrigId("A").id,
|
|
|
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
|
},
|
|
{
|
|
id: getCloneByOrigId("B").id,
|
|
|
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
|
},
|
|
]);
|
|
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C" },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements).toMatchObject([
|
|
{ id: "A" },
|
|
{ id: "B" },
|
|
{
|
|
id: getCloneByOrigId("A").id,
|
|
|
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
|
},
|
|
{
|
|
id: getCloneByOrigId("B").id,
|
|
|
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
|
},
|
|
{ id: "C" },
|
|
]);
|
|
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C", isSelected: true },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
"C",
|
|
getCloneByOrigId("C").id,
|
|
]);
|
|
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"], isSelected: true },
|
|
{ id: "D", groupIds: ["g2"], isSelected: true },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
"C",
|
|
"D",
|
|
getCloneByOrigId("C").id,
|
|
getCloneByOrigId("D").id,
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
{
|
|
selectedGroupIds: { g1: true },
|
|
},
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
"C",
|
|
getCloneByOrigId("C").id,
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"], isSelected: true },
|
|
],
|
|
{
|
|
selectedGroupIds: { g2: true },
|
|
},
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
"C",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
getCloneByOrigId("C").id,
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"], isSelected: true },
|
|
{ id: "D", groupIds: ["g3", "g4"], isSelected: true },
|
|
{ id: "E", groupIds: ["g3", "g4"], isSelected: true },
|
|
{ id: "F", groupIds: ["g4"], isSelected: true },
|
|
],
|
|
{
|
|
selectedGroupIds: { g2: true, g4: true },
|
|
},
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
"C",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
getCloneByOrigId("C").id,
|
|
"D",
|
|
"E",
|
|
"F",
|
|
getCloneByOrigId("D").id,
|
|
getCloneByOrigId("E").id,
|
|
getCloneByOrigId("F").id,
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g2"] },
|
|
{ id: "C", groupIds: ["g2"] },
|
|
],
|
|
{ editingGroupId: "g1" },
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
getCloneByOrigId("A").id,
|
|
"B",
|
|
"C",
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"] },
|
|
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"] },
|
|
],
|
|
{ editingGroupId: "g1" },
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
getCloneByOrigId("B").id,
|
|
"C",
|
|
]);
|
|
|
|
populateElements(
|
|
[
|
|
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g2"] },
|
|
],
|
|
{ editingGroupId: "g1" },
|
|
);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
getCloneByOrigId("A").id,
|
|
"B",
|
|
getCloneByOrigId("B").id,
|
|
"C",
|
|
]);
|
|
});
|
|
|
|
it("duplicating incorrectly interleaved elements (group elements should be together) should still produce reasonable result", () => {
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B" },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
]);
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"C",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("C").id,
|
|
"B",
|
|
]);
|
|
});
|
|
|
|
it("group-selected duplication should includes deleted elements that weren't selected on account of being deleted", () => {
|
|
populateElements([
|
|
{ id: "A", groupIds: ["g1"], isDeleted: true },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
{ id: "D" },
|
|
]);
|
|
expect(h.state.selectedGroupIds).toEqual({ g1: true });
|
|
API.executeAction(actionDuplicateSelection);
|
|
expect(h.elements.map((element) => element.id)).toEqual([
|
|
"A",
|
|
"B",
|
|
"C",
|
|
getCloneByOrigId("A").id,
|
|
getCloneByOrigId("B").id,
|
|
getCloneByOrigId("C").id,
|
|
"D",
|
|
]);
|
|
});
|
|
|
|
it("text-container binding should be atomic", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true },
|
|
{ id: "B" },
|
|
{ id: "C", containerId: "B" },
|
|
],
|
|
operations: [
|
|
[actionBringForward, ["B", "C", "A"]],
|
|
[actionSendBackward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A" },
|
|
{ id: "B", isSelected: true },
|
|
{ id: "C", containerId: "B" },
|
|
],
|
|
operations: [
|
|
[actionSendBackward, ["B", "C", "A"]],
|
|
[actionBringForward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", isSelected: true, groupIds: ["g1"] },
|
|
{ id: "B", groupIds: ["g1"] },
|
|
{ id: "C", containerId: "B", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionBringForward, ["B", "C", "A"]],
|
|
[actionSendBackward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C", containerId: "B", groupIds: ["g1"] },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [
|
|
[actionSendBackward, ["B", "C", "A"]],
|
|
[actionBringForward, ["A", "B", "C"]],
|
|
],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B", isSelected: true, groupIds: ["g1"] },
|
|
{ id: "C" },
|
|
{ id: "D", containerId: "C" },
|
|
],
|
|
appState: {
|
|
editingGroupId: "g1",
|
|
},
|
|
operations: [[actionBringForward, ["A", "B", "C", "D"]]],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("z-indexing with frames", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw />);
|
|
});
|
|
|
|
// naming scheme:
|
|
// F# ... frame element
|
|
// F#_# ... frame child of F# (rectangle)
|
|
// R# ... unrelated element (rectangle)
|
|
|
|
it("moving whole frame by one (normalized)", () => {
|
|
// normalized frame order
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringForward, ["R1", "F1_1", "F1_2", "F1", "R2"]],
|
|
// +1
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1_2", "F1"]],
|
|
// noop
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1_2", "F1"]],
|
|
// -1
|
|
[actionSendBackward, ["R1", "F1_1", "F1_2", "F1", "R2"]],
|
|
// -1
|
|
[actionSendBackward, ["F1_1", "F1_2", "F1", "R1", "R2"]],
|
|
// noop
|
|
[actionSendBackward, ["F1_1", "F1_2", "F1", "R1", "R2"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("moving whole frame by one (DENORMALIZED)", () => {
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringForward, ["R1", "F1_1", "F1", "F1_2", "R2"]],
|
|
// +1
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
// noop
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R1" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringForward, ["R1", "F1_1", "F1", "R2", "F1_2"]],
|
|
// +1
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
// noop
|
|
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "R1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R2" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R3" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringForward, ["R1", "F1_1", "R2", "F1", "R3", "F1_2"]],
|
|
// +1
|
|
// FIXME incorrect, should put F1_1 after R3
|
|
[actionBringForward, ["R1", "R2", "F1_1", "R3", "F1", "F1_2"]],
|
|
// +1
|
|
// FIXME should be noop from previous step after it's fixed
|
|
[actionBringForward, ["R1", "R2", "R3", "F1_1", "F1", "F1_2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "R1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R2" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R3" },
|
|
],
|
|
operations: [
|
|
// -1
|
|
[actionSendBackward, ["F1_1", "F1", "R1", "F1_2", "R2", "R3"]],
|
|
// -1
|
|
[actionSendBackward, ["F1_1", "F1", "F1_2", "R1", "R2", "R3"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("moving selected frame children by one (normalized)", () => {
|
|
// normalized frame order
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "R1" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringForward, ["F1_2", "F1_1", "F1", "R1"]],
|
|
// noop
|
|
[actionBringForward, ["F1_2", "F1_1", "F1", "R1"]],
|
|
],
|
|
});
|
|
|
|
// normalized frame order, multiple frames
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "R1" },
|
|
{ id: "F2_1", frameId: "F2", isSelected: true },
|
|
{ id: "F2_2", frameId: "F2" },
|
|
{ id: "F2", type: "frame" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[
|
|
actionBringForward,
|
|
["F1_2", "F1_1", "F1", "R1", "F2_2", "F2_1", "F2", "R2"],
|
|
],
|
|
// noop
|
|
[
|
|
actionBringForward,
|
|
["F1_2", "F1_1", "F1", "R1", "F2_2", "F2_1", "F2", "R2"],
|
|
],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("moving selected frame children by one (DENORMALIZED)", () => {
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R1" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
// NOTE not sure what we wanna do here
|
|
[actionBringForward, ["F1", "F1_2", "F1_1", "R1"]],
|
|
// noop
|
|
[actionBringForward, ["F1", "F1_2", "F1_1", "R1"]],
|
|
// -1
|
|
[actionSendBackward, ["F1", "F1_1", "F1_2", "R1"]],
|
|
// noop
|
|
[actionSendBackward, ["F1", "F1_1", "F1_2", "R1"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "R1" },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
// NOTE not sure what we wanna do here
|
|
[actionBringForward, ["R1", "F1", "F1_2", "F1_1", "R2"]],
|
|
// noop
|
|
[actionBringForward, ["R1", "F1", "F1_2", "F1_1", "R2"]],
|
|
// -1
|
|
[actionSendBackward, ["R1", "F1", "F1_1", "F1_2", "R2"]],
|
|
// noop
|
|
[actionSendBackward, ["R1", "F1", "F1_1", "F1_2", "R2"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("moving whole frame to front/end", () => {
|
|
// normalized frame order
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +∞
|
|
[actionBringToFront, ["R1", "R2", "F1_1", "F1_2", "F1"]],
|
|
// noop
|
|
[actionBringToFront, ["R1", "R2", "F1_1", "F1_2", "F1"]],
|
|
// -∞
|
|
[actionSendToBack, ["F1_1", "F1_2", "F1", "R1", "R2"]],
|
|
// noop
|
|
[actionSendToBack, ["F1_1", "F1_2", "F1", "R1", "R2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +∞
|
|
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
// noop
|
|
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
// -∞
|
|
[actionSendToBack, ["F1_1", "F1", "F1_2", "R1", "R2"]],
|
|
// noop
|
|
[actionSendToBack, ["F1_1", "F1", "F1_2", "R1", "R2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R1" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R2" },
|
|
],
|
|
operations: [
|
|
// +∞
|
|
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
|
|
],
|
|
});
|
|
|
|
// DENORMALIZED FRAME ORDER
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1" },
|
|
{ id: "R1" },
|
|
{ id: "F1", type: "frame", isSelected: true },
|
|
{ id: "R2" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "R3" },
|
|
],
|
|
operations: [
|
|
// +1
|
|
[actionBringToFront, ["R1", "R2", "R3", "F1_1", "F1", "F1_2"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "F2_1", frameId: "F2", isSelected: true },
|
|
{ id: "F2_2", frameId: "F2" },
|
|
{ id: "F2", type: "frame" },
|
|
],
|
|
operations: [
|
|
// +∞: each selected child moves to the front of its own frame
|
|
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
|
|
// -∞: each selected child moves to the back of its own frame
|
|
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
|
|
],
|
|
});
|
|
});
|
|
|
|
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
|
|
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
|
|
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
|
|
{ id: "F2", type: "frame" },
|
|
],
|
|
appState: { editingGroupId: "g1" },
|
|
operations: [
|
|
// -∞ (send to back, within the frame)
|
|
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
|
|
// +∞ (bring to front, within the frame)
|
|
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
|
|
* frame's children — and a group's members — are contiguous in the elements
|
|
* array. Such states shouldn't occur in normal use, but they CAN arise from
|
|
* bugs or broken input, because nothing re-defragments element order during
|
|
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
|
|
* tests so the reordering ops stay exercised against malformed order.
|
|
*
|
|
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
|
|
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
|
|
*
|
|
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
|
|
* contract for invalid input — it's whatever the slice math happens to
|
|
* produce. If a future change alters an `expected` order below, that is NOT
|
|
* necessarily a functional regression. First confirm from the diff that the
|
|
* hard contract still holds (nothing duplicated/lost), then update the
|
|
* expected order to match, provided it's deemed an improvement over the
|
|
* previous order, or it's an acceptable change given the underlying logic
|
|
* change.
|
|
*/
|
|
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw />);
|
|
});
|
|
|
|
const assertReorderPreservesElements = (
|
|
elements: Parameters<typeof populateElements>[0],
|
|
appState: Parameters<typeof populateElements>[1],
|
|
// each op is applied to a freshly-populated (broken) state
|
|
cases: [Actions, string[]][],
|
|
) => {
|
|
for (const [action, expected] of cases) {
|
|
populateElements(elements, appState);
|
|
const before = h.elements.map((el) => el.id);
|
|
|
|
expect(() => API.executeAction(action)).not.toThrow();
|
|
|
|
const after = h.elements.map((el) => el.id);
|
|
// hard contract:
|
|
expect(after.length).toBe(before.length); // no loss
|
|
expect(new Set(after).size).toBe(after.length); // no duplication
|
|
// soft snapshot (see block comment before changing):
|
|
expect(after).toEqual(expected);
|
|
}
|
|
};
|
|
|
|
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
|
|
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
|
|
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
|
|
// never a duplication/loss, and the op does not throw.
|
|
const elements: Parameters<typeof populateElements>[0] = [
|
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
|
{ id: "F2_1", frameId: "F2" },
|
|
{ id: "F1_2", frameId: "F1" },
|
|
{ id: "F1", type: "frame" },
|
|
{ id: "F2", type: "frame" },
|
|
];
|
|
assertReorderPreservesElements(elements, undefined, [
|
|
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
|
|
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
|
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
|
|
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
|
]);
|
|
});
|
|
|
|
it("discontiguous group, whole group selected", () => {
|
|
// g1 = {A, C}, scattered by the loose elements B and D.
|
|
const elements: Parameters<typeof populateElements>[0] = [
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "B" },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
{ id: "D" },
|
|
];
|
|
assertReorderPreservesElements(elements, undefined, [
|
|
// move-by-one leaves the group scattered (each run moves independently)
|
|
[actionBringForward, ["B", "A", "D", "C"]],
|
|
[actionSendBackward, ["A", "C", "B", "D"]],
|
|
// to-front / to-back gather the scattered members back into one block
|
|
[actionBringToFront, ["B", "D", "A", "C"]],
|
|
[actionSendToBack, ["A", "C", "B", "D"]],
|
|
]);
|
|
});
|
|
|
|
it("discontiguous group, single member selected in group-editing mode", () => {
|
|
const elements: Parameters<typeof populateElements>[0] = [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "B" },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
{ id: "D" },
|
|
];
|
|
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
|
|
[actionBringForward, ["A", "B", "C", "D"]],
|
|
[actionSendBackward, ["C", "A", "B", "D"]],
|
|
[actionBringToFront, ["A", "B", "C", "D"]],
|
|
[actionSendToBack, ["C", "A", "B", "D"]],
|
|
]);
|
|
});
|
|
|
|
it("two interleaved groups, both fully selected", () => {
|
|
const elements: Parameters<typeof populateElements>[0] = [
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "X", groupIds: ["g2"], isSelected: true },
|
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
|
{ id: "Y", groupIds: ["g2"], isSelected: true },
|
|
{ id: "Z" },
|
|
];
|
|
assertReorderPreservesElements(elements, undefined, [
|
|
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
|
|
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
|
|
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
|
|
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("z-index reordering with inconsistent group-editing state", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw />);
|
|
});
|
|
|
|
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "X", groupIds: ["g2"] },
|
|
{ id: "Y", groupIds: ["g2"] },
|
|
{ id: "R" },
|
|
],
|
|
appState: { editingGroupId: "g2" },
|
|
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
|
|
});
|
|
|
|
assertZindex({
|
|
elements: [
|
|
{ id: "A", groupIds: ["g1"] },
|
|
{ id: "C", groupIds: ["g1"] },
|
|
{ id: "X", groupIds: ["g2"], isSelected: true },
|
|
{ id: "Y", groupIds: ["g2"] },
|
|
{ id: "R" },
|
|
],
|
|
appState: { editingGroupId: "g1" },
|
|
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
|
|
});
|
|
});
|
|
});
|