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>pull/6188/head
parent
5a0334f37f
commit
a9c5bdb878
@ -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`);
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
};
|
Loading…
Reference in New Issue