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

@ -7,26 +7,17 @@ import {
import { getNonDeletedElements } from "@excalidraw/element";
import {
isBoundToContainer,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
getSelectionStateForElements,
} from "@excalidraw/element/selection";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons";
@ -65,52 +56,49 @@ export const actionDuplicateSelection = register({
}
}
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
duplicateElements({
type: "in-place",
elements,
idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
),
appState,
randomizeSeed: true,
overrides: (element) => ({
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
type: "in-place",
elements,
idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
reverseOrder: false,
});
),
appState,
randomizeSeed: true,
overrides: ({ origElement, origIdToDuplicateId }) => {
const duplicateFrameId =
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
return {
x: origElement.x + DEFAULT_GRID_SIZE / 2,
y: origElement.y + DEFAULT_GRID_SIZE / 2,
frameId: duplicateFrameId ?? origElement.frameId,
};
},
});
if (app.props.onDuplicate && nextElements) {
const mappedElements = app.props.onDuplicate(nextElements, elements);
if (app.props.onDuplicate && elementsWithDuplicates) {
const mappedElements = app.props.onDuplicate(
elementsWithDuplicates,
elements,
);
if (mappedElements) {
nextElements = mappedElements;
elementsWithDuplicates = mappedElements;
}
}
return {
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
elements: syncMovedIndices(
elementsWithDuplicates,
arrayToMap(duplicatedElements),
),
appState: {
...appState,
...updateLinearElementEditors(duplicatedElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
duplicatedElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
getNonDeletedElements(nextElements),
...getSelectionStateForElements(
duplicatedElements,
getNonDeletedElements(elementsWithDuplicates),
appState,
null,
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -130,24 +118,3 @@ export const actionDuplicateSelection = register({
/>
),
});
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
const linears = clonedElements.filter(isLinearElement);
if (linears.length === 1) {
const linear = linears[0];
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = clonedElements.every(
(el) => el.id === linear.id || boundElements.includes(el.id),
);
if (onlySingleLinearSelected) {
return {
selectedLinearElement: new LinearElementEditor(linear),
};
}
}
return {
selectedLinearElement: null,
};
};

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;

View file

@ -166,7 +166,7 @@ export default function LibraryMenuItems({
type: "everything",
elements: item.elements,
randomizeSeed: true,
}).newElements,
}).duplicatedElements,
};
});
},

View file

@ -1,40 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 50,
"id": "id2",
"index": "Zz",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 6,
"versionNonce": 1604849351,
"width": 30,
"x": 30,
"y": 20,
}
`;
exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@ -54,13 +20,47 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"roundness": {
"type": 3,
},
"seed": 1505387817,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 6,
"version": 5,
"versionNonce": 1505387817,
"width": 30,
"x": 30,
"y": 20,
}
`;
exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 50,
"id": "id2",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"seed": 1604849351,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 915032327,
"width": 30,
"x": -10,

View file

@ -2038,7 +2038,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id2": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -2128,8 +2128,16 @@ History {
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
"deleted": {
"selectedElementIds": {
"id2": true,
},
},
"inserted": {
"selectedElementIds": {
"id0": true,
},
},
},
},
"elementsChange": ElementsChange {
@ -2145,7 +2153,7 @@ History {
"frameId": null,
"groupIds": [],
"height": 10,
"index": "Zz",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
@ -2159,26 +2167,15 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 10,
"y": 10,
"x": 20,
"y": 20,
},
"inserted": {
"isDeleted": true,
},
},
},
"updated": Map {
"id0" => Delta {
"deleted": {
"x": 20,
"y": 20,
},
"inserted": {
"x": 10,
"y": 10,
},
},
},
"updated": Map {},
},
},
],
@ -10378,13 +10375,13 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
"id2": true,
"id6": true,
"id8": true,
"id9": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {
"id4": true,
"id7": true,
},
"selectedLinearElement": null,
"selectionElement": null,
@ -10648,8 +10645,26 @@ History {
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
"deleted": {
"selectedElementIds": {
"id6": true,
"id8": true,
"id9": true,
},
"selectedGroupIds": {
"id7": true,
},
},
"inserted": {
"selectedElementIds": {
"id0": true,
"id1": true,
"id2": true,
},
"selectedGroupIds": {
"id4": true,
},
},
},
},
"elementsChange": ElementsChange {
@ -10667,7 +10682,7 @@ History {
"id7",
],
"height": 10,
"index": "Zx",
"index": "a3",
"isDeleted": false,
"link": null,
"locked": false,
@ -10681,8 +10696,8 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 10,
"y": 10,
"x": 20,
"y": 20,
},
"inserted": {
"isDeleted": true,
@ -10700,7 +10715,7 @@ History {
"id7",
],
"height": 10,
"index": "Zy",
"index": "a4",
"isDeleted": false,
"link": null,
"locked": false,
@ -10714,8 +10729,8 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 30,
"y": 10,
"x": 40,
"y": 20,
},
"inserted": {
"isDeleted": true,
@ -10733,7 +10748,7 @@ History {
"id7",
],
"height": 10,
"index": "Zz",
"index": "a5",
"isDeleted": false,
"link": null,
"locked": false,
@ -10747,46 +10762,15 @@ History {
"strokeWidth": 2,
"type": "rectangle",
"width": 10,
"x": 50,
"y": 10,
"x": 60,
"y": 20,
},
"inserted": {
"isDeleted": true,
},
},
},
"updated": Map {
"id0" => Delta {
"deleted": {
"x": 20,
"y": 20,
},
"inserted": {
"x": 10,
"y": 10,
},
},
"id1" => Delta {
"deleted": {
"x": 40,
"y": 20,
},
"inserted": {
"x": 30,
"y": 10,
},
},
"id2" => Delta {
"deleted": {
"x": 60,
"y": 20,
},
"inserted": {
"x": 50,
"y": 10,
},
},
},
"updated": Map {},
},
},
],

View file

@ -307,6 +307,41 @@ describe("pasting & frames", () => {
});
});
it("should remove element from frame when pasted outside", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rect = API.createElement({
type: "rectangle",
frameId: frame.id,
x: 10,
y: 10,
width: 50,
height: 50,
});
API.setElements([frame]);
const clipboardJSON = await serializeAsClipboardJSON({
elements: [rect],
files: null,
});
mouse.moveTo(150, 150);
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
expect(h.elements.length).toBe(2);
expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(null);
});
});
it("should filter out elements not overlapping frame", async () => {
const frame = API.createElement({
type: "frame",

View file

@ -218,7 +218,7 @@ describe("Cropping and other features", async () => {
initialHeight / 2,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const duplicatedImage = duplicateElement(null, new Map(), image, {});
const duplicatedImage = duplicateElement(null, new Map(), image);
act(() => {
h.app.scene.insertElement(duplicatedImage);
});

View file

@ -724,7 +724,8 @@ export type PointerDownState = Readonly<{
scrollbars: ReturnType<typeof isOverScrollBars>;
// The previous pointer position
lastCoords: { x: number; y: number };
// map of original elements data
// original element frozen snapshots so we can access the original
// element attribute values at time of pointerdown
originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
resize: {
// Handle when resizing, might change during the pointer interaction
@ -758,6 +759,9 @@ export type PointerDownState = Readonly<{
hasOccurred: boolean;
// Might change during the pointer interaction
offset: { x: number; y: number } | null;
// by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication.
origin: { x: number; y: number };
};
// We need to have these in the state so that we can unsubscribe them
eventListeners: {