fix: duplicating/removing frame while children selected (#9079)

This commit is contained in:
David Luzar 2025-02-04 19:23:47 +01:00 committed by GitHub
parent 302664e500
commit 424e94a403
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3160 additions and 2065 deletions

View file

@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { act, assertElements, render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDeleteSelected } from "./actionDeleteSelected";
const { h } = window;
describe("deleting selected elements when frame selected should keep children + select them", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("frame only", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
it("frame + text container (text's frameId set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: f1.id,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text's frameId not set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text selected too)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + labeled arrow", async () => {
const f1 = API.createElement({
type: "frame",
});
const a1 = API.createElement({
type: "arrow",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: a1.id,
frameId: null,
});
mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, a1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: a1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + children selected", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1, r1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
});

View file

@ -18,6 +18,8 @@ import {
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getContainerElement } from "../element/textElement";
import { getFrameChildren } from "../frame";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -33,10 +35,50 @@ const deleteSelectedElements = (
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {}; const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
const elementsMap = app.scene.getNonDeletedElementsMap();
const processedElements = new Set<ExcalidrawElement["id"]>();
for (const frameId of framesToBeDeleted) {
const frameChildren = getFrameChildren(elements, frameId);
for (const el of frameChildren) {
if (processedElements.has(el.id)) {
continue;
}
if (isBoundToContainer(el)) {
const containerElement = getContainerElement(el, elementsMap);
if (containerElement) {
selectedElementIds[containerElement.id] = true;
}
} else {
selectedElementIds[el.id] = true;
}
processedElements.add(el.id);
}
}
let shouldSelectEditingGroup = true; let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => { const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
const boundElement = isBoundToContainer(el)
? getContainerElement(el, elementsMap)
: null;
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return el;
}
if (
boundElement?.frameId &&
framesToBeDeleted.has(boundElement?.frameId)
) {
return el;
}
if (el.boundElements) { if (el.boundElements) {
el.boundElements.forEach((candidate) => { el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id); const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
@ -59,7 +101,9 @@ const deleteSelectedElements = (
// if deleting a frame, remove the children from it and select them // if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) { if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false; shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true; if (!isBoundToContainer(el)) {
selectedElementIds[el.id] = true;
}
return newElementWith(el, { frameId: null }); return newElementWith(el, { frameId: null });
} }
@ -224,11 +268,13 @@ export const actionDeleteSelected = register({
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app); deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion( fixBindingsAfterDeletion(
nextElements, nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]), nextElements.filter((el) => el.isDeleted),
); );
nextAppState = handleGroupEditingState(nextAppState, nextElements); nextAppState = handleGroupEditingState(nextAppState, nextElements);

View file

@ -0,0 +1,530 @@
import { Excalidraw } from "../index";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDuplicateSelection } from "./actionDuplicateSelection";
import React from "react";
import { ORIG_ID } from "../constants";
const { h } = window;
describe("actionDuplicateSelection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("duplicating frames", () => {
it("frame selected only", async () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame selected only (with text container)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame + text container selected (order A)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame, rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: frame.id,
selected: true,
},
]);
});
it("frame + text container selected (order B)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([text, rectangle, frame]);
API.setSelectedElements([rectangle, frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ id: frame.id },
{
type: "rectangle",
[ORIG_ID]: `${rectangle.id}`,
},
{
[ORIG_ID]: `${text.id}`,
type: "text",
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
]);
});
});
describe("duplicating frame children", () => {
it("frame child selected", () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
it("frame text container selected (rectangle selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame bound text selected (container not selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame text container selected (text not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
// shouldn't happen
it("frame bound text selected (container not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id },
]);
});
it("frame bound container selected (text has no frameId)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
label: { frameId: null },
});
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
},
]);
});
});
describe("duplicating multiple frames", () => {
it("multiple frames selected (no children)", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
it("multiple frames selected (no children) + unrelated element", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, ellipse, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, selected: true },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
});
describe("duplicating containers/bound elements", () => {
it("labeled arrow (arrow selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
// shouldn't happen
it("labeled arrow (text selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
});
describe("duplicating groups", () => {
it("duplicate group containing frame (children don't have groupIds set)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicate group containing frame (children have groupIds)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
groupIds: ["A"],
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{
[ORIG_ID]: text.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicating element nested in group", () => {
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["B"],
});
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
API.setElements([ellipse, rect1, rect2]);
API.setSelectedElements([ellipse], "B");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
{ id: rect1.id, groupIds: ["A", "B"] },
{ id: rect2.id, groupIds: ["A", "B"] },
]);
});
});
});

