fix(editor): fix target element index when creating/adding elements to frames (#11257)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user