fix(editor): fix target element index when creating/adding elements to frames (#11257)

This commit is contained in:
David Luzar
2026-05-05 21:35:32 +02:00
committed by GitHub
parent 3004c642da
commit d2557474e2
21 changed files with 1405 additions and 262 deletions
+12 -36
View File
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
+7 -2
View File
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -324,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break;
}
}
+19 -2
View File
@@ -111,6 +111,9 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -170,6 +173,8 @@ export const duplicateElements = (
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
@@ -277,7 +282,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -293,13 +298,25 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+93 -37
View File
@@ -23,6 +23,7 @@ import {
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -491,10 +492,44 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements;
};
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
* Adds elements and their bound elements to frame. Reorders added elements to
* be just below frame, or just above its highest child (whichever is higher).
*
* @returns mutated allElements (same data structure)
*/
@@ -502,19 +537,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const commonFrameId = getCommonFrameId(elementsToAdd);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const finalElementsToAdd = new Set<ExcalidrawElement>();
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -525,7 +552,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
// - keep elements already in the frame so mixed selections can be reordered
// together
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -538,38 +566,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
if (element.frameId && element.frameId !== frame.id) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
finalElementsToAdd.add(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
// we don't always need to update the element if it's already in the frame,
// but we still need to accumulate in finalElementsToAdd so we potentially
// reorder them if added together
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
}
return allElements;
// (re)order elements to be just below the frame,
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
};
export const removeElementsFromFrame = (
@@ -623,13 +681,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
app.state,
).slice();
};
+20
View File
@@ -392,3 +392,23 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};