View file

@ -5,7 +5,13 @@ import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import {
selectGroupsForSelectedElements, selectGroupsForSelectedElements,
@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
getContainerElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { import {
@ -31,7 +42,6 @@ import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
@ -85,34 +95,66 @@ const duplicateElements = (
): Partial<ActionResult> => { ): Partial<ActionResult> => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map(); const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = []; const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const elementsMap = arrayToMap(elements);
const newElement = duplicateElement(
appState.editingGroupId, const duplicateAndOffsetElement = <
groupIdMap, T extends ExcalidrawElement | ExcalidrawElement[],
element, >(
{ element: T,
x: element.x + DEFAULT_GRID_SIZE / 2, ): T extends ExcalidrawElement[]
y: element.y + DEFAULT_GRID_SIZE / 2, ? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
}, },
[],
); );
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); return (
oldElements.push(element); Array.isArray(element) ? _newElements : _newElements[0] || null
newElements.push(newElement); ) as T extends ExcalidrawElement[]
return newElement; ? ExcalidrawElement[]
: ExcalidrawElement | null;
}; };
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap( const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, { getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}), }),
@ -130,122 +172,133 @@ const duplicateElements = (
// loop over them. // loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>(); const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => { const elementsWithClones: ExcalidrawElement[] = elements.slice();
for (const element of elements) {
processedIds.set(element.id, true); const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
} }
return elements;
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
}; };
const elementsWithClones: ExcalidrawElement[] = []; const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
let index = -1; for (const element of elements) {
if (processedIds.has(element.id)) {
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
continue; continue;
} }
const boundTextElement = getBoundTextElement(element, arrayToMap(elements)); if (!idsOfElementsToDuplicate.has(element.id)) {
const isElementAFrameLike = isFrameLikeElement(element); continue;
}
if (idsOfElementsToDuplicate.get(element.id)) { // groups
// if a group or a container/bound-text or frame, duplicate atomically // -------------------------------------------------------------------------
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
elementsWithClones.push( const groupId = getSelectedGroupForElement(appState, element);
...markAsProcessed([ if (groupId) {
...groupElements, const groupElements = getElementsInGroup(elements, groupId).flatMap(
...groupElements.map((element) => (element) =>
duplicateAndOffsetElement(element), isFrameLikeElement(element)
), ? [...getFrameChildren(elements, element.id), element]
]), : [element],
); );
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
duplicateAndOffsetElement(boundTextElement),
]),
);
continue;
}
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push( const targetIndex = findLastIndex(elementsWithClones, (el) => {
...markAsProcessed([ return el.groupIds?.includes(groupId);
...elementsInFrame, });
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
continue; insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
} continue;
} }
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well, // frame duplication
// they will have been copied along with the frame atomically in the // -------------------------------------------------------------------------
// above branch, so we must skip those elements here
// if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
// now, for elements do not belong any frames or elements whose frames continue;
// are selected (or elements that are left out from the above }
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) { if (isFrameLikeElement(element)) {
elementsWithClones.push( const frameId = element.id;
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
); );
});
if (boundTextElement) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
} }
} else {
elementsWithClones.push(...markAsProcessed([element])); continue;
} }
}
// step (2) if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
// second pass to remove duplicates. We loop from the end as it's likelier const targetIndex = findLastIndex(elementsWithClones, (el) => {
// that the last elements are in the correct order (contiguous or otherwise). return el.id === element.id || el.id === container?.id;
// Thus we need to reverse as the last step (3). });
const finalElementsReversed: ExcalidrawElement[] = []; if (container) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
const finalElementIds = new Map<ExcalidrawElement["id"], true>(); continue;
index = elementsWithClones.length;
while (--index >= 0) {
const element = elementsWithClones[index];
if (!finalElementIds.get(element.id)) {
finalElementIds.set(element.id, true);
finalElementsReversed.push(element);
} }
}
// step (3) // default duplication (regular elements)
const finalElements = syncMovedIndices( // -------------------------------------------------------------------------
finalElementsReversed.reverse(),
arrayToMap(newElements), insertAfterIndex(
); findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -260,7 +313,7 @@ const duplicateElements = (
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
finalElements, elementsWithClones,
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
@ -269,7 +322,7 @@ const duplicateElements = (
excludeElementsInFramesFromSelection(newElements); excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: elementsWithClones,
appState: { appState: {
...appState, ...appState,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@ -285,7 +338,7 @@ const duplicateElements = (
{}, {},
), ),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(elementsWithClones),
appState, appState,
null, null,
), ),

View file

@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3; export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element"; export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");

View file

@ -45,6 +45,7 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types"; import type { MarkOptional, Merge, Mutable } from "../utility-types";
@ -592,26 +593,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
return _deepCopyElement(val); return _deepCopyElement(val);
}; };
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};
/** /**
* utility wrapper to generate new id. In test env it reuses the old + postfix * utility wrapper to generate new id.
* for test assertions.
*/ */
export const regenerateId = ( const regenerateId = () => {
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
return randomId(); return randomId();
}; };
@ -637,7 +630,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
): Readonly<TElement> => { ): Readonly<TElement> => {
let copy = deepCopyElement(element); let copy = deepCopyElement(element);
copy.id = regenerateId(copy.id); if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = regenerateId();
copy.boundElements = null; copy.boundElements = null;
copy.updated = getUpdatedTimestamp(); copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger(); copy.seed = randomInteger();
@ -646,7 +643,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId, editingGroupId,
(groupId) => { (groupId) => {
if (!groupIdMapForOperation.has(groupId)) { if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, regenerateId(groupId)); groupIdMapForOperation.set(groupId, regenerateId());
} }
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
@ -692,7 +689,7 @@ export const duplicateElements = (
// if we haven't migrated the element id, but an old element with the same // if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it // id exists, generate a new id for it and return it
if (origElementsMap.has(id)) { if (origElementsMap.has(id)) {
const newId = regenerateId(id); const newId = regenerateId();
elementNewIdsMap.set(id, newId); elementNewIdsMap.set(id, newId);
return newId; return newId;
} }
@ -706,6 +703,9 @@ export const duplicateElements = (
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element); const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!; clonedElement.id = maybeGetNewId(element.id)!;
if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id);
}
if (opts?.randomizeSeed) { if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger(); clonedElement.seed = randomInteger();
@ -715,7 +715,7 @@ export const duplicateElements = (
if (clonedElement.groupIds) { if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => { clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) { if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId)); groupNewIdsMap.set(groupId, regenerateId());
} }
return groupNewIdsMap.get(groupId)!; return groupNewIdsMap.get(groupId)!;
}); });

View file

@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
// console.time(); return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
}; };

View file

@ -1,9 +1,8 @@
import React from "react";
import type { ExcalidrawElement } from "./element/types"; import type { ExcalidrawElement } from "./element/types";
import { convertToExcalidrawElements, Excalidraw } from "./index"; import { convertToExcalidrawElements, Excalidraw } from "./index";
import { API } from "./tests/helpers/api"; import { API } from "./tests/helpers/api";
import { Keyboard, Pointer } from "./tests/helpers/ui"; import { Keyboard, Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils"; import { getCloneByOrigId, render } from "./tests/test-utils";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
selectElementAndDuplicate(rect2); selectElementAndDuplicate(rect2);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(frame.id); expect(rect2.frameId).toBe(frame.id);
expectEqualIds([rect2_copy, rect2, frame]); expectEqualIds([rect2_copy, rect2, frame]);
@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
// move the rect2 outside the frame // move the rect2 outside the frame
selectElementAndDuplicate(rect2, [-1000, -1000]); selectElementAndDuplicate(rect2, [-1000, -1000]);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id); expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(null); expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]); expectEqualIds([rect2_copy, frame, rect2]);

View file

@ -103,6 +103,7 @@
"@types/pako": "1.0.3", "@types/pako": "1.0.3",
"@types/pica": "5.1.3", "@types/pica": "5.1.3",
"@types/resize-observer-browser": "0.1.7", "@types/resize-observer-browser": "0.1.7",
"ansicolor": "2.0.3",
"autoprefixer": "10.4.7", "autoprefixer": "10.4.7",
"babel-loader": "8.2.5", "babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1", "babel-plugin-transform-class-properties": "6.24.1",

