From d62ed7060773b98eac215f2f2fa4ebedf7bc482e Mon Sep 17 00:00:00 2001
From: dwelle <5153846+dwelle@users.noreply.github.com>
Date: Thu, 17 Apr 2025 14:51:14 +0200
Subject: [PATCH] fix: duplicating within group outside frame should remove
from group
---
packages/element/src/duplicate.ts | 8 +-
packages/element/tests/duplicate.test.tsx | 132 ++++++++++++++++++++--
packages/excalidraw/components/App.tsx | 9 +-
3 files changed, 138 insertions(+), 11 deletions(-)
diff --git a/packages/element/src/duplicate.ts b/packages/element/src/duplicate.ts
index 59beae43c..c2cee4c08 100644
--- a/packages/element/src/duplicate.ts
+++ b/packages/element/src/duplicate.ts
@@ -95,6 +95,7 @@ export const duplicateElements = (
elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean;
overrides?: (data: {
+ duplicateElement: ExcalidrawElement;
origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
@@ -377,12 +378,13 @@ export const duplicateElements = (
);
if (opts.overrides) {
- for (const copy of duplicatedElements) {
- const origElement = duplicateIdToOrigElement.get(copy.id);
+ for (const duplicateElement of duplicatedElements) {
+ const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
- copy,
+ duplicateElement,
opts.overrides({
+ duplicateElement,
origElement,
origIdToDuplicateId,
}),
diff --git a/packages/element/tests/duplicate.test.tsx b/packages/element/tests/duplicate.test.tsx
index 24042b347..409200b6e 100644
--- a/packages/element/tests/duplicate.test.tsx
+++ b/packages/element/tests/duplicate.test.tsx
@@ -408,6 +408,117 @@ describe("duplicating multiple elements", () => {
});
});
+describe("group-related duplication", () => {
+ beforeEach(async () => {
+ await render();
+ });
+
+ it("action-duplicating within group", async () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ groupIds: ["group1"],
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ groupIds: ["group1"],
+ });
+
+ API.setElements([rectangle1, rectangle2]);
+ API.setSelectedElements([rectangle2], "group1");
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ assertElements(h.elements, [
+ { id: rectangle1.id },
+ { id: rectangle2.id },
+ { [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
+ ]);
+ expect(h.state.editingGroupId).toBe("group1");
+ });
+
+ it("alt-duplicating within group", async () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ groupIds: ["group1"],
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ groupIds: ["group1"],
+ });
+
+ API.setElements([rectangle1, rectangle2]);
+ API.setSelectedElements([rectangle2], "group1");
+
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ mouse.down(rectangle2.x + 5, rectangle2.y + 5);
+ mouse.up(rectangle2.x + 50, rectangle2.y + 50);
+ });
+
+ assertElements(h.elements, [
+ { id: rectangle1.id },
+ { id: rectangle2.id },
+ { [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
+ ]);
+ expect(h.state.editingGroupId).toBe("group1");
+ });
+
+ it.skip("alt-duplicating within group away outside frame", () => {
+ const frame = API.createElement({
+ type: "frame",
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ });
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ x: 0,
+ y: 0,
+ width: 50,
+ height: 50,
+ groupIds: ["group1"],
+ frameId: frame.id,
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 50,
+ height: 50,
+ groupIds: ["group1"],
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, rectangle1, rectangle2]);
+ API.setSelectedElements([rectangle2], "group1");
+
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ mouse.down(rectangle2.x + 5, rectangle2.y + 5);
+ mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
+ });
+
+ // console.log(h.elements);
+
+ assertElements(h.elements, [
+ { id: frame.id },
+ { id: rectangle1.id, frameId: frame.id },
+ { id: rectangle2.id, frameId: frame.id },
+ { [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
+ ]);
+ expect(h.state.editingGroupId).toBe(null);
+ });
+});
+
describe("duplication z-order", () => {
beforeEach(async () => {
await render();
@@ -703,7 +814,7 @@ describe("duplication z-order", () => {
]);
});
- it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => {
+ it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
@@ -725,11 +836,18 @@ describe("duplication z-order", () => {
mouse.up(15, 15);
});
- expect(window.h.elements).toHaveLength(3);
-
- const newRect = window.h.elements[0];
-
- expect(arrow.endBinding?.elementId).toBe(newRect.id);
- expect(newRect.boundElements?.[0]?.id).toBe(arrow.id);
+ assertElements(h.elements, [
+ {
+ id: rect.id,
+ boundElements: expect.arrayContaining([
+ expect.objectContaining({ id: arrow.id }),
+ ]),
+ },
+ { [ORIG_ID]: rect.id, boundElements: [], selected: true },
+ {
+ id: arrow.id,
+ endBinding: expect.objectContaining({ elementId: rect.id }),
+ },
+ ]);
});
});
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
index 2b75249ba..6a7306422 100644
--- a/packages/excalidraw/components/App.tsx
+++ b/packages/excalidraw/components/App.tsx
@@ -8445,8 +8445,15 @@ class App extends React.Component {
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate,
- overrides: () => {
+ overrides: ({ duplicateElement, origElement }) => {
return {
+ // reset to the original element's frameId (unless we've
+ // duplicated alongside a frame in which case we need to
+ // keep the duplicate frame's id) so that the element
+ // frame membership is refreshed on pointerup
+ // NOTE this is a hacky solution and should be done
+ // differently
+ frameId: duplicateElement.frameId ?? origElement.frameId,
seed: randomInteger(),
};
},