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

@ -56,7 +56,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -1409,19 +1408,19 @@ const getLinearElementEdgeCoors = (
}; };
export const fixDuplicatedBindingsAfterDuplication = ( export const fixDuplicatedBindingsAfterDuplication = (
newElements: ExcalidrawElement[], duplicatedElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicatedElementsMap: NonDeletedSceneElementsMap, duplicateElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
for (const element of newElements) { for (const duplicateElement of duplicatedElements) {
if ("boundElements" in element && element.boundElements) { if ("boundElements" in duplicateElement && duplicateElement.boundElements) {
Object.assign(element, { Object.assign(duplicateElement, {
boundElements: element.boundElements.reduce( boundElements: duplicateElement.boundElements.reduce(
( (
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>, acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding, binding,
) => { ) => {
const newBindingId = oldIdToDuplicatedId.get(binding.id); const newBindingId = origIdToDuplicateId.get(binding.id);
if (newBindingId) { if (newBindingId) {
acc.push({ ...binding, id: newBindingId }); acc.push({ ...binding, id: newBindingId });
} }
@ -1432,46 +1431,47 @@ export const fixDuplicatedBindingsAfterDuplication = (
}); });
} }
if ("containerId" in element && element.containerId) { if ("containerId" in duplicateElement && duplicateElement.containerId) {
Object.assign(element, { Object.assign(duplicateElement, {
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null, containerId:
origIdToDuplicateId.get(duplicateElement.containerId) ?? null,
}); });
} }
if ("endBinding" in element && element.endBinding) { if ("endBinding" in duplicateElement && duplicateElement.endBinding) {
const newEndBindingId = oldIdToDuplicatedId.get( const newEndBindingId = origIdToDuplicateId.get(
element.endBinding.elementId, duplicateElement.endBinding.elementId,
); );
Object.assign(element, { Object.assign(duplicateElement, {
endBinding: newEndBindingId endBinding: newEndBindingId
? { ? {
...element.endBinding, ...duplicateElement.endBinding,
elementId: newEndBindingId, elementId: newEndBindingId,
} }
: null, : null,
}); });
} }
if ("startBinding" in element && element.startBinding) { if ("startBinding" in duplicateElement && duplicateElement.startBinding) {
const newEndBindingId = oldIdToDuplicatedId.get( const newEndBindingId = origIdToDuplicateId.get(
element.startBinding.elementId, duplicateElement.startBinding.elementId,
); );
Object.assign(element, { Object.assign(duplicateElement, {
startBinding: newEndBindingId startBinding: newEndBindingId
? { ? {
...element.startBinding, ...duplicateElement.startBinding,
elementId: newEndBindingId, elementId: newEndBindingId,
} }
: null, : null,
}); });
} }
if (isElbowArrow(element)) { if (isElbowArrow(duplicateElement)) {
Object.assign( Object.assign(
element, duplicateElement,
updateElbowArrowPoints(element, duplicatedElementsMap, { updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
points: [ points: [
element.points[0], duplicateElement.points[0],
element.points[element.points.length - 1], duplicateElement.points[duplicateElement.points.length - 1],
], ],
}), }),
); );
@ -1479,196 +1479,6 @@ export const fixDuplicatedBindingsAfterDuplication = (
} }
}; };
const fixReversedBindingsForBindables = (
original: ExcalidrawBindableElement,
duplicate: ExcalidrawBindableElement,
originalElements: Map<string, ExcalidrawElement>,
elementsWithClones: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
original.boundElements?.forEach((binding, idx) => {
if (binding.type !== "arrow") {
return;
}
const oldArrow = elementsWithClones.find((el) => el.id === binding.id);
if (!isBindingElement(oldArrow)) {
return;
}
if (originalElements.has(binding.id)) {
// Linked arrow is in the selection, so find the duplicate pair
const newArrowId = oldIdToDuplicatedId.get(binding.id) ?? binding.id;
const newArrow = elementsWithClones.find(
(el) => el.id === newArrowId,
)! as ExcalidrawArrowElement;
mutateElement(newArrow, {
startBinding:
oldArrow.startBinding?.elementId === binding.id
? {
...oldArrow.startBinding,
elementId: duplicate.id,
}
: newArrow.startBinding,
endBinding:
oldArrow.endBinding?.elementId === binding.id
? {
...oldArrow.endBinding,
elementId: duplicate.id,
}
: newArrow.endBinding,
});
mutateElement(duplicate, {
boundElements: [
...(duplicate.boundElements ?? []).filter(
(el) => el.id !== binding.id && el.id !== newArrowId,
),
{
type: "arrow",
id: newArrowId,
},
],
});
} else {
// Linked arrow is outside the selection,
// so we move the binding to the duplicate
mutateElement(oldArrow, {
startBinding:
oldArrow.startBinding?.elementId === original.id
? {
...oldArrow.startBinding,
elementId: duplicate.id,
}
: oldArrow.startBinding,
endBinding:
oldArrow.endBinding?.elementId === original.id
? {
...oldArrow.endBinding,
elementId: duplicate.id,
}
: oldArrow.endBinding,
});
mutateElement(duplicate, {
boundElements: [
...(duplicate.boundElements ?? []),
{
type: "arrow",
id: oldArrow.id,
},
],
});
mutateElement(original, {
boundElements:
original.boundElements?.filter((_, i) => i !== idx) ?? null,
});
}
});
};
const fixReversedBindingsForArrows = (
original: ExcalidrawArrowElement,
duplicate: ExcalidrawArrowElement,
originalElements: Map<string, ExcalidrawElement>,
bindingProp: "startBinding" | "endBinding",
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
elementsWithClones: ExcalidrawElement[],
) => {
const oldBindableId = original[bindingProp]?.elementId;
if (oldBindableId) {
if (originalElements.has(oldBindableId)) {
// Linked element is in the selection
const newBindableId =
oldIdToDuplicatedId.get(oldBindableId) ?? oldBindableId;
const newBindable = elementsWithClones.find(
(el) => el.id === newBindableId,
) as ExcalidrawBindableElement;
mutateElement(duplicate, {
[bindingProp]: {
...original[bindingProp],
elementId: newBindableId,
},
});
mutateElement(newBindable, {
boundElements: [
...(newBindable.boundElements ?? []).filter(
(el) => el.id !== original.id && el.id !== duplicate.id,
),
{
id: duplicate.id,
type: "arrow",
},
],
});
} else {
// Linked element is outside the selection
const originalBindable = elementsWithClones.find(
(el) => el.id === oldBindableId,
);
if (originalBindable) {
mutateElement(duplicate, {
[bindingProp]: original[bindingProp],
});
mutateElement(original, {
[bindingProp]: null,
});
mutateElement(originalBindable, {
boundElements: [
...(originalBindable.boundElements?.filter(
(el) => el.id !== original.id,
) ?? []),
{
id: duplicate.id,
type: "arrow",
},
],
});
}
}
}
};
export const fixReversedBindings = (
originalElements: Map<string, ExcalidrawElement>,
elementsWithClones: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
for (const original of originalElements.values()) {
const duplicate = elementsWithClones.find(
(el) => el.id === oldIdToDuplicatedId.get(original.id),
)!;
if (isBindableElement(original) && isBindableElement(duplicate)) {
fixReversedBindingsForBindables(
original,
duplicate,
originalElements,
elementsWithClones,
oldIdToDuplicatedId,
);
} else if (isArrowElement(original) && isArrowElement(duplicate)) {
fixReversedBindingsForArrows(
original,
duplicate,
originalElements,
"startBinding",
oldIdToDuplicatedId,
elementsWithClones,
);
fixReversedBindingsForArrows(
original,
duplicate,
originalElements,
"endBinding",
oldIdToDuplicatedId,
elementsWithClones,
);
}
}
};
export const fixBindingsAfterDeletion = ( export const fixBindingsAfterDeletion = (
sceneElements: readonly ExcalidrawElement[], sceneElements: readonly ExcalidrawElement[],
deletedElements: readonly ExcalidrawElement[], deletedElements: readonly ExcalidrawElement[],

View file

@ -36,10 +36,7 @@ import {
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { import { fixDuplicatedBindingsAfterDuplication } from "./binding";
fixDuplicatedBindingsAfterDuplication,
fixReversedBindings,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
@ -60,16 +57,14 @@ import type {
* multiple elements at once, share this map * multiple elements at once, share this map
* amongst all of them * amongst all of them
* @param element Element to duplicate * @param element Element to duplicate
* @param overrides Any element properties to override
*/ */
export const duplicateElement = <TElement extends ExcalidrawElement>( export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"], editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>, groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement, element: TElement,
overrides?: Partial<TElement>,
randomizeSeed?: boolean, randomizeSeed?: boolean,
): Readonly<TElement> => { ): Readonly<TElement> => {
let copy = deepCopyElement(element); const copy = deepCopyElement(element);
if (isTestEnv()) { if (isTestEnv()) {
__test__defineOrigId(copy, element.id); __test__defineOrigId(copy, element.id);
@ -92,9 +87,6 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
); );
if (overrides) {
copy = Object.assign(copy, overrides);
}
return copy; return copy;
}; };
@ -102,9 +94,14 @@ export const duplicateElements = (
opts: { opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean; randomizeSeed?: boolean;
overrides?: ( overrides?: (data: {
originalElement: ExcalidrawElement, duplicateElement: ExcalidrawElement;
) => Partial<ExcalidrawElement>; origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & ( } & (
| { | {
/** /**
@ -132,14 +129,6 @@ export const duplicateElements = (
editingGroupId: AppState["editingGroupId"]; editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
}; };
/**
* If true, duplicated elements are inserted _before_ specified
* elements. Case: alt-dragging elements to duplicate them.
*
* TODO: remove this once (if) we stop replacing the original element
* with the duplicated one in the scene array.
*/
reverseOrder: boolean;
} }
), ),
) => { ) => {
@ -153,8 +142,6 @@ export const duplicateElements = (
selectedGroupIds: {}, selectedGroupIds: {},
} as const); } as const);
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false;
// Ids of elements that have already been processed so we don't push them // Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving // into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge // discontiguous group of elements (can happen due to a bug, or in edge
@ -167,10 +154,17 @@ export const duplicateElements = (
// loop over them. // loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>(); const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map(); const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = []; const duplicatedElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const origElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const origIdToDuplicateId = new Map<
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap; const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate = const _idsOfElementsToDuplicate =
opts.type === "in-place" opts.type === "in-place"
@ -188,7 +182,7 @@ export const duplicateElements = (
elements = normalizeElementOrder(elements); elements = normalizeElementOrder(elements);
const elementsWithClones: ExcalidrawElement[] = elements.slice(); const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
// helper functions // helper functions
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -214,17 +208,17 @@ export const duplicateElements = (
appState.editingGroupId, appState.editingGroupId,
groupIdMap, groupIdMap,
element, element,
opts.overrides?.(element),
opts.randomizeSeed, opts.randomizeSeed,
); );
processedIds.set(newElement.id, true); processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement); duplicateElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
oldElements.push(element); origElements.push(element);
newElements.push(newElement); duplicatedElements.push(newElement);
acc.push(newElement); acc.push(newElement);
return acc; return acc;
@ -248,21 +242,12 @@ export const duplicateElements = (
return; return;
} }
if (reverseOrder && index < 1) { if (index > elementsWithDuplicates.length - 1) {
elementsWithClones.unshift(...castArray(elements)); elementsWithDuplicates.push(...castArray(elements));
return; return;
} }
if (!reverseOrder && index > elementsWithClones.length - 1) { elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
elementsWithClones.push(...castArray(elements));
return;
}
elementsWithClones.splice(
index + (reverseOrder ? 0 : 1),
0,
...castArray(elements),
);
}; };
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
@ -294,11 +279,7 @@ export const duplicateElements = (
: [element], : [element],
); );
const targetIndex = reverseOrder const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
? elementsWithClones.findIndex((el) => {
return el.groupIds?.includes(groupId);
})
: findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId); return el.groupIds?.includes(groupId);
}); });
@ -318,7 +299,7 @@ export const duplicateElements = (
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.frameId === frameId || el.id === frameId; return el.frameId === frameId || el.id === frameId;
}); });
@ -335,7 +316,7 @@ export const duplicateElements = (
if (hasBoundTextElement(element)) { if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return ( return (
el.id === element.id || el.id === element.id ||
("containerId" in el && el.containerId === element.id) ("containerId" in el && el.containerId === element.id)
@ -344,7 +325,7 @@ export const duplicateElements = (
if (boundTextElement) { if (boundTextElement) {
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
targetIndex + (reverseOrder ? -1 : 0), targetIndex,
copyElements([element, boundTextElement]), copyElements([element, boundTextElement]),
); );
} else { } else {
@ -357,7 +338,7 @@ export const duplicateElements = (
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap); const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.id === element.id || el.id === container?.id; return el.id === element.id || el.id === container?.id;
}); });
@ -377,7 +358,7 @@ export const duplicateElements = (
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id), findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
copyElements(element), copyElements(element),
); );
} }
@ -385,28 +366,38 @@ export const duplicateElements = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fixDuplicatedBindingsAfterDuplication( fixDuplicatedBindingsAfterDuplication(
newElements, duplicatedElements,
oldIdToDuplicatedId, origIdToDuplicateId,
duplicatedElementsMap as NonDeletedSceneElementsMap, duplicateElementsMap as NonDeletedSceneElementsMap,
); );
if (reverseOrder) {
fixReversedBindings(
_idsOfElementsToDuplicate,
elementsWithClones,
oldIdToDuplicatedId,
);
}
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
elementsWithClones, elementsWithDuplicates,
oldElements, origElements,
oldIdToDuplicatedId, origIdToDuplicateId,
); );
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
duplicateElement,
opts.overrides({
duplicateElement,
origElement,
origIdToDuplicateId,
}),
);
}
}
}
return { return {
newElements, duplicatedElements,
elementsWithClones, duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
}; };
}; };

View file

@ -41,33 +41,31 @@ import type {
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
nextElements: readonly ExcalidrawElement[], nextElements: readonly ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[], origElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => { ) => {
const nextElementMap = arrayToMap(nextElements) as Map< const nextElementMap = arrayToMap(nextElements) as Map<
ExcalidrawElement["id"], ExcalidrawElement["id"],
ExcalidrawElement ExcalidrawElement
>; >;
for (const element of oldElements) { for (const element of origElements) {
if (element.frameId) { if (element.frameId) {
// use its frameId to get the new frameId // use its frameId to get the new frameId
const nextElementId = oldIdToDuplicatedId.get(element.id); const nextElementId = origIdToDuplicateId.get(element.id);
const nextFrameId = oldIdToDuplicatedId.get(element.frameId); const nextFrameId = origIdToDuplicateId.get(element.frameId);
if (nextElementId) { const nextElement = nextElementId && nextElementMap.get(nextElementId);
const nextElement = nextElementMap.get(nextElementId);
if (nextElement) { if (nextElement) {
mutateElement( mutateElement(
nextElement, nextElement,
{ {
frameId: nextFrameId ?? element.frameId, frameId: nextFrameId ?? null,
}, },
false, false,
); );
} }
} }
} }
}
}; };
export function isElementIntersectingFrame( export function isElementIntersectingFrame(

View file

@ -7,13 +7,20 @@ import type {
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks"; import {
isBoundToContainer,
isFrameLikeElement,
isLinearElement,
} from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
@ -254,3 +261,48 @@ export const makeNextSelectedElementIds = (
return nextSelectedElementIds; return nextSelectedElementIds;
}; };
const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[],
) => {
const linears = targetElements.filter(isLinearElement);
if (linears.length === 1) {
const linear = linears[0];
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = targetElements.every(
(el) => el.id === linear.id || boundElements.includes(el.id),
);
if (onlySingleLinearSelected) {
return new LinearElementEditor(linear);
}
}
return null;
};
export const getSelectionStateForElements = (
targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
return {
selectedLinearElement: _getLinearElementEditor(targetElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
targetElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
allElements,
appState,
null,
),
};
};

View file

@ -67,7 +67,7 @@ describe("duplicating single elements", () => {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)], points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
}); });
const copy = duplicateElement(null, new Map(), element, undefined, true); const copy = duplicateElement(null, new Map(), element, true);
assertCloneObjects(element, copy); assertCloneObjects(element, copy);
@ -173,7 +173,7 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { newElements: clonedElements } = duplicateElements({ const { duplicatedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}); });
@ -181,10 +181,10 @@ describe("duplicating multiple elements", () => {
// generic id in-equality checks // generic id in-equality checks
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual( expect(origElements.map((e) => e.type)).toEqual(
clonedElements.map((e) => e.type), duplicatedElements.map((e) => e.type),
); );
origElements.forEach((origElement, idx) => { origElements.forEach((origElement, idx) => {
const clonedElement = clonedElements[idx]; const clonedElement = duplicatedElements[idx];
expect(origElement).toEqual( expect(origElement).toEqual(
expect.objectContaining({ expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id), id: expect.not.stringMatching(clonedElement.id),
@ -217,12 +217,12 @@ describe("duplicating multiple elements", () => {
}); });
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
const clonedArrows = clonedElements.filter( const clonedArrows = duplicatedElements.filter(
(e) => e.type === "arrow", (e) => e.type === "arrow",
) as ExcalidrawLinearElement[]; ) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] = const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
clonedElements as any as typeof origElements; duplicatedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id); expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect( expect(
@ -327,10 +327,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const { newElements: clonedElements } = duplicateElements({ const duplicatedElements = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}) as any as { newElements: typeof origElements }; }).duplicatedElements as any as typeof origElements;
const [ const [
clonedRectangle, clonedRectangle,
@ -338,7 +338,7 @@ describe("duplicating multiple elements", () => {
clonedArrow1, clonedArrow1,
clonedArrow2, clonedArrow2,
clonedArrow3, clonedArrow3,
] = clonedElements; ] = duplicatedElements;
expect(clonedRectangle.boundElements).toEqual([ expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" }, { id: clonedArrow1.id, type: "arrow" },
@ -374,12 +374,12 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { newElements: clonedElements } = duplicateElements({ const { duplicatedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}) as any as { newElements: typeof origElements }; });
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements; duplicatedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]); expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
@ -399,7 +399,7 @@ describe("duplicating multiple elements", () => {
}); });
const { const {
newElements: [clonedRectangle1], duplicatedElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] }); } = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
@ -408,6 +408,117 @@ describe("duplicating multiple elements", () => {
}); });
}); });
describe("group-related duplication", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("action-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it("alt-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it.skip("alt-duplicating within group away outside frame", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
API.setElements([frame, rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
});
// console.log(h.elements);
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle1.id, frameId: frame.id },
{ id: rectangle2.id, frameId: frame.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
]);
expect(h.state.editingGroupId).toBe(null);
});
});
describe("duplication z-order", () => { describe("duplication z-order", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@ -503,8 +614,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -538,8 +649,8 @@ describe("duplication z-order", () => {
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle2.id }, { id: rectangle2.id },
{ [ORIG_ID]: rectangle3.id }, { id: rectangle3.id },
{ id: rectangle3.id, selected: true }, { [ORIG_ID]: rectangle3.id, selected: true },
]); ]);
}); });
@ -569,8 +680,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -605,19 +716,19 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ [ORIG_ID]: rectangle2.id }, { id: rectangle2.id },
{ [ORIG_ID]: rectangle3.id }, { id: rectangle3.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id, selected: true }, { [ORIG_ID]: rectangle2.id, selected: true },
{ id: rectangle3.id, selected: true }, { [ORIG_ID]: rectangle3.id, selected: true },
]); ]);
}); });
it("reverse-duplicating text container (in-order)", async () => { it("alt-duplicating text container (in-order)", async () => {
const [rectangle, text] = API.createTextContainer(); const [rectangle, text] = API.createTextContainer();
API.setElements([rectangle, text]); API.setElements([rectangle, text]);
API.setSelectedElements([rectangle, text]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -625,20 +736,20 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id }, { id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id, containerId: getCloneByOrigId(rectangle.id)?.id,
}, },
{ id: rectangle.id, selected: true },
{ id: text.id, containerId: rectangle.id, selected: true },
]); ]);
}); });
it("reverse-duplicating text container (out-of-order)", async () => { it("alt-duplicating text container (out-of-order)", async () => {
const [rectangle, text] = API.createTextContainer(); const [rectangle, text] = API.createTextContainer();
API.setElements([text, rectangle]); API.setElements([text, rectangle]);
API.setSelectedElements([rectangle, text]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -646,21 +757,21 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id }, { id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id, containerId: getCloneByOrigId(rectangle.id)?.id,
}, },
{ id: rectangle.id, selected: true },
{ id: text.id, containerId: rectangle.id, selected: true },
]); ]);
}); });
it("reverse-duplicating labeled arrows (in-order)", async () => { it("alt-duplicating labeled arrows (in-order)", async () => {
const [arrow, text] = API.createLabeledArrow(); const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]); API.setElements([arrow, text]);
API.setSelectedElements([arrow, text]); API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -668,21 +779,24 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: arrow.id }, { id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id, containerId: getCloneByOrigId(arrow.id)?.id,
}, },
{ id: arrow.id, selected: true },
{ id: text.id, containerId: arrow.id, selected: true },
]); ]);
expect(h.state.selectedLinearElement).toEqual(
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
);
}); });
it("reverse-duplicating labeled arrows (out-of-order)", async () => { it("alt-duplicating labeled arrows (out-of-order)", async () => {
const [arrow, text] = API.createLabeledArrow(); const [arrow, text] = API.createLabeledArrow();
API.setElements([text, arrow]); API.setElements([text, arrow]);
API.setSelectedElements([arrow, text]); API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -690,17 +804,17 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: arrow.id }, { id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id, containerId: getCloneByOrigId(arrow.id)?.id,
}, },
{ id: arrow.id, selected: true },
{ id: text.id, containerId: arrow.id, selected: true },
]); ]);
}); });
it("reverse-duplicating bindable element with bound arrow should keep the arrow on the duplicate", () => { it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
const rect = UI.createElement("rectangle", { const rect = UI.createElement("rectangle", {
x: 0, x: 0,
y: 0, y: 0,
@ -722,11 +836,18 @@ describe("duplication z-order", () => {
mouse.up(15, 15); mouse.up(15, 15);
}); });
expect(window.h.elements).toHaveLength(3); assertElements(h.elements, [
{
const newRect = window.h.elements[0]; id: rect.id,
boundElements: expect.arrayContaining([
expect(arrow.endBinding?.elementId).toBe(newRect.id); expect.objectContaining({ id: arrow.id }),
expect(newRect.boundElements?.[0]?.id).toBe(arrow.id); ]),
},
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
{
id: arrow.id,
endBinding: expect.objectContaining({ elementId: rect.id }),
},
]);
}); });
}); });

