fix: Refactor and merge duplication and binding (#9246)
All checks were successful
Tests / test (push) Successful in 4m52s

This commit is contained in:
Márk Tolmács 2025-03-23 18:39:33 +01:00 committed by GitHub
parent 58990b41ae
commit 77aca48c84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1293 additions and 1085 deletions

View file

@ -1,5 +1,5 @@
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/duplicate";
import { t } from "../i18n"; import { t } from "../i18n";
import { randomId } from "../random"; import { randomId } from "../random";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";

View file

@ -288,6 +288,7 @@ export const actionDeleteSelected = register({
activeTool: updateActiveTool(appState, { type: "selection" }), activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null,
}, },
captureUpdate: isSomeElementSelected( captureUpdate: isSomeElementSelected(
getNonDeletedElements(elements), getNonDeletedElements(elements),

View file

@ -258,7 +258,7 @@ describe("actionDuplicateSelection", () => {
assertElements(h.elements, [ assertElements(h.elements, [
{ id: frame.id }, { id: frame.id },
{ id: text.id, frameId: frame.id }, { id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id }, { [ORIG_ID]: text.id, frameId: frame.id, selected: true },
]); ]);
}); });

View file

@ -1,29 +1,10 @@
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { DEFAULT_GRID_SIZE } from "../constants"; import { DEFAULT_GRID_SIZE } from "../constants";
import { duplicateElement, getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { fixBindingsAfterDuplication } from "../element/binding"; import { isBoundToContainer, isLinearElement } from "../element/typeChecks";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { import { selectGroupsForSelectedElements } from "../groups";
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "../frame";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
@ -32,19 +13,15 @@ import {
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { import { arrayToMap, getShortcutKey } from "../utils";
arrayToMap,
castArray, import { syncMovedIndices } from "../fractionalIndex";
findLastIndex,
getShortcutKey, import { duplicateElements } from "../element/duplicate";
invariant,
} from "../utils";
import { register } from "./register"; import { register } from "./register";
import type { ActionResult } from "./types";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -75,20 +52,54 @@ export const actionDuplicateSelection = register({
} }
} }
const nextState = duplicateElements(elements, appState); let { newElements: duplicatedElements, elementsWithClones: nextElements } =
duplicateElements({
if (app.props.onDuplicate && nextState.elements) { type: "in-place",
const mappedElements = app.props.onDuplicate(
nextState.elements,
elements, elements,
); idsOfElementsToDuplicate: arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
),
appState,
randomizeSeed: true,
overrides: (element) => ({
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
}),
reverseOrder: false,
});
if (app.props.onDuplicate && nextElements) {
const mappedElements = app.props.onDuplicate(nextElements, elements);
if (mappedElements) { if (mappedElements) {
nextState.elements = mappedElements; nextElements = mappedElements;
} }
} }
return { return {
...nextState, elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
appState: {
...appState,
...updateLinearElementEditors(duplicatedElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
duplicatedElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
getNonDeletedElements(nextElements),
appState,
null,
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
@ -107,259 +118,23 @@ export const actionDuplicateSelection = register({
), ),
}); });
const duplicateElements = ( const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
elements: readonly ExcalidrawElement[], const linears = clonedElements.filter(isLinearElement);
appState: AppState, if (linears.length === 1) {
): Partial<Exclude<ActionResult, false>> => { const linear = linears[0];
// --------------------------------------------------------------------------- const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = clonedElements.every(
const groupIdMap = new Map(); (el) => el.id === linear.id || boundElements.includes(el.id),
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements);
const duplicateAndOffsetElement = <
T extends ExcalidrawElement | ExcalidrawElement[],
>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
); );
return ( if (onlySingleLinearSelected) {
Array.isArray(element) ? _newElements : _newElements[0] || null return {
) as T extends ExcalidrawElement[] selectedLinearElement: new LinearElementEditor(linear),
? ExcalidrawElement[] };
: ExcalidrawElement | null;
};
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
// 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
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const elementsWithClones: ExcalidrawElement[] = elements.slice();
const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
} }
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
};
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
if (!idsOfElementsToDuplicate.has(element.id)) {
continue;
}
// groups
// -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
continue;
}
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.id === element.id || el.id === container?.id;
});
if (container) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
} }
// ---------------------------------------------------------------------------
bindTextToShapeAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
bindElementsToFramesAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return { return {
elements: elementsWithClones, selectedLinearElement: null,
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
},
{},
),
},
getNonDeletedElements(elementsWithClones),
appState,
null,
),
},
}; };
}; };

View file