View file

@ -2517,7 +2517,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [], "searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0_copy": true, "id1": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@ -2590,7 +2590,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 20, "height": 20,
"id": "id0_copy", "id": "id1",
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@ -2680,7 +2680,7 @@ History {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"selectedElementIds": { "selectedElementIds": {
"id0_copy": true, "id1": true,
}, },
}, },
"inserted": { "inserted": {
@ -2693,7 +2693,7 @@ History {
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {},
"removed": Map { "removed": Map {
"id0_copy" => Delta { "id1" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
"id": "id0_copy", "id": "id2",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,

View file

@ -2129,7 +2129,7 @@ History {
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {},
"removed": Map { "removed": Map {
"id0_copy" => Delta { "id2" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -10619,7 +10619,7 @@ History {
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {},
"removed": Map { "removed": Map {
"id0_copy" => Delta { "id6" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -10628,7 +10628,7 @@ History {
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
"id4_copy", "id7",
], ],
"height": 10, "height": 10,
"index": "a0", "index": "a0",
@ -10652,7 +10652,7 @@ History {
"isDeleted": true, "isDeleted": true,
}, },
}, },
"id1_copy" => Delta { "id8" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -10661,7 +10661,7 @@ History {
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
"id4_copy", "id7",
], ],
"height": 10, "height": 10,
"index": "a1", "index": "a1",
@ -10685,7 +10685,7 @@ History {
"isDeleted": true, "isDeleted": true,
}, },
}, },
"id2_copy" => Delta { "id9" => Delta {
"deleted": { "deleted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -10694,7 +10694,7 @@ History {
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [ "groupIds": [
"id4_copy", "id7",
], ],
"height": 10, "height": 10,
"index": "a2", "index": "a2",

View file

@ -40,6 +40,7 @@ import { createTestHook } from "../../components/App";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { pointFrom, type LocalPoint, type Radians } from "../../../math"; import { pointFrom, type LocalPoint, type Radians } from "../../../math";
import { selectGroupsForSelectedElements } from "../../groups";
const readFile = util.promisify(fs.readFile); const readFile = util.promisify(fs.readFile);
// so that window.h is available when App.tsx is not imported as well. // so that window.h is available when App.tsx is not imported as well.
@ -68,13 +69,21 @@ export class API {
}); });
}; };
static setSelectedElements = (elements: ExcalidrawElement[]) => { static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
act(() => { act(() => {
h.setState({ h.setState({
selectedElementIds: elements.reduce((acc, element) => { ...selectGroupsForSelectedElements(
acc[element.id] = true; {
return acc; editingGroupId: editingGroupId ?? null,
}, {} as Record<ExcalidrawElement["id"], true>), selectedElementIds: elements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
elements,
h.state,
h.app,
)
}); });
}); });
}; };
@ -158,7 +167,7 @@ export class API {
isDeleted?: boolean; isDeleted?: boolean;
frameId?: ExcalidrawElement["id"] | null; frameId?: ExcalidrawElement["id"] | null;
index?: ExcalidrawElement["index"]; index?: ExcalidrawElement["index"];
groupIds?: string[]; groupIds?: ExcalidrawElement["groupIds"];
// generic element props // generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"]; strokeColor?: ExcalidrawGenericElement["strokeColor"];
backgroundColor?: ExcalidrawGenericElement["backgroundColor"]; backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
@ -369,6 +378,84 @@ export class API {
return element as any; return element as any;
}; };
static createTextContainer = (opts?: {
frameId?: ExcalidrawElement["id"];
groupIds?: ExcalidrawElement["groupIds"];
label?: {
text?: string;
frameId?: ExcalidrawElement["id"] | null;
groupIds?: ExcalidrawElement["groupIds"];
};
}) => {
const rectangle = API.createElement({
type: "rectangle",
frameId: opts?.frameId || null,
groupIds: opts?.groupIds,
});
const text = API.createElement({
type: "text",
text: opts?.label?.text || "sample-text",
width: 50,
height: 20,
fontSize: 16,
containerId: rectangle.id,
frameId:
opts?.label?.frameId === undefined
? opts?.frameId ?? null
: opts?.label?.frameId ?? null,
groupIds: opts?.label?.groupIds === undefined
? opts?.groupIds
: opts?.label?.groupIds ,
});
mutateElement(
rectangle,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [rectangle, text];
};
static createLabeledArrow = (opts?: {
frameId?: ExcalidrawElement["id"];
label?: {
text?: string;
frameId?: ExcalidrawElement["id"] | null;
};
}) => {
const arrow = API.createElement({
type: "arrow",
frameId: opts?.frameId || null,
});
const text = API.createElement({
type: "text",
id: "text2",
width: 50,
height: 20,
containerId: arrow.id,
frameId:
opts?.label?.frameId === undefined
? opts?.frameId ?? null
: opts?.label?.frameId ?? null,
});
mutateElement(
arrow,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [arrow, text];
};
static readFile = async <T extends "utf8" | null>( static readFile = async <T extends "utf8" | null>(
filepath: string, filepath: string,
encoding?: T, encoding?: T,

View file

@ -7,6 +7,7 @@ import {
assertSelectedElements, assertSelectedElements,
render, render,
togglePopover, togglePopover,
getCloneByOrigId,
} from "./test-utils"; } from "./test-utils";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
@ -15,7 +16,7 @@ import { getDefaultAppState } from "../appState";
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react"; import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants"; import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
import type { AppState } from "../types"; import type { AppState } from "../types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { import {
@ -1138,8 +1139,8 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }), expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }), expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
]); ]);
expect(h.state.editingGroupId).toBeNull(); expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true }); expect(h.state.selectedGroupIds).toEqual({ A: true });
@ -1151,8 +1152,8 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }), expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }), expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }), expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
]); ]);
expect(h.state.editingGroupId).toBeNull(); expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual( expect(h.state.selectedGroupIds).not.toEqual(
@ -1171,14 +1172,14 @@ describe("history", () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ id: rect1.id, isDeleted: false }), expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }), expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }), expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
expect.objectContaining({ expect.objectContaining({
id: `${rect1.id}_copy_copy`, [ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
isDeleted: false, isDeleted: false,
}), }),
expect.objectContaining({ expect.objectContaining({
id: `${rect2.id}_copy_copy`, [ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
isDeleted: false, isDeleted: false,
}), }),
]), ]),

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { fireEvent, render, waitFor } from "./test-utils"; import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
import { act, queryByTestId } from "@testing-library/react"; import { act, queryByTestId } from "@testing-library/react";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants"; import { MIME_TYPES, ORIG_ID } from "../constants";
import type { LibraryItem, LibraryItems } from "../types"; import type { LibraryItem, LibraryItems } from "../types";
import { UI } from "./helpers/ui"; import { UI } from "./helpers/ui";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
@ -76,7 +76,7 @@ describe("library", () => {
}), }),
); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
}); });
@ -125,23 +125,27 @@ describe("library", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual(
expect.objectContaining({ expect.arrayContaining([
id: "rectangle1_copy", expect.objectContaining({
boundElements: expect.arrayContaining([ [ORIG_ID]: "rectangle1",
{ type: "text", id: "text1_copy" }, boundElements: expect.arrayContaining([
{ type: "arrow", id: "arrow1_copy" }, { type: "text", id: getCloneByOrigId("text1").id },
]), { type: "arrow", id: getCloneByOrigId("arrow1").id },
}), ]),
expect.objectContaining({ }),
id: "text1_copy", expect.objectContaining({
containerId: "rectangle1_copy", [ORIG_ID]: "text1",
}), containerId: getCloneByOrigId("rectangle1").id,
expect.objectContaining({ }),
id: "arrow1_copy", expect.objectContaining({
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }), [ORIG_ID]: "arrow1",
}), endBinding: expect.objectContaining({
]); elementId: getCloneByOrigId("rectangle1").id,
}),
}),
]),
);
}); });
}); });
@ -170,10 +174,11 @@ describe("library", () => {
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "elem1_copy", [ORIG_ID]: "elem1",
}), }),
expect.objectContaining({ expect.objectContaining({
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/), id: expect.not.stringMatching(/^elem1$/),
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
}), }),
]); ]);
}); });
@ -189,7 +194,7 @@ describe("library", () => {
}), }),
); );
await waitFor(() => { await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]); expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
}); });
expect(h.state.activeTool.type).toBe("selection"); expect(h.state.activeTool.type).toBe("selection");
}); });

View file

@ -11,6 +11,10 @@ import { getSelectedElements } from "../scene/selection";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui"; import { UI } from "./helpers/ui";
import { diffStringsUnified } from "jest-diff"; import { diffStringsUnified } from "jest-diff";
import ansi from "ansicolor";
import { ORIG_ID } from "../constants";
import { arrayToMap } from "../utils";
import type { AllPossibleKeys } from "../utility-types";
const customQueries = { const customQueries = {
...queries, ...queries,
@ -295,3 +299,150 @@ expect.addSnapshotSerializer({
); );
}, },
}); });
export const getCloneByOrigId = <T extends boolean = false>(
origId: ExcalidrawElement["id"],
returnNullIfNotExists: T = false as T,
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
const clonedElement = window.h.elements?.find(
(el) => (el as any)[ORIG_ID] === origId,
);
if (clonedElement) {
return clonedElement;
}
if (returnNullIfNotExists !== true) {
throw new Error(`cloned element not found for origId: ${origId}`);
}
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
};
/**
* Assertion helper that strips the actual elements of extra attributes
* so that diffs are easier to read in case of failure.
*
* Asserts element order as well, and selected element ids
* (when `selected: true` set for given element).
*
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
* If you need to refer to cloned element properties, you can use
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
*/
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
actualElements: readonly ExcalidrawElement[],
/** array order matters */
expectedElements: (Partial<Record<T, any>> & {
/** meta, will be stripped for element attribute checks */
selected?: true;
} & (
| {
id: ExcalidrawElement["id"];
}
| { [ORIG_ID]?: string }
))[],
) => {
const h = window.h;
const expectedElementsWithIds: (typeof expectedElements[number] & {
id: ExcalidrawElement["id"];
})[] = expectedElements.map((el) => {
if ("id" in el) {
return el;
}
const actualElement = actualElements.find(
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
);
if (actualElement) {
return { ...el, id: actualElement.id };
}
return {
...el,
id: "UNKNOWN_ID",
};
});
const map_expectedElements = arrayToMap(expectedElementsWithIds);
const selectedElementIds = expectedElementsWithIds.reduce(
(acc: Record<ExcalidrawElement["id"], true>, el) => {
if (el.selected) {
acc[el.id] = true;
}
return acc;
},
{},
);
const mappedActualElements = actualElements.map((el) => {
const expectedElement = map_expectedElements.get(el.id);
if (expectedElement) {
const pickedAttrs: Record<string, any> = {};
for (const key of Object.keys(expectedElement)) {
if (key === "selected") {
delete expectedElement.selected;
continue;
}
pickedAttrs[key] = (el as any)[key];
}
if (ORIG_ID in expectedElement) {
// @ts-ignore
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
}
return pickedAttrs;
}
return el;
});
try {
// testing order separately for even easier diffs
expect(actualElements.map((x) => x.id)).toEqual(
expectedElementsWithIds.map((x) => x.id),
);
} catch (err: any) {
let errStr = "\n\nmismatched element order\n\n";
errStr += `actual: ${ansi.lightGray(
`[${err.actual
.map((id: string, index: number) => {
const act = actualElements[index];
return `${
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
} (${act.type.slice(0, 4)}${
ORIG_ID in act ? `${(act as any)[ORIG_ID]}` : ""
})`;
})
.join(", ")}]`,
)}\n${ansi.lightGray(
`expected: [${err.expected
.map((exp: string, index: number) => {
const expEl = actualElements.find((el) => el.id === exp);
const origEl =
expEl &&
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
return expEl
? `${
exp === err.actual[index]
? ansi.green(expEl.id)
: ansi.red(expEl.id)
} (${expEl.type.slice(0, 4)}${origEl ? `${origEl.id}` : ""})`
: exp;
})
.join(", ")}]\n`,
)}`;
const error = new Error(errStr);
const stack = err.stack.split("\n");
stack.splice(1, 1);
error.stack = stack.join("\n");
throw error;
}
expect(mappedActualElements).toEqual(
expect.arrayContaining(expectedElementsWithIds),
);
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
};

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { act, render } from "./test-utils"; import { act, getCloneByOrigId, render } from "./test-utils";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { reseed } from "../random"; import { reseed } from "../random";
import { import {
@ -916,9 +916,9 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection); API.executeAction(actionDuplicateSelection);
expect(h.elements).toMatchObject([ expect(h.elements).toMatchObject([
{ id: "A" }, { id: "A" },
{ id: "A_copy" }, { id: getCloneByOrigId("A").id },
{ id: "B" }, { id: "B" },
{ id: "B_copy" }, { id: getCloneByOrigId("B").id },
]); ]);
populateElements([ populateElements([
@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
{ id: "A" }, { id: "A" },
{ id: "B" }, { id: "B" },
{ {
id: "A_copy", id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
{ {
id: "B_copy", id: getCloneByOrigId("B").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
@ -951,12 +951,12 @@ describe("z-index manipulation", () => {
{ id: "A" }, { id: "A" },
{ id: "B" }, { id: "B" },
{ {
id: "A_copy", id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
{ {
id: "B_copy", id: getCloneByOrigId("B").id,
groupIds: [expect.stringMatching(/.{3,}/)], groupIds: [expect.stringMatching(/.{3,}/)],
}, },
@ -972,10 +972,10 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"B", "B",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"C_copy", getCloneByOrigId("C").id,
]); ]);
populateElements([ populateElements([
@ -988,12 +988,12 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"B", "B",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"D", "D",
"C_copy", getCloneByOrigId("C").id,
"D_copy", getCloneByOrigId("D").id,
]); ]);
populateElements( populateElements(
@ -1010,10 +1010,10 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"B", "B",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C", "C",
"C_copy", getCloneByOrigId("C").id,
]); ]);
populateElements( populateElements(
@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
]); ]);
populateElements( populateElements(
@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
"D", "D",
"E", "E",
"F", "F",
"D_copy", getCloneByOrigId("D").id,
"E_copy", getCloneByOrigId("E").id,
"F_copy", getCloneByOrigId("F").id,
]); ]);
populateElements( populateElements(
@ -1076,7 +1076,7 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection); API.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"A_copy", getCloneByOrigId("A").id,
"B", "B",
"C", "C",
]); ]);
@ -1093,7 +1093,7 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"B", "B",
"B_copy", getCloneByOrigId("B").id,
"C", "C",
]); ]);
@ -1108,9 +1108,9 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection); API.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"A_copy", getCloneByOrigId("A").id,
"B", "B",
"B_copy", getCloneByOrigId("B").id,
"C", "C",
]); ]);
}); });
@ -1125,8 +1125,8 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([ expect(h.elements.map((element) => element.id)).toEqual([
"A", "A",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"C_copy", getCloneByOrigId("C").id,
"B", "B",
]); ]);
}); });
@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
"A", "A",
"B", "B",
"C", "C",
"A_copy", getCloneByOrigId("A").id,
"B_copy", getCloneByOrigId("B").id,
"C_copy", getCloneByOrigId("C").id,
"D", "D",
]); ]);
}); });

View file

@ -65,3 +65,6 @@ export type MakeBrand<T extends string> = {
/** Maybe just promise or already fulfilled one! */ /** Maybe just promise or already fulfilled one! */
export type MaybePromise<T> = T | Promise<T>; export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never;

View file

@ -1240,3 +1240,6 @@ export class PromisePool<T> {
export const escapeDoubleQuotes = (str: string) => { export const escapeDoubleQuotes = (str: string) => {
return str.replace(/"/g, "&quot;"); return str.replace(/"/g, "&quot;");
}; };
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];

View file

@ -3916,6 +3916,11 @@ ansi-styles@^6.0.0, ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
ansicolor@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/ansicolor/-/ansicolor-2.0.3.tgz#ec4448ae5baf8c2d62bf2dad52eac06ba0b5ea21"
integrity sha512-pzusTqk9VHrjgMCcTPDTTvfJfx6Q3+L5tQ6yKC8Diexmoit4YROTFIkxFvRTNL9y5s0Q8HrSrgerCD5bIC+Kiw==
anymatch@~3.1.2: anymatch@~3.1.2:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"