fix: sort bound text elements to fix text duplication z-index error (#5130)

* fix: sort bound text elements to fix text duplication z-index error

* improve & sort groups & add tests

* fix backtracking and discontiguous groups

---------

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-02-02 16:23:39 +08:00 committed by GitHub
parent 5a0334f37f
commit a9c5bdb878
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 761 additions and 118 deletions

View File

@ -16,8 +16,12 @@ import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import { bindTextToShapeAfterDuplication } from "../element/textElement";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
} from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
export const actionDuplicateSelection = register({
@ -64,6 +68,11 @@ const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<ActionResult> => {
// ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
@ -85,42 +94,112 @@ const duplicateElements = (
return newElement;
};
const finalElements: ExcalidrawElement[] = [];
let index = 0;
const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, true),
getSelectedElements(sortedElements, appState, true),
);
while (index < elements.length) {
const element = elements[index];
// Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => {
for (const element of elements) {
processedIds.set(element.id, true);
}
return elements;
};
const elementsWithClones: ExcalidrawElement[] = [];
let index = -1;
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
continue;
}
const boundTextElement = getBoundTextElement(element);
if (selectedElementIds.get(element.id)) {
if (element.groupIds.length) {
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) {
const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId);
finalElements.push(
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
const groupElements = getElementsInGroup(sortedElements, groupId);
elementsWithClones.push(
...markAsProcessed([
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
]),
);
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
duplicateAndOffsetElement(boundTextElement),
]),
);
index = index + groupElements.length;
continue;
}
}
finalElements.push(element, duplicateAndOffsetElement(element));
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
);
} else {
finalElements.push(element);
elementsWithClones.push(...markAsProcessed([element]));
}
index++;
}
// step (2)
// 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 finalElementsReversed: ExcalidrawElement[] = [];
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);
}
}
// step (3)
const finalElements = finalElementsReversed.reverse();
// ---------------------------------------------------------------------------
bindTextToShapeAfterDuplication(
finalElements,
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
elements: finalElements,

View File

@ -0,0 +1,402 @@
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { normalizeElementOrder } from "./sortElements";
import { ExcalidrawElement } from "./types";
const assertOrder = (
elements: readonly ExcalidrawElement[],
expectedOrder: string[],
) => {
const actualOrder = elements.map((element) => element.id);
expect(actualOrder).toEqual(expectedOrder);
};
describe("normalizeElementsOrder", () => {
it("sort bound-text elements", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
const otherElement = API.createElement({
id: "otherElement",
type: "rectangle",
boundElements: [],
});
const otherElement2 = API.createElement({
id: "otherElement2",
type: "rectangle",
boundElements: [],
});
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
assertOrder(normalizeElementOrder([container, boundText]), [
"container",
"boundText",
]);
assertOrder(normalizeElementOrder([boundText, container]), [
"container",
"boundText",
]);
assertOrder(
normalizeElementOrder([
boundText,
container,
otherElement,
otherElement2,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
"container",
"boundText",
"otherElement",
]);
assertOrder(
normalizeElementOrder([
container,
otherElement,
otherElement2,
boundText,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(
normalizeElementOrder([
boundText,
otherElement,
container,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// noop
assertOrder(
normalizeElementOrder([
otherElement,
container,
boundText,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// text has existing containerId, but container doesn't list is
// as a boundElement
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
API.createElement({
id: "container",
type: "rectangle",
}),
]),
["boundText", "container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
]),
["boundText"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "x", type: "text" }],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "arrow",
type: "arrow",
}),
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "arrow", type: "arrow" }],
}),
]),
["arrow", "container"],
);
});
it("normalize group order", () => {
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "rect3",
type: "rectangle",
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "B_rect3",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "B_rect5",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
);
// nested groups
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect2",
type: "rectangle",
groupIds: ["B", "A"],
}),
]),
["A_rect1", "BA_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
]),
["BA_rect1", "A_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect3",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "rect4",
type: "rectangle",
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect5",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "BA_rect6",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "CBA_rect7",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "X_rect8",
type: "rectangle",
groupIds: ["X"],
}),
API.createElement({
id: "rect9",
type: "rectangle",
}),
API.createElement({
id: "YX_rect10",
type: "rectangle",
groupIds: ["Y", "X"],
}),
API.createElement({
id: "X_rect11",
type: "rectangle",
groupIds: ["X"],
}),
]),
[
"BA_rect1",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"X_rect11",
"YX_rect10",
"rect9",
],
);
});
// TODO
it.skip("normalize boundElements array", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
],
});
expect(normalizeElementOrder([container, boundText])).toEqual([
expect.objectContaining({
id: container.id,
}),
expect.objectContaining({ id: boundText.id }),
]);
});
// should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
it.skip("normalizeElementsOrder() perf", () => {
const makeElements = (iterations: number) => {
const elements: ExcalidrawElement[] = [];
while (iterations--) {
const container = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["B", "A"],
});
const boundText = API.createElement({
type: "text",
containerId: container.id,
groupIds: ["A"],
});
const otherElement = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["C", "A"],
});
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
elements.push(boundText, otherElement, container);
}
return elements;
};
const elements = makeElements(10000);
const t0 = Date.now();
normalizeElementOrder(elements);
console.info(`${Date.now() - t0}ms`);
});
});