@ -6,7 +6,6 @@ import {
} from "../element/binding"; } from "../element/binding";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { resizeMultipleElements } from "../element/resizeElements"; import { resizeMultipleElements } from "../element/resizeElements";
import { import {
isArrowElement, isArrowElement,
@ -19,6 +18,8 @@ import { getSelectedElements } from "../scene";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { deepCopyElement } from "../element/duplicate";
import { register } from "./register"; import { register } from "./register";
import type { import type {

View file

@ -6,7 +6,6 @@ import {
} from "./constants"; } from "./constants";
import { createFile, isSupportedImageFileType } from "./data/blob"; import { createFile, isSupportedImageFileType } from "./data/blob";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { deepCopyElement } from "./element/newElement";
import { import {
isFrameLikeElement, isFrameLikeElement,
isInitializedImageElement, isInitializedImageElement,
@ -15,6 +14,8 @@ import { ExcalidrawError } from "./errors";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
import { deepCopyElement } from "./element/duplicate";
import type { Spreadsheet } from "./charts"; import type { Spreadsheet } from "./charts";
import type { import type {
ExcalidrawElement, ExcalidrawElement,

View file

@ -126,7 +126,6 @@ import { restore, restoreElements } from "../data/restore";
import { import {
dragNewElement, dragNewElement,
dragSelectedElements, dragSelectedElements,
duplicateElement,
getCommonBounds, getCommonBounds,
getCursorForResizingElement, getCursorForResizingElement,
getDragOffsetXY, getDragOffsetXY,
@ -152,7 +151,6 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
fixBindingsAfterDeletion, fixBindingsAfterDeletion,
fixBindingsAfterDuplication,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
isLinearElementSimpleAndAlreadyBound, isLinearElementSimpleAndAlreadyBound,
@ -163,9 +161,8 @@ import {
} from "../element/binding"; } from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, duplicateElements } from "../element/duplicate";
import { import {
deepCopyElement,
duplicateElements,
newFrameElement, newFrameElement,
newFreeDrawElement, newFreeDrawElement,
newEmbeddableElement, newEmbeddableElement,
@ -292,7 +289,6 @@ import {
} from "../element/image"; } from "../element/image";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { import {
bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
getContainerCenter, getContainerCenter,
getContainerElement, getContainerElement,
@ -309,7 +305,6 @@ import { Fonts, getLineHeight } from "../fonts";
import { import {
getFrameChildren, getFrameChildren,
isCursorInFrame, isCursorInFrame,
bindElementsToFramesAfterDuplication,
addElementsToFrame, addElementsToFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
removeElementsFromFrame, removeElementsFromFrame,
@ -3224,17 +3219,16 @@ 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 { newElements } = duplicateElements({
elements.map((element) => { type: "everything",
elements: elements.map((element) => {
return newElementWith(element, { return newElementWith(element, {
x: element.x + gridX - minX, x: element.x + gridX - minX,
y: element.y + gridY - minY, y: element.y + gridY - minY,
}); });
}), }),
{ randomizeSeed: !opts.retainSeed,
randomizeSeed: !opts.retainSeed, });
},
);
const prevElements = this.scene.getElementsIncludingDeleted(); const prevElements = this.scene.getElementsIncludingDeleted();
let nextElements = [...prevElements, ...newElements]; let nextElements = [...prevElements, ...newElements];
@ -6095,7 +6089,12 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
activeEmbeddable: { element: hitElement, state: "hover" }, activeEmbeddable: { element: hitElement, state: "hover" },
}); });
} else if (!hitElement || !isElbowArrow(hitElement)) { } else if (
!hitElement ||
// Ebow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
@ -6288,7 +6287,13 @@ class App extends React.Component<AppProps, AppState> {
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) { if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
} }
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if ( if (
@ -8138,6 +8143,7 @@ class App extends React.Component<AppProps, AppState> {
...this.state.selectedLinearElement, ...this.state.selectedLinearElement,
pointerDownState: ret.pointerDownState, pointerDownState: ret.pointerDownState,
selectedPointsIndices: ret.selectedPointsIndices, selectedPointsIndices: ret.selectedPointsIndices,
segmentMidPointHoveredCoords: null,
}, },
}); });
} }
@ -8147,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
...this.state.editingLinearElement, ...this.state.editingLinearElement,
pointerDownState: ret.pointerDownState, pointerDownState: ret.pointerDownState,
selectedPointsIndices: ret.selectedPointsIndices, selectedPointsIndices: ret.selectedPointsIndices,
segmentMidPointHoveredCoords: null,
}, },
}); });
} }
@ -8160,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const didDrag = LinearElementEditor.handlePointDragging( const newLinearElementEditor = LinearElementEditor.handlePointDragging(
event, event,
this, this,
pointerCoords.x, pointerCoords.x,
@ -8174,29 +8181,18 @@ class App extends React.Component<AppProps, AppState> {
linearElementEditor, linearElementEditor,
this.scene, this.scene,
); );
if (didDrag) { if (newLinearElementEditor) {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
pointerDownState.drag.hasOccurred = true; pointerDownState.drag.hasOccurred = true;
if (
this.state.editingLinearElement && this.setState({
!this.state.editingLinearElement.isDragging editingLinearElement: this.state.editingLinearElement
) { ? newLinearElementEditor
this.setState({ : null,
editingLinearElement: { selectedLinearElement: newLinearElementEditor,
...this.state.editingLinearElement, });
isDragging: true,
},
});
}
if (!this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: true,
},
});
}
return; return;
} }
} }
@ -8422,145 +8418,55 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.hit.hasBeenDuplicated = true; pointerDownState.hit.hasBeenDuplicated = true;
const nextElements = [];
const elementsToAppend = [];
const groupIdMap = new Map();
const oldIdToDuplicatedId = new Map();
const hitElement = pointerDownState.hit.element;
const selectedElementIds = new Set(
this.scene
.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
})
.map((element) => element.id),
);
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const hitElement = pointerDownState.hit.element;
for (const element of elements) { const selectedElements = this.scene.getSelectedElements({
const isInSelection = selectedElementIds: this.state.selectedElementIds,
selectedElementIds.has(element.id) || includeBoundTextElement: true,
// case: the state.selectedElementIds might not have been includeElementsInFrames: true,
// updated yet by the time this mousemove event is fired });
(element.id === hitElement?.id && if (
pointerDownState.hit.wasAddedToSelection); hitElement &&
// NOTE (mtolmacs): This is a temporary fix for very large scenes !selectedElements.find((el) => el.id === hitElement.id)
if ( ) {
Math.abs(element.x) > 1e7 || selectedElements.push(hitElement);
Math.abs(element.x) > 1e7 ||
Math.abs(element.width) > 1e7 ||
Math.abs(element.height) > 1e7
) {
console.error(
`Alt+dragging element in scene with invalid dimensions`,
element.x,
element.y,
element.width,
element.height,
isInSelection,
);
return;
}
if (isInSelection) {
const duplicatedElement = duplicateElement(
this.state.editingGroupId,
groupIdMap,
element,
);
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(duplicatedElement.x) > 1e7 ||
Math.abs(duplicatedElement.x) > 1e7 ||
Math.abs(duplicatedElement.width) > 1e7 ||
Math.abs(duplicatedElement.height) > 1e7
) {
console.error(
`Alt+dragging duplicated element with invalid dimensions`,
duplicatedElement.x,
duplicatedElement.y,
duplicatedElement.width,
duplicatedElement.height,
);
return;
}
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
// NOTE (mtolmacs): This is a temporary fix for very large scenes
if (
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.x) > 1e7 ||
Math.abs(origElement.width) > 1e7 ||
Math.abs(origElement.height) > 1e7
) {
console.error(
`Alt+dragging duplicated element with invalid dimensions`,
origElement.x,
origElement.y,
origElement.width,
origElement.height,
);
return;
}
mutateElement(duplicatedElement, {
x: origElement.x,
y: origElement.y,
});
// put duplicated element to pointerDownState.originalElements
// so that we can snap to the duplicated element without releasing
pointerDownState.originalElements.set(
duplicatedElement.id,
duplicatedElement,
);
nextElements.push(duplicatedElement);
elementsToAppend.push(element);
oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
} else {
nextElements.push(element);
}
} }
let nextSceneElements: ExcalidrawElement[] = [ const { newElements: clonedElements, elementsWithClones } =
...nextElements, duplicateElements({
...elementsToAppend, type: "in-place",
]; elements,
appState: this.state,
randomizeSeed: true,
idsOfElementsToDuplicate: new Map(
selectedElements.map((el) => [el.id, el]),
),
overrides: (el) => {
const origEl = pointerDownState.originalElements.get(el.id);
if (origEl) {
return {
x: origEl.x,
y: origEl.y,
};
}
return {};
},
reverseOrder: true,
});
clonedElements.forEach((element) => {
pointerDownState.originalElements.set(element.id, element);
});
const mappedNewSceneElements = this.props.onDuplicate?.( const mappedNewSceneElements = this.props.onDuplicate?.(
nextSceneElements, elementsWithClones,
elements, elements,
); );
nextSceneElements = mappedNewSceneElements || nextSceneElements; const nextSceneElements = syncMovedIndices(
mappedNewSceneElements || elementsWithClones,
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend)); arrayToMap(clonedElements),
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
oldIdToDuplicatedId,
);
fixBindingsAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
"duplicatesServeAsOld",
);
bindElementsToFramesAfterDuplication(
nextSceneElements,
elementsToAppend,
oldIdToDuplicatedId,
); );
this.scene.replaceAllElements(nextSceneElements); this.scene.replaceAllElements(nextSceneElements);

