Bläddra i källkod

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

David Luzar 5 månader sedan
förälder
incheckning
424e94a403

+ 211 - 0
packages/excalidraw/actions/actionDeleteSelected.test.tsx

@@ -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 },
+    ]);
+  });
+});

+ 48 - 2
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -18,6 +18,8 @@ import {
 import { updateActiveTool } from "../utils";
 import { TrashIcon } from "../components/icons";
 import { StoreAction } from "../store";
+import { getContainerElement } from "../element/textElement";
+import { getFrameChildren } from "../frame";
 
 const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
@@ -33,10 +35,50 @@ const deleteSelectedElements = (
 
   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;
 
   const nextElements = elements.map((el) => {
     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) {
         el.boundElements.forEach((candidate) => {
           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 (el.frameId && framesToBeDeleted.has(el.frameId)) {
       shouldSelectEditingGroup = false;
-      selectedElementIds[el.id] = true;
+      if (!isBoundToContainer(el)) {
+        selectedElementIds[el.id] = true;
+      }
       return newElementWith(el, { frameId: null });
     }
 
@@ -224,11 +268,13 @@ export const actionDeleteSelected = register({
         storeAction: StoreAction.CAPTURE,
       };
     }
+
     let { elements: nextElements, appState: nextAppState } =
       deleteSelectedElements(elements, appState, app);
+
     fixBindingsAfterDeletion(
       nextElements,
-      elements.filter(({ id }) => appState.selectedElementIds[id]),
+      nextElements.filter((el) => el.isDeleted),
     );
 
     nextAppState = handleGroupEditingState(nextAppState, nextElements);

+ 530 - 0
packages/excalidraw/actions/actionDuplicateSelection.test.tsx

@@ -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"] },
+      ]);
+    });
+  });
+});

+ 174 - 121
packages/excalidraw/actions/actionDuplicateSelection.tsx

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

+ 3 - 0
packages/excalidraw/constants.ts

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

+ 22 - 22
packages/excalidraw/element/newElement.ts

@@ -45,6 +45,7 @@ import {
   DEFAULT_FONT_SIZE,
   DEFAULT_TEXT_ALIGN,
   DEFAULT_VERTICAL_ALIGN,
+  ORIG_ID,
   VERTICAL_ALIGN,
 } from "../constants";
 import type { MarkOptional, Merge, Mutable } from "../utility-types";
@@ -592,26 +593,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
   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
- * for test assertions.
+ * utility wrapper to generate new id.
  */
-export 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;
-  }
+const regenerateId = () => {
   return randomId();
 };
 
