Fixing duplicate

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs 2025-03-17 22:58:56 +01:00
parent d05c163aec
commit 35c4c074d7
No known key found for this signature in database
11 changed files with 184 additions and 246 deletions

View file

@ -8440,10 +8440,25 @@ class App extends React.Component<AppProps, AppState> {
idsOfElementsToDuplicate: new Map( idsOfElementsToDuplicate: new Map(
selectedElements.map((el) => [el.id, el]), selectedElements.map((el) => [el.id, el]),
), ),
overrides: (el) => {
const origEl = pointerDownState.originalElements.get(el.id)!;
return {
x: origEl.x,
y: origEl.y,
};
},
});
clonedElements.forEach((element) => {
pointerDownState.originalElements.set(element.id, element);
}); });
const nextSceneElements = syncMovedIndices( const mappedNewSceneElements = this.props.onDuplicate?.(
elementsWithClones, elementsWithClones,
elements,
);
const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
arrayToMap(clonedElements), arrayToMap(clonedElements),
); );

View file

@ -49,7 +49,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -59,6 +58,10 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Mutable } from "../utility-types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
import type { import type {
@ -974,7 +977,6 @@ export const bindPointToSnapToElementOutline = (
otherPoint, otherPoint,
), ),
), ),
FIXED_BINDING_DISTANCE,
)[0]; )[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
@ -1147,7 +1149,7 @@ export const snapToMid = (
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads(
pointFrom(x - 2 * FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@ -1158,7 +1160,7 @@ export const snapToMid = (
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y - 2 * FIXED_BINDING_DISTANCE), pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@ -1169,7 +1171,7 @@ export const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom(x + width + 2 * FIXED_BINDING_DISTANCE, center[1]), pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
@ -1180,7 +1182,7 @@ export const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y + height + 2 * FIXED_BINDING_DISTANCE), pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
center, center,
angle, angle,
); );
@ -1412,107 +1414,75 @@ const getLinearElementEdgeCoors = (
); );
}; };
// We need to:
// 1: Update elements not selected to point to duplicated elements
// 2: Update duplicated elements to point to other duplicated elements
export const fixBindingsAfterDuplication = ( export const fixBindingsAfterDuplication = (
sceneElements: readonly ExcalidrawElement[], newElements: ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
// There are three copying mechanisms: Copy-paste, duplication and alt-drag. duplicatedElementsMap: NonDeletedSceneElementsMap,
// Only when alt-dragging the new "duplicates" act as the "old", while ) => {
// the "old" elements act as the "new copy" - essentially working reverse for (const element of newElements) {
// to the other two. if ("boundElements" in element && element.boundElements) {
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined, Object.assign(element, {
): void => { boundElements: element.boundElements.reduce(
// First collect all the binding/bindable elements, so we only update
// each once, regardless of whether they were duplicated or not.
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
const duplicateIdToOldId = new Map(
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
);
oldElements.forEach((oldElement) => {
const { boundElements } = oldElement;
if (boundElements != null && boundElements.length > 0) {
boundElements.forEach((boundElement) => {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
allBoundElementIds.add(boundElement.id);
}
});
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
if (isBindingElement(oldElement)) {
if (oldElement.startBinding != null) {
const { elementId } = oldElement.startBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.endBinding != null) {
const { elementId } = oldElement.endBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.startBinding != null || oldElement.endBinding != null) {
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
}
});
// Update the linear elements
( (
sceneElements.filter(({ id }) => acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
allBoundElementIds.has(id), binding,
) as ExcalidrawLinearElement[] ) => {
).forEach((element) => { const newBindingId = oldIdToDuplicatedId.get(binding.id);
const { startBinding, endBinding } = element; if (newBindingId) {
mutateElement(element, { acc.push({ ...binding, id: newBindingId });
startBinding: newBindingAfterDuplication( }
startBinding, return acc;
oldIdToDuplicatedId, },
[],
), ),
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
}); });
}
if ("containerId" in element && element.containerId) {
Object.assign(element, {
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
}); });
}
// Update the bindable shapes if ("endBinding" in element && element.endBinding) {
sceneElements const newEndBindingId = oldIdToDuplicatedId.get(
.filter(({ id }) => allBindableElementIds.has(id)) element.endBinding.elementId,
.forEach((bindableElement) => { );
const oldElementId = duplicateIdToOldId.get(bindableElement.id); Object.assign(element, {
const boundElements = sceneElements.find( endBinding: newEndBindingId
({ id }) => id === oldElementId,
)?.boundElements;
if (boundElements && boundElements.length > 0) {
mutateElement(bindableElement, {
boundElements: boundElements.map((boundElement) =>
oldIdToDuplicatedId.has(boundElement.id)
? { ? {
id: oldIdToDuplicatedId.get(boundElement.id)!, ...element.endBinding,
type: boundElement.type, elementId: newEndBindingId,
} }
: boundElement, : null,
),
}); });
} }
if ("startBinding" in element && element.startBinding) {
const newEndBindingId = oldIdToDuplicatedId.get(
element.startBinding.elementId,
);
Object.assign(element, {
startBinding: newEndBindingId
? {
...element.startBinding,
elementId: newEndBindingId,
}
: null,
}); });
}; }
const newBindingAfterDuplication = ( if (isElbowArrow(element)) {
binding: PointBinding | null, Object.assign(
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, element,
): PointBinding | null => { updateElbowArrowPoints(element, duplicatedElementsMap, {
if (binding == null) { points: [
return null; element.points[0],
element.points[element.points.length - 1],
],
}),
);
}
} }
return {
...binding,
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
};
}; };
export const fixBindingsAfterDeletion = ( export const fixBindingsAfterDeletion = (

View file

@ -209,6 +209,7 @@ describe("duplicating multiple elements", () => {
type: clonedText1.type, type: clonedText1.type,
}), }),
); );
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => { clonedArrows.forEach((arrow) => {
expect( expect(
@ -302,9 +303,9 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements( const { newElements: clonedElements } = duplicateElements(
origElements, origElements,
) as any as typeof origElements; ) as any as { newElements: typeof origElements };
const [ const [
clonedRectangle, clonedRectangle,
clonedText1, clonedText1,
@ -324,7 +325,7 @@ describe("duplicating multiple elements", () => {
elementId: clonedRectangle.id, elementId: clonedRectangle.id,
}); });
expect(clonedArrow2.endBinding).toBe(null); expect(clonedArrow2.endBinding).toBe(null);
console.log(clonedArrow3);
expect(clonedArrow3.startBinding).toBe(null); expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({ expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding, ...arrow3.endBinding,
@ -348,9 +349,9 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements( const { newElements: clonedElements } = duplicateElements(
origElements, origElements,
) as any as typeof origElements; ) as any as { newElements: typeof origElements };
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements; clonedElements;

View file

@ -28,17 +28,12 @@ import { bumpVersion } from "./mutateElement";
import { import {
hasBoundTextElement, hasBoundTextElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
} from "./typeChecks"; } from "./typeChecks";
import { import { getBoundTextElement, getContainerElement } from "./textElement";
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerElement,
} from "./textElement";
import { updateElbowArrowPoints } from "./elbowArrow"; import { fixBindingsAfterDuplication } from "./binding";
import type { AppState } from "../types"; import type { AppState } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
@ -315,83 +310,12 @@ export const duplicateElements = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const fixBindingsAfterDuplication = (
newElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicatedElementsMap: NonDeletedSceneElementsMap,
) => {
for (const element of newElements) {
if ("boundElements" in element && element.boundElements) {
Object.assign(element, {
boundElements: element.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = oldIdToDuplicatedId.get(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
),
});
}
if ("endBinding" in element && element.endBinding) {
const newEndBindingId = oldIdToDuplicatedId.get(
element.endBinding.elementId,
);
Object.assign(element, {
endBinding: newEndBindingId
? {
...element.endBinding,
elementId: newEndBindingId,
}
: null,
});
}
if ("startBinding" in element && element.startBinding) {
const newEndBindingId = oldIdToDuplicatedId.get(
element.startBinding.elementId,
);
Object.assign(element, {
startBinding: newEndBindingId
? {
...element.startBinding,
elementId: newEndBindingId,
}
: null,
});
}
if (isElbowArrow(element)) {
Object.assign(
element,
updateElbowArrowPoints(element, duplicatedElementsMap, {
points: [
element.points[0],
element.points[element.points.length - 1],
],
}),
);
}
}
};
fixBindingsAfterDuplication( fixBindingsAfterDuplication(
newElements, newElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
duplicatedElementsMap as NonDeletedSceneElementsMap, duplicatedElementsMap as NonDeletedSceneElementsMap,
); );
bindTextToShapeAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
elementsWithClones, elementsWithClones,
oldElements, oldElements,

View file

@ -358,7 +358,7 @@ describe("elbow arrow ui", () => {
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
}); });
it("keeps arrow shape when only the bound arrow is duplicated", async () => { it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
UI.createElement("rectangle", { UI.createElement("rectangle", {
x: -150, x: -150,
y: -150, y: -150,
@ -404,8 +404,8 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.elbowed).toBe(true); expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([ expect(duplicatedArrow.points).toEqual([
[0, 0], [0, 0],
[45, 0], [0, 100],
[45, 200], [90, 100],
[90, 200], [90, 200],
]); ]);
}); });

View file

@ -6,7 +6,7 @@ import {
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { getFontString, arrayToMap } from "../utils"; import { getFontString } from "../utils";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
@ -112,48 +112,6 @@ export const redrawTextBoundingBox = (
mutateElement(textElement, boundTextUpdates, informMutation); mutateElement(textElement, boundTextUpdates, informMutation);
}; };
export const bindTextToShapeAfterDuplication = (
newElements: ExcalidrawElement[],
oldElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
): void => {
const newElementsMap = arrayToMap(newElements) as Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
oldElements.forEach((element) => {
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
if (newTextElementId) {
const newContainer = newElementsMap.get(newElementId);
if (newContainer) {
mutateElement(newContainer, {
boundElements: (element.boundElements || [])
.filter(
(boundElement) =>
boundElement.id !== newTextElementId &&
boundElement.id !== boundTextElementId,
)
.concat({
type: "text",
id: newTextElementId,
}),
});
}
const newTextElement = newElementsMap.get(newTextElementId);
if (newTextElement && isTextElement(newTextElement)) {
mutateElement(newTextElement, {
containerId: newContainer ? newElementId : null,
});
}
}
}
});
};
export const handleBindTextResize = ( export const handleBindTextResize = (
container: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,

View file

@ -924,11 +924,12 @@ History {
], ],
], ],
"startBinding": null, "startBinding": null,
"y": 0,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
"elementId": "id166", "elementId": "id166",
"focus": -0, "focus": "0.00000",
"gap": 1, "gap": 1,
}, },
"points": [ "points": [
@ -943,9 +944,10 @@ History {
], ],
"startBinding": { "startBinding": {
"elementId": "id165", "elementId": "id165",
"focus": 0, "focus": "-0.00000",
"gap": 1, "gap": 1,
}, },
"y": "0.00000",
}, },
}, },
}, },