View file

@ -8,18 +8,20 @@ import React, {
import { MIME_TYPES } from "../constants"; import { MIME_TYPES } from "../constants";
import { serializeLibraryAsJSON } from "../data/json"; import { serializeLibraryAsJSON } from "../data/json";
import { duplicateElements } from "../element/newElement";
import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition"; import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { duplicateElements } from "../element/duplicate";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import { import {
LibraryMenuSection, LibraryMenuSection,
LibraryMenuSectionGrid, LibraryMenuSectionGrid,
} from "./LibraryMenuSection"; } from "./LibraryMenuSection";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import Stack from "./Stack"; import Stack from "./Stack";
@ -160,7 +162,11 @@ export default function LibraryMenuItems({
...item, ...item,
// duplicate each library item before inserting on canvas to confine // duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465 // ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }), elements: duplicateElements({
type: "everything",
elements: item.elements,
randomizeSeed: true,
}).newElements,
}; };
}); });
}, },

View file

@ -1,8 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate";
import { EVENT } from "../../constants"; import { EVENT } from "../../constants";
import { deepCopyElement } from "../../element/newElement";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import { CaptureUpdateAction } from "../../store"; import { CaptureUpdateAction } from "../../store";
import { cloneJSON } from "../../utils"; import { cloneJSON } from "../../utils";

View file

