mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
0cf36d6b30
commit
a5d6939826
13 changed files with 603 additions and 579 deletions
|
@ -56,7 +56,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
|||
import {
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
|
@ -1409,19 +1408,19 @@ const getLinearElementEdgeCoors = (
|
|||
};
|
||||
|
||||
export const fixDuplicatedBindingsAfterDuplication = (
|
||||
newElements: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
duplicatedElementsMap: NonDeletedSceneElementsMap,
|
||||
duplicatedElements: ExcalidrawElement[],
|
||||
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
duplicateElementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
for (const element of newElements) {
|
||||
if ("boundElements" in element && element.boundElements) {
|
||||
Object.assign(element, {
|
||||
boundElements: element.boundElements.reduce(
|
||||
for (const duplicateElement of duplicatedElements) {
|
||||
if ("boundElements" in duplicateElement && duplicateElement.boundElements) {
|
||||
Object.assign(duplicateElement, {
|
||||
boundElements: duplicateElement.boundElements.reduce(
|
||||
(
|
||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||
binding,
|
||||
) => {
|
||||
const newBindingId = oldIdToDuplicatedId.get(binding.id);
|
||||
const newBindingId = origIdToDuplicateId.get(binding.id);
|
||||
if (newBindingId) {
|
||||
acc.push({ ...binding, id: newBindingId });
|
||||
}
|
||||
|
@ -1432,46 +1431,47 @@ export const fixDuplicatedBindingsAfterDuplication = (
|
|||
});
|
||||
}
|
||||
|
||||
if ("containerId" in element && element.containerId) {
|
||||
Object.assign(element, {
|
||||
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
|
||||
if ("containerId" in duplicateElement && duplicateElement.containerId) {
|
||||
Object.assign(duplicateElement, {
|
||||
containerId:
|
||||
origIdToDuplicateId.get(duplicateElement.containerId) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if ("endBinding" in element && element.endBinding) {
|
||||
const newEndBindingId = oldIdToDuplicatedId.get(
|
||||
element.endBinding.elementId,
|
||||
if ("endBinding" in duplicateElement && duplicateElement.endBinding) {
|
||||
const newEndBindingId = origIdToDuplicateId.get(
|
||||
duplicateElement.endBinding.elementId,
|
||||
);
|
||||
Object.assign(element, {
|
||||
Object.assign(duplicateElement, {
|
||||
endBinding: newEndBindingId
|
||||
? {
|
||||
...element.endBinding,
|
||||
...duplicateElement.endBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if ("startBinding" in element && element.startBinding) {
|
||||
const newEndBindingId = oldIdToDuplicatedId.get(
|
||||
element.startBinding.elementId,
|
||||
if ("startBinding" in duplicateElement && duplicateElement.startBinding) {
|
||||
const newEndBindingId = origIdToDuplicateId.get(
|
||||
duplicateElement.startBinding.elementId,
|
||||
);
|
||||
Object.assign(element, {
|
||||
Object.assign(duplicateElement, {
|
||||
startBinding: newEndBindingId
|
||||
? {
|
||||
...element.startBinding,
|
||||
...duplicateElement.startBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
if (isElbowArrow(duplicateElement)) {
|
||||
Object.assign(
|
||||
element,
|
||||
updateElbowArrowPoints(element, duplicatedElementsMap, {
|
||||
duplicateElement,
|
||||
updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
|
||||
points: [
|
||||
element.points[0],
|
||||
element.points[element.points.length - 1],
|
||||
duplicateElement.points[0],
|
||||
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 = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
deletedElements: readonly ExcalidrawElement[],
|
||||
|
|
|
@ -36,10 +36,7 @@ import {
|
|||
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import {
|
||||
fixDuplicatedBindingsAfterDuplication,
|
||||
fixReversedBindings,
|
||||
} from "./binding";
|
||||
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
|
@ -60,16 +57,14 @@ import type {
|
|||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
randomizeSeed?: boolean,
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
const copy = deepCopyElement(element);
|
||||
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(copy, element.id);
|
||||
|
@ -92,9 +87,6 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
|
@ -102,9 +94,14 @@ export const duplicateElements = (
|
|||
opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
randomizeSeed?: boolean;
|
||||
overrides?: (
|
||||
originalElement: ExcalidrawElement,
|
||||
) => Partial<ExcalidrawElement>;
|
||||
overrides?: (data: {
|
||||
duplicateElement: ExcalidrawElement;
|
||||
origElement: ExcalidrawElement;
|
||||
origIdToDuplicateId: Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
}) => Partial<ExcalidrawElement>;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
|
@ -132,14 +129,6 @@ export const duplicateElements = (
|
|||
editingGroupId: AppState["editingGroupId"];
|
||||
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: {},
|
||||
} 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
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
|
@ -167,10 +154,17 @@ export const duplicateElements = (
|
|||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const duplicatedElements: ExcalidrawElement[] = [];
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
const origIdToDuplicateId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateIdToOrigElement = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>();
|
||||
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
const _idsOfElementsToDuplicate =
|
||||
opts.type === "in-place"
|
||||
|
@ -188,7 +182,7 @@ export const duplicateElements = (
|
|||
|
||||
elements = normalizeElementOrder(elements);
|
||||
|
||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
|
||||
|
||||
// helper functions
|
||||
// -------------------------------------------------------------------------
|
||||
|
@ -214,17 +208,17 @@ export const duplicateElements = (
|
|||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
opts.overrides?.(element),
|
||||
opts.randomizeSeed,
|
||||
);
|
||||
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
duplicateElementsMap.set(newElement.id, newElement);
|
||||
origIdToDuplicateId.set(element.id, newElement.id);
|
||||
duplicateIdToOrigElement.set(newElement.id, element);
|
||||
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
origElements.push(element);
|
||||
duplicatedElements.push(newElement);
|
||||
|
||||
acc.push(newElement);
|
||||
return acc;
|
||||
|
@ -248,21 +242,12 @@ export const duplicateElements = (
|
|||
return;
|
||||
}
|
||||
|
||||
if (reverseOrder && index < 1) {
|
||||
elementsWithClones.unshift(...castArray(elements));
|
||||
if (index > elementsWithDuplicates.length - 1) {
|
||||
elementsWithDuplicates.push(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reverseOrder && index > elementsWithClones.length - 1) {
|
||||
elementsWithClones.push(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithClones.splice(
|
||||
index + (reverseOrder ? 0 : 1),
|
||||
0,
|
||||
...castArray(elements),
|
||||
);
|
||||
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
||||
};
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
|
@ -294,13 +279,9 @@ export const duplicateElements = (
|
|||
: [element],
|
||||
);
|
||||
|
||||
const targetIndex = reverseOrder
|
||||
? elementsWithClones.findIndex((el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
})
|
||||
: findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
||||
continue;
|
||||
|
@ -318,7 +299,7 @@ export const duplicateElements = (
|
|||
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
return el.frameId === frameId || el.id === frameId;
|
||||
});
|
||||
|
||||
|
@ -335,7 +316,7 @@ export const duplicateElements = (
|
|||
if (hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
return (
|
||||
el.id === element.id ||
|
||||
("containerId" in el && el.containerId === element.id)
|
||||
|
@ -344,7 +325,7 @@ export const duplicateElements = (
|
|||
|
||||
if (boundTextElement) {
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex + (reverseOrder ? -1 : 0),
|
||||
targetIndex,
|
||||
copyElements([element, boundTextElement]),
|
||||
);
|
||||
} else {
|
||||
|
@ -357,7 +338,7 @@ export const duplicateElements = (
|
|||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
|
@ -377,7 +358,7 @@ export const duplicateElements = (
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
insertBeforeOrAfterIndex(
|
||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
|
||||
copyElements(element),
|
||||
);
|
||||
}
|
||||
|
@ -385,28 +366,38 @@ export const duplicateElements = (
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
fixDuplicatedBindingsAfterDuplication(
|
||||
newElements,
|
||||
oldIdToDuplicatedId,
|
||||
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
||||
duplicatedElements,
|
||||
origIdToDuplicateId,
|
||||
duplicateElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
if (reverseOrder) {
|
||||
fixReversedBindings(
|
||||
_idsOfElementsToDuplicate,
|
||||
elementsWithClones,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
}
|
||||
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
elementsWithDuplicates,
|
||||
origElements,
|
||||
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 {
|
||||
newElements,
|
||||
elementsWithClones,
|
||||
duplicatedElements,
|
||||
duplicateElementsMap,
|
||||
elementsWithDuplicates,
|
||||
origIdToDuplicateId,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -41,30 +41,28 @@ import type {
|
|||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
origElements: readonly ExcalidrawElement[],
|
||||
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
|
||||
for (const element of oldElements) {
|
||||
for (const element of origElements) {
|
||||
if (element.frameId) {
|
||||
// use its frameId to get the new frameId
|
||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
||||
if (nextElementId) {
|
||||
const nextElement = nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? element.frameId,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
const nextElementId = origIdToDuplicateId.get(element.id);
|
||||
const nextFrameId = origIdToDuplicateId.get(element.frameId);
|
||||
const nextElement = nextElementId && nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,13 +7,20 @@ import type {
|
|||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
|
@ -254,3 +261,48 @@ export const makeNextSelectedElementIds = (
|
|||
|
||||
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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -67,7 +67,7 @@ describe("duplicating single elements", () => {
|
|||
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);
|
||||
|
||||
|
@ -173,7 +173,7 @@ describe("duplicating multiple elements", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
|
@ -181,10 +181,10 @@ describe("duplicating multiple elements", () => {
|
|||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
clonedElements.map((e) => e.type),
|
||||
duplicatedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = clonedElements[idx];
|
||||
const clonedElement = duplicatedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
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",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
clonedElements as any as typeof origElements;
|
||||
duplicatedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
|
@ -327,10 +327,10 @@ describe("duplicating multiple elements", () => {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
const duplicatedElements = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
}).duplicatedElements as any as typeof origElements;
|
||||
|
||||
const [
|
||||
clonedRectangle,
|
||||
|
@ -338,7 +338,7 @@ describe("duplicating multiple elements", () => {
|
|||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = clonedElements;
|
||||
] = duplicatedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
|
@ -374,12 +374,12 @@ describe("duplicating multiple elements", () => {
|
|||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}) as any as { newElements: typeof origElements };
|
||||
});
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
duplicatedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
|
@ -399,7 +399,7 @@ describe("duplicating multiple elements", () => {
|
|||
});
|
||||
|
||||
const {
|
||||
newElements: [clonedRectangle1],
|
||||
duplicatedElements: [clonedRectangle1],
|
||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||
|
||||
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", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
@ -503,8 +614,8 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
|
@ -538,8 +649,8 @@ describe("duplication z-order", () => {
|
|||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -569,8 +680,8 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
|
@ -605,19 +716,19 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id, selected: true },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle2.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();
|
||||
API.setElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
|
@ -625,20 +736,20 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
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();
|
||||
API.setElements([text, rectangle]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
|
@ -646,21 +757,21 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
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();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
|
@ -668,21 +779,24 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
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();
|
||||
|
||||
API.setElements([text, arrow]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
|
@ -690,17 +804,17 @@ describe("duplication z-order", () => {
|
|||
});
|
||||
|
||||
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,
|
||||
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", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
@ -722,11 +836,18 @@ describe("duplication z-order", () => {
|
|||
mouse.up(15, 15);
|
||||
});
|
||||
|
||||
expect(window.h.elements).toHaveLength(3);
|
||||
|
||||
const newRect = window.h.elements[0];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(newRect.id);
|
||||
expect(newRect.boundElements?.[0]?.id).toBe(arrow.id);
|
||||
assertElements(h.elements, [
|
||||
{
|
||||
id: rect.id,
|
||||
boundElements: expect.arrayContaining([
|
||||
expect.objectContaining({ id: arrow.id }),
|
||||
]),
|
||||
},
|
||||
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
|
||||
{
|
||||
id: arrow.id,
|
||||
endBinding: expect.objectContaining({ elementId: rect.id }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue