mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: duplicating/removing frame while children selected (#9079)
This commit is contained in:
parent
302664e500
commit
424e94a403
21 changed files with 3160 additions and 2065 deletions
211
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
211
packages/excalidraw/actions/actionDeleteSelected.test.tsx
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Excalidraw, mutateElement } from "../index";
|
||||||
|
import { act, assertElements, render } from "../tests/test-utils";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("deleting selected elements when frame selected should keep children + select them", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame only", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const r1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([f1, r1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: r1.id, isDeleted: false, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + text container (text's frameId set)", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const r1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const t1 = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
fontSize: 20,
|
||||||
|
containerId: r1.id,
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(r1, {
|
||||||
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([f1, r1, t1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: r1.id, isDeleted: false, selected: true },
|
||||||
|
{ id: t1.id, isDeleted: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + text container (text's frameId not set)", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const r1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const t1 = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
fontSize: 20,
|
||||||
|
containerId: r1.id,
|
||||||
|
frameId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(r1, {
|
||||||
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([f1, r1, t1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: r1.id, isDeleted: false, selected: true },
|
||||||
|
{ id: t1.id, isDeleted: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + text container (text selected too)", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const r1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const t1 = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
fontSize: 20,
|
||||||
|
containerId: r1.id,
|
||||||
|
frameId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(r1, {
|
||||||
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([f1, r1, t1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1, t1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: r1.id, isDeleted: false, selected: true },
|
||||||
|
{ id: t1.id, isDeleted: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + labeled arrow", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const a1 = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const t1 = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
fontSize: 20,
|
||||||
|
containerId: a1.id,
|
||||||
|
frameId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(a1, {
|
||||||
|
boundElements: [{ type: "text", id: t1.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([f1, a1, t1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1, t1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: a1.id, isDeleted: false, selected: true },
|
||||||
|
{ id: t1.id, isDeleted: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + children selected", async () => {
|
||||||
|
const f1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
const r1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: f1.id,
|
||||||
|
});
|
||||||
|
API.setElements([f1, r1]);
|
||||||
|
|
||||||
|
API.setSelectedElements([f1, r1]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDeleteSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: f1.id, isDeleted: true },
|
||||||
|
{ id: r1.id, isDeleted: false, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -18,6 +18,8 @@ import {
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { getContainerElement } from "../element/textElement";
|
||||||
|
import { getFrameChildren } from "../frame";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
@ -33,10 +35,50 @@ const deleteSelectedElements = (
|
||||||
|
|
||||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||||
|
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
const processedElements = new Set<ExcalidrawElement["id"]>();
|
||||||
|
|
||||||
|
for (const frameId of framesToBeDeleted) {
|
||||||
|
const frameChildren = getFrameChildren(elements, frameId);
|
||||||
|
for (const el of frameChildren) {
|
||||||
|
if (processedElements.has(el.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoundToContainer(el)) {
|
||||||
|
const containerElement = getContainerElement(el, elementsMap);
|
||||||
|
if (containerElement) {
|
||||||
|
selectedElementIds[containerElement.id] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
}
|
||||||
|
processedElements.add(el.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let shouldSelectEditingGroup = true;
|
let shouldSelectEditingGroup = true;
|
||||||
|
|
||||||
const nextElements = elements.map((el) => {
|
const nextElements = elements.map((el) => {
|
||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
|
const boundElement = isBoundToContainer(el)
|
||||||
|
? getContainerElement(el, elementsMap)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
|
shouldSelectEditingGroup = false;
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
boundElement?.frameId &&
|
||||||
|
framesToBeDeleted.has(boundElement?.frameId)
|
||||||
|
) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
if (el.boundElements) {
|
if (el.boundElements) {
|
||||||
el.boundElements.forEach((candidate) => {
|
el.boundElements.forEach((candidate) => {
|
||||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||||
|
@ -59,7 +101,9 @@ const deleteSelectedElements = (
|
||||||
// if deleting a frame, remove the children from it and select them
|
// if deleting a frame, remove the children from it and select them
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
shouldSelectEditingGroup = false;
|
shouldSelectEditingGroup = false;
|
||||||
selectedElementIds[el.id] = true;
|
if (!isBoundToContainer(el)) {
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
}
|
||||||
return newElementWith(el, { frameId: null });
|
return newElementWith(el, { frameId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,11 +268,13 @@ export const actionDeleteSelected = register({
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState, app);
|
deleteSelectedElements(elements, appState, app);
|
||||||
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
nextElements.filter((el) => el.isDeleted),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||||
|
|
530
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
530
packages/excalidraw/actions/actionDuplicateSelection.test.tsx
Normal file
|
@ -0,0 +1,530 @@
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
assertElements,
|
||||||
|
getCloneByOrigId,
|
||||||
|
render,
|
||||||
|
} from "../tests/test-utils";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||||
|
import React from "react";
|
||||||
|
import { ORIG_ID } from "../constants";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("actionDuplicateSelection", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicating frames", () => {
|
||||||
|
it("frame selected only", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle]);
|
||||||
|
API.setSelectedElements([frame]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame selected only (with text container)", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle, text]);
|
||||||
|
API.setSelectedElements([frame]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
},
|
||||||
|
{ [ORIG_ID]: frame.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + text container selected (order A)", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle, text]);
|
||||||
|
API.setSelectedElements([frame, rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: rectangle.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[ORIG_ID]: frame.id,
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame + text container selected (order B)", async () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([text, rectangle, frame]);
|
||||||
|
API.setSelectedElements([rectangle, frame]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: frame.id },
|
||||||
|
{
|
||||||
|
type: "rectangle",
|
||||||
|
[ORIG_ID]: `${rectangle.id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[ORIG_ID]: `${text.id}`,
|
||||||
|
type: "text",
|
||||||
|
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
},
|
||||||
|
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicating frame children", () => {
|
||||||
|
it("frame child selected", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame text container selected (rectangle selected)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle, text]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id).id,
|
||||||
|
frameId: frame.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame bound text selected (container not selected)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle, text]);
|
||||||
|
API.setSelectedElements([text]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id).id,
|
||||||
|
frameId: frame.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame text container selected (text not exists)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// shouldn't happen
|
||||||
|
it("frame bound text selected (container not exists)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, text] = API.createTextContainer({ frameId: frame.id });
|
||||||
|
|
||||||
|
API.setElements([frame, text]);
|
||||||
|
API.setSelectedElements([text]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: text.id, frameId: frame.id },
|
||||||
|
{ [ORIG_ID]: text.id, frameId: frame.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("frame bound container selected (text has no frameId)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({
|
||||||
|
frameId: frame.id,
|
||||||
|
label: { frameId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle, text]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, containerId: rectangle.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
containerId: getCloneByOrigId(rectangle.id).id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicating multiple frames", () => {
|
||||||
|
it("multiple frames selected (no children)", () => {
|
||||||
|
const frame1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame2 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
||||||
|
API.setSelectedElements([frame1, frame2]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rect1.id, frameId: frame1.id },
|
||||||
|
{ id: frame1.id },
|
||||||
|
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame1.id, selected: true },
|
||||||
|
{ id: ellipse.id },
|
||||||
|
{ id: rect2.id, frameId: frame2.id },
|
||||||
|
{ id: frame2.id },
|
||||||
|
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame2.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple frames selected (no children) + unrelated element", () => {
|
||||||
|
const frame1 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame1.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const frame2 = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: frame2.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
||||||
|
API.setSelectedElements([frame1, ellipse, frame2]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rect1.id, frameId: frame1.id },
|
||||||
|
{ id: frame1.id },
|
||||||
|
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame1.id, selected: true },
|
||||||
|
{ id: ellipse.id },
|
||||||
|
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||||
|
{ id: rect2.id, frameId: frame2.id },
|
||||||
|
{ id: frame2.id },
|
||||||
|
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame2.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicating containers/bound elements", () => {
|
||||||
|
it("labeled arrow (arrow selected)", () => {
|
||||||
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
|
API.setElements([arrow, text]);
|
||||||
|
API.setSelectedElements([arrow]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: arrow.id },
|
||||||
|
{ id: text.id, containerId: arrow.id },
|
||||||
|
{ [ORIG_ID]: arrow.id, selected: true },
|
||||||
|
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// shouldn't happen
|
||||||
|
it("labeled arrow (text selected)", () => {
|
||||||
|
const [arrow, text] = API.createLabeledArrow();
|
||||||
|
|
||||||
|
API.setElements([arrow, text]);
|
||||||
|
API.setSelectedElements([text]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: arrow.id },
|
||||||
|
{ id: text.id, containerId: arrow.id },
|
||||||
|
{ [ORIG_ID]: arrow.id, selected: true },
|
||||||
|
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicating groups", () => {
|
||||||
|
it("duplicate group containing frame (children don't have groupIds set)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle, text, frame, ellipse]);
|
||||||
|
API.setSelectedElements([frame, ellipse]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, frameId: frame.id },
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: ellipse.id },
|
||||||
|
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||||
|
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
|
||||||
|
{ [ORIG_ID]: frame.id, selected: true },
|
||||||
|
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicate group containing frame (children have groupIds)", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rectangle, text] = API.createTextContainer({
|
||||||
|
frameId: frame.id,
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
groupIds: ["A"],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle, text, frame, ellipse]);
|
||||||
|
API.setSelectedElements([frame, ellipse]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: rectangle.id, frameId: frame.id },
|
||||||
|
{ id: text.id, frameId: frame.id },
|
||||||
|
{ id: frame.id },
|
||||||
|
{ id: ellipse.id },
|
||||||
|
{
|
||||||
|
[ORIG_ID]: rectangle.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[ORIG_ID]: text.id,
|
||||||
|
frameId: getCloneByOrigId(frame.id)?.id,
|
||||||
|
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
{ [ORIG_ID]: frame.id, selected: true },
|
||||||
|
{ [ORIG_ID]: ellipse.id, selected: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicating element nested in group", () => {
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
groupIds: ["B"],
|
||||||
|
});
|
||||||
|
const rect1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["A", "B"],
|
||||||
|
});
|
||||||
|
const rect2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["A", "B"],
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([ellipse, rect1, rect2]);
|
||||||
|
API.setSelectedElements([ellipse], "B");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertElements(h.elements, [
|
||||||
|
{ id: ellipse.id },
|
||||||
|
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
|
||||||
|
{ id: rect1.id, groupIds: ["A", "B"] },
|
||||||
|
{ id: rect2.id, groupIds: ["A", "B"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,7 +5,13 @@ import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
castArray,
|
||||||
|
findLastIndex,
|
||||||
|
getShortcutKey,
|
||||||
|
invariant,
|
||||||
|
} from "../utils";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
|
@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
getContainerElement,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import { normalizeElementOrder } from "../element/sortElements";
|
import { normalizeElementOrder } from "../element/sortElements";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
|
@ -31,7 +42,6 @@ import {
|
||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
|
@ -85,34 +95,66 @@ const duplicateElements = (
|
||||||
): Partial<ActionResult> => {
|
): Partial<ActionResult> => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// step (1)
|
|
||||||
|
|
||||||
const sortedElements = normalizeElementOrder(elements);
|
|
||||||
const groupIdMap = new Map();
|
const groupIdMap = new Map();
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
const elementsMap = arrayToMap(elements);
|
||||||
const newElement = duplicateElement(
|
|
||||||
appState.editingGroupId,
|
const duplicateAndOffsetElement = <
|
||||||
groupIdMap,
|
T extends ExcalidrawElement | ExcalidrawElement[],
|
||||||
element,
|
>(
|
||||||
{
|
element: T,
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
): T extends ExcalidrawElement[]
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
? ExcalidrawElement[]
|
||||||
|
: ExcalidrawElement | null => {
|
||||||
|
const elements = castArray(element);
|
||||||
|
|
||||||
|
const _newElements = elements.reduce(
|
||||||
|
(acc: ExcalidrawElement[], element) => {
|
||||||
|
if (processedIds.has(element.id)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedIds.set(element.id, true);
|
||||||
|
|
||||||
|
const newElement = duplicateElement(
|
||||||
|
appState.editingGroupId,
|
||||||
|
groupIdMap,
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
processedIds.set(newElement.id, true);
|
||||||
|
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
|
|
||||||
|
oldElements.push(element);
|
||||||
|
newElements.push(newElement);
|
||||||
|
|
||||||
|
acc.push(newElement);
|
||||||
|
return acc;
|
||||||
},
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
return (
|
||||||
oldElements.push(element);
|
Array.isArray(element) ? _newElements : _newElements[0] || null
|
||||||
newElements.push(newElement);
|
) as T extends ExcalidrawElement[]
|
||||||
return newElement;
|
? ExcalidrawElement[]
|
||||||
|
: ExcalidrawElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
elements = normalizeElementOrder(elements);
|
||||||
|
|
||||||
const idsOfElementsToDuplicate = arrayToMap(
|
const idsOfElementsToDuplicate = arrayToMap(
|
||||||
getSelectedElements(sortedElements, appState, {
|
getSelectedElements(elements, appState, {
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
}),
|
}),
|
||||||
|
@ -130,122 +172,133 @@ const duplicateElements = (
|
||||||
// loop over them.
|
// loop over them.
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
|
||||||
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||||
for (const element of elements) {
|
|
||||||
processedIds.set(element.id, true);
|
const insertAfterIndex = (
|
||||||
|
index: number,
|
||||||
|
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
invariant(index !== -1, "targetIndex === -1 ");
|
||||||
|
|
||||||
|
if (!Array.isArray(elements) && !elements) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return elements;
|
|
||||||
|
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
|
||||||
};
|
};
|
||||||
|
|
||||||
const elementsWithClones: ExcalidrawElement[] = [];
|
const frameIdsToDuplicate = new Set(
|
||||||
|
elements
|
||||||
|
.filter(
|
||||||
|
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
||||||
|
)
|
||||||
|
.map((el) => el.id),
|
||||||
|
);
|
||||||
|
|
||||||
let index = -1;
|
for (const element of elements) {
|
||||||
|
if (processedIds.has(element.id)) {
|
||||||
while (++index < sortedElements.length) {
|
|
||||||
const element = sortedElements[index];
|
|
||||||
|
|
||||||
if (processedIds.get(element.id)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
if (!idsOfElementsToDuplicate.has(element.id)) {
|
||||||
const isElementAFrameLike = isFrameLikeElement(element);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
// groups
|
||||||
// if a group or a container/bound-text or frame, duplicate atomically
|
// -------------------------------------------------------------------------
|
||||||
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
|
||||||
if (groupId) {
|
|
||||||
// TODO:
|
|
||||||
// remove `.flatMap...`
|
|
||||||
// if the elements in a frame are grouped when the frame is grouped
|
|
||||||
const groupElements = getElementsInGroup(
|
|
||||||
sortedElements,
|
|
||||||
groupId,
|
|
||||||
).flatMap((element) =>
|
|
||||||
isFrameLikeElement(element)
|
|
||||||
? [...getFrameChildren(elements, element.id), element]
|
|
||||||
: [element],
|
|
||||||
);
|
|
||||||
|
|
||||||
elementsWithClones.push(
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
...markAsProcessed([
|
if (groupId) {
|
||||||
...groupElements,
|
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
||||||
...groupElements.map((element) =>
|
(element) =>
|
||||||
duplicateAndOffsetElement(element),
|
isFrameLikeElement(element)
|
||||||
),
|
? [...getFrameChildren(elements, element.id), element]
|
||||||
]),
|
: [element],
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (boundTextElement) {
|
|
||||||
elementsWithClones.push(
|
|
||||||
...markAsProcessed([
|
|
||||||
element,
|
|
||||||
boundTextElement,
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
duplicateAndOffsetElement(boundTextElement),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isElementAFrameLike) {
|
|
||||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
|
||||||
|
|
||||||
elementsWithClones.push(
|
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||||
...markAsProcessed([
|
return el.groupIds?.includes(groupId);
|
||||||
...elementsInFrame,
|
});
|
||||||
element,
|
|
||||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
// since elements in frames have a lower z-index than the frame itself,
|
|
||||||
// they will be looped first and if their frames are selected as well,
|
// frame duplication
|
||||||
// they will have been copied along with the frame atomically in the
|
// -------------------------------------------------------------------------
|
||||||
// above branch, so we must skip those elements here
|
|
||||||
//
|
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
||||||
// now, for elements do not belong any frames or elements whose frames
|
continue;
|
||||||
// are selected (or elements that are left out from the above
|
}
|
||||||
// steps for whatever reason) we (should at least) duplicate them here
|
|
||||||
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
if (isFrameLikeElement(element)) {
|
||||||
elementsWithClones.push(
|
const frameId = element.id;
|
||||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
|
||||||
|
const frameChildren = getFrameChildren(elements, frameId);
|
||||||
|
|
||||||
|
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||||
|
return el.frameId === frameId || el.id === frameId;
|
||||||
|
});
|
||||||
|
|
||||||
|
insertAfterIndex(
|
||||||
|
targetIndex,
|
||||||
|
duplicateAndOffsetElement([...frameChildren, element]),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// text container
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (hasBoundTextElement(element)) {
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||||
|
return (
|
||||||
|
el.id === element.id ||
|
||||||
|
("containerId" in el && el.containerId === element.id)
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
insertAfterIndex(
|
||||||
|
targetIndex,
|
||||||
|
duplicateAndOffsetElement([element, boundTextElement]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
elementsWithClones.push(...markAsProcessed([element]));
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// step (2)
|
if (isBoundToContainer(element)) {
|
||||||
|
const container = getContainerElement(element, elementsMap);
|
||||||
|
|
||||||
// second pass to remove duplicates. We loop from the end as it's likelier
|
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||||
// that the last elements are in the correct order (contiguous or otherwise).
|
return el.id === element.id || el.id === container?.id;
|
||||||
// Thus we need to reverse as the last step (3).
|
});
|
||||||
|
|
||||||
const finalElementsReversed: ExcalidrawElement[] = [];
|
if (container) {
|
||||||
|
insertAfterIndex(
|
||||||
|
targetIndex,
|
||||||
|
duplicateAndOffsetElement([container, element]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
||||||
|
}
|
||||||
|
|
||||||
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
continue;
|
||||||
index = elementsWithClones.length;
|
|
||||||
|
|
||||||
while (--index >= 0) {
|
|
||||||
const element = elementsWithClones[index];
|
|
||||||
if (!finalElementIds.get(element.id)) {
|
|
||||||
finalElementIds.set(element.id, true);
|
|
||||||
finalElementsReversed.push(element);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// step (3)
|
// default duplication (regular elements)
|
||||||
const finalElements = syncMovedIndices(
|
// -------------------------------------------------------------------------
|
||||||
finalElementsReversed.reverse(),
|
|
||||||
arrayToMap(newElements),
|
insertAfterIndex(
|
||||||
);
|
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -260,7 +313,7 @@ const duplicateElements = (
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
bindElementsToFramesAfterDuplication(
|
bindElementsToFramesAfterDuplication(
|
||||||
finalElements,
|
elementsWithClones,
|
||||||
oldElements,
|
oldElements,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
|
@ -269,7 +322,7 @@ const duplicateElements = (
|
||||||
excludeElementsInFramesFromSelection(newElements);
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: finalElements,
|
elements: elementsWithClones,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
|
@ -285,7 +338,7 @@ const duplicateElements = (
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(finalElements),
|
getNonDeletedElements(elementsWithClones),
|
||||||
appState,
|
appState,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|
|
@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
||||||
|
|
||||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
||||||
export const ELEMENT_LINK_KEY = "element";
|
export const ELEMENT_LINK_KEY = "element";
|
||||||
|
|
||||||
|
/** used in tests */
|
||||||
|
export const ORIG_ID = Symbol.for("__test__originalId__");
|
||||||
|
|
|
@ -45,6 +45,7 @@ import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
|
ORIG_ID,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||||
|
@ -592,26 +593,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||||
return _deepCopyElement(val);
|
return _deepCopyElement(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
||||||
|
Object.defineProperty(clonedObj, ORIG_ID, {
|
||||||
|
value: origId,
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
* utility wrapper to generate new id.
|
||||||
* for test assertions.
|
|
||||||
*/
|
*/
|
||||||
export const regenerateId = (
|
const regenerateId = () => {
|
||||||
/** supply null if no previous id exists */
|
|
||||||
previousId: string | null,
|
|
||||||
) => {
|
|
||||||
if (isTestEnv() && previousId) {
|
|
||||||
let nextId = `${previousId}_copy`;
|
|
||||||
// `window.h` may not be defined in some unit tests
|
|
||||||
if (
|
|
||||||
window.h?.app
|
|
||||||
?.getSceneElementsIncludingDeleted()
|
|
||||||
.find((el: ExcalidrawElement) => el.id === nextId)
|
|
||||||
) {
|
|
||||||
nextId += "_copy";
|
|
||||||
}
|
|
||||||
return nextId;
|
|
||||||
}
|
|
||||||
return randomId();
|
return randomId();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -637,7 +630,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||||
): Readonly<TElement> => {
|
): Readonly<TElement> => {
|
||||||
let copy = deepCopyElement(element);
|
let copy = deepCopyElement(element);
|
||||||
|
|
||||||
copy.id = regenerateId(copy.id);
|
if (isTestEnv()) {
|
||||||
|
__test__defineOrigId(copy, element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy.id = regenerateId();
|
||||||
copy.boundElements = null;
|
copy.boundElements = null;
|
||||||
copy.updated = getUpdatedTimestamp();
|
copy.updated = getUpdatedTimestamp();
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
|
@ -646,7 +643,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||||
editingGroupId,
|
editingGroupId,
|
||||||
(groupId) => {
|
(groupId) => {
|
||||||
if (!groupIdMapForOperation.has(groupId)) {
|
if (!groupIdMapForOperation.has(groupId)) {
|
||||||
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
groupIdMapForOperation.set(groupId, regenerateId());
|
||||||
}
|
}
|
||||||
return groupIdMapForOperation.get(groupId)!;
|
return groupIdMapForOperation.get(groupId)!;
|
||||||
},
|
},
|
||||||
|
@ -692,7 +689,7 @@ export const duplicateElements = (
|
||||||
// if we haven't migrated the element id, but an old element with the same
|
// if we haven't migrated the element id, but an old element with the same
|
||||||
// id exists, generate a new id for it and return it
|
// id exists, generate a new id for it and return it
|
||||||
if (origElementsMap.has(id)) {
|
if (origElementsMap.has(id)) {
|
||||||
const newId = regenerateId(id);
|
const newId = regenerateId();
|
||||||
elementNewIdsMap.set(id, newId);
|
elementNewIdsMap.set(id, newId);
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
|
@ -706,6 +703,9 @@ export const duplicateElements = (
|
||||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
clonedElement.id = maybeGetNewId(element.id)!;
|
||||||
|
if (isTestEnv()) {
|
||||||
|
__test__defineOrigId(clonedElement, element.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (opts?.randomizeSeed) {
|
if (opts?.randomizeSeed) {
|
||||||
clonedElement.seed = randomInteger();
|
clonedElement.seed = randomInteger();
|
||||||
|
@ -715,7 +715,7 @@ export const duplicateElements = (
|
||||||
if (clonedElement.groupIds) {
|
if (clonedElement.groupIds) {
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
if (!groupNewIdsMap.has(groupId)) {
|
||||||
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
groupNewIdsMap.set(groupId, regenerateId());
|
||||||
}
|
}
|
||||||
return groupNewIdsMap.get(groupId)!;
|
return groupNewIdsMap.get(groupId)!;
|
||||||
});
|
});
|
||||||
|
|
|
@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
|
||||||
export const normalizeElementOrder = (
|
export const normalizeElementOrder = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
// console.time();
|
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||||
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
|
||||||
// console.timeEnd();
|
|
||||||
return ret;
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import React from "react";
|
|
||||||
import type { ExcalidrawElement } from "./element/types";
|
import type { ExcalidrawElement } from "./element/types";
|
||||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
||||||
import { render } from "./tests/test-utils";
|
import { getCloneByOrigId, render } from "./tests/test-utils";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
|
||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
|
||||||
|
|
||||||
selectElementAndDuplicate(rect2);
|
selectElementAndDuplicate(rect2);
|
||||||
|
|
||||||
|
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(frame.id);
|
expect(rect2.frameId).toBe(frame.id);
|
||||||
expectEqualIds([rect2_copy, rect2, frame]);
|
expectEqualIds([rect2_copy, rect2, frame]);
|
||||||
|
@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
|
||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
|
||||||
|
|
||||||
// move the rect2 outside the frame
|
// move the rect2 outside the frame
|
||||||
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
||||||
|
|
||||||
|
const rect2_copy = getCloneByOrigId(rect2.id);
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(null);
|
expect(rect2.frameId).toBe(null);
|
||||||
expectEqualIds([rect2_copy, frame, rect2]);
|
expectEqualIds([rect2_copy, frame, rect2]);
|
||||||
|
|
|
@ -103,6 +103,7 @@
|
||||||
"@types/pako": "1.0.3",
|
"@types/pako": "1.0.3",
|
||||||
"@types/pica": "5.1.3",
|
"@types/pica": "5.1.3",
|
||||||
"@types/resize-observer-browser": "0.1.7",
|
"@types/resize-observer-browser": "0.1.7",
|
||||||
|
"ansicolor": "2.0.3",
|
||||||
"autoprefixer": "10.4.7",
|
"autoprefixer": "10.4.7",
|
||||||
"babel-loader": "8.2.5",
|
"babel-loader": "8.2.5",
|
||||||
"babel-plugin-transform-class-properties": "6.24.1",
|
"babel-plugin-transform-class-properties": "6.24.1",
|
||||||
|
|
|
@ -2517,7 +2517,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
"searchMatches": [],
|
"searchMatches": [],
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0_copy": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
|
@ -2590,7 +2590,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 20,
|
"height": 20,
|
||||||
"id": "id0_copy",
|
"id": "id1",
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
@ -2680,7 +2680,7 @@ History {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0_copy": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
|
@ -2693,7 +2693,7 @@ History {
|
||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id0_copy" => Delta {
|
"id1" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,7 +10,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0_copy",
|
"id": "id2",
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
"link": null,
|
"link": null,
|
||||||
|
|
|
@ -2129,7 +2129,7 @@ History {
|
||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id0_copy" => Delta {
|
"id2" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
|
@ -10619,7 +10619,7 @@ History {
|
||||||
"elementsChange": ElementsChange {
|
"elementsChange": ElementsChange {
|
||||||
"added": Map {},
|
"added": Map {},
|
||||||
"removed": Map {
|
"removed": Map {
|
||||||
"id0_copy" => Delta {
|
"id6" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
|
@ -10628,7 +10628,7 @@ History {
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id4_copy",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a0",
|
"index": "a0",
|
||||||
|
@ -10652,7 +10652,7 @@ History {
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id1_copy" => Delta {
|
"id8" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
|
@ -10661,7 +10661,7 @@ History {
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id4_copy",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a1",
|
"index": "a1",
|
||||||
|
@ -10685,7 +10685,7 @@ History {
|
||||||
"isDeleted": true,
|
"isDeleted": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id2_copy" => Delta {
|
"id9" => Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
|
@ -10694,7 +10694,7 @@ History {
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [
|
"groupIds": [
|
||||||
"id4_copy",
|
"id7",
|
||||||
],
|
],
|
||||||
"height": 10,
|
"height": 10,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
|
|
|
@ -40,6 +40,7 @@ import { createTestHook } from "../../components/App";
|
||||||
import type { Action } from "../../actions/types";
|
import type { Action } from "../../actions/types";
|
||||||
import { mutateElement } from "../../element/mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
|
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
|
||||||
|
import { selectGroupsForSelectedElements } from "../../groups";
|
||||||
|
|
||||||
const readFile = util.promisify(fs.readFile);
|
const readFile = util.promisify(fs.readFile);
|
||||||
// so that window.h is available when App.tsx is not imported as well.
|
// so that window.h is available when App.tsx is not imported as well.
|
||||||
|
@ -68,13 +69,21 @@ export class API {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static setSelectedElements = (elements: ExcalidrawElement[]) => {
|
static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
|
||||||
act(() => {
|
act(() => {
|
||||||
h.setState({
|
h.setState({
|
||||||
selectedElementIds: elements.reduce((acc, element) => {
|
...selectGroupsForSelectedElements(
|
||||||
acc[element.id] = true;
|
{
|
||||||
return acc;
|
editingGroupId: editingGroupId ?? null,
|
||||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
selectedElementIds: elements.reduce((acc, element) => {
|
||||||
|
acc[element.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||||
|
},
|
||||||
|
elements,
|
||||||
|
h.state,
|
||||||
|
h.app,
|
||||||
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -158,7 +167,7 @@ export class API {
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
frameId?: ExcalidrawElement["id"] | null;
|
frameId?: ExcalidrawElement["id"] | null;
|
||||||
index?: ExcalidrawElement["index"];
|
index?: ExcalidrawElement["index"];
|
||||||
groupIds?: string[];
|
groupIds?: ExcalidrawElement["groupIds"];
|
||||||
// generic element props
|
// generic element props
|
||||||
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
strokeColor?: ExcalidrawGenericElement["strokeColor"];
|
||||||
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
|
||||||
|
@ -369,6 +378,84 @@ export class API {
|
||||||
return element as any;
|
return element as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static createTextContainer = (opts?: {
|
||||||
|
frameId?: ExcalidrawElement["id"];
|
||||||
|
groupIds?: ExcalidrawElement["groupIds"];
|
||||||
|
label?: {
|
||||||
|
text?: string;
|
||||||
|
frameId?: ExcalidrawElement["id"] | null;
|
||||||
|
groupIds?: ExcalidrawElement["groupIds"];
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
frameId: opts?.frameId || null,
|
||||||
|
groupIds: opts?.groupIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
text: opts?.label?.text || "sample-text",
|
||||||
|
width: 50,
|
||||||
|
height: 20,
|
||||||
|
fontSize: 16,
|
||||||
|
containerId: rectangle.id,
|
||||||
|
frameId:
|
||||||
|
opts?.label?.frameId === undefined
|
||||||
|
? opts?.frameId ?? null
|
||||||
|
: opts?.label?.frameId ?? null,
|
||||||
|
groupIds: opts?.label?.groupIds === undefined
|
||||||
|
? opts?.groupIds
|
||||||
|
: opts?.label?.groupIds ,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
rectangle,
|
||||||
|
{
|
||||||
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [rectangle, text];
|
||||||
|
};
|
||||||
|
|
||||||
|
static createLabeledArrow = (opts?: {
|
||||||
|
frameId?: ExcalidrawElement["id"];
|
||||||
|
label?: {
|
||||||
|
text?: string;
|
||||||
|
frameId?: ExcalidrawElement["id"] | null;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const arrow = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
frameId: opts?.frameId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
id: "text2",
|
||||||
|
width: 50,
|
||||||
|
height: 20,
|
||||||
|
containerId: arrow.id,
|
||||||
|
frameId:
|
||||||
|
opts?.label?.frameId === undefined
|
||||||
|
? opts?.frameId ?? null
|
||||||
|
: opts?.label?.frameId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mutateElement(
|
||||||
|
arrow,
|
||||||
|
{
|
||||||
|
boundElements: [{ type: "text", id: text.id }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [arrow, text];
|
||||||
|
};
|
||||||
|
|
||||||
static readFile = async <T extends "utf8" | null>(
|
static readFile = async <T extends "utf8" | null>(
|
||||||
filepath: string,
|
filepath: string,
|
||||||
encoding?: T,
|
encoding?: T,
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
assertSelectedElements,
|
assertSelectedElements,
|
||||||
render,
|
render,
|
||||||
togglePopover,
|
togglePopover,
|
||||||
|
getCloneByOrigId,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||||
|
@ -15,7 +16,7 @@ import { getDefaultAppState } from "../appState";
|
||||||
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import {
|
import {
|
||||||
|
@ -1138,8 +1139,8 @@ describe("history", () => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
||||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
||||||
]);
|
]);
|
||||||
expect(h.state.editingGroupId).toBeNull();
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
expect(h.state.selectedGroupIds).toEqual({ A: true });
|
||||||
|
@ -1151,8 +1152,8 @@ describe("history", () => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
|
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
|
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
|
||||||
]);
|
]);
|
||||||
expect(h.state.editingGroupId).toBeNull();
|
expect(h.state.editingGroupId).toBeNull();
|
||||||
expect(h.state.selectedGroupIds).not.toEqual(
|
expect(h.state.selectedGroupIds).not.toEqual(
|
||||||
|
@ -1171,14 +1172,14 @@ describe("history", () => {
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
expect.objectContaining({ id: rect1.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
expect.objectContaining({ id: rect2.id, isDeleted: false }),
|
||||||
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
|
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
|
||||||
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
|
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: `${rect1.id}_copy_copy`,
|
[ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: `${rect2.id}_copy_copy`,
|
[ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { fireEvent, render, waitFor } from "./test-utils";
|
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
|
||||||
import { act, queryByTestId } from "@testing-library/react";
|
import { act, queryByTestId } from "@testing-library/react";
|
||||||
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES, ORIG_ID } from "../constants";
|
||||||
import type { LibraryItem, LibraryItems } from "../types";
|
import type { LibraryItem, LibraryItems } from "../types";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { serializeLibraryAsJSON } from "../data/json";
|
import { serializeLibraryAsJSON } from "../data/json";
|
||||||
|
@ -76,7 +76,7 @@ describe("library", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -125,23 +125,27 @@ describe("library", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual(
|
||||||
expect.objectContaining({
|
expect.arrayContaining([
|
||||||
id: "rectangle1_copy",
|
expect.objectContaining({
|
||||||
boundElements: expect.arrayContaining([
|
[ORIG_ID]: "rectangle1",
|
||||||
{ type: "text", id: "text1_copy" },
|
boundElements: expect.arrayContaining([
|
||||||
{ type: "arrow", id: "arrow1_copy" },
|
{ type: "text", id: getCloneByOrigId("text1").id },
|
||||||
]),
|
{ type: "arrow", id: getCloneByOrigId("arrow1").id },
|
||||||
}),
|
]),
|
||||||
expect.objectContaining({
|
}),
|
||||||
id: "text1_copy",
|
expect.objectContaining({
|
||||||
containerId: "rectangle1_copy",
|
[ORIG_ID]: "text1",
|
||||||
}),
|
containerId: getCloneByOrigId("rectangle1").id,
|
||||||
expect.objectContaining({
|
}),
|
||||||
id: "arrow1_copy",
|
expect.objectContaining({
|
||||||
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
|
[ORIG_ID]: "arrow1",
|
||||||
}),
|
endBinding: expect.objectContaining({
|
||||||
]);
|
elementId: getCloneByOrigId("rectangle1").id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -170,10 +174,11 @@ describe("library", () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "elem1_copy",
|
[ORIG_ID]: "elem1",
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/),
|
id: expect.not.stringMatching(/^elem1$/),
|
||||||
|
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -189,7 +194,7 @@ describe("library", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||||
});
|
});
|
||||||
expect(h.state.activeTool.type).toBe("selection");
|
expect(h.state.activeTool.type).toBe("selection");
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,10 @@ import { getSelectedElements } from "../scene/selection";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { diffStringsUnified } from "jest-diff";
|
import { diffStringsUnified } from "jest-diff";
|
||||||
|
import ansi from "ansicolor";
|
||||||
|
import { ORIG_ID } from "../constants";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
import type { AllPossibleKeys } from "../utility-types";
|
||||||
|
|
||||||
const customQueries = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
|
@ -295,3 +299,150 @@ expect.addSnapshotSerializer({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getCloneByOrigId = <T extends boolean = false>(
|
||||||
|
origId: ExcalidrawElement["id"],
|
||||||
|
returnNullIfNotExists: T = false as T,
|
||||||
|
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
|
||||||
|
const clonedElement = window.h.elements?.find(
|
||||||
|
(el) => (el as any)[ORIG_ID] === origId,
|
||||||
|
);
|
||||||
|
if (clonedElement) {
|
||||||
|
return clonedElement;
|
||||||
|
}
|
||||||
|
if (returnNullIfNotExists !== true) {
|
||||||
|
throw new Error(`cloned element not found for origId: ${origId}`);
|
||||||
|
}
|
||||||
|
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assertion helper that strips the actual elements of extra attributes
|
||||||
|
* so that diffs are easier to read in case of failure.
|
||||||
|
*
|
||||||
|
* Asserts element order as well, and selected element ids
|
||||||
|
* (when `selected: true` set for given element).
|
||||||
|
*
|
||||||
|
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
|
||||||
|
* If you need to refer to cloned element properties, you can use
|
||||||
|
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
|
||||||
|
*/
|
||||||
|
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
||||||
|
actualElements: readonly ExcalidrawElement[],
|
||||||
|
/** array order matters */
|
||||||
|
expectedElements: (Partial<Record<T, any>> & {
|
||||||
|
/** meta, will be stripped for element attribute checks */
|
||||||
|
selected?: true;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
id: ExcalidrawElement["id"];
|
||||||
|
}
|
||||||
|
| { [ORIG_ID]?: string }
|
||||||
|
))[],
|
||||||
|
) => {
|
||||||
|
const h = window.h;
|
||||||
|
|
||||||
|
const expectedElementsWithIds: (typeof expectedElements[number] & {
|
||||||
|
id: ExcalidrawElement["id"];
|
||||||
|
})[] = expectedElements.map((el) => {
|
||||||
|
if ("id" in el) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
const actualElement = actualElements.find(
|
||||||
|
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
|
||||||
|
);
|
||||||
|
if (actualElement) {
|
||||||
|
return { ...el, id: actualElement.id };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
id: "UNKNOWN_ID",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const map_expectedElements = arrayToMap(expectedElementsWithIds);
|
||||||
|
|
||||||
|
const selectedElementIds = expectedElementsWithIds.reduce(
|
||||||
|
(acc: Record<ExcalidrawElement["id"], true>, el) => {
|
||||||
|
if (el.selected) {
|
||||||
|
acc[el.id] = true;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedActualElements = actualElements.map((el) => {
|
||||||
|
const expectedElement = map_expectedElements.get(el.id);
|
||||||
|
if (expectedElement) {
|
||||||
|
const pickedAttrs: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const key of Object.keys(expectedElement)) {
|
||||||
|
if (key === "selected") {
|
||||||
|
delete expectedElement.selected;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pickedAttrs[key] = (el as any)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ORIG_ID in expectedElement) {
|
||||||
|
// @ts-ignore
|
||||||
|
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickedAttrs;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// testing order separately for even easier diffs
|
||||||
|
expect(actualElements.map((x) => x.id)).toEqual(
|
||||||
|
expectedElementsWithIds.map((x) => x.id),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
let errStr = "\n\nmismatched element order\n\n";
|
||||||
|
|
||||||
|
errStr += `actual: ${ansi.lightGray(
|
||||||
|
`[${err.actual
|
||||||
|
.map((id: string, index: number) => {
|
||||||
|
const act = actualElements[index];
|
||||||
|
|
||||||
|
return `${
|
||||||
|
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
|
||||||
|
} (${act.type.slice(0, 4)}${
|
||||||
|
ORIG_ID in act ? ` ↳ ${(act as any)[ORIG_ID]}` : ""
|
||||||
|
})`;
|
||||||
|
})
|
||||||
|
.join(", ")}]`,
|
||||||
|
)}\n${ansi.lightGray(
|
||||||
|
`expected: [${err.expected
|
||||||
|
.map((exp: string, index: number) => {
|
||||||
|
const expEl = actualElements.find((el) => el.id === exp);
|
||||||
|
const origEl =
|
||||||
|
expEl &&
|
||||||
|
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
|
||||||
|
return expEl
|
||||||
|
? `${
|
||||||
|
exp === err.actual[index]
|
||||||
|
? ansi.green(expEl.id)
|
||||||
|
: ansi.red(expEl.id)
|
||||||
|
} (${expEl.type.slice(0, 4)}${origEl ? ` ↳ ${origEl.id}` : ""})`
|
||||||
|
: exp;
|
||||||
|
})
|
||||||
|
.join(", ")}]\n`,
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const error = new Error(errStr);
|
||||||
|
const stack = err.stack.split("\n");
|
||||||
|
stack.splice(1, 1);
|
||||||
|
error.stack = stack.join("\n");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mappedActualElements).toEqual(
|
||||||
|
expect.arrayContaining(expectedElementsWithIds),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { act, render } from "./test-utils";
|
import { act, getCloneByOrigId, render } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import {
|
import {
|
||||||
|
@ -916,9 +916,9 @@ describe("z-index manipulation", () => {
|
||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements).toMatchObject([
|
expect(h.elements).toMatchObject([
|
||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: "A_copy" },
|
{ id: getCloneByOrigId("A").id },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{ id: "B_copy" },
|
{ id: getCloneByOrigId("B").id },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements([
|
populateElements([
|
||||||
|
@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
|
||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{
|
{
|
||||||
id: "A_copy",
|
id: getCloneByOrigId("A").id,
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "B_copy",
|
id: getCloneByOrigId("B").id,
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
|
@ -951,12 +951,12 @@ describe("z-index manipulation", () => {
|
||||||
{ id: "A" },
|
{ id: "A" },
|
||||||
{ id: "B" },
|
{ id: "B" },
|
||||||
{
|
{
|
||||||
id: "A_copy",
|
id: getCloneByOrigId("A").id,
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "B_copy",
|
id: getCloneByOrigId("B").id,
|
||||||
|
|
||||||
groupIds: [expect.stringMatching(/.{3,}/)],
|
groupIds: [expect.stringMatching(/.{3,}/)],
|
||||||
},
|
},
|
||||||
|
@ -972,10 +972,10 @@ describe("z-index manipulation", () => {
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C",
|
"C",
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements([
|
populateElements([
|
||||||
|
@ -988,12 +988,12 @@ describe("z-index manipulation", () => {
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C",
|
"C",
|
||||||
"D",
|
"D",
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
"D_copy",
|
getCloneByOrigId("D").id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
|
@ -1010,10 +1010,10 @@ describe("z-index manipulation", () => {
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C",
|
"C",
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
|
@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
|
@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
"D",
|
"D",
|
||||||
"E",
|
"E",
|
||||||
"F",
|
"F",
|
||||||
"D_copy",
|
getCloneByOrigId("D").id,
|
||||||
"E_copy",
|
getCloneByOrigId("E").id,
|
||||||
"F_copy",
|
getCloneByOrigId("F").id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
populateElements(
|
populateElements(
|
||||||
|
@ -1076,7 +1076,7 @@ describe("z-index manipulation", () => {
|
||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
|
@ -1093,7 +1093,7 @@ describe("z-index manipulation", () => {
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -1108,9 +1108,9 @@ describe("z-index manipulation", () => {
|
||||||
API.executeAction(actionDuplicateSelection);
|
API.executeAction(actionDuplicateSelection);
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B",
|
"B",
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C",
|
"C",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -1125,8 +1125,8 @@ describe("z-index manipulation", () => {
|
||||||
expect(h.elements.map((element) => element.id)).toEqual([
|
expect(h.elements.map((element) => element.id)).toEqual([
|
||||||
"A",
|
"A",
|
||||||
"C",
|
"C",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
"B",
|
"B",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
|
||||||
"A",
|
"A",
|
||||||
"B",
|
"B",
|
||||||
"C",
|
"C",
|
||||||
"A_copy",
|
getCloneByOrigId("A").id,
|
||||||
"B_copy",
|
getCloneByOrigId("B").id,
|
||||||
"C_copy",
|
getCloneByOrigId("C").id,
|
||||||
"D",
|
"D",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
@ -65,3 +65,6 @@ export type MakeBrand<T extends string> = {
|
||||||
|
|
||||||
/** Maybe just promise or already fulfilled one! */
|
/** Maybe just promise or already fulfilled one! */
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
|
// get union of all keys from the union of types
|
||||||
|
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|
|
@ -1240,3 +1240,6 @@ export class PromisePool<T> {
|
||||||
export const escapeDoubleQuotes = (str: string) => {
|
export const escapeDoubleQuotes = (str: string) => {
|
||||||
return str.replace(/"/g, """);
|
return str.replace(/"/g, """);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
|
@ -3916,6 +3916,11 @@ ansi-styles@^6.0.0, ansi-styles@^6.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||||
|
|
||||||
|
ansicolor@2.0.3:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/ansicolor/-/ansicolor-2.0.3.tgz#ec4448ae5baf8c2d62bf2dad52eac06ba0b5ea21"
|
||||||
|
integrity sha512-pzusTqk9VHrjgMCcTPDTTvfJfx6Q3+L5tQ6yKC8Diexmoit4YROTFIkxFvRTNL9y5s0Q8HrSrgerCD5bIC+Kiw==
|
||||||
|
|
||||||
anymatch@~3.1.2:
|
anymatch@~3.1.2:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue