Bladeren bron

fix: Refactor and merge duplication and binding (#9246)

Márk Tolmács 4 maanden geleden
bovenliggende
commit
77aca48c84
29 gewijzigde bestanden met toevoegingen van 1290 en 1082 verwijderingen
  1. 1 1
      packages/excalidraw/actions/actionAddToLibrary.ts
  2. 1 0
      packages/excalidraw/actions/actionDeleteSelected.tsx
  3. 1 1
      packages/excalidraw/actions/actionDuplicateSelection.test.tsx
  4. 62 287
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  5. 2 1
      packages/excalidraw/actions/actionFlip.ts
  6. 2 1
      packages/excalidraw/clipboard.ts
  7. 71 165
      packages/excalidraw/components/App.tsx
  8. 8 2
      packages/excalidraw/components/LibraryMenuItems.tsx
  9. 2 1
      packages/excalidraw/components/Stats/DragInput.tsx
  10. 154 178
      packages/excalidraw/element/binding.ts
  11. 332 13
      packages/excalidraw/element/duplicate.test.tsx
  12. 484 0
      packages/excalidraw/element/duplicate.ts
  13. 3 3
      packages/excalidraw/element/elbowArrow.test.tsx
  14. 43 38
      packages/excalidraw/element/elbowArrow.ts
  15. 2 2
      packages/excalidraw/element/flowchart.ts
  16. 4 4
      packages/excalidraw/element/heading.ts
  17. 1 1
      packages/excalidraw/element/index.ts
  18. 65 28
      packages/excalidraw/element/linearElementEditor.ts
  19. 3 269
      packages/excalidraw/element/newElement.ts
  20. 1 43
      packages/excalidraw/element/textElement.ts
  21. 4 1
      packages/excalidraw/groups.ts
  22. 19 18
      packages/excalidraw/renderer/interactiveScene.ts
  23. 2 1
      packages/excalidraw/store.ts
  24. 2 2
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  25. 6 4
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  26. 5 5
      packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
  27. 4 12
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  28. 2 1
      packages/excalidraw/tests/fractionalIndex.test.ts
  29. 4 0
      packages/excalidraw/tests/regressionTests.test.tsx

+ 1 - 1
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";

+ 1 - 0
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),

+ 1 - 1
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 },
       ]);
     });
 

+ 62 - 287
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<ExcalidrawElement["id"], true>, element) => {
+              if (!isBoundToContainer(element)) {
+                acc[element.id] = true;
+              }
+              return acc;
+            }, {}),
+          },
+          getNonDeletedElements(nextElements),
+          appState,
+          null,
+        ),
+      },
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };
   },
@@ -107,259 +118,23 @@ export const actionDuplicateSelection = register({
   ),
 });
 
-const duplicateElements = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-): Partial<Exclude<ActionResult, false>> => {
-  // ---------------------------------------------------------------------------
-
-  const groupIdMap = new Map();
-  const newElements: ExcalidrawElement[] = [];
-  const oldElements: ExcalidrawElement[] = [];
-  const oldIdToDuplicatedId = new Map();
-  const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
-
-  const elementsMap = arrayToMap(elements);
-
-  const duplicateAndOffsetElement = <
-    T extends ExcalidrawElement | ExcalidrawElement[],
-  >(
-    element: T,
-  ): T extends ExcalidrawElement[]
-    ? ExcalidrawElement[]
-    : ExcalidrawElement | null => {
-    const elements = castArray(element);
-
-    const _newElements = elements.reduce(
-      (acc: ExcalidrawElement[], element) => {
-        if (processedIds.has(element.id)) {
-          return acc;
-        }
-
-        processedIds.set(element.id, true);
-
-        const newElement = duplicateElement(
-          appState.editingGroupId,
-          groupIdMap,
-          element,
-          {
-            x: element.x + DEFAULT_GRID_SIZE / 2,
-            y: element.y + DEFAULT_GRID_SIZE / 2,
-          },
-        );
-
-        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<ExcalidrawElement["id"], true>();
-
-  const elementsWithClones: ExcalidrawElement[] = elements.slice();
-
-  const insertAfterIndex = (
-    index: number,
-    elements: ExcalidrawElement | null | ExcalidrawElement[],
-  ) => {
-    invariant(index !== -1, "targetIndex === -1 ");
-
-    if (!Array.isArray(elements) && !elements) {
-      return;
+    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<ExcalidrawElement["id"], true>, element) => {
-              if (!isBoundToContainer(element)) {
-                acc[element.id] = true;
-              }
-              return acc;
-            },
-            {},
-          ),
-        },
-        getNonDeletedElements(elementsWithClones),
-        appState,
-        null,
-      ),
-    },
+    selectedLinearElement: null,
   };
 };