View file

@ -1,5 +1,73 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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": "id0",
"index": "a0",
"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": 5,
"versionNonce": 23633383,
"width": 30,
"x": -10,
"y": 60,
}
`;
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": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 6,
"versionNonce": 1604849351,
"width": 30,
"x": 30,
"y": 20,
}
`;
exports[`move element > rectangle 5`] = ` exports[`move element > rectangle 5`] = `
{ {
"angle": 0, "angle": 0,

View file

@ -2153,8 +2153,8 @@ History {
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 10, "width": 10,
"x": 20, "x": 10,
"y": 20, "y": 10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -10643,8 +10643,8 @@ History {
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 10, "width": 10,
"x": 20, "x": 10,
"y": 20, "y": 10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -10676,8 +10676,8 @@ History {
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 10, "width": 10,
"x": 40, "x": 30,
"y": 20, "y": 10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
@ -10709,8 +10709,8 @@ History {
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"width": 10, "width": 10,
"x": 60, "x": 50,
"y": 20, "y": 10,
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,

View file

@ -174,9 +174,9 @@ describe("duplicate element on move when ALT is clicked", () => {
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(2); expect(h.elements.length).toEqual(2);
// previous element should stay intact // behavior should be the same as Ctrl+D
expect([h.elements[0].x, h.elements[0].y]).toEqual([30, 20]); expect([h.elements[0].x, h.elements[0].y]).toEqual([-10, 60]);
expect([h.elements[1].x, h.elements[1].y]).toEqual([-10, 60]); expect([h.elements[1].x, h.elements[1].y]).toEqual([30, 20]);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });

View file

@ -505,12 +505,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1, 0);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1, 0);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@ -533,11 +533,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1, 0);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0, 0);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });