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 { deepCopyElement } from "../element/newElement";
import { deepCopyElement } from "../element/duplicate";
import { t } from "../i18n";
import { randomId } from "../random";
import { CaptureUpdateAction } from "../store";

View file

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

View file

@ -258,7 +258,7 @@ describe("actionDuplicateSelection", () => {
assertElements(h.elements, [
{ id: 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 { DuplicateIcon } from "../components/icons";
import { DEFAULT_GRID_SIZE } from "../constants";
import { duplicateElement, getNonDeletedElements } from "../element";
import { fixBindingsAfterDuplication } from "../element/binding";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { getNonDeletedElements } from "../element";
import { isBoundToContainer, isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "../frame";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { selectGroupsForSelectedElements } from "../groups";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
@ -32,19 +13,15 @@ import {
getSelectedElements,
} from "../scene/selection";
import { CaptureUpdateAction } from "../store";
import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { arrayToMap, getShortcutKey } from "../utils";
import { syncMovedIndices } from "../fractionalIndex";
import { duplicateElements } from "../element/duplicate";
import { register } from "./register";
import type { ActionResult } from "./types";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@ -75,20 +52,54 @@ export const actionDuplicateSelection = register({
}
}
const nextState = duplicateElements(elements, appState);
if (app.props.onDuplicate && nextState.elements) {
const mappedElements = app.props.onDuplicate(
nextState.elements,
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
duplicateElements({
type: "in-place",
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) {
nextState.elements = mappedElements;
nextElements = mappedElements;
}
}
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,
};
},
@ -107,259 +118,23 @@ export const actionDuplicateSelection = register({
),
});
const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<Exclude<ActionResult, false>> => {
// ---------------------------------------------------------------------------
const groupIdMap = new Map();
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,
},
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
const linears = clonedElements.filter(isLinearElement);
if (linears.length === 1) {
const linear = linears[0];
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = clonedElements.every(
(el) => el.id === linear.id || boundElements.includes(el.id),
);
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;
if (onlySingleLinearSelected) {
return {
selectedLinearElement: new LinearElementEditor(linear),
};
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 {
elements: elementsWithClones,
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,
),
},
selectedLinearElement: null,
};
};

View file

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

View file

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

View file

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

View file

@ -8,18 +8,20 @@ import React, {
import { MIME_TYPES } from "../constants";
import { serializeLibraryAsJSON } from "../data/json";
import { duplicateElements } from "../element/newElement";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n";
import { arrayToMap } from "../utils";
import { duplicateElements } from "../element/duplicate";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import {
LibraryMenuSection,
LibraryMenuSectionGrid,
} from "./LibraryMenuSection";
import Spinner from "./Spinner";
import Stack from "./Stack";
@ -160,7 +162,11 @@ export default function LibraryMenuItems({
...item,
// duplicate each library item before inserting on canvas to confine
// 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 { useEffect, useRef, useState } from "react";
import { deepCopyElement } from "@excalidraw/excalidraw/element/duplicate";
import { EVENT } from "../../constants";
import { deepCopyElement } from "../../element/newElement";
import { KEYS } from "../../keys";
import { CaptureUpdateAction } from "../../store";
import { cloneJSON } from "../../utils";

View file

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

View file

@ -1,16 +1,32 @@
import React from "react";
import { pointFrom } 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 { 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 { duplicateElement, duplicateElements } from "./newElement";
import { duplicateElement, duplicateElements } from "./duplicate";
import type { ExcalidrawLinearElement } from "./types";
const { h } = window;
const mouse = new Pointer("mouse");
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
@ -45,7 +61,7 @@ describe("duplicating single elements", () => {
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);
@ -64,6 +80,8 @@ describe("duplicating single elements", () => {
...element,
id: copy.id,
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 clonedElements = duplicateElements(origElements);
const { newElements: clonedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
// generic id in-equality checks
// --------------------------------------------------------------------------
@ -206,6 +227,7 @@ describe("duplicating multiple elements", () => {
type: clonedText1.type,
}),
);
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => {
expect(
@ -281,7 +303,7 @@ describe("duplicating multiple elements", () => {
const arrow3 = API.createElement({
type: "arrow",
id: "arrow2",
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
@ -299,9 +321,11 @@ describe("duplicating multiple elements", () => {
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const { newElements: clonedElements } = duplicateElements({
type: "everything",
elements: origElements,
}) as any as { newElements: typeof origElements };
const [
clonedRectangle,
clonedText1,
@ -321,7 +345,6 @@ describe("duplicating multiple elements", () => {
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
@ -345,9 +368,10 @@ describe("duplicating multiple elements", () => {
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const clonedElements = duplicateElements(
origElements,
) as any as typeof origElements;
const { newElements: clonedElements } = duplicateElements({
type: "everything",
elements: origElements,
}) as any as { newElements: typeof origElements };
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements;
@ -368,10 +392,305 @@ describe("duplicating multiple elements", () => {
groupIds: ["g1"],
});
const [clonedRectangle1] = duplicateElements([rectangle1]);
const {
newElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
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);
});
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", {
x: -150,
y: -150,
@ -404,8 +404,8 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[0, 100],
[90, 100],
[90, 200],
]);
});

View file

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

View file

@ -1,4 +1,5 @@
import {
normalizeRadians,
pointFrom,
pointRotateRads,
pointScaleFromOrigin,
@ -30,8 +31,9 @@ export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
b: Point,
) => {
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) {
return HEADING_UP;
} 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
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = <
Point extends GlobalPoint | LocalPoint,
>(
export const headingForPointFromElement = <Point extends GlobalPoint>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
p: Readonly<Point>,

View file

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

View file

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

View file

@ -6,21 +6,14 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN,
} from "../constants";
import { getLineHeight } from "../fonts";
import { getNewGroupIdsForDuplication } from "../groups";
import { randomInteger, randomId } from "../random";
import {
arrayToMap,
getFontString,
getUpdatedTimestamp,
isTestEnv,
} from "../utils";
import { getFontString, getUpdatedTimestamp } from "../utils";
import { getResizedElementAbsoluteCoords } from "./bounds";
import { bumpVersion, newElementWith } from "./mutateElement";
import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
@ -35,7 +28,6 @@ import type {
ExcalidrawGenericElement,
NonDeleted,
TextAlign,
GroupId,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
@ -50,8 +42,7 @@ import type {
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types";
import type { AppState } from "../types";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import type { MarkOptional, Merge } from "../utility-types";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -538,260 +529,3 @@ export const newImageElement = (
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,
VERTICAL_ALIGN,
} from "../constants";
import { getFontString, arrayToMap } from "../utils";
import { getFontString } from "../utils";
import {
resetOriginalContainerCache,
@ -112,48 +112,6 @@ export const redrawTextBoundingBox = (
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 = (
container: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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