@ -13,7 +13,6 @@ import {
vectorCross, vectorCross,
pointsEqual, pointsEqual,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
round,
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision"; import { isPointOnShape } from "@excalidraw/utils/collision";
@ -24,7 +23,10 @@ import { KEYS } from "../keys";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import { import {
arrayToMap, arrayToMap,
invariant,
isBindingFallthroughEnabled, isBindingFallthroughEnabled,
isDevEnv,
isTestEnv,
tupleToCoors, tupleToCoors,
} from "../utils"; } from "../utils";
@ -36,11 +38,8 @@ import {
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToBindableElement } from "./distance";
import { import {
compareHeading,
HEADING_DOWN,
HEADING_RIGHT,
HEADING_UP,
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal,
vectorToHeading, vectorToHeading,
type Heading, type Heading,
} from "./heading"; } from "./heading";
@ -50,7 +49,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@ -60,6 +58,10 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Mutable } from "../utility-types";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
import type { import type {
@ -837,14 +839,19 @@ export const updateBoundElements = (
}> => update !== null, }> => update !== null,
); );
LinearElementEditor.movePoints(element, updates, { LinearElementEditor.movePoints(
...(changedElement.id === element.startBinding?.elementId element,
? { startBinding: bindings.startBinding } updates,
: {}), {
...(changedElement.id === element.endBinding?.elementId ...(changedElement.id === element.startBinding?.elementId
? { endBinding: bindings.endBinding } ? { startBinding: bindings.startBinding }
: {}), : {}),
}); ...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
elementsMap as NonDeletedSceneElementsMap,
);
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
@ -925,103 +932,104 @@ const getDistanceForBinding = (
export const bindPointToSnapToElementOutline = ( export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement | undefined, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
): GlobalPoint => { ): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement); if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
}
const aabb = aabbForElement(bindableElement);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
arrow.x + localP[0], arrow.x + localP[0],
arrow.y + localP[1], arrow.y + localP[1],
); );
const p = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb);
const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x + arrow.points[adjacentPointIdx][0],
arrow.y + arrow.points[adjacentPointIdx][1],
),
center,
arrow.angle ?? 0,
);
if (bindableElement && aabb) { let intersection: GlobalPoint | null = null;
const center = getCenterForBounds(aabb); if (elbowed) {
const isHorizontal = headingIsHorizontal(
const intersection = intersectElementWithLineSegment( headingForPointFromElement(bindableElement, aabb, globalP),
);
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0],
!isHorizontal ? center[1] : edgePoint[1],
);
intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( lineSegment(
center, otherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(p, center)), vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height), Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
center, otherPoint,
), ),
), ),
)[0]; )[0];
const currentDistance = pointDistance(p, center); } else {
const fullDistance = Math.max( intersection = intersectElementWithLineSegment(
pointDistance(intersection ?? p, center), bindableElement,
PRECISION, lineSegment(
); adjacentPoint,
const ratio = round(currentDistance / fullDistance, 6); pointFromVector(
switch (true) {
case ratio > 0.9:
if (
currentDistance - fullDistance > FIXED_BINDING_DISTANCE ||
// Too close to determine vector from intersection to p
pointDistanceSq(p, intersection) < PRECISION
) {
return p;
}
return pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(p, intersection ?? center)), vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE, pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
intersection ?? center, adjacentPoint,
); ),
),
default: FIXED_BINDING_DISTANCE,
return headingToMidBindPoint(p, bindableElement, aabb); ).sort(
} (g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
)[0];
} }
return p; if (
}; !intersection ||
// Too close to determine vector from intersection to edgePoint
const headingToMidBindPoint = ( pointDistanceSq(edgePoint, intersection) < PRECISION
p: GlobalPoint, ) {
bindableElement: ExcalidrawBindableElement, return edgePoint;
aabb: Bounds,
): GlobalPoint => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return pointRotateRads(
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return pointRotateRads(
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return pointRotateRads(
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
} }
if (elbowed) {
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
@ -1411,107 +1419,75 @@ const getLinearElementEdgeCoors = (
); );
}; };
// We need to:
// 1: Update elements not selected to point to duplicated elements
// 2: Update duplicated elements to point to other duplicated elements
export const fixBindingsAfterDuplication = ( export const fixBindingsAfterDuplication = (
sceneElements: readonly ExcalidrawElement[], newElements: ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
// There are three copying mechanisms: Copy-paste, duplication and alt-drag. duplicatedElementsMap: NonDeletedSceneElementsMap,
// Only when alt-dragging the new "duplicates" act as the "old", while ) => {
// the "old" elements act as the "new copy" - essentially working reverse for (const element of newElements) {
// to the other two. if ("boundElements" in element && element.boundElements) {
duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined, Object.assign(element, {
): void => { boundElements: element.boundElements.reduce(
// First collect all the binding/bindable elements, so we only update (
// each once, regardless of whether they were duplicated or not. acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set(); binding,
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set(); ) => {
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld"; const newBindingId = oldIdToDuplicatedId.get(binding.id);
const duplicateIdToOldId = new Map( if (newBindingId) {
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]), acc.push({ ...binding, id: newBindingId });
); }
oldElements.forEach((oldElement) => { return acc;
const { boundElements } = oldElement; },
if (boundElements != null && boundElements.length > 0) { [],
boundElements.forEach((boundElement) => { ),
if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
allBoundElementIds.add(boundElement.id);
}
}); });
allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
} }
if (isBindingElement(oldElement)) {
if (oldElement.startBinding != null) { if ("containerId" in element && element.containerId) {
const { elementId } = oldElement.startBinding; Object.assign(element, {
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) { containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
allBindableElementIds.add(elementId); });
}
}
if (oldElement.endBinding != null) {
const { elementId } = oldElement.endBinding;
if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
allBindableElementIds.add(elementId);
}
}
if (oldElement.startBinding != null || oldElement.endBinding != null) {
allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
}
} }
});
// Update the linear elements if ("endBinding" in element && element.endBinding) {
( const newEndBindingId = oldIdToDuplicatedId.get(
sceneElements.filter(({ id }) => element.endBinding.elementId,
allBoundElementIds.has(id), );
) as ExcalidrawLinearElement[] Object.assign(element, {
).forEach((element) => { endBinding: newEndBindingId
const { startBinding, endBinding } = element; ? {
mutateElement(element, { ...element.endBinding,
startBinding: newBindingAfterDuplication( elementId: newEndBindingId,
startBinding, }
oldIdToDuplicatedId, : null,
), });
endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId), }
}); if ("startBinding" in element && element.startBinding) {
}); const newEndBindingId = oldIdToDuplicatedId.get(
element.startBinding.elementId,
);
Object.assign(element, {
startBinding: newEndBindingId
? {
...element.startBinding,
elementId: newEndBindingId,
}
: null,
});
}
// Update the bindable shapes if (isElbowArrow(element)) {
sceneElements Object.assign(
.filter(({ id }) => allBindableElementIds.has(id)) element,
.forEach((bindableElement) => { updateElbowArrowPoints(element, duplicatedElementsMap, {
const oldElementId = duplicateIdToOldId.get(bindableElement.id); points: [
const boundElements = sceneElements.find( element.points[0],
({ id }) => id === oldElementId, element.points[element.points.length - 1],
)?.boundElements; ],
}),
if (boundElements && boundElements.length > 0) { );
mutateElement(bindableElement, { }
boundElements: boundElements.map((boundElement) =>
oldIdToDuplicatedId.has(boundElement.id)
? {
id: oldIdToDuplicatedId.get(boundElement.id)!,
type: boundElement.type,
}
: boundElement,
),
});
}
});
};
const newBindingAfterDuplication = (
binding: PointBinding | null,
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
): PointBinding | null => {
if (binding == null) {
return null;
} }
return {
...binding,
elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
};
}; };
export const fixBindingsAfterDeletion = ( export const fixBindingsAfterDeletion = (

View file

@ -1,16 +1,32 @@
import React from "react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { FONT_FAMILY, ORIG_ID, ROUNDNESS } from "../constants";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { isPrimitive } from "../utils"; import { isPrimitive } from "../utils";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { Excalidraw } from "..";
import { actionDuplicateSelection } from "../actions";
import { Keyboard, Pointer } from "../tests/helpers/ui";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { duplicateElement, duplicateElements } from "./newElement";
import { duplicateElement, duplicateElements } from "./duplicate";
import type { ExcalidrawLinearElement } from "./types"; import type { ExcalidrawLinearElement } from "./types";
const { h } = window;
const mouse = new Pointer("mouse");
const assertCloneObjects = (source: any, clone: any) => { const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) { for (const key in clone) {
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) { if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
@ -45,7 +61,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); const copy = duplicateElement(null, new Map(), element, undefined, true);
assertCloneObjects(element, copy); assertCloneObjects(element, copy);
@ -64,6 +80,8 @@ describe("duplicating single elements", () => {
...element, ...element,
id: copy.id, id: copy.id,
seed: copy.seed, seed: copy.seed,
version: copy.version,
versionNonce: copy.versionNonce,
}); });
}); });
@ -149,7 +167,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const clonedElements = duplicateElements(origElements); const { newElements: clonedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
// generic id in-equality checks // generic id in-equality checks
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -206,6 +227,7 @@ describe("duplicating multiple elements", () => {
type: clonedText1.type, type: clonedText1.type,
}), }),
); );
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => { clonedArrows.forEach((arrow) => {
expect( expect(
@ -281,7 +303,7 @@ describe("duplicating multiple elements", () => {
const arrow3 = API.createElement({ const arrow3 = API.createElement({
type: "arrow", type: "arrow",
id: "arrow2", id: "arrow3",
startBinding: { startBinding: {
elementId: "rectangle-not-exists", elementId: "rectangle-not-exists",
focus: 0.2, focus: 0.2,
@ -299,9 +321,11 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements( const { newElements: clonedElements } = duplicateElements({
origElements, type: "everything",
) as any as typeof origElements; elements: origElements,
}) as any as { newElements: typeof origElements };
const [ const [
clonedRectangle, clonedRectangle,
clonedText1, clonedText1,
@ -321,7 +345,6 @@ describe("duplicating multiple elements", () => {
elementId: clonedRectangle.id, elementId: clonedRectangle.id,
}); });
expect(clonedArrow2.endBinding).toBe(null); expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null); expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({ expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding, ...arrow3.endBinding,
@ -345,9 +368,10 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements( const { newElements: clonedElements } = duplicateElements({
origElements, type: "everything",
) as any as typeof origElements; elements: origElements,
}) as any as { newElements: typeof origElements };
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements; clonedElements;
@ -368,10 +392,305 @@ describe("duplicating multiple elements", () => {
groupIds: ["g1"], groupIds: ["g1"],
}); });
const [clonedRectangle1] = duplicateElements([rectangle1]); const {
newElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
}); });
}); });
}); });
describe("duplication z-order", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle1]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle3]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id },
{ id: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle3);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle3.id },
{ id: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id },
{ id: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
});
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 },
]);
});
it("reverse-duplicating text container (in-order)", async () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([rectangle, text]);
API.setSelectedElements([rectangle, text]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id },
{
[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 () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([text, rectangle]);
API.setSelectedElements([rectangle, text]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id },
{
[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 () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow, text]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ [ORIG_ID]: arrow.id },
{
[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 labeled arrows (out-of-order)", async () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([text, arrow]);
API.setSelectedElements([arrow, text]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ [ORIG_ID]: arrow.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id,
},
{ id: arrow.id, selected: true },
{ id: text.id, containerId: arrow.id, selected: true },
]);
});
});