123
src/element/sortElements.ts Normal file
View File

@ -0,0 +1,123 @@
import { arrayToMapWithIndex } from "../utils";
import { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
};
const groupHandledElements = new Map<string, true>();
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements;
}
return [...sortedElements];
};
/**
* In theory, when we have text elements bound to a container, they
* should be right after the container element in the elements array.
* However, this is not guaranteed due to old and potential future bugs.
*
* This function sorts containers and their bound texts together. It prefers
* original z-index of container (i.e. it moves bound text elements after
* containers).
*/
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => {
if (!element) {
return;
}
if (element.boundElements?.length) {
sortedElements.add(element);
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child[0]);
origElements[child[1]] = null;
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
} else {
sortedElements.add(element);
origElements[idx] = null;
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error(
"normalizeBoundElementsOrder: lost some elements... bailing!",
);
return elements;
}
return [...sortedElements];
};
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
// console.time();
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
};

View File

@ -127,10 +127,16 @@ export const bindTextToShapeAfterDuplication = (
const newContainer = sceneElementMap.get(newElementId);
if (newContainer) {
mutateElement(newContainer, {
boundElements: (newContainer.boundElements || []).concat({
type: "text",
id: newTextElementId,
}),
boundElements: (element.boundElements || [])
.filter(
(boundElement) =>
boundElement.id !== newTextElementId &&
boundElement.id !== boundTextElementId,
)
.concat({
type: "text",
id: newTextElementId,
}),
});
}
const newTextElement = sceneElementMap.get(newTextElementId);

View File

@ -1,6 +1,7 @@
import { PRECEDING_ELEMENT_KEY } from "../../constants";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { arrayToMapWithIndex } from "../../utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
@ -33,30 +34,13 @@ const shouldDiscardRemoteElement = (
return false;
};
const getElementsMapWithIndex = <T extends ExcalidrawElement>(
elements: readonly T[],
) =>
elements.reduce(
(
acc: {
[key: string]: [element: T, index: number] | undefined;
},
element: T,
idx,
) => {
acc[element.id] = [element, idx];
return acc;
},
{},
);
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
getElementsMapWithIndex<ExcalidrawElement>(localElements);
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
@ -69,7 +53,7 @@ export const reconcileElements = (
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData[remoteElement.id];
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
@ -105,21 +89,21 @@ export const reconcileElements = (
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
];
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
];
]);
cursor++;
}
} else {
let idx = localElementsData[parent]
? localElementsData[parent]![1]
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
@ -127,38 +111,38 @@ export const reconcileElements = (
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
];
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
];
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
];
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData[remoteElement.id] = [remoteElement, local[1]];
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData[remoteElement.id] = [
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
];
]);
}
}

View File

@ -11,6 +11,7 @@ import {
} from "../actions";
import { AppState } from "../types";
import { API } from "./helpers/api";
import { selectGroupsForSelectedElements } from "../groups";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -34,6 +35,7 @@ const populateElements = (
height?: number;
containerId?: string;
}[],
appState?: Partial<AppState>,
) => {
const selectedElementIds: any = {};
@ -84,6 +86,11 @@ const populateElements = (
});
h.setState({
...selectGroupsForSelectedElements(
{ ...h.state, ...appState, selectedElementIds },
h.elements,
),
...appState,
selectedElementIds,
});
@ -111,11 +118,7 @@ const assertZindex = ({
appState?: Partial<AppState>;
operations: [Actions, string[]][];
}) => {
const selectedElementIds = populateElements(elements);
h.setState({
editingGroupId: appState?.editingGroupId || null,
});
const selectedElementIds = populateElements(elements, appState);
operations.forEach(([action, expected]) => {
h.app.actionManager.executeAction(action);
@ -884,9 +887,6 @@ describe("z-index manipulation", () => {
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "B", groupIds: ["g1"], isSelected: true },
]);
h.setState({
selectedGroupIds: { g1: true },
});
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements).toMatchObject([
{ id: "A" },
@ -908,9 +908,6 @@ describe("z-index manipulation", () => {
{ id: "B", groupIds: ["g1"], isSelected: true },
{ id: "C" },
]);
h.setState({
selectedGroupIds: { g1: true },
});
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements).toMatchObject([
{ id: "A" },
@ -933,9 +930,6 @@ describe("z-index manipulation", () => {
{ id: "B", groupIds: ["g1"], isSelected: true },
{ id: "C", isSelected: true },
]);
h.setState({
selectedGroupIds: { g1: true },
});
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -952,9 +946,6 @@ describe("z-index manipulation", () => {
{ id: "C", groupIds: ["g2"], isSelected: true },
{ id: "D", groupIds: ["g2"], isSelected: true },
]);
h.setState({
selectedGroupIds: { g1: true, g2: true },
});
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -967,14 +958,16 @@ describe("z-index manipulation", () => {
"D_copy",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
]);
h.setState({
selectedGroupIds: { g1: true },
});
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
],
{
selectedGroupIds: { g1: true },
},
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -985,14 +978,16 @@ describe("z-index manipulation", () => {
"C_copy",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
]);
h.setState({
selectedGroupIds: { g2: true },
});
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
],
{
selectedGroupIds: { g2: true },
},
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -1003,17 +998,19 @@ describe("z-index manipulation", () => {
"C_copy",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
{ id: "D", groupIds: ["g3", "g4"], isSelected: true },
{ id: "E", groupIds: ["g3", "g4"], isSelected: true },
{ id: "F", groupIds: ["g4"], isSelected: true },
]);
h.setState({
selectedGroupIds: { g2: true, g4: true },
});
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
{ id: "D", groupIds: ["g3", "g4"], isSelected: true },
{ id: "E", groupIds: ["g3", "g4"], isSelected: true },
{ id: "F", groupIds: ["g4"], isSelected: true },
],
{
selectedGroupIds: { g2: true, g4: true },
},
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -1030,11 +1027,14 @@ describe("z-index manipulation", () => {
"F_copy",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"] },
{ id: "C", groupIds: ["g2"] },
]);
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"] },
{ id: "C", groupIds: ["g2"] },
],
{ editingGroupId: "g1" },
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -1043,11 +1043,14 @@ describe("z-index manipulation", () => {
"C",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"] },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"] },
]);
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"] },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"] },
],
{ editingGroupId: "g1" },
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -1056,11 +1059,14 @@ describe("z-index manipulation", () => {
"C",
]);
populateElements([
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"], isSelected: true },
]);
populateElements(
[
{ id: "A", groupIds: ["g1", "g2"], isSelected: true },
{ id: "B", groupIds: ["g1", "g2"], isSelected: true },
{ id: "C", groupIds: ["g2"] },
],
{ editingGroupId: "g1" },
);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
@ -1068,7 +1074,42 @@ describe("z-index manipulation", () => {
"B",
"B_copy",
"C",
]);
});
it("duplicating incorrectly interleaved elements (group elements should be together) should still produce reasonable result", () => {
populateElements([
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
]);
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"C",
"A_copy",
"C_copy",
"B",
]);
});
it("group-selected duplication should includes deleted elements that weren't selected on account of being deleted", () => {
populateElements([
{ id: "A", groupIds: ["g1"], isDeleted: true },
{ id: "B", groupIds: ["g1"], isSelected: true },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
]);
expect(h.state.selectedGroupIds).toEqual({ g1: true });
h.app.actionManager.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"B",
"C",
"A_copy",
"B_copy",
"C_copy",
"D",
]);
});

View File

@ -607,6 +607,14 @@ export const arrayToMap = <T extends { id: string } | string>(
}, new Map());
};
export const arrayToMapWithIndex = <T extends { id: string }>(
elements: readonly T[],
) =>
elements.reduce((acc, element: T, idx) => {
acc.set(element.id, [element, idx]);
return acc;
}, new Map<string, [element: T, index: number]>());
export const isTestEnv = () =>
typeof process !== "undefined" && process.env?.NODE_ENV === "test";