View file

@ -7,26 +7,17 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import {
isBoundToContainer,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { import {
excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
getSelectionStateForElements,
} from "@excalidraw/element/selection"; } from "@excalidraw/element/selection";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate"; import { duplicateElements } from "@excalidraw/element/duplicate";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
@ -65,8 +56,7 @@ export const actionDuplicateSelection = register({
} }
} }
let { newElements: duplicatedElements, elementsWithClones: nextElements } = let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
duplicateElements({
type: "in-place", type: "in-place",
elements, elements,
idsOfElementsToDuplicate: arrayToMap( idsOfElementsToDuplicate: arrayToMap(
@ -77,40 +67,38 @@ export const actionDuplicateSelection = register({
), ),
appState, appState,
randomizeSeed: true, randomizeSeed: true,
overrides: (element) => ({ overrides: ({ origElement, origIdToDuplicateId }) => {
x: element.x + DEFAULT_GRID_SIZE / 2, const duplicateFrameId =
y: element.y + DEFAULT_GRID_SIZE / 2, origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
}), return {
reverseOrder: false, x: origElement.x + DEFAULT_GRID_SIZE / 2,
y: origElement.y + DEFAULT_GRID_SIZE / 2,
frameId: duplicateFrameId ?? origElement.frameId,
};
},
}); });
if (app.props.onDuplicate && nextElements) { if (app.props.onDuplicate && elementsWithDuplicates) {
const mappedElements = app.props.onDuplicate(nextElements, elements); const mappedElements = app.props.onDuplicate(
elementsWithDuplicates,
elements,
);
if (mappedElements) { if (mappedElements) {
nextElements = mappedElements; elementsWithDuplicates = mappedElements;
} }
} }
return { return {
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)), elements: syncMovedIndices(
elementsWithDuplicates,
arrayToMap(duplicatedElements),
),
appState: { appState: {
...appState, ...appState,
...updateLinearElementEditors(duplicatedElements), ...getSelectionStateForElements(
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
duplicatedElements, duplicatedElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => { getNonDeletedElements(elementsWithDuplicates),
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
getNonDeletedElements(nextElements),
appState, appState,
null,
), ),
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, 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 { import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectionStateForElements,
makeNextSelectedElementIds, makeNextSelectedElementIds,
} from "@excalidraw/element/selection"; } from "@excalidraw/element/selection";
@ -3267,7 +3268,7 @@ class App extends React.Component<AppProps, AppState> {
const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize()); const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
const { newElements } = duplicateElements({ const { duplicatedElements } = duplicateElements({
type: "everything", type: "everything",
elements: elements.map((element) => { elements: elements.map((element) => {
return newElementWith(element, { return newElementWith(element, {
@ -3279,7 +3280,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
let nextElements = [...prevElements, ...newElements]; let nextElements = [...prevElements, ...duplicatedElements];
const mappedNewSceneElements = this.props.onDuplicate?.( const mappedNewSceneElements = this.props.onDuplicate?.(
nextElements, nextElements,
@ -3288,13 +3289,13 @@ class App extends React.Component<AppProps, AppState> {
nextElements = mappedNewSceneElements || nextElements; nextElements = mappedNewSceneElements || nextElements;
syncMovedIndices(nextElements, arrayToMap(newElements)); syncMovedIndices(nextElements, arrayToMap(duplicatedElements));
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
if (topLayerFrame) { if (topLayerFrame) {
const eligibleElements = filterElementsEligibleAsFrameChildren( const eligibleElements = filterElementsEligibleAsFrameChildren(
newElements, duplicatedElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame( addElementsToFrame(
@ -3307,7 +3308,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
newElements.forEach((newElement) => { duplicatedElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) { if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement( const container = getContainerElement(
newElement, 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 // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
if (isSafari) { if (isSafari) {
Fonts.loadElementsFonts(newElements).then((fontFaces) => { Fonts.loadElementsFonts(duplicatedElements).then((fontFaces) => {
this.fonts.onLoaded(fontFaces); this.fonts.onLoaded(fontFaces);
}); });
} }
@ -3335,7 +3336,7 @@ class App extends React.Component<AppProps, AppState> {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
const nextElementsToSelect = const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements); excludeElementsInFramesFromSelection(duplicatedElements);
this.setState( this.setState(
{ {
@ -3378,7 +3379,7 @@ class App extends React.Component<AppProps, AppState> {
this.setActiveTool({ type: "selection" }); this.setActiveTool({ type: "selection" });
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(newElements, { this.scrollToContent(duplicatedElements, {
fitToContent: true, fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(), canvasOffsets: this.getEditorUIOffsets(),
}); });
@ -6942,6 +6943,7 @@ class App extends React.Component<AppProps, AppState> {
drag: { drag: {
hasOccurred: false, hasOccurred: false,
offset: null, offset: null,
origin: { ...origin },
}, },
eventListeners: { eventListeners: {
onMove: null, onMove: null,
@ -8236,8 +8238,8 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.state !== "active" this.state.activeEmbeddable?.state !== "active"
) { ) {
const dragOffset = { const dragOffset = {
x: pointerCoords.x - pointerDownState.origin.x, x: pointerCoords.x - pointerDownState.drag.origin.x,
y: pointerCoords.y - pointerDownState.origin.y, y: pointerCoords.y - pointerDownState.drag.origin.y,
}; };
const originalElements = [ const originalElements = [
@ -8432,52 +8434,112 @@ class App extends React.Component<AppProps, AppState> {
selectedElements.map((el) => [el.id, el]), selectedElements.map((el) => [el.id, el]),
); );
const { newElements: clonedElements, elementsWithClones } = const {
duplicateElements({ duplicatedElements,
duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
} = duplicateElements({
type: "in-place", type: "in-place",
elements, elements,
appState: this.state, appState: this.state,
randomizeSeed: true, randomizeSeed: true,
idsOfElementsToDuplicate, idsOfElementsToDuplicate,
overrides: (el) => { 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 mappedClonedElements = elementsWithDuplicates.map((el) => {
if (idsOfElementsToDuplicate.has(el.id)) {
const origEl = pointerDownState.originalElements.get(el.id); const origEl = pointerDownState.originalElements.get(el.id);
if (origEl) { if (origEl) {
return { return newElementWith(el, {
x: origEl.x, x: origEl.x,
y: origEl.y, y: origEl.y,
seed: origEl.seed, });
};
} }
return {};
},
reverseOrder: true,
});
clonedElements.forEach((element) => {
pointerDownState.originalElements.set(element.id, element);
});
const mappedNewSceneElements = this.props.onDuplicate?.(
elementsWithClones,
elements,
);
const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
arrayToMap(clonedElements),
).map((el) => {
if (idsOfElementsToDuplicate.has(el.id)) {
return newElementWith(el, {
seed: randomInteger(),
});
} }
return el; return el;
}); });
this.scene.replaceAllElements(nextSceneElements); 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.maybeCacheVisibleGaps(event, selectedElements, true);
this.maybeCacheReferenceSnapPoints(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
});
} }
return; return;

View file

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

View file

@ -1,40 +1,6 @@
// 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`] = ` 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, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -54,13 +20,47 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1505387817, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "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, "versionNonce": 915032327,
"width": 30, "width": 30,
"x": -10, "x": -10,

View file

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

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 () => { it("should filter out elements not overlapping frame", async () => {
const frame = API.createElement({ const frame = API.createElement({
type: "frame", type: "frame",

View file

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

View file

@ -724,7 +724,8 @@ export type PointerDownState = Readonly<{
scrollbars: ReturnType<typeof isOverScrollBars>; scrollbars: ReturnType<typeof isOverScrollBars>;
// The previous pointer position // The previous pointer position
lastCoords: { x: number; y: number }; 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>>; originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
resize: { resize: {
// Handle when resizing, might change during the pointer interaction // Handle when resizing, might change during the pointer interaction
@ -758,6 +759,9 @@ export type PointerDownState = Readonly<{
hasOccurred: boolean; hasOccurred: boolean;
// Might change during the pointer interaction // Might change during the pointer interaction
offset: { x: number; y: number } | null; 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 // We need to have these in the state so that we can unsubscribe them
eventListeners: { eventListeners: {