View file

@ -0,0 +1,484 @@
import { ORIG_ID } from "../constants";
import {
getElementsInGroup,
getNewGroupIdsForDuplication,
getSelectedGroupForElement,
} from "../groups";
import { randomId, randomInteger } from "../random";
import {
arrayToMap,
castArray,
findLastIndex,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "../frame";
import { normalizeElementOrder } from "./sortElements";
import { bumpVersion } from "./mutateElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixBindingsAfterDuplication } from "./binding";
import type { AppState } from "../types";
import type { Mutable } from "../utility-types";
import type {
ElementsMap,
ExcalidrawElement,
GroupId,
NonDeletedSceneElementsMap,
} from "./types";
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
* introduction of gruoping/ungrouping elements.
* @param editingGroupId The current group being edited. The new
* element will inherit this group and its
* parents.
* @param groupIdMapForOperation A Map that maps old group IDs to
* duplicated ones. If you are duplicating
* 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);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
}
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId());
}
return groupIdMapForOperation.get(groupId)!;
},
);
if (overrides) {
copy = Object.assign(copy, overrides);
}
return copy;
};
export const duplicateElements = (
opts: {
elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean;
overrides?: (
originalElement: ExcalidrawElement,
) => Partial<ExcalidrawElement>;
} & (
| {
/**
* Duplicates all elements in array.
*
* Use this when programmaticaly duplicating elements, without direct
* user interaction.
*/
type: "everything";
}
| {
/**
* Duplicates specified elements and inserts them back into the array
* in specified order.
*
* Use this when duplicating Scene elements, during user interaction
* such as alt-drag or on duplicate action.
*/
type: "in-place";
idsOfElementsToDuplicate: Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
appState: {
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;
}
),
) => {
let { elements } = opts;
const appState =
"appState" in opts
? opts.appState
: ({
editingGroupId: null,
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
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// 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 elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate =
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
// For sanity
if (opts.type === "in-place") {
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
elements
.filter((el) => el.groupIds?.includes(groupId))
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
}
}
elements = normalizeElementOrder(elements);
const elementsWithClones: ExcalidrawElement[] = elements.slice();
// helper functions
// -------------------------------------------------------------------------
// Used for the heavy lifing of copying a single element, a group of elements
// an element with bound text etc.
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
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);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
);
return (
Array.isArray(element) ? _newElements : _newElements[0] || null
) as T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null;
};
// Helper to position cloned elements in the Z-order the product needs it
const insertBeforeOrAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
if (!elements) {
return;
}
if (reverseOrder && index < 1) {
elementsWithClones.unshift(...castArray(elements));
return;
}
if (!reverseOrder && index > elementsWithClones.length - 1) {
elementsWithClones.push(...castArray(elements));
return;
}
elementsWithClones.splice(
index + (reverseOrder ? 0 : 1),
0,
...castArray(elements),
);
};
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
if (!_idsOfElementsToDuplicate.has(element.id)) {
continue;
}
// groups
// -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
const targetIndex = reverseOrder
? elementsWithClones.findIndex((el) => {
return el.groupIds?.includes(groupId);
})
: findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertBeforeOrAfterIndex(
targetIndex,
copyElements([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertBeforeOrAfterIndex(
targetIndex + (reverseOrder ? -1 : 0),
copyElements([element, boundTextElement]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.id === element.id || el.id === container?.id;
});
if (container) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([container, element]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertBeforeOrAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id),
copyElements(element),
);
}
// ---------------------------------------------------------------------------
fixBindingsAfterDuplication(
newElements,
oldIdToDuplicatedId,
duplicatedElementsMap as NonDeletedSceneElementsMap,
);
bindElementsToFramesAfterDuplication(
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
return {
newElements,
elementsWithClones,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
//
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
}
if (Array.isArray(val)) {
let k = val.length;
const arr = new Array(k);
while (k--) {
arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (import.meta.env.DEV) {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val;
};
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};

View file

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

View file

@ -238,16 +238,6 @@ const handleSegmentRenormalization = (
nextPoints.map((p) => nextPoints.map((p) =>
pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y), pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
), ),
arrow.startBinding &&
getBindableElementForId(
arrow.startBinding.elementId,
elementsMap,
),
arrow.endBinding &&
getBindableElementForId(
arrow.endBinding.elementId,
elementsMap,
),
), ),
) ?? [], ) ?? [],
), ),
@ -341,9 +331,6 @@ const handleSegmentRelease = (
y, y,
), ),
], ],
startBinding &&
getBindableElementForId(startBinding.elementId, elementsMap),
endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
{ isDragging: false }, { isDragging: false },
); );
@ -983,6 +970,8 @@ export const updateElbowArrowPoints = (
); );
} }
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
const updatedPoints: readonly LocalPoint[] = updates.points const updatedPoints: readonly LocalPoint[] = updates.points
? updates.points && updates.points.length === 2 ? updates.points && updates.points.length === 2
? arrow.points.map((p, idx) => ? arrow.points.map((p, idx) =>
@ -995,7 +984,7 @@ export const updateElbowArrowPoints = (
: updates.points.slice() : updates.points.slice()
: arrow.points.slice(); : arrow.points.slice();
// 0. During all element replacement in the scene, we just need to renormalize // During all element replacement in the scene, we just need to renormalize
// the arrow // the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
const { const {
@ -1016,11 +1005,12 @@ export const updateElbowArrowPoints = (
getBindableElementForId(startBinding.elementId, elementsMap); getBindableElementForId(startBinding.elementId, elementsMap);
const endElement = const endElement =
endBinding && getBindableElementForId(endBinding.elementId, elementsMap); endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
const areUpdatedPointsValid = validateElbowPoints(updatedPoints);
if ( if (
(startBinding && !startElement) || (startBinding && !startElement && areUpdatedPointsValid) ||
(endBinding && !endElement) || (endBinding && !endElement && areUpdatedPointsValid) ||
(elementsMap.size === 0 && validateElbowPoints(updatedPoints)) || (elementsMap.size === 0 && areUpdatedPointsValid) ||
(Object.keys(restOfTheUpdates).length === 0 && (Object.keys(restOfTheUpdates).length === 0 &&
(startElement?.id !== startBinding?.elementId || (startElement?.id !== startBinding?.elementId ||
endElement?.id !== endBinding?.elementId)) endElement?.id !== endBinding?.elementId))
@ -1055,12 +1045,22 @@ export const updateElbowArrowPoints = (
}, },
elementsMap, elementsMap,
updatedPoints, updatedPoints,
startElement,
endElement,
options, options,
); );
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? []; // 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && areUpdatedPointsValid) {
return normalizeArrowElementUpdate(
updatedPoints.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
),
arrow.fixedSegments,
arrow.startIsSpecial,
arrow.endIsSpecial,
);
}
//// ////
// 1. Renormalize the arrow // 1. Renormalize the arrow
@ -1084,7 +1084,7 @@ export const updateElbowArrowPoints = (
arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity), arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
), ),
) && ) &&
validateElbowPoints(updatedPoints) areUpdatedPointsValid
) { ) {
return {}; return {};
} }
@ -1195,8 +1195,6 @@ const getElbowArrowData = (
}, },
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
startElement: ExcalidrawBindableElement | null,
endElement: ExcalidrawBindableElement | null,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
@ -1211,8 +1209,8 @@ const getElbowArrowData = (
GlobalPoint GlobalPoint
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y)); >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
let hoveredStartElement = startElement; let hoveredStartElement = null;
let hoveredEndElement = endElement; let hoveredEndElement = null;
if (options?.isDragging) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
@ -1221,39 +1219,47 @@ const getElbowArrowData = (
elementsMap, elementsMap,
elements, elements,
options?.zoom, options?.zoom,
) || startElement; ) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement( getHoveredElement(
origEndGlobalPoint, origEndGlobalPoint,
elementsMap, elementsMap,
elements, elements,
options?.zoom, options?.zoom,
) || endElement; ) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
null
: null;
hoveredEndElement = arrow.endBinding
? getBindableElementForId(arrow.endBinding.elementId, elementsMap) || null
: null;
} }
const startGlobalPoint = getGlobalPoint( const startGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
} as ExcalidrawElbowArrowElement, } as ExcalidrawElbowArrowElement,
"start", "start",
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
startElement,
hoveredStartElement, hoveredStartElement,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
{ {
...arrow, ...arrow,
type: "arrow",
elbowed: true, elbowed: true,
points: nextPoints, points: nextPoints,
} as ExcalidrawElbowArrowElement, } as ExcalidrawElbowArrowElement,
"end", "end",
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
endElement,
hoveredEndElement, hoveredEndElement,
options?.isDragging, options?.isDragging,
); );
@ -2199,36 +2205,35 @@ const getGlobalPoint = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
boundElement?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (hoveredElement) { if (element) {
const snapPoint = bindPointToSnapToElementOutline( const snapPoint = bindPointToSnapToElementOutline(
arrow, arrow,
hoveredElement, element,
startOrEnd, startOrEnd,
); );
return snapToMid(hoveredElement, snapPoint); return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (boundElement) { if (element) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
boundElement, element,
); );
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(boundElement, fixedGlobalPoint) - distanceToBindableElement(element, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
: fixedGlobalPoint; : fixedGlobalPoint;
} }

