fix: group defragmenting (#11269)

This commit is contained in:
David Luzar
2026-05-02 15:50:58 +02:00
committed by GitHub
parent 278cd35772
commit 3f5fdec04e
3 changed files with 109 additions and 68 deletions
+3
View File
@@ -250,6 +250,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements)); elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
}; };
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
elements elements
.filter( .filter(
+63 -65
View File
@@ -1,59 +1,56 @@
import { arrayToMapWithIndex } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => { const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice(); const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
const sortedElements = new Set<ExcalidrawElement>(); return element.groupIds[element.groupIds.length - level - 1];
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>(); const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
origElements.forEach((element, idx) => { for (const element of levelElements) {
if (groupHandledElements.has(element.id)) { const groupId = groupIdAtLevel(element, level);
return; if (groupId === undefined) {
} slots.push(element);
if (element.groupIds?.length) { continue;
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 { let bucket = buckets.get(groupId);
sortedElements.add(element); if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
} }
});
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
if (sortedElements.size !== elements.length) { if (sortedElements.length !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!"); console.error("defragmentGroups: lost some elements... bailing!");
return elements; return elements;
} }
return [...sortedElements]; return sortedElements;
}; };
/** /**
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = ( const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
const elementsMap = arrayToMapWithIndex(elements); const elementsMap = arrayToMap(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>(); const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => { for (const element of elements) {
if (!element) { if (sortedElements.has(element)) {
return; continue;
} }
if (element.boundElements?.length) { if (element.boundElements?.length) {
sortedElements.add(element); sortedElements.add(element);
origElements[idx] = null; for (const boundElement of element.boundElements) {
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id); const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") { if (child && boundElement.type === "text") {
sortedElements.add(child[0]); sortedElements.add(child);
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 { continue;
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
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return // if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data // original instead as that's better than losing data
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = ( export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => { ) => {
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements)); return normalizeBoundElementsOrder(defragmentGroups(elements));
}; };
+43 -3
View File
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
]), ]),
[ [
"BA_rect1", "BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5", "BA_rect5",
"BA_rect6", "BA_rect6",
"A_rect2", "A_rect2",
"A_rect5", "A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4", "rect4",
"X_rect8", "X_rect8",
"X_rect11",
"YX_rect10", "YX_rect10",
"X_rect11",
"rect9", "rect9",
], ],
); );
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
}); });
// TODO // TODO