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 {
|
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[],
|
||||||
|
|
|
@ -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,13 +279,9 @@ export const duplicateElements = (
|
||||||
: [element],
|
: [element],
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetIndex = reverseOrder
|
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||||
? elementsWithClones.findIndex((el) => {
|
return el.groupIds?.includes(groupId);
|
||||||
return el.groupIds?.includes(groupId);
|
});
|
||||||
})
|
|
||||||
: findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return el.groupIds?.includes(groupId);
|
|
||||||
});
|
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
||||||
continue;
|
continue;
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -41,30 +41,28 @@ 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 ?? null,
|
||||||
frameId: nextFrameId ?? element.frameId,
|
},
|
||||||
},
|
false,
|
||||||
false,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -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 }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,52 +56,49 @@ 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(
|
getSelectedElements(elements, appState, {
|
||||||
getSelectedElements(elements, appState, {
|
includeBoundTextElement: true,
|
||||||
includeBoundTextElement: true,
|
includeElementsInFrames: true,
|
||||||
includeElementsInFrames: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
appState,
|
|
||||||
randomizeSeed: true,
|
|
||||||
overrides: (element) => ({
|
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
|
||||||
}),
|
}),
|
||||||
reverseOrder: false,
|
),
|
||||||
});
|
appState,
|
||||||
|
randomizeSeed: true,
|
||||||
|
overrides: ({ origElement, origIdToDuplicateId }) => {
|
||||||
|
const duplicateFrameId =
|
||||||
|
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
|
||||||
|
return {
|
||||||
|
x: origElement.x + DEFAULT_GRID_SIZE / 2,
|
||||||
|
y: origElement.y + DEFAULT_GRID_SIZE / 2,
|
||||||
|
frameId: duplicateFrameId ?? origElement.frameId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (app.props.onDuplicate && nextElements) {
|
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(
|
duplicatedElements,
|
||||||
{
|
getNonDeletedElements(elementsWithDuplicates),
|
||||||
editingGroupId: appState.editingGroupId,
|
|
||||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
|
||||||
duplicatedElements,
|
|
||||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -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,
|
||||||
type: "in-place",
|
duplicateElementsMap,
|
||||||
elements,
|
elementsWithDuplicates,
|
||||||
appState: this.state,
|
origIdToDuplicateId,
|
||||||
randomizeSeed: true,
|
} = duplicateElements({
|
||||||
idsOfElementsToDuplicate,
|
type: "in-place",
|
||||||
overrides: (el) => {
|
elements,
|
||||||
const origEl = pointerDownState.originalElements.get(el.id);
|
appState: this.state,
|
||||||
|
randomizeSeed: true,
|
||||||
if (origEl) {
|
idsOfElementsToDuplicate,
|
||||||
return {
|
overrides: ({ duplicateElement, origElement }) => {
|
||||||
x: origEl.x,
|
return {
|
||||||
y: origEl.y,
|
// reset to the original element's frameId (unless we've
|
||||||
seed: origEl.seed,
|
// 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
|
||||||
return {};
|
// differently
|
||||||
},
|
frameId: duplicateElement.frameId ?? origElement.frameId,
|
||||||
reverseOrder: true,
|
seed: randomInteger(),
|
||||||
});
|
};
|
||||||
clonedElements.forEach((element) => {
|
},
|
||||||
pointerDownState.originalElements.set(element.id, element);
|
});
|
||||||
|
duplicatedElements.forEach((element) => {
|
||||||
|
pointerDownState.originalElements.set(
|
||||||
|
element.id,
|
||||||
|
deepCopyElement(element),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
const mappedClonedElements = elementsWithDuplicates.map((el) => {
|
||||||
elementsWithClones,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextSceneElements = syncMovedIndices(
|
|
||||||
mappedNewSceneElements || elementsWithClones,
|
|
||||||
arrayToMap(clonedElements),
|
|
||||||
).map((el) => {
|
|
||||||
if (idsOfElementsToDuplicate.has(el.id)) {
|
if (idsOfElementsToDuplicate.has(el.id)) {
|
||||||
return newElementWith(el, {
|
const origEl = pointerDownState.originalElements.get(el.id);
|
||||||
seed: randomInteger(),
|
|
||||||
});
|
if (origEl) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
x: origEl.x,
|
||||||
|
y: origEl.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.replaceAllElements(nextSceneElements);
|
const mappedNewSceneElements = this.props.onDuplicate?.(
|
||||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
mappedClonedElements,
|
||||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
elements,
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsWithIndices = syncMovedIndices(
|
||||||
|
mappedNewSceneElements || mappedClonedElements,
|
||||||
|
arrayToMap(duplicatedElements),
|
||||||
|
);
|
||||||
|
|
||||||
|
// we need to update synchronously so as to keep pointerDownState,
|
||||||
|
// appState, and scene elements in sync
|
||||||
|
flushSync(() => {
|
||||||
|
// swap hit element with the duplicated one
|
||||||
|
if (pointerDownState.hit.element) {
|
||||||
|
const cloneId = origIdToDuplicateId.get(
|
||||||
|
pointerDownState.hit.element.id,
|
||||||
|
);
|
||||||
|
const clonedElement =
|
||||||
|
cloneId && duplicateElementsMap.get(cloneId);
|
||||||
|
pointerDownState.hit.element = clonedElement || null;
|
||||||
|
}
|
||||||
|
// swap hit elements with the duplicated ones
|
||||||
|
pointerDownState.hit.allHitElements =
|
||||||
|
pointerDownState.hit.allHitElements.reduce(
|
||||||
|
(
|
||||||
|
acc: typeof pointerDownState.hit.allHitElements,
|
||||||
|
origHitElement,
|
||||||
|
) => {
|
||||||
|
const cloneId = origIdToDuplicateId.get(origHitElement.id);
|
||||||
|
const clonedElement =
|
||||||
|
cloneId && duplicateElementsMap.get(cloneId);
|
||||||
|
if (clonedElement) {
|
||||||
|
acc.push(clonedElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// update drag origin to the position at which we started
|
||||||
|
// the duplication so that the drag offset is correct
|
||||||
|
pointerDownState.drag.origin = viewportCoordsToSceneCoords(
|
||||||
|
event,
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
// switch selected elements to the duplicated ones
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
...getSelectionStateForElements(
|
||||||
|
duplicatedElements,
|
||||||
|
this.scene.getNonDeletedElements(),
|
||||||
|
prevState,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.scene.replaceAllElements(elementsWithIndices);
|
||||||
|
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||||
|
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -166,7 +166,7 @@ export default function LibraryMenuItems({
|
||||||
type: "everything",
|
type: "everything",
|
||||||
elements: item.elements,
|
elements: item.elements,
|
||||||
randomizeSeed: true,
|
randomizeSeed: true,
|
||||||
}).newElements,
|
}).duplicatedElements,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue