From 77aca48c8437b5bcc356b144c9677616ca80c7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Sun, 23 Mar 2025 18:39:33 +0100 Subject: [PATCH] fix: Refactor and merge duplication and binding (#9246) --- .../excalidraw/actions/actionAddToLibrary.ts | 2 +- .../actions/actionDeleteSelected.tsx | 1 + .../actions/actionDuplicateSelection.test.tsx | 2 +- .../actions/actionDuplicateSelection.tsx | 349 +++---------- packages/excalidraw/actions/actionFlip.ts | 3 +- packages/excalidraw/clipboard.ts | 3 +- packages/excalidraw/components/App.tsx | 238 +++------ .../components/LibraryMenuItems.tsx | 10 +- .../excalidraw/components/Stats/DragInput.tsx | 3 +- packages/excalidraw/element/binding.ts | 336 ++++++------ ...{newElement.test.ts => duplicate.test.tsx} | 345 ++++++++++++- packages/excalidraw/element/duplicate.ts | 484 ++++++++++++++++++ .../excalidraw/element/elbowArrow.test.tsx | 6 +- packages/excalidraw/element/elbowArrow.ts | 81 +-- packages/excalidraw/element/flowchart.ts | 4 +- packages/excalidraw/element/heading.ts | 8 +- packages/excalidraw/element/index.ts | 2 +- .../excalidraw/element/linearElementEditor.ts | 93 +++- packages/excalidraw/element/newElement.ts | 272 +--------- packages/excalidraw/element/textElement.ts | 44 +- packages/excalidraw/groups.ts | 5 +- .../excalidraw/renderer/interactiveScene.ts | 37 +- packages/excalidraw/store.ts | 3 +- .../__snapshots__/contextmenu.test.tsx.snap | 4 +- .../tests/__snapshots__/history.test.tsx.snap | 10 +- .../tests/__snapshots__/move.test.tsx.snap | 10 +- .../regressionTests.test.tsx.snap | 16 +- .../excalidraw/tests/fractionalIndex.test.ts | 3 +- .../excalidraw/tests/regressionTests.test.tsx | 4 + 29 files changed, 1293 insertions(+), 1085 deletions(-) rename packages/excalidraw/element/{newElement.test.ts => duplicate.test.tsx} (52%) create mode 100644 packages/excalidraw/element/duplicate.ts diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index b246918a04..b793533084 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -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"; diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index fe3f8c5d71..7e5ed919c2 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -288,6 +288,7 @@ export const actionDeleteSelected = register({ activeTool: updateActiveTool(appState, { type: "selection" }), multiElement: null, activeEmbeddable: null, + selectedLinearElement: null, }, captureUpdate: isSomeElementSelected( getNonDeletedElements(elements), diff --git a/packages/excalidraw/actions/actionDuplicateSelection.test.tsx b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx index 2c1d44e925..bbaae2ed6a 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.test.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.test.tsx @@ -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 }, ]); }); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 0125f0288c..a6dd9d2e12 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -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, 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> => { - // --------------------------------------------------------------------------- - - const groupIdMap = new Map(); - const newElements: ExcalidrawElement[] = []; - const oldElements: ExcalidrawElement[] = []; - const oldIdToDuplicatedId = new Map(); - const duplicatedElementsMap = new Map(); - - 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; - }, - [], +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), ); - return ( - Array.isArray(element) ? _newElements : _newElements[0] || null - ) as T extends ExcalidrawElement[] - ? 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(); - - const elementsWithClones: ExcalidrawElement[] = elements.slice(); - - const insertAfterIndex = ( - index: number, - elements: ExcalidrawElement | null | ExcalidrawElement[], - ) => { - invariant(index !== -1, "targetIndex === -1 "); - - if (!Array.isArray(elements) && !elements) { - return; + if (onlySingleLinearSelected) { + return { + selectedLinearElement: new LinearElementEditor(linear), + }; } - - 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, element) => { - if (!isBoundToContainer(element)) { - acc[element.id] = true; - } - return acc; - }, - {}, - ), - }, - getNonDeletedElements(elementsWithClones), - appState, - null, - ), - }, + selectedLinearElement: null, }; }; diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index bfc7fb7218..6c91445a37 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -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 { diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 1b0e9942a9..397c350ffc 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -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, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6017b73b70..8239493ba7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { 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, - }, - ); + randomizeSeed: !opts.retainSeed, + }); const prevElements = this.scene.getElementsIncludingDeleted(); let nextElements = [...prevElements, ...newElements]; @@ -6095,7 +6089,12 @@ class App extends React.Component { 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,7 +6287,13 @@ class App extends React.Component { if (isHoveringAPointHandle || segmentMidPointHoveredCoords) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } 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)) { if ( @@ -8138,6 +8143,7 @@ class App extends React.Component { ...this.state.selectedLinearElement, pointerDownState: ret.pointerDownState, selectedPointsIndices: ret.selectedPointsIndices, + segmentMidPointHoveredCoords: null, }, }); } @@ -8147,6 +8153,7 @@ class App extends React.Component { ...this.state.editingLinearElement, pointerDownState: ret.pointerDownState, selectedPointsIndices: ret.selectedPointsIndices, + segmentMidPointHoveredCoords: null, }, }); } @@ -8160,7 +8167,7 @@ class App extends React.Component { return; } - const didDrag = LinearElementEditor.handlePointDragging( + const newLinearElementEditor = LinearElementEditor.handlePointDragging( event, this, pointerCoords.x, @@ -8174,29 +8181,18 @@ class App extends React.Component { 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, - }, - }); - } - if (!this.state.selectedLinearElement.isDragging) { - this.setState({ - selectedLinearElement: { - ...this.state.selectedLinearElement, - isDragging: true, - }, - }); - } + + this.setState({ + editingLinearElement: this.state.editingLinearElement + ? newLinearElementEditor + : null, + selectedLinearElement: newLinearElementEditor, + }); + return; } } @@ -8422,145 +8418,55 @@ class App extends React.Component { 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(); - - 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 - ) { - 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); - } + const hitElement = pointerDownState.hit.element; + const selectedElements = this.scene.getSelectedElements({ + selectedElementIds: this.state.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); + if ( + hitElement && + !selectedElements.find((el) => el.id === hitElement.id) + ) { + selectedElements.push(hitElement); } - let nextSceneElements: ExcalidrawElement[] = [ - ...nextElements, - ...elementsToAppend, - ]; + 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); + + 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?.( - 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); diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index af5b9d3e67..3e32021e0f 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -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, }; }); }, diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index fbea33e551..4ee8c75808 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -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"; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 93c5292f1d..6799d1a6b2 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -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, { - ...(changedElement.id === element.startBinding?.elementId - ? { startBinding: bindings.startBinding } - : {}), - ...(changedElement.id === element.endBinding?.elementId - ? { endBinding: bindings.endBinding } - : {}), - }); + 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( arrow.x + localP[0], arrow.y + localP[1], ); - const p = isRectanguloidElement(bindableElement) + const edgePoint = isRectanguloidElement(bindableElement) ? avoidRectangularCorner(bindableElement, globalP) : globalP; + const elbowed = isElbowArrow(arrow); + const center = getCenterForBounds(aabb); + const adjacentPointIdx = startOrEnd === "start" ? 1 : arrow.points.length - 2; + const adjacentPoint = pointRotateRads( + pointFrom( + arrow.x + arrow.points[adjacentPointIdx][0], + arrow.y + arrow.points[adjacentPointIdx][1], + ), + center, + arrow.angle ?? 0, + ); - if (bindableElement && aabb) { - const center = getCenterForBounds(aabb); - - const intersection = intersectElementWithLineSegment( + let intersection: GlobalPoint | null = null; + if (elbowed) { + const isHorizontal = headingIsHorizontal( + headingForPointFromElement(bindableElement, aabb, globalP), + ); + const otherPoint = pointFrom( + isHorizontal ? center[0] : edgePoint[0], + !isHorizontal ? center[1] : edgePoint[1], + ); + intersection = intersectElementWithLineSegment( bindableElement, lineSegment( - center, + otherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(p, center)), - Math.max(bindableElement.width, bindableElement.height), + vectorNormalize(vectorFromPoint(edgePoint, otherPoint)), + Math.max(bindableElement.width, bindableElement.height) * 2, ), - center, + 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; - } - - return pointFromVector( + } else { + intersection = intersectElementWithLineSegment( + bindableElement, + lineSegment( + adjacentPoint, + pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(p, intersection ?? center)), - ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE, + vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), + pointDistance(edgePoint, adjacentPoint) + + Math.max(bindableElement.width, bindableElement.height) * 2, ), - intersection ?? center, - ); - - default: - return headingToMidBindPoint(p, bindableElement, aabb); - } + adjacentPoint, + ), + ), + FIXED_BINDING_DISTANCE, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), + )[0]; } - 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, - ); + 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(edgePoint, intersection)), + scalar, + ), + 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, - // 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 = new Set(); - const allBindableElementIds: Set = 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); - } + duplicatedElementsMap: NonDeletedSceneElementsMap, +) => { + for (const element of newElements) { + if ("boundElements" in element && element.boundElements) { + Object.assign(element, { + boundElements: element.boundElements.reduce( + ( + acc: Mutable>, + binding, + ) => { + const newBindingId = oldIdToDuplicatedId.get(binding.id); + if (newBindingId) { + acc.push({ ...binding, id: newBindingId }); + } + return acc; + }, + [], + ), }); - 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)!); - } + + if ("containerId" in element && element.containerId) { + Object.assign(element, { + containerId: oldIdToDuplicatedId.get(element.containerId) ?? null, + }); } - }); - // Update the linear elements - ( - sceneElements.filter(({ id }) => - allBoundElementIds.has(id), - ) as ExcalidrawLinearElement[] - ).forEach((element) => { - const { startBinding, endBinding } = element; - mutateElement(element, { - startBinding: newBindingAfterDuplication( - startBinding, - oldIdToDuplicatedId, - ), - endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId), - }); - }); + if ("endBinding" in element && element.endBinding) { + const newEndBindingId = oldIdToDuplicatedId.get( + element.endBinding.elementId, + ); + Object.assign(element, { + endBinding: newEndBindingId + ? { + ...element.endBinding, + elementId: newEndBindingId, + } + : null, + }); + } + 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 - 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) - ? { - id: oldIdToDuplicatedId.get(boundElement.id)!, - type: boundElement.type, - } - : boundElement, - ), - }); - } - }); -}; - -const newBindingAfterDuplication = ( - binding: PointBinding | null, - oldIdToDuplicatedId: Map, -): 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 = ( diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/duplicate.test.tsx similarity index 52% rename from packages/excalidraw/element/newElement.test.ts rename to packages/excalidraw/element/duplicate.test.tsx index 418ede1be9..8e070ff980 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/packages/excalidraw/element/duplicate.test.tsx @@ -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(1, 2), pointFrom(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(); + }); + + 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 }, + ]); + }); +}); diff --git a/packages/excalidraw/element/duplicate.ts b/packages/excalidraw/element/duplicate.ts new file mode 100644 index 0000000000..c126c57c9c --- /dev/null +++ b/packages/excalidraw/element/duplicate.ts @@ -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 = ( + editingGroupId: AppState["editingGroupId"], + groupIdMapForOperation: Map, + element: TElement, + overrides?: Partial, + randomizeSeed?: boolean, +): Readonly => { + 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; + } & ( + | { + /** + * 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(); + const groupIdMap = new Map(); + const newElements: ExcalidrawElement[] = []; + const oldElements: ExcalidrawElement[] = []; + const oldIdToDuplicatedId = new Map(); + const duplicatedElementsMap = new Map(); + 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 = ( + 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 = ( + val: T, +): Mutable => { + return _deepCopyElement(val); +}; + +const __test__defineOrigId = (clonedObj: object, origId: string) => { + Object.defineProperty(clonedObj, ORIG_ID, { + value: origId, + writable: false, + enumerable: false, + }); +}; diff --git a/packages/excalidraw/element/elbowArrow.test.tsx b/packages/excalidraw/element/elbowArrow.test.tsx index 91e280d30f..621d2640fc 100644 --- a/packages/excalidraw/element/elbowArrow.test.tsx +++ b/packages/excalidraw/element/elbowArrow.test.tsx @@ -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], ]); }); diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts index 5e44b6ea6d..a8c22abcc9 100644 --- a/packages/excalidraw/element/elbowArrow.ts +++ b/packages/excalidraw/element/elbowArrow.ts @@ -238,16 +238,6 @@ const handleSegmentRenormalization = ( nextPoints.map((p) => pointFrom(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(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(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; } diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index 9880f27aff..1790ef3f04 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/packages/excalidraw/element/flowchart.ts @@ -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); + ] as Readonly); acc.push({ relative, diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index ddebeca53e..474923515e 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -1,4 +1,5 @@ import { + normalizeRadians, pointFrom, pointRotateRads, pointScaleFromOrigin, @@ -30,8 +31,9 @@ export const headingForDiamond = ( 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 = ( element: Readonly, aabb: Readonly, p: Readonly, diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index abe84e031f..6244e2740c 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -14,8 +14,8 @@ export { newLinearElement, newArrowElement, newImageElement, - duplicateElement, } from "./newElement"; +export { duplicateElement } from "./duplicate"; export { getElementAbsoluteCoords, getElementBounds, diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f9b23f0481..adc4362369 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -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); - mutateElement(element, updates, true, { - isDragging: options?.isDragging, - }); + 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); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index d11c4c20f9..8d0a9bbd82 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -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, @@ -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 = ( - val: T, -): Mutable => { - 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 = ( - editingGroupId: AppState["editingGroupId"], - groupIdMapForOperation: Map, - element: TElement, - overrides?: Partial, -): Readonly => { - 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(); - - for (const element of elements) { - const clonedElement: Mutable = _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>, - 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; -}; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9893ba5d6e..b8399f0385 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -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, -): 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, diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index cedc4af0f0..61b493fd40 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -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 diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 257decd622..b0971f9f21 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -886,23 +886,24 @@ const _renderInteractiveScene = ({ ); } - if ( - isElbowArrow(selectedElements[0]) && - appState.selectedLinearElement && - appState.selectedLinearElement.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 - ) - ) { - renderLinearElementPointHighlight(context, appState, elementsMap); + // 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 ( + isElbowArrow(firstSelectedLinear) + ? editor.hoverPointIndex === 0 || + editor.hoverPointIndex === firstSelectedLinear.points.length - 1 + : editor.hoverPointIndex >= 0 + ) { + renderLinearElementPointHighlight(context, appState, elementsMap); + } } // Paint selected elements @@ -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; diff --git a/packages/excalidraw/store.ts b/packages/excalidraw/store.ts index 8b00658840..1723d0aa1e 100644 --- a/packages/excalidraw/store.ts +++ b/packages/excalidraw/store.ts @@ -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"; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index f77eb8ddbd..89629b93e8 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index d740e975c0..3f523d005d 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 90236a4dd4..4001c3b178 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -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, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 5d48ead6cf..4e9c659d0f 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -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, }, diff --git a/packages/excalidraw/tests/fractionalIndex.test.ts b/packages/excalidraw/tests/fractionalIndex.test.ts index dbd55bd92c..e9eb576e70 100644 --- a/packages/excalidraw/tests/fractionalIndex.test.ts +++ b/packages/excalidraw/tests/fractionalIndex.test.ts @@ -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"; diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 8407f07666..42d726f1d9 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -1184,3 +1184,7 @@ it( expect(API.getSelectedElements().length).toBe(1); }, ); + +// +// DEPRECATED: DO NOT ADD TESTS HERE +//