View file

@ -1,4 +1,4 @@
import { pointFrom, type LocalPoint } from "@excalidraw/math"; import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame"; import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -94,7 +94,7 @@ const getNodeRelatives = (
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x, edgePoint[0] + el.x,
edgePoint[1] + el.y, edgePoint[1] + el.y,
] as Readonly<LocalPoint>); ] as Readonly<GlobalPoint>);
acc.push({ acc.push({
relative, relative,

View file

@ -1,4 +1,5 @@
import { import {
normalizeRadians,
pointFrom, pointFrom,
pointRotateRads, pointRotateRads,
pointScaleFromOrigin, pointScaleFromOrigin,
@ -30,8 +31,9 @@ export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
b: Point, b: Point,
) => { ) => {
const angle = radiansToDegrees( const angle = radiansToDegrees(
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians, normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
); );
if (angle >= 315 || angle < 45) { if (angle >= 315 || angle < 45) {
return HEADING_UP; return HEADING_UP;
} else if (angle >= 45 && angle < 135) { } else if (angle >= 45 && angle < 135) {
@ -77,9 +79,7 @@ export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
// Gets the heading for the point by creating a bounding box around the rotated // Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of // close fitting bounding box, then creating 4 search cones around the center of
// the external bbox. // the external bbox.
export const headingForPointFromElement = < export const headingForPointFromElement = <Point extends GlobalPoint>(
Point extends GlobalPoint | LocalPoint,
>(
element: Readonly<ExcalidrawBindableElement>, element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>, aabb: Readonly<Bounds>,
p: Readonly<Point>, p: Readonly<Point>,

View file

@ -14,8 +14,8 @@ export {
newLinearElement, newLinearElement,
newArrowElement, newArrowElement,
newImageElement, newImageElement,
duplicateElement,
} from "./newElement"; } from "./newElement";
export { duplicateElement } from "./duplicate";
export { export {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getElementBounds, getElementBounds,

View file

@ -25,14 +25,19 @@ import {
import { getGridPoint } from "../snapping"; import { getGridPoint } from "../snapping";
import { invariant, tupleToCoors } from "../utils"; import { invariant, tupleToCoors } from "../utils";
import Scene from "../scene/Scene";
import { import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow";
import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds"; import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { bumpVersion, mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isBindingElement, isBindingElement,
@ -57,7 +62,6 @@ import type {
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import type Scene from "../scene/Scene";
import type { Store } from "../store"; import type { Store } from "../store";
import type { import type {
AppState, AppState,
@ -67,6 +71,7 @@ import type {
NullableGridSize, NullableGridSize,
Zoom, Zoom,
} from "../types"; } from "../types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
const editorMidPointsCache: { const editorMidPointsCache: {
@ -232,15 +237,15 @@ export class LinearElementEditor {
) => void, ) => void,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
scene: Scene, scene: Scene,
): boolean { ): LinearElementEditor | null {
if (!linearElementEditor) { if (!linearElementEditor) {
return false; return null;
} }
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return false; return null;
} }
if ( if (
@ -248,24 +253,18 @@ export class LinearElementEditor {
!linearElementEditor.pointerDownState.lastClickedIsEndPoint && !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0 linearElementEditor.pointerDownState.lastClickedPoint !== 0
) { ) {
return false; return null;
} }
const selectedPointsIndices = isElbowArrow(element) const selectedPointsIndices = isElbowArrow(element)
? linearElementEditor.selectedPointsIndices ? [
?.reduce( !!linearElementEditor.selectedPointsIndices?.includes(0)
(startEnd, index) => ? 0
(index === 0 : undefined,
? [0, startEnd[1]] !!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
: [startEnd[0], element.points.length - 1]) as [ ? element.points.length - 1
boolean | number, : undefined,
boolean | number, ].filter((idx): idx is number => idx !== undefined)
],
[false, false] as [number | boolean, number | boolean],
)
.filter(
(idx: number | boolean): idx is number => typeof idx === "number",
)
: linearElementEditor.selectedPointsIndices; : linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element) const lastClickedPoint = isElbowArrow(element)
? linearElementEditor.pointerDownState.lastClickedPoint > 0 ? linearElementEditor.pointerDownState.lastClickedPoint > 0
@ -274,9 +273,7 @@ export class LinearElementEditor {
: linearElementEditor.pointerDownState.lastClickedPoint; : linearElementEditor.pointerDownState.lastClickedPoint;
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint] as const draggingPoint = element.points[lastClickedPoint];
| [number, number]
| undefined;
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if ( if (
@ -384,10 +381,28 @@ export class LinearElementEditor {
} }
} }
return true; return {
...linearElementEditor,
selectedPointsIndices,
segmentMidPointHoveredCoords:
lastClickedPoint !== 0 &&
lastClickedPoint !== element.points.length - 1
? this.getPointGlobalCoordinates(
element,
draggingPoint,
elementsMap,
)
: null,
hoverPointIndex:
lastClickedPoint === 0 ||
lastClickedPoint === element.points.length - 1
? lastClickedPoint
: -1,
isDragging: true,
};
} }
return false; return null;
} }
static handlePointerUp( static handlePointerUp(
@ -1264,6 +1279,7 @@ export class LinearElementEditor {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
sceneElementsMap?: NonDeletedSceneElementsMap,
) { ) {
const { points } = element; const { points } = element;
@ -1307,6 +1323,7 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true, dragging || targetPoint.isDragging === true,
false, false,
), ),
sceneElementsMap,
}, },
); );
} }
@ -1420,6 +1437,7 @@ export class LinearElementEditor {
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap;
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
@ -1445,9 +1463,28 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints); updates.points = Array.from(nextPoints);
mutateElement(element, updates, true, { if (!options?.sceneElementsMap || Scene.getScene(element)) {
isDragging: options?.isDragging, mutateElement(element, updates, true, {
}); isDragging: options?.isDragging,
});
} else {
// The element is not in the scene, so we need to use the provided
// scene map.
Object.assign(element, {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
element,
options.sceneElementsMap,
updates,
{
isDragging: options?.isDragging,
},
),
});
}
bumpVersion(element);
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);

View file

@ -6,21 +6,14 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { getLineHeight } from "../fonts"; import { getLineHeight } from "../fonts";
import { getNewGroupIdsForDuplication } from "../groups";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { import { getFontString, getUpdatedTimestamp } from "../utils";
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { bumpVersion, newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement"; import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@ -35,7 +28,6 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
NonDeleted, NonDeleted,
TextAlign, TextAlign,
GroupId,
VerticalAlign, VerticalAlign,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
@ -50,8 +42,7 @@ import type {
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import type { AppState } from "../types"; import type { MarkOptional, Merge } from "../utility-types";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">, Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -538,260 +529,3 @@ export const newImageElement = (
crop: opts.crop ?? null, crop: opts.crop ?? null,
}; };
}; };
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
//
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
}
if (Array.isArray(val)) {
let k = val.length;
const arr = new Array(k);
while (k--) {
arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (import.meta.env.DEV) {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val;
};
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};
/**
* utility wrapper to generate new id.
*/
const regenerateId = () => {
return randomId();
};
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
* introduction of gruoping/ungrouping elements.
* @param editingGroupId The current group being edited. The new
* element will inherit this group and its
* parents.
* @param groupIdMapForOperation A Map that maps old group IDs to
* duplicated ones. If you are duplicating
* 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>,
): Readonly<TElement> => {
let copy = deepCopyElement(element);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = regenerateId();
copy.boundElements = null;
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, regenerateId());
}
return groupIdMapForOperation.get(groupId)!;
},
);
if (overrides) {
copy = Object.assign(copy, overrides);
}
return copy;
};
/**
* Clones elements, regenerating their ids (including bindings) and group ids.
*
* If bindings don't exist in the elements array, they are removed. Therefore,
* it's advised to supply the whole elements array, or sets of elements that
* are encapsulated (such as library items), if the purpose is to retain
* bindings to the cloned elements intact.
*
* NOTE by default does not randomize or regenerate anything except the id.
*/
export const duplicateElements = (
elements: readonly ExcalidrawElement[],
opts?: {
/** NOTE also updates version flags and `updated` */
randomizeSeed: boolean;
},
) => {
const clonedElements: ExcalidrawElement[] = [];
const origElementsMap = arrayToMap(elements);
// used for for migrating old ids to new ids
const elementNewIdsMap = new Map<
/* orig */ ExcalidrawElement["id"],
/* new */ ExcalidrawElement["id"]
>();
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
// if we've already migrated the element id, return the new one directly
if (elementNewIdsMap.has(id)) {
return elementNewIdsMap.get(id)!;
}
// if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it
if (origElementsMap.has(id)) {
const newId = regenerateId();
elementNewIdsMap.set(id, newId);
return newId;
}
// if old element doesn't exist, return null to mark it for removal
return null;
};
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
for (const element of elements) {
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!;
if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id);
}
if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger();
bumpVersion(clonedElement);
}
if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId());
}
return groupNewIdsMap.get(groupId)!;
});
}
if ("containerId" in clonedElement && clonedElement.containerId) {
const newContainerId = maybeGetNewId(clonedElement.containerId);
clonedElement.containerId = newContainerId;
}
if ("boundElements" in clonedElement && clonedElement.boundElements) {
clonedElement.boundElements = clonedElement.boundElements.reduce(
(
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding,
) => {
const newBindingId = maybeGetNewId(binding.id);
if (newBindingId) {
acc.push({ ...binding, id: newBindingId });
}
return acc;
},
[],
);
}
if ("endBinding" in clonedElement && clonedElement.endBinding) {
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
clonedElement.endBinding = newEndBindingId
? {
...clonedElement.endBinding,
elementId: newEndBindingId,
}
: null;
}
if ("startBinding" in clonedElement && clonedElement.startBinding) {
const newEndBindingId = maybeGetNewId(
clonedElement.startBinding.elementId,
);
clonedElement.startBinding = newEndBindingId
? {
...clonedElement.startBinding,
elementId: newEndBindingId,
}
: null;
}
if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
}
clonedElements.push(clonedElement);
}
return clonedElements;
};

