fix: duplicating/removing frame while children selected (#9079)
This commit is contained in:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
let index = -1;
|
||||
|
||||
while (++index < sortedElements.length) {
|
||||
const element = sortedElements[index];
|
||||
|
||||
if (processedIds.get(element.id)) {
|
||||
for (const element of elements) {
|
||||
if (processedIds.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||
const isElementAFrameLike = isFrameLikeElement(element);
|
||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
// groups
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||
(element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
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)]),
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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]));
|
||||
|
||||
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
|
||||
// that the last elements are in the correct order (contiguous or otherwise).
|
||||
// Thus we need to reverse as the last step (3).
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||
if (container) {
|
||||
insertAfterIndex(
|
||||
targetIndex,
|
||||
duplicateAndOffsetElement([container, element]),
|
||||
);
|
||||
} else {
|
||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||
}
|
||||
|
||||
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||
index = elementsWithClones.length;
|
||||
|
||||
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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user