fix: keep orig elem in place on alt-duplication (#9403)

* fix: keep orig elem in place on alt-duplication

* clarify comment

* fix: incorrect selection on duplicating labeled containers

* fix: duplicating within group outside frame should remove from group
This commit is contained in:
David Luzar 2025-04-17 16:08:07 +02:00 committed by GitHub
parent 0cf36d6b30
commit a5d6939826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 603 additions and 579 deletions

View file

@ -279,6 +279,7 @@ import {
import {
excludeElementsInFramesFromSelection,
getSelectionStateForElements,
makeNextSelectedElementIds,
} from "@excalidraw/element/selection";
@ -3267,7 +3268,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
const { newElements } = duplicateElements({
const { duplicatedElements } = duplicateElements({
type: "everything",
elements: elements.map((element) => {
return newElementWith(element, {
@ -3279,7 +3280,7 @@ class App extends React.Component<AppProps, AppState> {
});
const prevElements = this.scene.getElementsIncludingDeleted();
let nextElements = [...prevElements, ...newElements];
let nextElements = [...prevElements, ...duplicatedElements];
const mappedNewSceneElements = this.props.onDuplicate?.(
nextElements,
@ -3288,13 +3289,13 @@ class App extends React.Component<AppProps, AppState> {
nextElements = mappedNewSceneElements || nextElements;
syncMovedIndices(nextElements, arrayToMap(newElements));
syncMovedIndices(nextElements, arrayToMap(duplicatedElements));
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
if (topLayerFrame) {
const eligibleElements = filterElementsEligibleAsFrameChildren(
newElements,
duplicatedElements,
topLayerFrame,
);
addElementsToFrame(
@ -3307,7 +3308,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => {
duplicatedElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(
newElement,
@ -3323,7 +3324,7 @@ class App extends React.Component<AppProps, AppState> {
// paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
if (isSafari) {
Fonts.loadElementsFonts(newElements).then((fontFaces) => {
Fonts.loadElementsFonts(duplicatedElements).then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
}
@ -3335,7 +3336,7 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
excludeElementsInFramesFromSelection(duplicatedElements);
this.setState(
{
@ -3378,7 +3379,7 @@ class App extends React.Component<AppProps, AppState> {
this.setActiveTool({ type: "selection" });
if (opts.fitToContent) {
this.scrollToContent(newElements, {
this.scrollToContent(duplicatedElements, {
fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(),
});
@ -6942,6 +6943,7 @@ class App extends React.Component<AppProps, AppState> {
drag: {
hasOccurred: false,
offset: null,
origin: { ...origin },
},
eventListeners: {
onMove: null,
@ -8236,8 +8238,8 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.state !== "active"
) {
const dragOffset = {
x: pointerCoords.x - pointerDownState.origin.x,
y: pointerCoords.y - pointerDownState.origin.y,
x: pointerCoords.x - pointerDownState.drag.origin.x,
y: pointerCoords.y - pointerDownState.drag.origin.y,
};
const originalElements = [
@ -8432,52 +8434,112 @@ class App extends React.Component<AppProps, AppState> {
selectedElements.map((el) => [el.id, el]),
);
const { newElements: clonedElements, elementsWithClones } =
duplicateElements({
type: "in-place",
elements,
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate,
overrides: (el) => {
const origEl = pointerDownState.originalElements.get(el.id);
if (origEl) {
return {
x: origEl.x,
y: origEl.y,
seed: origEl.seed,
};
}
return {};
},
reverseOrder: true,
});
clonedElements.forEach((element) => {
pointerDownState.originalElements.set(element.id, element);
const {
duplicatedElements,
duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
} = duplicateElements({
type: "in-place",
elements,
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate,
overrides: ({ duplicateElement, origElement }) => {
return {
// reset to the original element's frameId (unless we've
// duplicated alongside a frame in which case we need to
// keep the duplicate frame's id) so that the element
// frame membership is refreshed on pointerup
// NOTE this is a hacky solution and should be done
// differently
frameId: duplicateElement.frameId ?? origElement.frameId,
seed: randomInteger(),
};
},
});
duplicatedElements.forEach((element) => {
pointerDownState.originalElements.set(
element.id,
deepCopyElement(element),
);
});
const mappedNewSceneElements = this.props.onDuplicate?.(
elementsWithClones,
elements,
);
const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
arrayToMap(clonedElements),
).map((el) => {
const mappedClonedElements = elementsWithDuplicates.map((el) => {
if (idsOfElementsToDuplicate.has(el.id)) {
return newElementWith(el, {
seed: randomInteger(),
});
const origEl = pointerDownState.originalElements.get(el.id);
if (origEl) {
return newElementWith(el, {
x: origEl.x,
y: origEl.y,
});
}
}
return el;
});
this.scene.replaceAllElements(nextSceneElements);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
const mappedNewSceneElements = this.props.onDuplicate?.(
mappedClonedElements,
elements,
);
const elementsWithIndices = syncMovedIndices(
mappedNewSceneElements || mappedClonedElements,
arrayToMap(duplicatedElements),
);
// we need to update synchronously so as to keep pointerDownState,
// appState, and scene elements in sync
flushSync(() => {
// swap hit element with the duplicated one
if (pointerDownState.hit.element) {
const cloneId = origIdToDuplicateId.get(
pointerDownState.hit.element.id,
);
const clonedElement =
cloneId && duplicateElementsMap.get(cloneId);
pointerDownState.hit.element = clonedElement || null;
}
// swap hit elements with the duplicated ones
pointerDownState.hit.allHitElements =
pointerDownState.hit.allHitElements.reduce(
(
acc: typeof pointerDownState.hit.allHitElements,
origHitElement,
) => {
const cloneId = origIdToDuplicateId.get(origHitElement.id);
const clonedElement =
cloneId && duplicateElementsMap.get(cloneId);
if (clonedElement) {
acc.push(clonedElement);
}
return acc;
},
[],
);
// update drag origin to the position at which we started
// the duplication so that the drag offset is correct
pointerDownState.drag.origin = viewportCoordsToSceneCoords(
event,
this.state,
);
// switch selected elements to the duplicated ones
this.setState((prevState) => ({
...getSelectionStateForElements(
duplicatedElements,
this.scene.getNonDeletedElements(),
prevState,
),
}));
this.scene.replaceAllElements(elementsWithIndices);
this.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
});
}
return;