+ 2 - 1
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 {

+ 2 - 1
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,

+ 71 - 165
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<AppProps, AppState> {
 
     const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
 
-    const newElements = duplicateElements(
-      elements.map((element) => {
+    const { newElements } = duplicateElements({
+      type: "everything",
+      elements: elements.map((element) => {
         return newElementWith(element, {
           x: element.x + gridX - minX,
           y: element.y + gridY - minY,
         });
       }),
-      {
-        randomizeSeed: !opts.retainSeed,
-      },
-    );
+      randomizeSeed: !opts.retainSeed,
+    });
 
     const prevElements = this.scene.getElementsIncludingDeleted();
     let nextElements = [...prevElements, ...newElements];
@@ -6095,7 +6089,12 @@ class App extends React.Component<AppProps, AppState> {
             this.setState({
               activeEmbeddable: { element: hitElement, state: "hover" },
             });
-          } else if (!hitElement || !isElbowArrow(hitElement)) {
+          } else if (
+            !hitElement ||
+            // Ebow arrows can only be moved when unconnected
+            !isElbowArrow(hitElement) ||
+            !(hitElement.startBinding || hitElement.endBinding)
+          ) {
             setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
             if (this.state.activeEmbeddable?.state === "hover") {
               this.setState({ activeEmbeddable: null });
@@ -6288,7 +6287,13 @@ class App extends React.Component<AppProps, AppState> {
         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<AppProps, AppState> {
                   ...this.state.selectedLinearElement,
                   pointerDownState: ret.pointerDownState,
                   selectedPointsIndices: ret.selectedPointsIndices,
+                  segmentMidPointHoveredCoords: null,
                 },
               });
             }
@@ -8147,6 +8153,7 @@ class App extends React.Component<AppProps, AppState> {
                   ...this.state.editingLinearElement,
                   pointerDownState: ret.pointerDownState,
                   selectedPointsIndices: ret.selectedPointsIndices,
+                  segmentMidPointHoveredCoords: null,
                 },
               });
             }
@@ -8160,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
           return;
         }
 
-        const didDrag = LinearElementEditor.handlePointDragging(
+        const newLinearElementEditor = LinearElementEditor.handlePointDragging(
           event,
           this,
           pointerCoords.x,
@@ -8174,29 +8181,18 @@ class App extends React.Component<AppProps, AppState> {
           linearElementEditor,
           this.scene,
         );
-        if (didDrag) {
+        if (newLinearElementEditor) {
           pointerDownState.lastCoords.x = pointerCoords.x;
           pointerDownState.lastCoords.y = pointerCoords.y;
           pointerDownState.drag.hasOccurred = true;
-          if (
-            this.state.editingLinearElement &&
-            !this.state.editingLinearElement.isDragging
-          ) {
-            this.setState({
-              editingLinearElement: {
-                ...this.state.editingLinearElement,
-                isDragging: true,
-              },
-            });
-          }
-          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<AppProps, AppState> {
 
             pointerDownState.hit.hasBeenDuplicated = true;
 
-            const nextElements = [];
-            const elementsToAppend = [];
-            const groupIdMap = new Map();
-            const oldIdToDuplicatedId = new Map();
-            const hitElement = pointerDownState.hit.element;
-            const selectedElementIds = new Set(
-              this.scene
-                .getSelectedElements({
-                  selectedElementIds: this.state.selectedElementIds,
-                  includeBoundTextElement: true,
-                  includeElementsInFrames: true,
-                })
-                .map((element) => element.id),
-            );
-
             const elements = this.scene.getElementsIncludingDeleted();
+            const 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);
+            }
 
-            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,
-                );
+            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);
 
-                nextElements.push(duplicatedElement);
-                elementsToAppend.push(element);
-                oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
-              } else {
-                nextElements.push(element);
-              }
-            }
+                  if (origEl) {
+                    return {
+                      x: origEl.x,
+                      y: origEl.y,
+                    };
+                  }
 
-            let nextSceneElements: ExcalidrawElement[] = [
-              ...nextElements,
-              ...elementsToAppend,
-            ];
+                  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);

+ 8 - 2
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,
         };
       });
     },

+ 2 - 1
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";

