fix: Refactor and merge duplication and binding (#9246)
All checks were successful
Tests / test (push) Successful in 4m52s

This commit is contained in:
Márk Tolmács 2025-03-23 18:39:33 +01:00 committed by GitHub
parent 58990b41ae
commit 77aca48c84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1293 additions and 1085 deletions

View file

@ -126,7 +126,6 @@ import { restore, restoreElements } from "../data/restore";
import {
dragNewElement,
dragSelectedElements,
duplicateElement,
getCommonBounds,
getCursorForResizingElement,
getDragOffsetXY,
@ -152,7 +151,6 @@ import {
bindOrUnbindLinearElement,
bindOrUnbindLinearElements,
fixBindingsAfterDeletion,
fixBindingsAfterDuplication,
getHoveredElementForBinding,
isBindingEnabled,
isLinearElementSimpleAndAlreadyBound,
@ -163,9 +161,8 @@ import {
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, duplicateElements } from "../element/duplicate";
import {
deepCopyElement,
duplicateElements,
newFrameElement,
newFreeDrawElement,
newEmbeddableElement,
@ -292,7 +289,6 @@ import {
} from "../element/image";
import { fileOpen } from "../data/filesystem";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerCenter,
getContainerElement,
@ -309,7 +305,6 @@ import { Fonts, getLineHeight } from "../fonts";
import {
getFrameChildren,
isCursorInFrame,
bindElementsToFramesAfterDuplication,
addElementsToFrame,
replaceAllElementsInFrame,
removeElementsFromFrame,
@ -3224,17 +3219,16 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
const newElements = duplicateElements(
elements.map((element) => {
const { newElements } = duplicateElements({
type: "everything",
elements: elements.map((element) => {
return newElementWith(element, {
x: element.x + gridX - minX,
y: element.y + gridY - minY,
});
}),
{
randomizeSeed: !opts.retainSeed,
},
);
randomizeSeed: !opts.retainSeed,
});
const prevElements = this.scene.getElementsIncludingDeleted();
let nextElements = [...prevElements, ...newElements];
@ -6095,7 +6089,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (!hitElement || !isElbowArrow(hitElement)) {
} else if (
!hitElement ||
// Ebow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
@ -6288,7 +6287,13 @@ class App extends React.Component<AppProps, AppState> {
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if (
@ -8138,6 +8143,7 @@ class App extends React.Component<AppProps, AppState> {
...this.state.selectedLinearElement,
pointerDownState: ret.pointerDownState,
selectedPointsIndices: ret.selectedPointsIndices,
segmentMidPointHoveredCoords: null,
},
});
}
@ -8147,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
...this.state.editingLinearElement,
pointerDownState: ret.pointerDownState,
selectedPointsIndices: ret.selectedPointsIndices,
segmentMidPointHoveredCoords: null,
},
});
}
@ -8160,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const didDrag = LinearElementEditor.handlePointDragging(
const newLinearElementEditor = LinearElementEditor.handlePointDragging(
event,
this,
pointerCoords.x,
@ -8174,29 +8181,18 @@ class App extends React.Component<AppProps, AppState> {
linearElementEditor,
this.scene,
);
if (didDrag) {
if (newLinearElementEditor) {
pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true;
if (
this.state.editingLinearElement &&
!this.state.editingLinearElement.isDragging
) {
this.setState({
editingLinearElement: {
...this.state.editingLinearElement,
isDragging: true,
},
});
}
if (!this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: true,
},
});
}
this.setState({
editingLinearElement: this.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
});
return;
}
}
@ -8422,145 +8418,55 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.hasBeenDuplicated = true;
const nextElements = [];
const elementsToAppend = [];
const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element;
const selectedElementIds = new Set(
this.scene
.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
})
.map((element) => element.id),
);
const elements = this.scene.getElementsIncludingDeleted();
for (const element of elements) {
const isInSelection =
selectedElementIds.has(element.id) ||
// case: the state.selectedElementIds might not have been
// updated yet by the time this mousemove event is fired
(element.id === hitElement?.id &&
pointerDownState.hit.wasAddedToSelection);
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(element.x) > 1e7 ||
Math.abs(element.x) > 1e7 ||
Math.abs(element.width) > 1e7 ||
Math.abs(element.height) > 1e7
) {
console.error(
`Alt+dragging element in scene with invalid dimensions`,
element.x,
element.y,
element.width,
element.height,
isInSelection,
);
return;
}
if (isInSelection) {
const duplicatedElement = duplicateElement(
this.state.editingGroupId,
groupIdMap,
element,
);
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(duplicatedElement.x) > 1e7 ||
Math.abs(duplicatedElement.x) > 1e7 ||
Math.abs(duplicatedElement.width) > 1e7 ||
Math.abs(duplicatedElement.height) > 1e7
) {
console.error(
`Alt+dragging duplicated element with invalid dimensions`,
duplicatedElement.x,
duplicatedElement.y,
duplicatedElement.width,
duplicatedElement.height,
);
return;
}
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.width) > 1e7 ||
Math.abs(origElement.height) > 1e7
) {
console.error(
`Alt+dragging duplicated element with invalid dimensions`,
origElement.x,
origElement.y,
origElement.width,
origElement.height,
);
return;
}
mutateElement(duplicatedElement, {
x: origElement.x,
y: origElement.y,
});
// put duplicated element to pointerDownState.originalElements
// so that we can snap to the duplicated element without releasing
pointerDownState.originalElements.set(
duplicatedElement.id,
duplicatedElement,
);
nextElements.push(duplicatedElement);
elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
} else {
nextElements.push(element);
}
const hitElement = pointerDownState.hit.element;
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (
hitElement &&
!selectedElements.find((el) => el.id === hitElement.id)
) {
selectedElements.push(hitElement);
}
let nextSceneElements: ExcalidrawElement[] = [
...nextElements,
...elementsToAppend,
];
const { newElements: clonedElements, elementsWithClones } =
duplicateElements({
type: "in-place",
elements,
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate: new Map(
selectedElements.map((el) => [el.id, el]),
),
overrides: (el) => {
const origEl = pointerDownState.originalElements.get(el.id);
if (origEl) {
return {
x: origEl.x,
y: origEl.y,
};
}
return {};
},
reverseOrder: true,
});
clonedElements.forEach((element) => {
pointerDownState.originalElements.set(element.id, element);
});
const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements,
elementsWithClones,
elements,
);
nextSceneElements = mappedNewSceneElements || nextSceneElements;
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
"duplicatesServeAsOld",
);
bindElementsToFramesAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
arrayToMap(clonedElements),
);
this.scene.replaceAllElements(nextSceneElements);

View file

@ -8,18 +8,20 @@ import React, {
import { MIME_TYPES } from "../constants";
import { serializeLibraryAsJSON } from "../data/json";
import { duplicateElements } from "../element/newElement";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n";
import { arrayToMap } from "../utils";
import { duplicateElements } from "../element/duplicate";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import {
LibraryMenuSection,
LibraryMenuSectionGrid,
} from "./LibraryMenuSection";
import Spinner from "./Spinner";
import Stack from "./Stack";
@ -160,7 +162,11 @@ export default function LibraryMenuItems({
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
elements: duplicateElements({
type: "everything",
elements: item.elements,
randomizeSeed: true,
}).newElements,
};
});
},

View file

@ -1,8 +1,9 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate";
import { EVENT } from "../../constants";
import { deepCopyElement } from "../../element/newElement";
import { KEYS } from "../../keys";
import { CaptureUpdateAction } from "../../store";
import { cloneJSON } from "../../utils";