View file

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

View file

@ -214,7 +214,10 @@ export const isSelectedViaGroup = (
) => getSelectedGroupForElement(appState, element) != null; ) => getSelectedGroupForElement(appState, element) != null;
export const getSelectedGroupForElement = ( export const getSelectedGroupForElement = (
appState: InteractiveCanvasAppState, appState: Pick<
InteractiveCanvasAppState,
"editingGroupId" | "selectedGroupIds"
>,
element: ExcalidrawElement, element: ExcalidrawElement,
) => ) =>
element.groupIds element.groupIds

View file

@ -886,23 +886,24 @@ const _renderInteractiveScene = ({
); );
} }
if ( // Arrows have a different highlight behavior when
isElbowArrow(selectedElements[0]) && // they are the only selected element
appState.selectedLinearElement && if (appState.selectedLinearElement) {
appState.selectedLinearElement.segmentMidPointHoveredCoords const editor = appState.selectedLinearElement;
) { const firstSelectedLinear = selectedElements.find(
renderElbowArrowMidPointHighlight(context, appState); (el) => el.id === editor.elementId, // Don't forget bound text elements!
} else if ( );
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 && if (editor.segmentMidPointHoveredCoords) {
!( renderElbowArrowMidPointHighlight(context, appState);
isElbowArrow(selectedElements[0]) && } else if (
appState.selectedLinearElement.hoverPointIndex > 0 && isElbowArrow(firstSelectedLinear)
appState.selectedLinearElement.hoverPointIndex < ? editor.hoverPointIndex === 0 ||
selectedElements[0].points.length - 1 editor.hoverPointIndex === firstSelectedLinear.points.length - 1
) : editor.hoverPointIndex >= 0
) { ) {
renderLinearElementPointHighlight(context, appState, elementsMap); renderLinearElementPointHighlight(context, appState, elementsMap);
}
} }
// Paint selected elements // Paint selected elements
@ -1073,7 +1074,7 @@ const _renderInteractiveScene = ({
const dashedLinePadding = const dashedLinePadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
context.fillStyle = oc.white; context.fillStyle = oc.white;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
const initialLineDash = context.getLineDash(); const initialLineDash = context.getLineDash();
context.setLineDash([2 / appState.zoom.value]); context.setLineDash([2 / appState.zoom.value]);
const lineWidth = context.lineWidth; const lineWidth = context.lineWidth;

View file

@ -2,10 +2,11 @@ import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change"; import { AppStateChange, ElementsChange } from "./change";
import { ENV } from "./constants"; import { ENV } from "./constants";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { deepCopyElement } from "./element/newElement";
import { Emitter } from "./emitter"; import { Emitter } from "./emitter";
import { isShallowEqual } from "./utils"; import { isShallowEqual } from "./utils";
import { deepCopyElement } from "./element/duplicate";
import type { OrderedExcalidrawElement } from "./element/types"; import type { OrderedExcalidrawElement } from "./element/types";
import type { AppState, ObservedAppState } from "./types"; import type { AppState, ObservedAppState } from "./types";
import type { ValueOf } from "./utility-types"; import type { ValueOf } from "./utility-types";

View file

@ -2606,8 +2606,8 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 5,
"versionNonce": 238820263, "versionNonce": 400692809,
"width": 20, "width": 20,
"x": 0, "x": 0,
"y": 10, "y": 10,

View file

@ -15172,9 +15172,11 @@ History {
"selectedElementIds": { "selectedElementIds": {
"id61": true, "id61": true,
}, },
"selectedLinearElementId": "id61",
}, },
"inserted": { "inserted": {
"selectedElementIds": {}, "selectedElementIds": {},
"selectedLinearElementId": null,
}, },
}, },
}, },
@ -18946,7 +18948,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 4,
"width": 100, "width": 100,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -18980,7 +18982,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 4,
"width": 100, "width": 100,
"x": 110, "x": 110,
"y": 110, "y": 110,
@ -19014,7 +19016,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 7,
"width": 100, "width": 100,
"x": 10, "x": 10,
"y": 10, "y": 10,
@ -19048,7 +19050,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 7,
"width": 100, "width": 100,
"x": 110, "x": 110,
"y": 110, "y": 110,