@@ -637,7 +630,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
 ): Readonly<TElement> => {
   let copy = deepCopyElement(element);
 
-  copy.id = regenerateId(copy.id);
+  if (isTestEnv()) {
+    __test__defineOrigId(copy, element.id);
+  }
+
+  copy.id = regenerateId();
   copy.boundElements = null;
   copy.updated = getUpdatedTimestamp();
   copy.seed = randomInteger();
@@ -646,7 +643,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
     editingGroupId,
     (groupId) => {
       if (!groupIdMapForOperation.has(groupId)) {
-        groupIdMapForOperation.set(groupId, regenerateId(groupId));
+        groupIdMapForOperation.set(groupId, regenerateId());
       }
       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
     // id exists, generate a new id for it and return it
     if (origElementsMap.has(id)) {
-      const newId = regenerateId(id);
+      const newId = regenerateId();
       elementNewIdsMap.set(id, newId);
       return newId;
     }
@@ -706,6 +703,9 @@ export const duplicateElements = (
     const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
 
     clonedElement.id = maybeGetNewId(element.id)!;
+    if (isTestEnv()) {
+      __test__defineOrigId(clonedElement, element.id);
+    }
 
     if (opts?.randomizeSeed) {
       clonedElement.seed = randomInteger();
@@ -715,7 +715,7 @@ export const duplicateElements = (
     if (clonedElement.groupIds) {
       clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
         if (!groupNewIdsMap.has(groupId)) {
-          groupNewIdsMap.set(groupId, regenerateId(groupId));
+          groupNewIdsMap.set(groupId, regenerateId());
         }
         return groupNewIdsMap.get(groupId)!;
       });

+ 1 - 4
packages/excalidraw/element/sortElements.ts

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

+ 5 - 6
packages/excalidraw/frame.test.tsx

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

+ 1 - 0
packages/excalidraw/package.json

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

+ 4 - 4
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 124 - 124
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


+ 1 - 1
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap

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

+ 7 - 7
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

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

+ 93 - 6
packages/excalidraw/tests/helpers/api.ts

@@ -40,6 +40,7 @@ import { createTestHook } from "../../components/App";
 import type { Action } from "../../actions/types";
 import { mutateElement } from "../../element/mutateElement";
 import { pointFrom, type LocalPoint, type Radians } from "../../../math";
+import { selectGroupsForSelectedElements } from "../../groups";
 
 const readFile = util.promisify(fs.readFile);
 // 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(() => {
       h.setState({
-        selectedElementIds: elements.reduce((acc, element) => {
-          acc[element.id] = true;
-          return acc;
-        }, {} as Record<ExcalidrawElement["id"], true>),
+        ...selectGroupsForSelectedElements(
+        {
+          editingGroupId: editingGroupId ?? null,
+          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;
     frameId?: ExcalidrawElement["id"] | null;
     index?: ExcalidrawElement["index"];
-    groupIds?: string[];
+    groupIds?: ExcalidrawElement["groupIds"];
     // generic element props
     strokeColor?: ExcalidrawGenericElement["strokeColor"];
     backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
@@ -369,6 +378,84 @@ export class API {
     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>(
     filepath: string,
     encoding?: T,

+ 10 - 9
packages/excalidraw/tests/history.test.tsx

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

+ 28 - 23
packages/excalidraw/tests/library.test.tsx

@@ -1,11 +1,11 @@
 import React from "react";
 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 { Excalidraw } from "../index";
 import { API } from "./helpers/api";
-import { MIME_TYPES } from "../constants";
+import { MIME_TYPES, ORIG_ID } from "../constants";
 import type { LibraryItem, LibraryItems } from "../types";
 import { UI } from "./helpers/ui";
 import { serializeLibraryAsJSON } from "../data/json";
@@ -76,7 +76,7 @@ describe("library", () => {
       }),
     );
     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(() => {
-      expect(h.elements).toEqual([
-        expect.objectContaining({
-          id: "rectangle1_copy",
-          boundElements: expect.arrayContaining([
-            { type: "text", id: "text1_copy" },
-            { type: "arrow", id: "arrow1_copy" },
-          ]),
-        }),
-        expect.objectContaining({
-          id: "text1_copy",
-          containerId: "rectangle1_copy",
-        }),
-        expect.objectContaining({
-          id: "arrow1_copy",
-          endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
-        }),
-      ]);
+      expect(h.elements).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            [ORIG_ID]: "rectangle1",
+            boundElements: expect.arrayContaining([
+              { type: "text", id: getCloneByOrigId("text1").id },
+              { type: "arrow", id: getCloneByOrigId("arrow1").id },
+            ]),
+          }),
+          expect.objectContaining({
+            [ORIG_ID]: "text1",
+            containerId: getCloneByOrigId("rectangle1").id,
+          }),
+          expect.objectContaining({
+            [ORIG_ID]: "arrow1",
+            endBinding: expect.objectContaining({
+              elementId: getCloneByOrigId("rectangle1").id,
+            }),
+          }),
+        ]),
+      );
     });
   });
 
@@ -170,10 +174,11 @@ describe("library", () => {
     await waitFor(() => {
       expect(h.elements).toEqual([
         expect.objectContaining({
-          id: "elem1_copy",
+          [ORIG_ID]: "elem1",
         }),
         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(() => {
-      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");
   });

+ 151 - 0
packages/excalidraw/tests/test-utils.ts

@@ -11,6 +11,10 @@ import { getSelectedElements } from "../scene/selection";
 import type { ExcalidrawElement } from "../element/types";
 import { UI } from "./helpers/ui";
 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 = {
   ...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);
+};

+ 35 - 35
packages/excalidraw/tests/zindex.test.tsx

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

+ 3 - 0
packages/excalidraw/utility-types.ts

@@ -65,3 +65,6 @@ export type MakeBrand<T extends string> = {
 
 /** Maybe just promise or already fulfilled one! */
 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;

+ 3 - 0
packages/excalidraw/utils.ts

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

+ 5 - 0
yarn.lock

@@ -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"
   integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
 
[email protected]:
+  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:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"

Vissa filer visades inte eftersom för många filer har ändrats