+ 154 - 178
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<GlobalPoint>(
     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<GlobalPoint>(
+      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<GlobalPoint>(
+      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;
-};
+  if (
+    !intersection ||
+    // Too close to determine vector from intersection to edgePoint
+    pointDistanceSq(edgePoint, intersection) < PRECISION
+  ) {
+    return edgePoint;
+  }
 
-const headingToMidBindPoint = (
-  p: GlobalPoint,
-  bindableElement: ExcalidrawBindableElement,
-  aabb: Bounds,
-): GlobalPoint => {
-  const center = getCenterForBounds(aabb);
-  const heading = vectorToHeading(vectorFromPoint(p, center));
+  if (elbowed) {
+    const scalar =
+      pointDistanceSq(edgePoint, center) -
+        pointDistanceSq(intersection, center) >
+      0
+        ? FIXED_BINDING_DISTANCE
+        : -FIXED_BINDING_DISTANCE;
 
-  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,
-      );
+    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<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
-  // There are three copying mechanisms: Copy-paste, duplication and alt-drag.
-  // Only when alt-dragging the new "duplicates" act as the "old", while
-  // the "old" elements act as the "new copy" - essentially working reverse
-  // to the other two.
-  duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
-): void => {
-  // First collect all the binding/bindable elements, so we only update
-  // each once, regardless of whether they were duplicated or not.
-  const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
-  const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
-  const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
-  const duplicateIdToOldId = new Map(
-    [...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
-  );
-  oldElements.forEach((oldElement) => {
-    const { boundElements } = oldElement;
-    if (boundElements != null && boundElements.length > 0) {
-      boundElements.forEach((boundElement) => {
-        if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
-          allBoundElementIds.add(boundElement.id);
-        }
+  duplicatedElementsMap: NonDeletedSceneElementsMap,
+) => {
+  for (const element of newElements) {
+    if ("boundElements" in element && element.boundElements) {
+      Object.assign(element, {
+        boundElements: element.boundElements.reduce(
+          (
+            acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
+            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)!);
-      }
     }
-  });
 
-  // 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 ("containerId" in element && element.containerId) {
+      Object.assign(element, {
+        containerId: oldIdToDuplicatedId.get(element.containerId) ?? null,
+      });
+    }
 
-  // Update the bindable shapes
-  sceneElements
-    .filter(({ id }) => allBindableElementIds.has(id))
-    .forEach((bindableElement) => {
-      const oldElementId = duplicateIdToOldId.get(bindableElement.id);
-      const boundElements = sceneElements.find(
-        ({ id }) => id === oldElementId,
-      )?.boundElements;
-
-      if (boundElements && boundElements.length > 0) {
-        mutateElement(bindableElement, {
-          boundElements: boundElements.map((boundElement) =>
-            oldIdToDuplicatedId.has(boundElement.id)
-              ? {
-                  id: oldIdToDuplicatedId.get(boundElement.id)!,
-                  type: boundElement.type,
-                }
-              : boundElement,
-          ),
-        });
-      }
-    });
-};
+    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,
+      });
+    }
 
-const newBindingAfterDuplication = (
-  binding: PointBinding | null,
-  oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
-): PointBinding | null => {
-  if (binding == null) {
-    return null;
+    if (isElbowArrow(element)) {
+      Object.assign(
+        element,
+        updateElbowArrowPoints(element, duplicatedElementsMap, {
+          points: [
+            element.points[0],
+            element.points[element.points.length - 1],
+          ],
+        }),
+      );
+    }
   }
-  return {
-    ...binding,
-    elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
-  };
 };
 
 export const fixBindingsAfterDeletion = (

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

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

+ 3 - 3
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],
     ]);
   });

+ 43 - 38
packages/excalidraw/element/elbowArrow.ts

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

+ 2 - 2
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<LocalPoint>);
+        ] as Readonly<GlobalPoint>);
 
         acc.push({
           relative,

+ 4 - 4
packages/excalidraw/element/heading.ts

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

+ 1 - 1
packages/excalidraw/element/index.ts

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

+ 65 - 28
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);

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

+ 1 - 43
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<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
-): void => {
-  const newElementsMap = arrayToMap(newElements) as Map<
-    ExcalidrawElement["id"],
-    ExcalidrawElement
-  >;
-  oldElements.forEach((element) => {
-    const newElementId = oldIdToDuplicatedId.get(element.id) as string;
-    const boundTextElementId = getBoundTextElementId(element);
-
-    if (boundTextElementId) {
-      const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
-      if (newTextElementId) {
-        const newContainer = newElementsMap.get(newElementId);
-        if (newContainer) {
-          mutateElement(newContainer, {
-            boundElements: (element.boundElements || [])
-              .filter(
-                (boundElement) =>
-                  boundElement.id !== newTextElementId &&
-                  boundElement.id !== boundTextElementId,
-              )
-              .concat({
-                type: "text",
-                id: newTextElementId,
-              }),
-          });
-        }
-        const newTextElement = newElementsMap.get(newTextElementId);
-        if (newTextElement && isTextElement(newTextElement)) {
-          mutateElement(newTextElement, {
-            containerId: newContainer ? newElementId : null,
-          });
-        }
-      }
-    }
-  });
-};
-
 export const handleBindTextResize = (
   container: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,

+ 4 - 1
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

+ 19 - 18
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;

+ 2 - 1
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";

+ 2 - 2
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,

+ 6 - 4
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,

+ 5 - 5
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,

+ 4 - 12
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,
             },

+ 2 - 1
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";

+ 4 - 0
packages/excalidraw/tests/regressionTests.test.tsx

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