View file

@ -11,7 +11,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
"id": "id2", "id": "id2",
"index": "a0", "index": "Zz",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -26,8 +26,8 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 6,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@ -45,7 +45,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"index": "a1", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -60,7 +60,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 5,
"versionNonce": 23633383, "versionNonce": 23633383,
"width": 30, "width": 30,
"x": -10, "x": -10,

View file

@ -2139,7 +2139,7 @@ History {
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 10, "height": 10,
"index": "a0", "index": "Zz",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -2164,12 +2164,10 @@ History {
"updated": Map { "updated": Map {
"id0" => Delta { "id0" => Delta {
"deleted": { "deleted": {
"index": "a1",
"x": 20, "x": 20,
"y": 20, "y": 20,
}, },
"inserted": { "inserted": {
"index": "a0",
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
@ -10631,7 +10629,7 @@ History {
"id7", "id7",
], ],
"height": 10, "height": 10,
"index": "a0", "index": "Zx",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -10664,7 +10662,7 @@ History {
"id7", "id7",
], ],
"height": 10, "height": 10,
"index": "a1", "index": "Zy",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -10697,7 +10695,7 @@ History {
"id7", "id7",
], ],
"height": 10, "height": 10,
"index": "a2", "index": "Zz",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
"locked": false, "locked": false,
@ -10722,36 +10720,30 @@ History {
"updated": Map { "updated": Map {
"id0" => Delta { "id0" => Delta {
"deleted": { "deleted": {
"index": "a3",
"x": 20, "x": 20,
"y": 20, "y": 20,
}, },
"inserted": { "inserted": {
"index": "a0",
"x": 10, "x": 10,
"y": 10, "y": 10,
}, },
}, },
"id1" => Delta { "id1" => Delta {
"deleted": { "deleted": {
"index": "a4",
"x": 40, "x": 40,
"y": 20, "y": 20,
}, },
"inserted": { "inserted": {
"index": "a1",
"x": 30, "x": 30,
"y": 10, "y": 10,
}, },
}, },
"id2" => Delta { "id2" => Delta {
"deleted": { "deleted": {
"index": "a5",
"x": 60, "x": 60,
"y": 20, "y": 20,
}, },
"inserted": { "inserted": {
"index": "a2",
"x": 50, "x": 50,
"y": 10, "y": 10,
}, },

View file

@ -1,7 +1,6 @@
/* eslint-disable no-lone-blocks */ /* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing"; import { generateKeyBetween } from "fractional-indexing";
import { deepCopyElement } from "../element/newElement";
import { InvalidFractionalIndexError } from "../errors"; import { InvalidFractionalIndexError } from "../errors";
import { import {
syncInvalidIndices, syncInvalidIndices,
@ -10,6 +9,8 @@ import {
} from "../fractionalIndex"; } from "../fractionalIndex";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { deepCopyElement } from "../element/duplicate";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import type { ExcalidrawElement, FractionalIndex } from "../element/types"; import type { ExcalidrawElement, FractionalIndex } from "../element/types";

View file

@ -1184,3 +1184,7 @@ it(
expect(API.getSelectedElements().length).toBe(1); expect(API.getSelectedElements().length).toBe(1);
}, },
); );
//
// DEPRECATED: DO NOT ADD TESTS HERE
//