Bladeren bron

feat: Elbow arrow segment fixing & positioning (#8952)

Co-authored-by: dwelle <[email protected]>
Co-authored-by: David Luzar <[email protected]>
Márk Tolmács 7 maanden geleden
bovenliggende
commit
91ebf8b0ea
33 gewijzigde bestanden met toevoegingen van 3270 en 1704 verwijderingen
  1. 2 9
      packages/excalidraw/actions/actionDeleteSelected.tsx
  2. 10 9
      packages/excalidraw/actions/actionFlip.test.tsx
  3. 23 17
      packages/excalidraw/actions/actionFlip.ts
  4. 145 136
      packages/excalidraw/actions/actionProperties.tsx
  5. 185 101
      packages/excalidraw/components/App.tsx
  6. 40 12
      packages/excalidraw/data/restore.ts
  7. 12 25
      packages/excalidraw/element/binding.ts
  8. 18 9
      packages/excalidraw/element/dragElements.ts
  9. 112 9
      packages/excalidraw/element/elbowArrow.test.tsx
  10. 2111 0
      packages/excalidraw/element/elbowArrow.ts
  11. 4 12
      packages/excalidraw/element/flowchart.ts
  12. 31 7
      packages/excalidraw/element/heading.ts
  13. 183 131
      packages/excalidraw/element/linearElementEditor.ts
  14. 42 4
      packages/excalidraw/element/mutateElement.ts
  15. 28 5
      packages/excalidraw/element/newElement.ts
  16. 52 7
      packages/excalidraw/element/resizeElements.ts
  17. 0 1110
      packages/excalidraw/element/routing.ts
  18. 23 0
      packages/excalidraw/element/types.ts
  19. 0 1
      packages/excalidraw/fractionalIndex.ts
  20. 1 0
      packages/excalidraw/package.json
  21. 72 40
      packages/excalidraw/renderer/interactiveScene.ts
  22. 26 22
      packages/excalidraw/scene/Shape.ts
  23. 11 1
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  24. 3 3
      packages/excalidraw/tests/cropElement.test.tsx
  25. 2 0
      packages/excalidraw/tests/helpers/api.ts
  26. 2 1
      packages/excalidraw/tests/history.test.tsx
  27. 13 18
      packages/excalidraw/tests/linearElementEditor.test.tsx
  28. 1 1
      packages/excalidraw/tests/resize.test.tsx
  29. 31 0
      packages/excalidraw/tests/test-utils.ts
  30. 20 0
      packages/excalidraw/visualdebug.ts
  31. 37 3
      packages/math/line.ts
  32. 20 1
      packages/math/point.ts
  33. 10 10
      yarn.lock

+ 2 - 9
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -18,14 +18,12 @@ import {
 import { updateActiveTool } from "../utils";
 import { TrashIcon } from "../components/icons";
 import { StoreAction } from "../store";
-import { mutateElbowArrow } from "../element/routing";
 
 const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
   appState: AppState,
   app: AppClassProperties,
 ) => {
-  const elementsMap = app.scene.getNonDeletedElementsMap();
   const framesToBeDeleted = new Set(
     getSelectedElements(
       elements.filter((el) => isFrameLikeElement(el)),
@@ -51,7 +49,7 @@ const deleteSelectedElements = (
               endBinding:
                 el.id === bound.endBinding?.elementId ? null : bound.endBinding,
             });
-            mutateElbowArrow(bound, elementsMap, bound.points);
+            mutateElement(bound, { points: bound.points });
           }
         });
       }
@@ -208,12 +206,7 @@ export const actionDeleteSelected = register({
           : endBindingElement,
       };
 
-      LinearElementEditor.deletePoints(
-        element,
-        selectedPointsIndices,
-        elementsMap,
-        appState.zoom,
-      );
+      LinearElementEditor.deletePoints(element, selectedPointsIndices);
 
       return {
         elements,

+ 10 - 9
packages/excalidraw/actions/actionFlip.test.tsx

@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
         },
         startArrowhead: null,
         endArrowhead: "arrow",
+        fixedSegments: null,
         points: [
           pointFrom(0, 0),
           pointFrom(0, -35),
-          pointFrom(-90.9, -35),
-          pointFrom(-90.9, 204.9),
-          pointFrom(65.1, 204.9),
+          pointFrom(-90, -35),
+          pointFrom(-90, 204),
+          pointFrom(66, 204),
         ],
         elbowed: true,
       }),
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
     API.executeAction(actionFlipHorizontal);
     API.executeAction(actionFlipHorizontal);
 
-    const rec1 = h.elements.find((el) => el.id === "rec1");
-    expect(rec1?.x).toBeCloseTo(100);
-    expect(rec1?.y).toBeCloseTo(100);
+    const rec1 = h.elements.find((el) => el.id === "rec1")!;
+    expect(rec1.x).toBeCloseTo(100, 0);
+    expect(rec1.y).toBeCloseTo(100, 0);
 
-    const rec2 = h.elements.find((el) => el.id === "rec2");
-    expect(rec2?.x).toBeCloseTo(220);
-    expect(rec2?.y).toBeCloseTo(250);
+    const rec2 = h.elements.find((el) => el.id === "rec2")!;
+    expect(rec2.x).toBeCloseTo(220, 0);
+    expect(rec2.y).toBeCloseTo(250, 0);
   });
 });
 

+ 23 - 17
packages/excalidraw/actions/actionFlip.ts

@@ -24,8 +24,8 @@ import {
   isElbowArrow,
   isLinearElement,
 } from "../element/typeChecks";
-import { mutateElbowArrow } from "../element/routing";
 import { mutateElement, newElementWith } from "../element/mutateElement";
+import { deepCopyElement } from "../element/newElement";
 import { getCommonBoundingBox } from "../element/bounds";
 
 export const actionFlipHorizontal = register({
@@ -134,12 +134,24 @@ const flipElements = (
 
   const { midX, midY } = getCommonBoundingBox(selectedElements);
 
-  resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
-    flipByX: flipDirection === "horizontal",
-    flipByY: flipDirection === "vertical",
-    shouldResizeFromCenter: true,
-    shouldMaintainAspectRatio: true,
-  });
+  resizeMultipleElements(
+    selectedElements,
+    elementsMap,
+    "nw",
+    app.scene,
+    new Map(
+      Array.from(elementsMap.values()).map((element) => [
+        element.id,
+        deepCopyElement(element),
+      ]),
+    ),
+    {
+      flipByX: flipDirection === "horizontal",
+      flipByY: flipDirection === "vertical",
+      shouldResizeFromCenter: true,
+      shouldMaintainAspectRatio: true,
+    },
+  );
 
   bindOrUnbindLinearElements(
     selectedElements.filter(isLinearElement),
@@ -181,16 +193,10 @@ const flipElements = (
     }),
   );
   elbowArrows.forEach((element) =>
-    mutateElbowArrow(
-      element,
-      elementsMap,
-      element.points,
-      undefined,
-      undefined,
-      {
-        informMutation: false,
-      },
-    ),
+    mutateElement(element, {
+      x: element.x + diffX,
+      y: element.y + diffY,
+    }),
   );
   // ---------------------------------------------------------------------------
 

+ 145 - 136
packages/excalidraw/actions/actionProperties.tsx

@@ -116,10 +116,9 @@ import {
   calculateFixedPointForElbowArrowBinding,
   getHoveredElementForBinding,
 } from "../element/binding";
-import { mutateElbowArrow } from "../element/routing";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import type { LocalPoint } from "../../math";
-import { pointFrom, vector } from "../../math";
+import { pointFrom } from "../../math";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -1560,152 +1559,162 @@ export const actionChangeArrowType = register({
   label: "Change arrow types",
   trackEvent: false,
   perform: (elements, appState, value, app) => {
-    return {
-      elements: changeProperty(elements, appState, (el) => {
-        if (!isArrowElement(el)) {
-          return el;
-        }
-        const newElement = newElementWith(el, {
-          roundness:
-            value === ARROW_TYPE.round
-              ? {
-                  type: ROUNDNESS.PROPORTIONAL_RADIUS,
-                }
-              : null,
-          elbowed: value === ARROW_TYPE.elbow,
-          points:
-            value === ARROW_TYPE.elbow || el.elbowed
-              ? [el.points[0], el.points[el.points.length - 1]]
-              : el.points,
-        });
-
-        if (isElbowArrow(newElement)) {
-          const elementsMap = app.scene.getNonDeletedElementsMap();
+    const newElements = changeProperty(elements, appState, (el) => {
+      if (!isArrowElement(el)) {
+        return el;
+      }
+      const newElement = newElementWith(el, {
+        roundness:
+          value === ARROW_TYPE.round
+            ? {
+                type: ROUNDNESS.PROPORTIONAL_RADIUS,
+              }
+            : null,
+        elbowed: value === ARROW_TYPE.elbow,
+        points:
+          value === ARROW_TYPE.elbow || el.elbowed
+            ? [el.points[0], el.points[el.points.length - 1]]
+            : el.points,
+      });
 
-          app.dismissLinearEditor();
+      if (isElbowArrow(newElement)) {
+        const elementsMap = app.scene.getNonDeletedElementsMap();
 
-          const startGlobalPoint =
-            LinearElementEditor.getPointAtIndexGlobalCoordinates(
-              newElement,
-              0,
-              elementsMap,
-            );
-          const endGlobalPoint =
-            LinearElementEditor.getPointAtIndexGlobalCoordinates(
-              newElement,
-              -1,
-              elementsMap,
-            );
-          const startHoveredElement =
-            !newElement.startBinding &&
-            getHoveredElementForBinding(
-              tupleToCoors(startGlobalPoint),
-              elements,
-              elementsMap,
-              appState.zoom,
-              true,
-            );
-          const endHoveredElement =
-            !newElement.endBinding &&
-            getHoveredElementForBinding(
-              tupleToCoors(endGlobalPoint),
-              elements,
-              elementsMap,
-              appState.zoom,
-              true,
-            );
-          const startElement = startHoveredElement
-            ? startHoveredElement
-            : newElement.startBinding &&
-              (elementsMap.get(
-                newElement.startBinding.elementId,
-              ) as ExcalidrawBindableElement);
-          const endElement = endHoveredElement
-            ? endHoveredElement
-            : newElement.endBinding &&
-              (elementsMap.get(
-                newElement.endBinding.elementId,
-              ) as ExcalidrawBindableElement);
-
-          const finalStartPoint = startHoveredElement
-            ? bindPointToSnapToElementOutline(
-                startGlobalPoint,
-                endGlobalPoint,
-                startHoveredElement,
-                elementsMap,
-              )
-            : startGlobalPoint;
-          const finalEndPoint = endHoveredElement
-            ? bindPointToSnapToElementOutline(
-                endGlobalPoint,
-                startGlobalPoint,
-                endHoveredElement,
-                elementsMap,
-              )
-            : endGlobalPoint;
+        app.dismissLinearEditor();
 
-          startHoveredElement &&
-            bindLinearElement(
-              newElement,
+        const startGlobalPoint =
+          LinearElementEditor.getPointAtIndexGlobalCoordinates(
+            newElement,
+            0,
+            elementsMap,
+          );
+        const endGlobalPoint =
+          LinearElementEditor.getPointAtIndexGlobalCoordinates(
+            newElement,
+            -1,
+            elementsMap,
+          );
+        const startHoveredElement =
+          !newElement.startBinding &&
+          getHoveredElementForBinding(
+            tupleToCoors(startGlobalPoint),
+            elements,
+            elementsMap,
+            appState.zoom,
+          );
+        const endHoveredElement =
+          !newElement.endBinding &&
+          getHoveredElementForBinding(
+            tupleToCoors(endGlobalPoint),
+            elements,
+            elementsMap,
+            appState.zoom,
+          );
+        const startElement = startHoveredElement
+          ? startHoveredElement
+          : newElement.startBinding &&
+            (elementsMap.get(
+              newElement.startBinding.elementId,
+            ) as ExcalidrawBindableElement);
+        const endElement = endHoveredElement
+          ? endHoveredElement
+          : newElement.endBinding &&
+            (elementsMap.get(
+              newElement.endBinding.elementId,
+            ) as ExcalidrawBindableElement);
+
+        const finalStartPoint = startHoveredElement
+          ? bindPointToSnapToElementOutline(
+              startGlobalPoint,
+              endGlobalPoint,
               startHoveredElement,
-              "start",
               elementsMap,
-            );
-          endHoveredElement &&
-            bindLinearElement(
-              newElement,
+            )
+          : startGlobalPoint;
+        const finalEndPoint = endHoveredElement
+          ? bindPointToSnapToElementOutline(
+              endGlobalPoint,
+              startGlobalPoint,
               endHoveredElement,
-              "end",
               elementsMap,
-            );
+            )
+          : endGlobalPoint;
 
-          mutateElbowArrow(
+        startHoveredElement &&
+          bindLinearElement(
             newElement,
+            startHoveredElement,
+            "start",
             elementsMap,
-            [finalStartPoint, finalEndPoint].map(
-              (p): LocalPoint =>
-                pointFrom(p[0] - newElement.x, p[1] - newElement.y),
-            ),
-            vector(0, 0),
-            {
-              ...(startElement && newElement.startBinding
-                ? {
-                    startBinding: {
-                      // @ts-ignore TS cannot discern check above
-                      ...newElement.startBinding!,
-                      ...calculateFixedPointForElbowArrowBinding(
-                        newElement,
-                        startElement,
-                        "start",
-                        elementsMap,
-                      ),
-                    },
-                  }
-                : {}),
-              ...(endElement && newElement.endBinding
-                ? {
-                    endBinding: {
-                      // @ts-ignore TS cannot discern check above
-                      ...newElement.endBinding,
-                      ...calculateFixedPointForElbowArrowBinding(
-                        newElement,
-                        endElement,
-                        "end",
-                        elementsMap,
-                      ),
-                    },
-                  }
-                : {}),
-            },
           );
-        }
+        endHoveredElement &&
+          bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
+
+        mutateElement(newElement, {
+          points: [finalStartPoint, finalEndPoint].map(
+            (p): LocalPoint =>
+              pointFrom(p[0] - newElement.x, p[1] - newElement.y),
+          ),
+          ...(startElement && newElement.startBinding
+            ? {
+                startBinding: {
+                  // @ts-ignore TS cannot discern check above
+                  ...newElement.startBinding!,
+                  ...calculateFixedPointForElbowArrowBinding(
+                    newElement,
+                    startElement,
+                    "start",
+                    elementsMap,
+                  ),
+                },
+              }
+            : {}),
+          ...(endElement && newElement.endBinding
+            ? {
+                endBinding: {
+                  // @ts-ignore TS cannot discern check above
+                  ...newElement.endBinding,
+                  ...calculateFixedPointForElbowArrowBinding(
+                    newElement,
+                    endElement,
+                    "end",
+                    elementsMap,
+                  ),
+                },
+              }
+            : {}),
+        });
 
-        return newElement;
-      }),
-      appState: {
-        ...appState,
-        currentItemArrowType: value,
-      },
+        LinearElementEditor.updateEditorMidPointsCache(
+          newElement,
+          elementsMap,
+          app.state,
+        );
+      }
+
+      return newElement;
+    });
+
+    const newState = {
+      ...appState,
+      currentItemArrowType: value,
+    };
+
+    // Change the arrow type and update any other state settings for
+    // the arrow.
+    const selectedId = appState.selectedLinearElement?.elementId;
+    if (selectedId) {
+      const selected = newElements.find((el) => el.id === selectedId);
+      if (selected) {
+        newState.selectedLinearElement = new LinearElementEditor(
+          selected as ExcalidrawLinearElement,
+        );
+      }
+    }
+
+    return {
+      elements: newElements,
+      appState: newState,
       storeAction: StoreAction.CAPTURE,
     };
   },

+ 185 - 101
packages/excalidraw/components/App.tsx

@@ -165,6 +165,7 @@ import {
   isTextBindableContainer,
   isElbowArrow,
   isFlowchartNodeElement,
+  isBindableElement,
 } from "../element/typeChecks";
 import type {
   ExcalidrawBindableElement,
@@ -189,7 +190,6 @@ import type {
   MagicGenerationData,
   ExcalidrawNonSelectionElement,
   ExcalidrawArrowElement,
-  NonDeletedSceneElementsMap,
 } from "../element/types";
 import { getCenter, getDistance } from "../gesture";
 import {
@@ -292,7 +292,6 @@ import {
   getDateTime,
   isShallowEqual,
   arrayToMap,
-  toBrandedType,
 } from "../utils";
 import {
   createSrcDoc,
@@ -443,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
 import { getVisibleSceneBounds } from "../element/bounds";
 import { isMaybeMermaidDefinition } from "../mermaid";
 import NewElementCanvas from "./canvases/NewElementCanvas";
-import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
 import {
   FlowChartCreator,
   FlowChartNavigator,
@@ -3184,49 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
     retainSeed?: boolean;
     fitToContent?: boolean;
   }) => {
-    let elements = opts.elements.map((el, _, elements) => {
-      if (isElbowArrow(el)) {
-        const startEndElements = [
-          el.startBinding &&
-            elements.find((l) => l.id === el.startBinding?.elementId),
-          el.endBinding &&
-            elements.find((l) => l.id === el.endBinding?.elementId),
-        ];
-        const startBinding = startEndElements[0] ? el.startBinding : null;
-        const endBinding = startEndElements[1] ? el.endBinding : null;
-        return {
-          ...el,
-          ...updateElbowArrow(
-            {
-              ...el,
-              startBinding,
-              endBinding,
-            },
-            toBrandedType<NonDeletedSceneElementsMap>(
-              new Map(
-                startEndElements
-                  .filter((x) => x != null)
-                  .map(
-                    (el) =>
-                      [el!.id, el] as [
-                        string,
-                        Ordered<NonDeletedExcalidrawElement>,
-                      ],
-                  ),
-              ),
-            ),
-            [el.points[0], el.points[el.points.length - 1]],
-            undefined,
-            {
-              zoom: this.state.zoom,
-            },
-          ),
-        };
-      }
-
-      return el;
-    });
-    elements = restoreElements(elements, null, undefined);
+    const elements = restoreElements(opts.elements, null, undefined);
     const [minX, minY, maxX, maxY] = getCommonBounds(elements);
 
     const elementsCenterX = distance(minX, maxX) / 2;
@@ -4377,7 +4333,6 @@ class App extends React.Component<AppProps, AppState> {
 
           updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
             simultaneouslyUpdated: selectedElements,
-            zoom: this.state.zoom,
           });
         });
 
@@ -5365,6 +5320,11 @@ class App extends React.Component<AppProps, AppState> {
 
     const selectedElements = this.scene.getSelectedElements(this.state);
 
+    let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+      event,
+      this.state,
+    );
+
     if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
       if (
         event[KEYS.CTRL_OR_CMD] &&
@@ -5378,6 +5338,64 @@ class App extends React.Component<AppProps, AppState> {
           editingLinearElement: new LinearElementEditor(selectedElements[0]),
         });
         return;
+      } else if (
+        this.state.selectedLinearElement &&
+        isElbowArrow(selectedElements[0])
+      ) {
+        const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+          this.state.selectedLinearElement,
+          { x: sceneX, y: sceneY },
+          this.state,
+          this.scene.getNonDeletedElementsMap(),
+        );
+        const midPoint = hitCoords
+          ? LinearElementEditor.getSegmentMidPointIndex(
+              this.state.selectedLinearElement,
+              this.state,
+              hitCoords,
+              this.scene.getNonDeletedElementsMap(),
+            )
+          : -1;
+
+        if (midPoint && midPoint > -1) {
+          this.store.shouldCaptureIncrement();
+          LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
+
+          const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+            {
+              ...this.state.selectedLinearElement,
+              segmentMidPointHoveredCoords: null,
+            },
+            { x: sceneX, y: sceneY },
+            this.state,
+            this.scene.getNonDeletedElementsMap(),
+          );
+          const nextIndex = nextCoords
+            ? LinearElementEditor.getSegmentMidPointIndex(
+                this.state.selectedLinearElement,
+                this.state,
+                nextCoords,
+                this.scene.getNonDeletedElementsMap(),
+              )
+            : null;
+
+          this.setState({
+            selectedLinearElement: {
+              ...this.state.selectedLinearElement,
+              pointerDownState: {
+                ...this.state.selectedLinearElement.pointerDownState,
+                segmentMidpoint: {
+                  index: nextIndex,
+                  value: hitCoords,
+                  added: false,
+                },
+              },
+              segmentMidPointHoveredCoords: nextCoords,
+            },
+          });
+
+          return;
+        }
       }
     }
 
@@ -5388,11 +5406,6 @@ class App extends React.Component<AppProps, AppState> {
 
     resetCursor(this.interactiveCanvas);
 
-    let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
-      event,
-      this.state,
-    );
-
     const selectedGroupIds = getSelectedGroupIds(this.state);
 
     if (selectedGroupIds.length > 0) {
@@ -5849,41 +5862,23 @@ class App extends React.Component<AppProps, AppState> {
         if (isPathALoop(points, this.state.zoom.value)) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
         }
-        if (isElbowArrow(multiElement)) {
-          mutateElbowArrow(
-            multiElement,
-            this.scene.getNonDeletedElementsMap(),
-            [
+        // update last uncommitted point
+        mutateElement(
+          multiElement,
+          {
+            points: [
               ...points.slice(0, -1),
               pointFrom<LocalPoint>(
                 lastCommittedX + dxFromLastCommitted,
                 lastCommittedY + dyFromLastCommitted,
               ),
             ],
-            undefined,
-            undefined,
-            {
-              isDragging: true,
-              informMutation: false,
-              zoom: this.state.zoom,
-            },
-          );
-        } else {
-          // update last uncommitted point
-          mutateElement(
-            multiElement,
-            {
-              points: [
-                ...points.slice(0, -1),
-                pointFrom<LocalPoint>(
-                  lastCommittedX + dxFromLastCommitted,
-                  lastCommittedY + dyFromLastCommitted,
-                ),
-              ],
-            },
-            false,
-          );
-        }
+          },
+          false,
+          {
+            isDragging: true,
+          },
+        );
 
         // in this path, we're mutating multiElement to reflect
         // how it will be after adding pointer position as the next point
@@ -6049,7 +6044,7 @@ class App extends React.Component<AppProps, AppState> {
             this.setState({
               activeEmbeddable: { element: hitElement, state: "hover" },
             });
-          } else {
+          } else if (!hitElement || !isElbowArrow(hitElement)) {
             setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
             if (this.state.activeEmbeddable?.state === "hover") {
               this.setState({ activeEmbeddable: null });
@@ -6235,14 +6230,18 @@ class App extends React.Component<AppProps, AppState> {
             this.state,
             this.scene.getNonDeletedElementsMap(),
           );
-
-        if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
+        const isHoveringAPointHandle = isElbowArrow(element)
+          ? hoverPointIndex === 0 ||
+            hoverPointIndex === element.points.length - 1
+          : hoverPointIndex >= 0;
+        if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
         } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
         }
       } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
         if (
+          // Ebow arrows can only be moved when unconnected
           !isElbowArrow(element) ||
           !(element.startBinding || element.endBinding)
         ) {
@@ -6972,6 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
       if (
         selectedElements.length === 1 &&
         !this.state.editingLinearElement &&
+        !isElbowArrow(selectedElements[0]) &&
         !(
           this.state.selectedLinearElement &&
           this.state.selectedLinearElement.hoverPointIndex !== -1
@@ -7673,6 +7673,10 @@ class App extends React.Component<AppProps, AppState> {
               locked: false,
               frameId: topLayerFrame ? topLayerFrame.id : null,
               elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
+              fixedSegments:
+                this.state.currentItemArrowType === ARROW_TYPE.elbow
+                  ? []
+                  : null,
             })
           : newLinearElement({
               type: elementType,
@@ -7913,6 +7917,63 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
       const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
+
+      if (
+        this.state.selectedLinearElement &&
+        this.state.selectedLinearElement.elbowed &&
+        this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
+      ) {
+        const [gridX, gridY] = getGridPoint(
+          pointerCoords.x,
+          pointerCoords.y,
+          event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+        );
+
+        let index =
+          this.state.selectedLinearElement.pointerDownState.segmentMidpoint
+            .index;
+        if (index < 0) {
+          const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+            {
+              ...this.state.selectedLinearElement,
+              segmentMidPointHoveredCoords: null,
+            },
+            { x: gridX, y: gridY },
+            this.state,
+            this.scene.getNonDeletedElementsMap(),
+          );
+          index = nextCoords
+            ? LinearElementEditor.getSegmentMidPointIndex(
+                this.state.selectedLinearElement,
+                this.state,
+                nextCoords,
+                this.scene.getNonDeletedElementsMap(),
+              )
+            : -1;
+        }
+
+        const ret = LinearElementEditor.moveFixedSegment(
+          this.state.selectedLinearElement,
+          index,
+          gridX,
+          gridY,
+          this.scene.getNonDeletedElementsMap(),
+        );
+
+        flushSync(() => {
+          if (this.state.selectedLinearElement) {
+            this.setState({
+              selectedLinearElement: {
+                ...this.state.selectedLinearElement,
+                segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
+                pointerDownState: ret.pointerDownState,
+              },
+            });
+          }
+        });
+        return;
+      }
+
       const lastPointerCoords =
         this.lastPointerMoveCoords ?? pointerDownState.origin;
       this.lastPointerMoveCoords = pointerCoords;
@@ -8265,7 +8326,7 @@ class App extends React.Component<AppProps, AppState> {
 
           // when we're editing the name of a frame, we want the user to be
           // able to select and interact with the text input
-          !this.state.editingFrame &&
+          if (!this.state.editingFrame) {
             dragSelectedElements(
               pointerDownState,
               selectedElements,
@@ -8274,6 +8335,7 @@ class App extends React.Component<AppProps, AppState> {
               snapOffset,
               event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
             );
+          }
 
           this.setState({
             selectedElementsAreBeingDragged: true,
@@ -8449,26 +8511,17 @@ class App extends React.Component<AppProps, AppState> {
               },
               false,
             );
-          } else if (points.length > 1 && isElbowArrow(newElement)) {
-            mutateElbowArrow(
-              newElement,
-              elementsMap,
-              [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
-              vector(0, 0),
-              undefined,
-              {
-                isDragging: true,
-                informMutation: false,
-                zoom: this.state.zoom,
-              },
-            );
-          } else if (points.length === 2) {
+          } else if (
+            points.length === 2 ||
+            (points.length > 1 && isElbowArrow(newElement))
+          ) {
             mutateElement(
               newElement,
               {
                 points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
               },
               false,
+              { isDragging: true },
             );
           }
 
@@ -8663,6 +8716,24 @@ class App extends React.Component<AppProps, AppState> {
         selectedElementsAreBeingDragged: false,
       });
       const elementsMap = this.scene.getNonDeletedElementsMap();
+
+      if (
+        pointerDownState.drag.hasOccurred &&
+        pointerDownState.hit?.element?.id
+      ) {
+        const element = elementsMap.get(pointerDownState.hit.element.id);
+        if (isBindableElement(element)) {
+          // Renormalize elbow arrows when they are changed via indirect move
+          element.boundElements
+            ?.filter((e) => e.type === "arrow")
+            .map((e) => elementsMap.get(e.id))
+            .filter((e) => isElbowArrow(e))
+            .forEach((e) => {
+              !!e && mutateElement(e, {}, true);
+            });
+        }
+      }
+
       // Handle end of dragging a point of a linear element, might close a loop
       // and sets binding element
       if (this.state.editingLinearElement) {
@@ -8687,6 +8758,17 @@ class App extends React.Component<AppProps, AppState> {
           }
         }
       } else if (this.state.selectedLinearElement) {
+        // Normalize elbow arrow points, remove close parallel segments
+        if (this.state.selectedLinearElement.elbowed) {
+          const element = LinearElementEditor.getElement(
+            this.state.selectedLinearElement.elementId,
+            this.scene.getNonDeletedElementsMap(),
+          );
+          if (element) {
+            mutateElement(element, {}, true);
+          }
+        }
+
         if (
           pointerDownState.hit?.element?.id !==
           this.state.selectedLinearElement.elementId
@@ -9126,10 +9208,10 @@ class App extends React.Component<AppProps, AppState> {
         this.state.selectedLinearElement?.elementId !== hitElement?.id &&
         isLinearElement(hitElement)
       ) {
-        const selectedELements = this.scene.getSelectedElements(this.state);
+        const selectedElements = this.scene.getSelectedElements(this.state);
         // set selectedLinearElement when no other element selected except
         // the one we've hit
-        if (selectedELements.length === 1) {
+        if (selectedElements.length === 1) {
           this.setState({
             selectedLinearElement: new LinearElementEditor(hitElement),
           });
@@ -9337,6 +9419,8 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (
+        // not elbow midpoint dragged
+        !(hitElement && isElbowArrow(hitElement)) &&
         // not dragged
         !pointerDownState.drag.hasOccurred &&
         // not resized

+ 40 - 12
packages/excalidraw/data/restore.ts

@@ -1,5 +1,6 @@
 import type {
   ExcalidrawArrowElement,
+  ExcalidrawElbowArrowElement,
   ExcalidrawElement,
   ExcalidrawElementType,
   ExcalidrawLinearElement,
@@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
   return DEFAULT_FONT_FAMILY;
 };
 
-const repairBinding = (
-  element: ExcalidrawLinearElement,
+const repairBinding = <T extends ExcalidrawLinearElement>(
+  element: T,
   binding: PointBinding | FixedPointBinding | null,
-): PointBinding | FixedPointBinding | null => {
+): T extends ExcalidrawElbowArrowElement
+  ? FixedPointBinding | null
+  : PointBinding | FixedPointBinding | null => {
   if (!binding) {
     return null;
   }
 
-  return {
-    ...binding,
-    focus: binding.focus || 0,
-    ...(isElbowArrow(element) && isFixedPointBinding(binding)
+  const focus = binding.focus || 0;
+
+  if (isElbowArrow(element)) {
+    const fixedPointBinding:
+      | ExcalidrawElbowArrowElement["startBinding"]
+      | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
       ? {
+          ...binding,
+          focus,
           fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
         }
-      : {}),
-  };
+      : null;
+
+    return fixedPointBinding;
+  }
+
+  return {
+    ...binding,
+    focus,
+  } as T extends ExcalidrawElbowArrowElement
+    ? FixedPointBinding | null
+    : PointBinding | FixedPointBinding | null;
 };
 
 const restoreElementWithProperties = <
@@ -308,8 +324,7 @@ const restoreElement = (
         ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
       }
 
-      // TODO: Separate arrow from linear element
-      return restoreElementWithProperties(element as ExcalidrawArrowElement, {
+      const base = {
         type: element.type,
         startBinding: repairBinding(element, element.startBinding),
         endBinding: repairBinding(element, element.endBinding),
@@ -321,7 +336,20 @@ const restoreElement = (
         y,
         elbowed: (element as ExcalidrawArrowElement).elbowed,
         ...getSizeFromPoints(points),
-      });
+      } as const;
+
+      // TODO: Separate arrow from linear element
+      return isElbowArrow(element)
+        ? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
+            ...base,
+            elbowed: true,
+            startBinding: repairBinding(element, element.startBinding),
+            endBinding: repairBinding(element, element.endBinding),
+            fixedSegments: element.fixedSegments,
+            startIsSpecial: element.startIsSpecial,
+            endIsSpecial: element.endIsSpecial,
+          })
+        : restoreElementWithProperties(element as ExcalidrawArrowElement, base);
     }
 
     // generic elements

+ 12 - 25
packages/excalidraw/element/binding.ts

@@ -623,11 +623,9 @@ export const updateBoundElements = (
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
     newSize?: { width: number; height: number };
     changedElements?: Map<string, OrderedExcalidrawElement>;
-    zoom?: AppState["zoom"];
   },
 ) => {
-  const { newSize, simultaneouslyUpdated, changedElements, zoom } =
-    options ?? {};
+  const { newSize, simultaneouslyUpdated } = options ?? {};
   const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
     simultaneouslyUpdated,
   );
@@ -661,7 +659,7 @@ export const updateBoundElements = (
 
     // `linearElement` is being moved/scaled already, just update the binding
     if (simultaneouslyUpdatedElementIds.has(element.id)) {
-      mutateElement(element, bindings);
+      mutateElement(element, bindings, true);
       return;
     }
 
@@ -703,23 +701,14 @@ export const updateBoundElements = (
       }> => update !== null,
     );
 
-    LinearElementEditor.movePoints(
-      element,
-      updates,
-      elementsMap,
-      {
-        ...(changedElement.id === element.startBinding?.elementId
-          ? { startBinding: bindings.startBinding }
-          : {}),
-        ...(changedElement.id === element.endBinding?.elementId
-          ? { endBinding: bindings.endBinding }
-          : {}),
-      },
-      {
-        changedElements,
-        zoom,
-      },
-    );
+    LinearElementEditor.movePoints(element, updates, {
+      ...(changedElement.id === element.startBinding?.elementId
+        ? { startBinding: bindings.startBinding }
+        : {}),
+      ...(changedElement.id === element.endBinding?.elementId
+        ? { endBinding: bindings.endBinding }
+        : {}),
+    });
 
     const boundText = getBoundTextElement(element, elementsMap);
     if (boundText && !boundText.isDeleted) {
@@ -778,9 +767,7 @@ export const getHeadingForElbowArrowSnap = (
     );
   }
 
-  const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
-
-  return pointHeading;
+  return headingForPointFromElement(bindableElement, aabb, p);
 };
 
 const getDistanceForBinding = (
@@ -2283,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
   );
 };
 
-const getGlobalFixedPoints = (
+export const getGlobalFixedPoints = (
   arrow: ExcalidrawElbowArrowElement,
   elementsMap: ElementsMap,
 ): [GlobalPoint, GlobalPoint] => {

+ 18 - 9
packages/excalidraw/element/dragElements.ts

@@ -42,9 +42,20 @@ export const dragSelectedElements = (
     return;
   }
 
-  const selectedElements = _selectedElements.filter(
-    (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
-  );
+  const selectedElements = _selectedElements.filter((element) => {
+    if (isElbowArrow(element) && element.startBinding && element.endBinding) {
+      const startElement = _selectedElements.find(
+        (el) => el.id === element.startBinding?.elementId,
+      );
+      const endElement = _selectedElements.find(
+        (el) => el.id === element.endBinding?.elementId,
+      );
+
+      return startElement && endElement;
+    }
+
+    return true;
+  });
 
   // we do not want a frame and its elements to be selected at the same time
   // but when it happens (due to some bug), we want to avoid updating element
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
 
   elementsToUpdate.forEach((element) => {
     updateElementCoords(pointerDownState, element, adjustedOffset);
-    if (
+    if (!isArrowElement(element)) {
       // skip arrow labels since we calculate its position during render
-      !isArrowElement(element)
-    ) {
       const textElement = getBoundTextElement(
         element,
         scene.getNonDeletedElementsMap(),
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
       if (textElement) {
         updateElementCoords(pointerDownState, textElement, adjustedOffset);
       }
+      updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
+        simultaneouslyUpdated: Array.from(elementsToUpdate),
+      });
     }
-    updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
-      simultaneouslyUpdated: Array.from(elementsToUpdate),
-    });
   });
 };
 

+ 112 - 9
packages/excalidraw/element/routing.test.tsx → packages/excalidraw/element/elbowArrow.test.tsx

@@ -9,20 +9,121 @@ import {
   render,
 } from "../tests/test-utils";
 import { bindLinearElement } from "./binding";
-import { Excalidraw } from "../index";
-import { mutateElbowArrow } from "./routing";
+import { Excalidraw, mutateElement } from "../index";
 import type {
   ExcalidrawArrowElement,
   ExcalidrawBindableElement,
   ExcalidrawElbowArrowElement,
 } from "./types";
 import { ARROW_TYPE } from "../constants";
+import type { LocalPoint } from "../../math";
 import { pointFrom } from "../../math";
 
 const { h } = window;
 
 const mouse = new Pointer("mouse");
 
+describe("elbow arrow segment move", () => {
+  beforeEach(async () => {
+    localStorage.clear();
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+  });
+
+  it("can move the second segment of a fully connected elbow arrow", () => {
+    UI.createElement("rectangle", {
+      x: -100,
+      y: -50,
+      width: 100,
+      height: 100,
+    });
+    UI.createElement("rectangle", {
+      x: 200,
+      y: 150,
+      width: 100,
+      height: 100,
+    });
+
+    UI.clickTool("arrow");
+    UI.clickOnTestId("elbow-arrow");
+
+    mouse.reset();
+    mouse.moveTo(0, 0);
+    mouse.click();
+    mouse.moveTo(200, 200);
+    mouse.click();
+
+    mouse.reset();
+    mouse.moveTo(100, 100);
+    mouse.down();
+    mouse.moveTo(115, 100);
+    mouse.up();
+
+    const arrow = h.scene.getSelectedElements(
+      h.state,
+    )[0] as ExcalidrawElbowArrowElement;
+
+    expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
+    expect(arrow.fixedSegments?.length).toBe(1);
+
+    expect(arrow.points).toCloselyEqualPoints([
+      [0, 0],
+      [110, 0],
+      [110, 200],
+      [190, 200],
+    ]);
+
+    mouse.reset();
+    mouse.moveTo(105, 74.275);
+    mouse.doubleClick();
+
+    expect(arrow.points).toCloselyEqualPoints([
+      [0, 0],
+      [110, 0],
+      [110, 200],
+      [190, 200],
+    ]);
+  });
+
+  it("can move the second segment of an unconnected elbow arrow", () => {
+    UI.clickTool("arrow");
+    UI.clickOnTestId("elbow-arrow");
+
+    mouse.reset();
+    mouse.moveTo(0, 0);
+    mouse.click();
+    mouse.moveTo(250, 200);
+    mouse.click();
+
+    mouse.reset();
+    mouse.moveTo(125, 100);
+    mouse.down();
+    mouse.moveTo(130, 100);
+    mouse.up();
+
+    const arrow = h.scene.getSelectedElements(
+      h.state,
+    )[0] as ExcalidrawArrowElement;
+
+    expect(arrow.points).toCloselyEqualPoints([
+      [0, 0],
+      [130, 0],
+      [130, 200],
+      [250, 200],
+    ]);
+
+    mouse.reset();
+    mouse.moveTo(130, 100);
+    mouse.doubleClick();
+
+    expect(arrow.points).toCloselyEqualPoints([
+      [0, 0],
+      [125, 0],
+      [125, 200],
+      [250, 200],
+    ]);
+  });
+});
+
 describe("elbow arrow routing", () => {
   it("can properly generate orthogonal arrow points", () => {
     const scene = new Scene();
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
       elbowed: true,
     }) as ExcalidrawElbowArrowElement;
     scene.insertElement(arrow);
-    mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
-      pointFrom(-45 - arrow.x, -100.1 - arrow.y),
-      pointFrom(45 - arrow.x, 99.9 - arrow.y),
-    ]);
+    mutateElement(arrow, {
+      points: [
+        pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
+        pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
+      ],
+    });
     expect(arrow.points).toEqual([
       [0, 0],
       [0, 100],
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
     expect(arrow.startBinding).not.toBe(null);
     expect(arrow.endBinding).not.toBe(null);
 
-    mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
+    mutateElement(arrow, {
+      points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
+    });
 
     expect(arrow.points).toEqual([
       [0, 0],
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
     expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
       [0, 0],
       [35, 0],
-      [35, 90],
-      [35, 90], // Note that coordinates are rounded above!
       [35, 165],
       [103, 165],
     ]);

+ 2111 - 0
packages/excalidraw/element/elbowArrow.ts

@@ -0,0 +1,2111 @@
+import {
+  pointDistance,
+  pointFrom,
+  pointScaleFromOrigin,
+  pointsEqual,
+  pointTranslate,
+  vector,
+  vectorCross,
+  vectorFromPoint,
+  vectorScale,
+  type GlobalPoint,
+  type LocalPoint,
+} from "../../math";
+import BinaryHeap from "../binaryheap";
+import { getSizeFromPoints } from "../points";
+import { aabbForElement, pointInsideBounds } from "../shapes";
+import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
+import type { AppState } from "../types";
+import {
+  bindPointToSnapToElementOutline,
+  distanceToBindableElement,
+  avoidRectangularCorner,
+  getHoveredElementForBinding,
+  FIXED_BINDING_DISTANCE,
+  getHeadingForElbowArrowSnap,
+  getGlobalFixedPointForBindableElement,
+  snapToMid,
+} from "./binding";
+import type { Bounds } from "./bounds";
+import type { Heading } from "./heading";
+import {
+  compareHeading,
+  flipHeading,
+  HEADING_DOWN,
+  HEADING_LEFT,
+  HEADING_RIGHT,
+  HEADING_UP,
+  headingForPointIsHorizontal,
+  headingIsHorizontal,
+  vectorToHeading,
+  headingForPoint,
+} from "./heading";
+import { type ElementUpdate } from "./mutateElement";
+import { isBindableElement, isRectanguloidElement } from "./typeChecks";
+import {
+  type ExcalidrawElbowArrowElement,
+  type NonDeletedSceneElementsMap,
+  type SceneElementsMap,
+} from "./types";
+import type {
+  Arrowhead,
+  ElementsMap,
+  ExcalidrawBindableElement,
+  FixedPointBinding,
+  FixedSegment,
+} from "./types";
+
+type GridAddress = [number, number] & { _brand: "gridaddress" };
+
+type Node = {
+  f: number;
+  g: number;
+  h: number;
+  closed: boolean;
+  visited: boolean;
+  parent: Node | null;
+  pos: GlobalPoint;
+  addr: GridAddress;
+};
+
+type Grid = {
+  row: number;
+  col: number;
+  data: (Node | null)[];
+};
+
+type ElbowArrowState = {
+  x: number;
+  y: number;
+  startBinding: FixedPointBinding | null;
+  endBinding: FixedPointBinding | null;
+  startArrowhead: Arrowhead | null;
+  endArrowhead: Arrowhead | null;
+};
+
+type ElbowArrowData = {
+  dynamicAABBs: Bounds[];
+  startDonglePosition: GlobalPoint | null;
+  startGlobalPoint: GlobalPoint;
+  startHeading: Heading;
+  endDonglePosition: GlobalPoint | null;
+  endGlobalPoint: GlobalPoint;
+  endHeading: Heading;
+  commonBounds: Bounds;
+  hoveredStartElement: ExcalidrawBindableElement | null;
+  hoveredEndElement: ExcalidrawBindableElement | null;
+};
+
+const DEDUP_TRESHOLD = 1;
+export const BASE_PADDING = 40;
+
+const handleSegmentRenormalization = (
+  arrow: ExcalidrawElbowArrowElement,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+) => {
+  const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
+    ? structuredClone(arrow.fixedSegments)
+    : null;
+
+  if (nextFixedSegments) {
+    const _nextPoints: GlobalPoint[] = [];
+
+    arrow.points
+      .map((p) => pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]))
+      .forEach((p, i, points) => {
+        if (i < 2) {
+          return _nextPoints.push(p);
+        }
+
+        const currentSegmentIsHorizontal = headingForPoint(p, points[i - 1]);
+        const prevSegmentIsHorizontal = headingForPoint(
+          points[i - 1],
+          points[i - 2],
+        );
+
+        if (
+          // Check if previous two points are on the same line
+          compareHeading(currentSegmentIsHorizontal, prevSegmentIsHorizontal)
+        ) {
+          const prevSegmentIdx =
+            nextFixedSegments?.findIndex(
+              (segment) => segment.index === i - 1,
+            ) ?? -1;
+          const segmentIdx =
+            nextFixedSegments?.findIndex((segment) => segment.index === i) ??
+            -1;
+
+          // If the current segment is a fixed segment, update its start point
+          if (segmentIdx !== -1) {
+            nextFixedSegments[segmentIdx].start = pointFrom<LocalPoint>(
+              points[i - 2][0] - arrow.x,
+              points[i - 2][1] - arrow.y,
+            );
+          }
+
+          // Remove the fixed segment status from the previous segment if it is
+          // a fixed segment, because we are going to unify that segment with
+          // the current one
+          if (prevSegmentIdx !== -1) {
+            nextFixedSegments.splice(prevSegmentIdx, 1);
+          }
+
+          // Remove the duplicate point
+          _nextPoints.splice(-1, 1);
+
+          // Update fixed point indices
+          nextFixedSegments.forEach((segment) => {
+            if (segment.index > i - 1) {
+              segment.index -= 1;
+            }
+          });
+        }
+
+        return _nextPoints.push(p);
+      });
+
+    const nextPoints: GlobalPoint[] = [];
+
+    _nextPoints.forEach((p, i, points) => {
+      if (i < 3) {
+        return nextPoints.push(p);
+      }
+
+      if (
+        // Remove segments that are too short
+        pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD
+      ) {
+        const prevPrevSegmentIdx =
+          nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
+          -1;
+        const prevSegmentIdx =
+          nextFixedSegments?.findIndex((segment) => segment.index === i - 1) ??
+          -1;
+
+        // Remove the previous fixed segment if it exists (i.e. the segment
+        // which will be removed due to being parallel or too short)
+        if (prevSegmentIdx !== -1) {
+          nextFixedSegments.splice(prevSegmentIdx, 1);
+        }
+
+        // Remove the fixed segment status from the segment 2 steps back
+        // if it is a fixed segment, because we are going to unify that
+        // segment with the current one
+        if (prevPrevSegmentIdx !== -1) {
+          nextFixedSegments.splice(prevPrevSegmentIdx, 1);
+        }
+
+        nextPoints.splice(-2, 2);
+
+        // Since we have to remove two segments, update any fixed segment
+        nextFixedSegments.forEach((segment) => {
+          if (segment.index > i - 2) {
+            segment.index -= 2;
+          }
+        });
+
+        // Remove aligned segment points
+        const isHorizontal = headingForPointIsHorizontal(p, points[i - 1]);
+
+        return nextPoints.push(
+          pointFrom<GlobalPoint>(
+            !isHorizontal ? points[i - 2][0] : p[0],
+            isHorizontal ? points[i - 2][1] : p[1],
+          ),
+        );
+      }
+
+      nextPoints.push(p);
+    });
+
+    const filteredNextFixedSegments = nextFixedSegments.filter(
+      (segment) =>
+        segment.index !== 1 && segment.index !== nextPoints.length - 1,
+    );
+    if (filteredNextFixedSegments.length === 0) {
+      return normalizeArrowElementUpdate(
+        getElbowArrowCornerPoints(
+          removeElbowArrowShortSegments(
+            routeElbowArrow(
+              arrow,
+              getElbowArrowData(
+                arrow,
+                elementsMap,
+                nextPoints.map((p) =>
+                  pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
+                ),
+              ),
+            ) ?? [],
+          ),
+        ),
+        filteredNextFixedSegments,
+        null,
+        null,
+      );
+    }
+
+    return normalizeArrowElementUpdate(
+      nextPoints,
+      filteredNextFixedSegments,
+      arrow.startIsSpecial,
+      arrow.endIsSpecial,
+    );
+  }
+
+  return {
+    x: arrow.x,
+    y: arrow.y,
+    points: arrow.points,
+    fixedSegments: arrow.fixedSegments,
+    startIsSpecial: arrow.startIsSpecial,
+    endIsSpecial: arrow.endIsSpecial,
+  };
+};
+
+const handleSegmentRelease = (
+  arrow: ExcalidrawElbowArrowElement,
+  fixedSegments: FixedSegment[],
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+) => {
+  const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
+  const oldFixedSegmentIndices =
+    arrow.fixedSegments?.map((segment) => segment.index) ?? [];
+  const deletedSegmentIdx = oldFixedSegmentIndices.findIndex(
+    (idx) => !newFixedSegmentIndices.includes(idx),
+  );
+
+  if (deletedSegmentIdx === -1 || !arrow.fixedSegments?.[deletedSegmentIdx]) {
+    return {
+      points: arrow.points,
+    };
+  }
+
+  const deletedIdx = arrow.fixedSegments[deletedSegmentIdx].index;
+
+  // Find prev and next fixed segments
+  const prevSegment = arrow.fixedSegments[deletedSegmentIdx - 1];
+  const nextSegment = arrow.fixedSegments[deletedSegmentIdx + 1];
+
+  // We need to render a sub-arrow path to restore deleted segments
+  const x = arrow.x + (prevSegment ? prevSegment.end[0] : 0);
+  const y = arrow.y + (prevSegment ? prevSegment.end[1] : 0);
+  const {
+    startHeading,
+    endHeading,
+    startGlobalPoint,
+    endGlobalPoint,
+    hoveredStartElement,
+    hoveredEndElement,
+    ...rest
+  } = getElbowArrowData(
+    {
+      x,
+      y,
+      startBinding: prevSegment ? null : arrow.startBinding,
+      endBinding: nextSegment ? null : arrow.endBinding,
+      startArrowhead: null,
+      endArrowhead: null,
+    },
+    elementsMap,
+    [
+      pointFrom<LocalPoint>(0, 0),
+      pointFrom<LocalPoint>(
+        arrow.x +
+          (nextSegment?.start[0] ?? arrow.points[arrow.points.length - 1][0]) -
+          x,
+        arrow.y +
+          (nextSegment?.start[1] ?? arrow.points[arrow.points.length - 1][1]) -
+          y,
+      ),
+    ],
+    { isDragging: false },
+  );
+
+  const { points: restoredPoints } = normalizeArrowElementUpdate(
+    getElbowArrowCornerPoints(
+      removeElbowArrowShortSegments(
+        routeElbowArrow(arrow, {
+          startHeading,
+          endHeading,
+          startGlobalPoint,
+          endGlobalPoint,
+          hoveredStartElement,
+          hoveredEndElement,
+          ...rest,
+        }) ?? [],
+      ),
+    ),
+    fixedSegments,
+    null,
+    null,
+  );
+
+  const nextPoints: GlobalPoint[] = [];
+
+  // First part of the arrow are the old points
+  if (prevSegment) {
+    for (let i = 0; i < prevSegment.index; i++) {
+      nextPoints.push(
+        pointFrom<GlobalPoint>(
+          arrow.x + arrow.points[i][0],
+          arrow.y + arrow.points[i][1],
+        ),
+      );
+    }
+  }
+
+  restoredPoints.forEach((p) => {
+    nextPoints.push(
+      pointFrom<GlobalPoint>(
+        arrow.x + (prevSegment ? prevSegment.end[0] : 0) + p[0],
+        arrow.y + (prevSegment ? prevSegment.end[1] : 0) + p[1],
+      ),
+    );
+  });
+
+  // Last part of the arrow are the old points too
+  if (nextSegment) {
+    for (let i = nextSegment.index; i < arrow.points.length; i++) {
+      nextPoints.push(
+        pointFrom<GlobalPoint>(
+          arrow.x + arrow.points[i][0],
+          arrow.y + arrow.points[i][1],
+        ),
+      );
+    }
+  }
+
+  // Update nextFixedSegments
+  const originalSegmentCountDiff =
+    (nextSegment?.index ?? arrow.points.length) - (prevSegment?.index ?? 0) - 1;
+
+  const nextFixedSegments = fixedSegments.map((segment) => {
+    if (segment.index > deletedIdx) {
+      return {
+        ...segment,
+        index:
+          segment.index -
+          originalSegmentCountDiff +
+          (restoredPoints.length - 1),
+      };
+    }
+
+    return segment;
+  });
+
+  const simplifiedPoints = nextPoints.flatMap((p, i) => {
+    const prev = nextPoints[i - 1];
+    const next = nextPoints[i + 1];
+
+    if (prev && next) {
+      const prevHeading = headingForPoint(p, prev);
+      const nextHeading = headingForPoint(next, p);
+
+      if (compareHeading(prevHeading, nextHeading)) {
+        // Update subsequent fixed segment indices
+        nextFixedSegments.forEach((segment) => {
+          if (segment.index > i) {
+            segment.index -= 1;
+          }
+        });
+
+        return [];
+      } else if (compareHeading(prevHeading, flipHeading(nextHeading))) {
+        // Update subsequent fixed segment indices
+        nextFixedSegments.forEach((segment) => {
+          if (segment.index > i) {
+            segment.index += 1;
+          }
+        });
+
+        return [p, p];
+      }
+    }
+
+    return [p];
+  });
+
+  return normalizeArrowElementUpdate(
+    simplifiedPoints,
+    nextFixedSegments,
+    false,
+    false,
+  );
+};
+
+/**
+ *
+ */
+const handleSegmentMove = (
+  arrow: ExcalidrawElbowArrowElement,
+  fixedSegments: FixedSegment[],
+  startHeading: Heading,
+  endHeading: Heading,
+  hoveredStartElement: ExcalidrawBindableElement | null,
+  hoveredEndElement: ExcalidrawBindableElement | null,
+): ElementUpdate<ExcalidrawElbowArrowElement> => {
+  const activelyModifiedSegmentIdx = fixedSegments
+    .map((segment, i) => {
+      if (
+        arrow.fixedSegments == null ||
+        arrow.fixedSegments[i] === undefined ||
+        arrow.fixedSegments[i].index !== segment.index
+      ) {
+        return i;
+      }
+
+      return (segment.start[0] !== arrow.fixedSegments![i].start[0] &&
+        segment.end[0] !== arrow.fixedSegments![i].end[0]) !==
+        (segment.start[1] !== arrow.fixedSegments![i].start[1] &&
+          segment.end[1] !== arrow.fixedSegments![i].end[1])
+        ? i
+        : null;
+    })
+    .filter((idx) => idx !== null)
+    .shift();
+
+  if (activelyModifiedSegmentIdx == null) {
+    return { points: arrow.points };
+  }
+
+  const firstSegmentIdx =
+    arrow.fixedSegments?.findIndex((segment) => segment.index === 1) ?? -1;
+  const lastSegmentIdx =
+    arrow.fixedSegments?.findIndex(
+      (segment) => segment.index === arrow.points.length - 1,
+    ) ?? -1;
+
+  // Handle special case for first segment move
+  const segmentLength = pointDistance(
+    fixedSegments[activelyModifiedSegmentIdx].start,
+    fixedSegments[activelyModifiedSegmentIdx].end,
+  );
+  const segmentIsTooShort = segmentLength < BASE_PADDING + 5;
+  if (
+    firstSegmentIdx === -1 &&
+    fixedSegments[activelyModifiedSegmentIdx].index === 1 &&
+    hoveredStartElement
+  ) {
+    const startIsHorizontal = headingIsHorizontal(startHeading);
+    const startIsPositive = startIsHorizontal
+      ? compareHeading(startHeading, HEADING_RIGHT)
+      : compareHeading(startHeading, HEADING_DOWN);
+    const padding = startIsPositive
+      ? segmentIsTooShort
+        ? segmentLength / 2
+        : BASE_PADDING
+      : segmentIsTooShort
+      ? -segmentLength / 2
+      : -BASE_PADDING;
+    fixedSegments[activelyModifiedSegmentIdx].start = pointFrom<LocalPoint>(
+      fixedSegments[activelyModifiedSegmentIdx].start[0] +
+        (startIsHorizontal ? padding : 0),
+      fixedSegments[activelyModifiedSegmentIdx].start[1] +
+        (!startIsHorizontal ? padding : 0),
+    );
+  }
+
+  // Handle special case for last segment move
+  if (
+    lastSegmentIdx === -1 &&
+    fixedSegments[activelyModifiedSegmentIdx].index ===
+      arrow.points.length - 1 &&
+    hoveredEndElement
+  ) {
+    const endIsHorizontal = headingIsHorizontal(endHeading);
+    const endIsPositive = endIsHorizontal
+      ? compareHeading(endHeading, HEADING_RIGHT)
+      : compareHeading(endHeading, HEADING_DOWN);
+    const padding = endIsPositive
+      ? segmentIsTooShort
+        ? segmentLength / 2
+        : BASE_PADDING
+      : segmentIsTooShort
+      ? -segmentLength / 2
+      : -BASE_PADDING;
+    fixedSegments[activelyModifiedSegmentIdx].end = pointFrom<LocalPoint>(
+      fixedSegments[activelyModifiedSegmentIdx].end[0] +
+        (endIsHorizontal ? padding : 0),
+      fixedSegments[activelyModifiedSegmentIdx].end[1] +
+        (!endIsHorizontal ? padding : 0),
+    );
+  }
+
+  // Translate all fixed segments to global coordinates
+  const nextFixedSegments = fixedSegments.map((segment) => ({
+    ...segment,
+    start: pointFrom<GlobalPoint>(
+      arrow.x + segment.start[0],
+      arrow.y + segment.start[1],
+    ),
+    end: pointFrom<GlobalPoint>(
+      arrow.x + segment.end[0],
+      arrow.y + segment.end[1],
+    ),
+  }));
+
+  // For start, clone old arrow points
+  const newPoints: GlobalPoint[] = arrow.points.map((p, i) =>
+    pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
+  );
+
+  const startIdx = nextFixedSegments[activelyModifiedSegmentIdx].index - 1;
+  const endIdx = nextFixedSegments[activelyModifiedSegmentIdx].index;
+  const start = nextFixedSegments[activelyModifiedSegmentIdx].start;
+  const end = nextFixedSegments[activelyModifiedSegmentIdx].end;
+  const prevSegmentIsHorizontal =
+    newPoints[startIdx - 1] &&
+    !pointsEqual(newPoints[startIdx], newPoints[startIdx - 1])
+      ? headingForPointIsHorizontal(
+          newPoints[startIdx - 1],
+          newPoints[startIdx],
+        )
+      : undefined;
+  const nextSegmentIsHorizontal =
+    newPoints[endIdx + 1] &&
+    !pointsEqual(newPoints[endIdx], newPoints[endIdx + 1])
+      ? headingForPointIsHorizontal(newPoints[endIdx + 1], newPoints[endIdx])
+      : undefined;
+
+  // Override the segment points with the actively moved fixed segment
+  if (prevSegmentIsHorizontal !== undefined) {
+    const dir = prevSegmentIsHorizontal ? 1 : 0;
+    newPoints[startIdx - 1][dir] = start[dir];
+  }
+  newPoints[startIdx] = start;
+  newPoints[endIdx] = end;
+  if (nextSegmentIsHorizontal !== undefined) {
+    const dir = nextSegmentIsHorizontal ? 1 : 0;
+    newPoints[endIdx + 1][dir] = end[dir];
+  }
+
+  // Override neighboring fixedSegment start/end points, if any
+  const prevSegmentIdx = nextFixedSegments.findIndex(
+    (segment) => segment.index === startIdx,
+  );
+  if (prevSegmentIdx !== -1) {
+    // Align the next segment points with the moved segment
+    const dir = headingForPointIsHorizontal(
+      nextFixedSegments[prevSegmentIdx].end,
+      nextFixedSegments[prevSegmentIdx].start,
+    )
+      ? 1
+      : 0;
+    nextFixedSegments[prevSegmentIdx].start[dir] = start[dir];
+    nextFixedSegments[prevSegmentIdx].end = start;
+  }
+
+  const nextSegmentIdx = nextFixedSegments.findIndex(
+    (segment) => segment.index === endIdx + 1,
+  );
+  if (nextSegmentIdx !== -1) {
+    // Align the next segment points with the moved segment
+    const dir = headingForPointIsHorizontal(
+      nextFixedSegments[nextSegmentIdx].end,
+      nextFixedSegments[nextSegmentIdx].start,
+    )
+      ? 1
+      : 0;
+    nextFixedSegments[nextSegmentIdx].end[dir] = end[dir];
+    nextFixedSegments[nextSegmentIdx].start = end;
+  }
+
+  // First segment move needs an additional segment
+  if (firstSegmentIdx === -1 && startIdx === 0) {
+    const startIsHorizontal = hoveredStartElement
+      ? headingIsHorizontal(startHeading)
+      : headingForPointIsHorizontal(newPoints[1], newPoints[0]);
+    newPoints.unshift(
+      pointFrom<GlobalPoint>(
+        startIsHorizontal ? start[0] : arrow.x + arrow.points[0][0],
+        !startIsHorizontal ? start[1] : arrow.y + arrow.points[0][1],
+      ),
+    );
+
+    if (hoveredStartElement) {
+      newPoints.unshift(
+        pointFrom<GlobalPoint>(
+          arrow.x + arrow.points[0][0],
+          arrow.y + arrow.points[0][1],
+        ),
+      );
+    }
+
+    for (const segment of nextFixedSegments) {
+      segment.index += hoveredStartElement ? 2 : 1;
+    }
+  }
+
+  // Last segment move needs an additional segment
+  if (lastSegmentIdx === -1 && endIdx === arrow.points.length - 1) {
+    const endIsHorizontal = headingIsHorizontal(endHeading);
+    newPoints.push(
+      pointFrom<GlobalPoint>(
+        endIsHorizontal
+          ? end[0]
+          : arrow.x + arrow.points[arrow.points.length - 1][0],
+        !endIsHorizontal
+          ? end[1]
+          : arrow.y + arrow.points[arrow.points.length - 1][1],
+      ),
+    );
+    if (hoveredEndElement) {
+      newPoints.push(
+        pointFrom<GlobalPoint>(
+          arrow.x + arrow.points[arrow.points.length - 1][0],
+          arrow.y + arrow.points[arrow.points.length - 1][1],
+        ),
+      );
+    }
+  }
+
+  return normalizeArrowElementUpdate(
+    newPoints,
+    nextFixedSegments.map((segment) => ({
+      ...segment,
+      start: pointFrom<LocalPoint>(
+        segment.start[0] - arrow.x,
+        segment.start[1] - arrow.y,
+      ),
+      end: pointFrom<LocalPoint>(
+        segment.end[0] - arrow.x,
+        segment.end[1] - arrow.y,
+      ),
+    })),
+    false, // If you move a segment, there is no special point anymore
+    false, // If you move a segment, there is no special point anymore
+  );
+};
+
+const handleEndpointDrag = (
+  arrow: ExcalidrawElbowArrowElement,
+  updatedPoints: readonly LocalPoint[],
+  fixedSegments: FixedSegment[],
+  startHeading: Heading,
+  endHeading: Heading,
+  startGlobalPoint: GlobalPoint,
+  endGlobalPoint: GlobalPoint,
+  hoveredStartElement: ExcalidrawBindableElement | null,
+  hoveredEndElement: ExcalidrawBindableElement | null,
+) => {
+  let startIsSpecial = arrow.startIsSpecial ?? null;
+  let endIsSpecial = arrow.endIsSpecial ?? null;
+  const globalUpdatedPoints = updatedPoints.map((p, i) =>
+    i === 0
+      ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
+      : i === updatedPoints.length - 1
+      ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
+      : pointFrom<GlobalPoint>(
+          arrow.x + arrow.points[i][0],
+          arrow.y + arrow.points[i][1],
+        ),
+  );
+  const nextFixedSegments = fixedSegments.map((segment) => ({
+    ...segment,
+    start: pointFrom<GlobalPoint>(
+      arrow.x + (segment.start[0] - updatedPoints[0][0]),
+      arrow.y + (segment.start[1] - updatedPoints[0][1]),
+    ),
+    end: pointFrom<GlobalPoint>(
+      arrow.x + (segment.end[0] - updatedPoints[0][0]),
+      arrow.y + (segment.end[1] - updatedPoints[0][1]),
+    ),
+  }));
+  const newPoints: GlobalPoint[] = [];
+
+  // Add the inside points
+  const offset = 2 + (startIsSpecial ? 1 : 0);
+  const endOffset = 2 + (endIsSpecial ? 1 : 0);
+  while (newPoints.length + offset < globalUpdatedPoints.length - endOffset) {
+    newPoints.push(globalUpdatedPoints[newPoints.length + offset]);
+  }
+
+  // Calculate the moving second point connection and add the start point
+  {
+    const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
+    const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
+    const startIsHorizontal = headingIsHorizontal(startHeading);
+    const secondIsHorizontal = headingIsHorizontal(
+      vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
+    );
+
+    if (hoveredStartElement && startIsHorizontal === secondIsHorizontal) {
+      const positive = startIsHorizontal
+        ? compareHeading(startHeading, HEADING_RIGHT)
+        : compareHeading(startHeading, HEADING_DOWN);
+      newPoints.unshift(
+        pointFrom<GlobalPoint>(
+          !secondIsHorizontal
+            ? thirdPoint[0]
+            : startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING),
+          secondIsHorizontal
+            ? thirdPoint[1]
+            : startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING),
+        ),
+      );
+      newPoints.unshift(
+        pointFrom<GlobalPoint>(
+          startIsHorizontal
+            ? startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING)
+            : startGlobalPoint[0],
+          !startIsHorizontal
+            ? startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING)
+            : startGlobalPoint[1],
+        ),
+      );
+      if (!startIsSpecial) {
+        startIsSpecial = true;
+        for (const segment of nextFixedSegments) {
+          if (segment.index > 1) {
+            segment.index += 1;
+          }
+        }
+      }
+    } else {
+      newPoints.unshift(
+        pointFrom<GlobalPoint>(
+          !secondIsHorizontal ? secondPoint[0] : startGlobalPoint[0],
+          secondIsHorizontal ? secondPoint[1] : startGlobalPoint[1],
+        ),
+      );
+      if (startIsSpecial) {
+        startIsSpecial = false;
+        for (const segment of nextFixedSegments) {
+          if (segment.index > 1) {
+            segment.index -= 1;
+          }
+        }
+      }
+    }
+    newPoints.unshift(startGlobalPoint);
+  }
+
+  // Calculate the moving second to last point connection
+  {
+    const secondToLastPoint =
+      globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
+    const thirdToLastPoint =
+      globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
+    const endIsHorizontal = headingIsHorizontal(endHeading);
+    const secondIsHorizontal = headingForPointIsHorizontal(
+      thirdToLastPoint,
+      secondToLastPoint,
+    );
+    if (hoveredEndElement && endIsHorizontal === secondIsHorizontal) {
+      const positive = endIsHorizontal
+        ? compareHeading(endHeading, HEADING_RIGHT)
+        : compareHeading(endHeading, HEADING_DOWN);
+      newPoints.push(
+        pointFrom<GlobalPoint>(
+          !secondIsHorizontal
+            ? thirdToLastPoint[0]
+            : endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING),
+          secondIsHorizontal
+            ? thirdToLastPoint[1]
+            : endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING),
+        ),
+      );
+      newPoints.push(
+        pointFrom<GlobalPoint>(
+          endIsHorizontal
+            ? endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING)
+            : endGlobalPoint[0],
+          !endIsHorizontal
+            ? endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING)
+            : endGlobalPoint[1],
+        ),
+      );
+      if (!endIsSpecial) {
+        endIsSpecial = true;
+      }
+    } else {
+      newPoints.push(
+        pointFrom<GlobalPoint>(
+          !secondIsHorizontal ? secondToLastPoint[0] : endGlobalPoint[0],
+          secondIsHorizontal ? secondToLastPoint[1] : endGlobalPoint[1],
+        ),
+      );
+      if (endIsSpecial) {
+        endIsSpecial = false;
+      }
+    }
+  }
+
+  newPoints.push(endGlobalPoint);
+
+  return normalizeArrowElementUpdate(
+    newPoints,
+    nextFixedSegments
+      .map(({ index }) => ({
+        index,
+        start: newPoints[index - 1],
+        end: newPoints[index],
+      }))
+      .map((segment) => ({
+        ...segment,
+        start: pointFrom<LocalPoint>(
+          segment.start[0] - startGlobalPoint[0],
+          segment.start[1] - startGlobalPoint[1],
+        ),
+        end: pointFrom<LocalPoint>(
+          segment.end[0] - startGlobalPoint[0],
+          segment.end[1] - startGlobalPoint[1],
+        ),
+      })),
+    startIsSpecial,
+    endIsSpecial,
+  );
+};
+
+/**
+ *
+ */
+export const updateElbowArrowPoints = (
+  arrow: Readonly<ExcalidrawElbowArrowElement>,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  updates: {
+    points?: readonly LocalPoint[];
+    fixedSegments?: FixedSegment[] | null;
+  },
+  options?: {
+    isDragging?: boolean;
+  },
+): ElementUpdate<ExcalidrawElbowArrowElement> => {
+  if (arrow.points.length < 2) {
+    return { points: updates.points ?? arrow.points };
+  }
+
+  if (!import.meta.env.PROD) {
+    invariant(
+      !updates.points || updates.points.length >= 2,
+      "Updated point array length must match the arrow point length, contain " +
+        "exactly the new start and end points or not be specified at all (i.e. " +
+        "you can't add new points between start and end manually to elbow arrows)",
+    );
+
+    invariant(
+      !arrow.fixedSegments ||
+        arrow.fixedSegments
+          .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
+          .every(Boolean),
+      "Fixed segments must be either horizontal or vertical",
+    );
+
+    invariant(
+      !updates.fixedSegments ||
+        updates.fixedSegments
+          .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
+          .every(Boolean),
+      "Updates to fixed segments must be either horizontal or vertical",
+    );
+
+    invariant(
+      arrow.points
+        .slice(1)
+        .map(
+          (p, i) => p[0] === arrow.points[i][0] || p[1] === arrow.points[i][1],
+        ),
+      "Elbow arrow segments must be either horizontal or vertical",
+    );
+  }
+
+  const updatedPoints: readonly LocalPoint[] = updates.points
+    ? updates.points && updates.points.length === 2
+      ? arrow.points.map((p, idx) =>
+          idx === 0
+            ? updates.points![0]
+            : idx === arrow.points.length - 1
+            ? updates.points![1]
+            : p,
+        )
+      : structuredClone(updates.points)
+    : structuredClone(arrow.points);
+
+  const {
+    startHeading,
+    endHeading,
+    startGlobalPoint,
+    endGlobalPoint,
+    hoveredStartElement,
+    hoveredEndElement,
+    ...rest
+  } = getElbowArrowData(arrow, elementsMap, updatedPoints, options);
+
+  const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
+
+  ////
+  // 1. Renormalize the arrow
+  ////
+  if (!updates.points && !updates.fixedSegments) {
+    return handleSegmentRenormalization(arrow, elementsMap);
+  }
+
+  ////
+  // 2. Just normal elbow arrow things
+  ////
+  if (fixedSegments.length === 0) {
+    return normalizeArrowElementUpdate(
+      getElbowArrowCornerPoints(
+        removeElbowArrowShortSegments(
+          routeElbowArrow(arrow, {
+            startHeading,
+            endHeading,
+            startGlobalPoint,
+            endGlobalPoint,
+            hoveredStartElement,
+            hoveredEndElement,
+            ...rest,
+          }) ?? [],
+        ),
+      ),
+      fixedSegments,
+      null,
+      null,
+    );
+  }
+
+  ////
+  // 3. Handle releasing a fixed segment
+  if ((arrow.fixedSegments?.length ?? 0) > fixedSegments.length) {
+    return handleSegmentRelease(arrow, fixedSegments, elementsMap);
+  }
+
+  ////
+  // 4. Handle manual segment move
+  ////
+  if (!updates.points) {
+    return handleSegmentMove(
+      arrow,
+      fixedSegments,
+      startHeading,
+      endHeading,
+      hoveredStartElement,
+      hoveredEndElement,
+    );
+  }
+
+  ////
+  // 5. Handle resize
+  if (updates.points && updates.fixedSegments) {
+    return updates;
+  }
+
+  ////
+  // 6. One or more segments are fixed and endpoints are moved
+  //
+  // The key insights are:
+  // - When segments are fixed, the arrow will keep the exact amount of segments
+  // - Fixed segments are "replacements" for exactly one segment in the old arrow
+  ////
+  return handleEndpointDrag(
+    arrow,
+    updatedPoints,
+    fixedSegments,
+    startHeading,
+    endHeading,
+    startGlobalPoint,
+    endGlobalPoint,
+    hoveredStartElement,
+    hoveredEndElement,
+  );
+};
+
+/**
+ * Retrieves data necessary for calculating the elbow arrow path.
+ *
+ * @param arrow - The arrow object containing its properties.
+ * @param elementsMap - A map of elements in the scene.
+ * @param nextPoints - The next set of points for the arrow.
+ * @param options - Optional parameters for the calculation.
+ * @param options.isDragging - Indicates if the arrow is being dragged.
+ * @param options.startIsMidPoint - Indicates if the start point is a midpoint.
+ * @param options.endIsMidPoint - Indicates if the end point is a midpoint.
+ *
+ * @returns An object containing various properties needed for elbow arrow calculations:
+ * - dynamicAABBs: Dynamically generated axis-aligned bounding boxes.
+ * - startDonglePosition: The position of the start dongle.
+ * - startGlobalPoint: The global coordinates of the start point.
+ * - startHeading: The heading direction from the start point.
+ * - endDonglePosition: The position of the end dongle.
+ * - endGlobalPoint: The global coordinates of the end point.
+ * - endHeading: The heading direction from the end point.
+ * - commonBounds: The common bounding box that encompasses both start and end points.
+ * - hoveredStartElement: The element being hovered over at the start point.
+ * - hoveredEndElement: The element being hovered over at the end point.
+ */
+const getElbowArrowData = (
+  arrow: {
+    x: number;
+    y: number;
+    startBinding: FixedPointBinding | null;
+    endBinding: FixedPointBinding | null;
+    startArrowhead: Arrowhead | null;
+    endArrowhead: Arrowhead | null;
+  },
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  nextPoints: readonly LocalPoint[],
+  options?: {
+    isDragging?: boolean;
+    zoom?: AppState["zoom"];
+  },
+) => {
+  const origStartGlobalPoint: GlobalPoint = pointTranslate<
+    LocalPoint,
+    GlobalPoint
+  >(nextPoints[0], vector(arrow.x, arrow.y));
+  const origEndGlobalPoint: GlobalPoint = pointTranslate<
+    LocalPoint,
+    GlobalPoint
+  >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
+  const startElement =
+    arrow.startBinding &&
+    getBindableElementForId(arrow.startBinding.elementId, elementsMap);
+  const endElement =
+    arrow.endBinding &&
+    getBindableElementForId(arrow.endBinding.elementId, elementsMap);
+  const [hoveredStartElement, hoveredEndElement] = options?.isDragging
+    ? getHoveredElements(
+        origStartGlobalPoint,
+        origEndGlobalPoint,
+        elementsMap,
+        options?.zoom,
+      )
+    : [startElement, endElement];
+  const startGlobalPoint = getGlobalPoint(
+    arrow.startBinding?.fixedPoint,
+    origStartGlobalPoint,
+    origEndGlobalPoint,
+    elementsMap,
+    startElement,
+    hoveredStartElement,
+    options?.isDragging,
+  );
+  const endGlobalPoint = getGlobalPoint(
+    arrow.endBinding?.fixedPoint,
+    origEndGlobalPoint,
+    origStartGlobalPoint,
+    elementsMap,
+    endElement,
+    hoveredEndElement,
+    options?.isDragging,
+  );
+  const startHeading = getBindPointHeading(
+    startGlobalPoint,
+    endGlobalPoint,
+    elementsMap,
+    hoveredStartElement,
+    origStartGlobalPoint,
+  );
+  const endHeading = getBindPointHeading(
+    endGlobalPoint,
+    startGlobalPoint,
+    elementsMap,
+    hoveredEndElement,
+    origEndGlobalPoint,
+  );
+  const startPointBounds = [
+    startGlobalPoint[0] - 2,
+    startGlobalPoint[1] - 2,
+    startGlobalPoint[0] + 2,
+    startGlobalPoint[1] + 2,
+  ] as Bounds;
+  const endPointBounds = [
+    endGlobalPoint[0] - 2,
+    endGlobalPoint[1] - 2,
+    endGlobalPoint[0] + 2,
+    endGlobalPoint[1] + 2,
+  ] as Bounds;
+  const startElementBounds = hoveredStartElement
+    ? aabbForElement(
+        hoveredStartElement,
+        offsetFromHeading(
+          startHeading,
+          arrow.startArrowhead
+            ? FIXED_BINDING_DISTANCE * 6
+            : FIXED_BINDING_DISTANCE * 2,
+          1,
+        ),
+      )
+    : startPointBounds;
+  const endElementBounds = hoveredEndElement
+    ? aabbForElement(
+        hoveredEndElement,
+        offsetFromHeading(
+          endHeading,
+          arrow.endArrowhead
+            ? FIXED_BINDING_DISTANCE * 6
+            : FIXED_BINDING_DISTANCE * 2,
+          1,
+        ),
+      )
+    : endPointBounds;
+  const boundsOverlap =
+    pointInsideBounds(
+      startGlobalPoint,
+      hoveredEndElement
+        ? aabbForElement(
+            hoveredEndElement,
+            offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
+          )
+        : endPointBounds,
+    ) ||
+    pointInsideBounds(
+      endGlobalPoint,
+      hoveredStartElement
+        ? aabbForElement(
+            hoveredStartElement,
+            offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
+          )
+        : startPointBounds,
+    );
+  const commonBounds = commonAABB(
+    boundsOverlap
+      ? [startPointBounds, endPointBounds]
+      : [startElementBounds, endElementBounds],
+  );
+  const dynamicAABBs = generateDynamicAABBs(
+    boundsOverlap ? startPointBounds : startElementBounds,
+    boundsOverlap ? endPointBounds : endElementBounds,
+    commonBounds,
+    boundsOverlap
+      ? offsetFromHeading(
+          startHeading,
+          !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
+          0,
+        )
+      : offsetFromHeading(
+          startHeading,
+          !hoveredStartElement && !hoveredEndElement
+            ? 0
+            : BASE_PADDING -
+                (arrow.startArrowhead
+                  ? FIXED_BINDING_DISTANCE * 6
+                  : FIXED_BINDING_DISTANCE * 2),
+          BASE_PADDING,
+        ),
+    boundsOverlap
+      ? offsetFromHeading(
+          endHeading,
+          !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
+          0,
+        )
+      : offsetFromHeading(
+          endHeading,
+          !hoveredStartElement && !hoveredEndElement
+            ? 0
+            : BASE_PADDING -
+                (arrow.endArrowhead
+                  ? FIXED_BINDING_DISTANCE * 6
+                  : FIXED_BINDING_DISTANCE * 2),
+          BASE_PADDING,
+        ),
+    boundsOverlap,
+    hoveredStartElement && aabbForElement(hoveredStartElement),
+    hoveredEndElement && aabbForElement(hoveredEndElement),
+  );
+  const startDonglePosition = getDonglePosition(
+    dynamicAABBs[0],
+    startHeading,
+    startGlobalPoint,
+  );
+  const endDonglePosition = getDonglePosition(
+    dynamicAABBs[1],
+    endHeading,
+    endGlobalPoint,
+  );
+
+  return {
+    dynamicAABBs,
+    startDonglePosition,
+    startGlobalPoint,
+    startHeading,
+    endDonglePosition,
+    endGlobalPoint,
+    endHeading,
+    commonBounds,
+    hoveredStartElement,
+    hoveredEndElement,
+    boundsOverlap,
+    startElementBounds,
+    endElementBounds,
+  };
+};
+
+/**
+ * Generate the elbow arrow segments
+ *
+ * @param arrow
+ * @param elementsMap
+ * @param nextPoints
+ * @param options
+ * @returns
+ */
+const routeElbowArrow = (
+  arrow: ElbowArrowState,
+  elbowArrowData: ElbowArrowData,
+): GlobalPoint[] | null => {
+  const {
+    dynamicAABBs,
+    startDonglePosition,
+    startGlobalPoint,
+    startHeading,
+    endDonglePosition,
+    endGlobalPoint,
+    endHeading,
+    commonBounds,
+    hoveredEndElement,
+  } = elbowArrowData;
+
+  // Canculate Grid positions
+  const grid = calculateGrid(
+    dynamicAABBs,
+    startDonglePosition ? startDonglePosition : startGlobalPoint,
+    startHeading,
+    endDonglePosition ? endDonglePosition : endGlobalPoint,
+    endHeading,
+    commonBounds,
+  );
+
+  const startDongle =
+    startDonglePosition && pointToGridNode(startDonglePosition, grid);
+  const endDongle =
+    endDonglePosition && pointToGridNode(endDonglePosition, grid);
+
+  // Do not allow stepping on the true end or true start points
+  const endNode = pointToGridNode(endGlobalPoint, grid);
+  if (endNode && hoveredEndElement) {
+    endNode.closed = true;
+  }
+  const startNode = pointToGridNode(startGlobalPoint, grid);
+  if (startNode && arrow.startBinding) {
+    startNode.closed = true;
+  }
+  const dongleOverlap =
+    startDongle &&
+    endDongle &&
+    (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) ||
+      pointInsideBounds(endDongle.pos, dynamicAABBs[0]));
+
+  // Create path to end dongle from start dongle
+  const path = astar(
+    startDongle ? startDongle : startNode!,
+    endDongle ? endDongle : endNode!,
+    grid,
+    startHeading ? startHeading : HEADING_RIGHT,
+    endHeading ? endHeading : HEADING_RIGHT,
+    dongleOverlap ? [] : dynamicAABBs,
+  );
+
+  if (path) {
+    const points = path.map((node) => [
+      node.pos[0],
+      node.pos[1],
+    ]) as GlobalPoint[];
+    startDongle && points.unshift(startGlobalPoint);
+    endDongle && points.push(endGlobalPoint);
+
+    return points;
+  }
+
+  return null;
+};
+
+const offsetFromHeading = (
+  heading: Heading,
+  head: number,
+  side: number,
+): [number, number, number, number] => {
+  switch (heading) {
+    case HEADING_UP:
+      return [head, side, side, side];
+    case HEADING_RIGHT:
+      return [side, head, side, side];
+    case HEADING_DOWN:
+      return [side, side, head, side];
+  }
+
+  return [side, side, side, head];
+};
+
+/**
+ * Routing algorithm based on the A* path search algorithm.
+ * @see https://www.geeksforgeeks.org/a-search-algorithm/
+ *
+ * Binary heap is used to optimize node lookup.
+ * See {@link calculateGrid} for the grid calculation details.
+ *
+ * Additional modifications added due to aesthetic route reasons:
+ * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier)
+ * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment
+ */
+const astar = (
+  start: Node,
+  end: Node,
+  grid: Grid,
+  startHeading: Heading,
+  endHeading: Heading,
+  aabbs: Bounds[],
+) => {
+  const bendMultiplier = m_dist(start.pos, end.pos);
+  const open = new BinaryHeap<Node>((node) => node.f);
+
+  open.push(start);
+
+  while (open.size() > 0) {
+    // Grab the lowest f(x) to process next.  Heap keeps this sorted for us.
+    const current = open.pop();
+
+    if (!current || current.closed) {
+      // Current is not passable, continue with next element
+      continue;
+    }
+
+    // End case -- result has been found, return the traced path.
+    if (current === end) {
+      return pathTo(start, current);
+    }
+
+    // Normal case -- move current from open to closed, process each of its neighbors.
+    current.closed = true;
+
+    // Find all neighbors for the current node.
+    const neighbors = getNeighbors(current.addr, grid);
+
+    for (let i = 0; i < 4; i++) {
+      const neighbor = neighbors[i];
+
+      if (!neighbor || neighbor.closed) {
+        // Not a valid node to process, skip to next neighbor.
+        continue;
+      }
+
+      // Intersect
+      const neighborHalfPoint = pointScaleFromOrigin(
+        neighbor.pos,
+        current.pos,
+        0.5,
+      );
+      if (
+        isAnyTrue(
+          ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)),
+        )
+      ) {
+        continue;
+      }
+
+      // The g score is the shortest distance from start to current node.
+      // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
+      const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
+      const previousDirection = current.parent
+        ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
+        : startHeading;
+
+      // Do not allow going in reverse
+      const reverseHeading = flipHeading(previousDirection);
+      const neighborIsReverseRoute =
+        compareHeading(reverseHeading, neighborHeading) ||
+        (gridAddressesEqual(start.addr, neighbor.addr) &&
+          compareHeading(neighborHeading, startHeading)) ||
+        (gridAddressesEqual(end.addr, neighbor.addr) &&
+          compareHeading(neighborHeading, endHeading));
+      if (neighborIsReverseRoute) {
+        continue;
+      }
+
+      const directionChange = previousDirection !== neighborHeading;
+      const gScore =
+        current.g +
+        m_dist(neighbor.pos, current.pos) +
+        (directionChange ? Math.pow(bendMultiplier, 3) : 0);
+
+      const beenVisited = neighbor.visited;
+
+      if (!beenVisited || gScore < neighbor.g) {
+        const estBendCount = estimateSegmentCount(
+          neighbor,
+          end,
+          neighborHeading,
+          endHeading,
+        );
+        // Found an optimal (so far) path to this node.  Take score for node to see how good it is.
+        neighbor.visited = true;
+        neighbor.parent = current;
+        neighbor.h =
+          m_dist(end.pos, neighbor.pos) +
+          estBendCount * Math.pow(bendMultiplier, 2);
+        neighbor.g = gScore;
+        neighbor.f = neighbor.g + neighbor.h;
+        if (!beenVisited) {
+          // Pushing to heap will put it in proper place based on the 'f' value.
+          open.push(neighbor);
+        } else {
+          // Already seen the node, but since it has been rescored we need to reorder it in the heap
+          open.rescoreElement(neighbor);
+        }
+      }
+    }
+  }
+
+  return null;
+};
+
+const pathTo = (start: Node, node: Node) => {
+  let curr = node;
+  const path = [];
+  while (curr.parent) {
+    path.unshift(curr);
+    curr = curr.parent;
+  }
+  path.unshift(start);
+
+  return path;
+};
+
+const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
+  Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
+
+/**
+ * Create dynamically resizing, always touching
+ * bounding boxes having a minimum extent represented
+ * by the given static bounds.
+ */
+const generateDynamicAABBs = (
+  a: Bounds,
+  b: Bounds,
+  common: Bounds,
+  startDifference?: [number, number, number, number],
+  endDifference?: [number, number, number, number],
+  disableSideHack?: boolean,
+  startElementBounds?: Bounds | null,
+  endElementBounds?: Bounds | null,
+): Bounds[] => {
+  const startEl = startElementBounds ?? a;
+  const endEl = endElementBounds ?? b;
+  const [startUp, startRight, startDown, startLeft] = startDifference ?? [
+    0, 0, 0, 0,
+  ];
+  const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0];
+
+  const first = [
+    a[0] > b[2]
+      ? a[1] > b[3] || a[3] < b[1]
+        ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
+        : (startEl[0] + endEl[2]) / 2
+      : a[0] > b[0]
+      ? a[0] - startLeft
+      : common[0] - startLeft,
+    a[1] > b[3]
+      ? a[0] > b[2] || a[2] < b[0]
+        ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
+        : (startEl[1] + endEl[3]) / 2
+      : a[1] > b[1]
+      ? a[1] - startUp
+      : common[1] - startUp,
+    a[2] < b[0]
+      ? a[1] > b[3] || a[3] < b[1]
+        ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
+        : (startEl[2] + endEl[0]) / 2
+      : a[2] < b[2]
+      ? a[2] + startRight
+      : common[2] + startRight,
+    a[3] < b[1]
+      ? a[0] > b[2] || a[2] < b[0]
+        ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
+        : (startEl[3] + endEl[1]) / 2
+      : a[3] < b[3]
+      ? a[3] + startDown
+      : common[3] + startDown,
+  ] as Bounds;
+  const second = [
+    b[0] > a[2]
+      ? b[1] > a[3] || b[3] < a[1]
+        ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
+        : (endEl[0] + startEl[2]) / 2
+      : b[0] > a[0]
+      ? b[0] - endLeft
+      : common[0] - endLeft,
+    b[1] > a[3]
+      ? b[0] > a[2] || b[2] < a[0]
+        ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
+        : (endEl[1] + startEl[3]) / 2
+      : b[1] > a[1]
+      ? b[1] - endUp
+      : common[1] - endUp,
+    b[2] < a[0]
+      ? b[1] > a[3] || b[3] < a[1]
+        ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
+        : (endEl[2] + startEl[0]) / 2
+      : b[2] < a[2]
+      ? b[2] + endRight
+      : common[2] + endRight,
+    b[3] < a[1]
+      ? b[0] > a[2] || b[2] < a[0]
+        ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
+        : (endEl[3] + startEl[1]) / 2
+      : b[3] < a[3]
+      ? b[3] + endDown
+      : common[3] + endDown,
+  ] as Bounds;
+
+  const c = commonAABB([first, second]);
+  if (
+    !disableSideHack &&
+    first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 &&
+    first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001
+  ) {
+    const [endCenterX, endCenterY] = [
+      (second[0] + second[2]) / 2,
+      (second[1] + second[3]) / 2,
+    ];
+    if (b[0] > a[2] && a[1] > b[3]) {
+      // BOTTOM LEFT
+      const cX = first[2] + (second[0] - first[2]) / 2;
+      const cY = second[3] + (first[1] - second[3]) / 2;
+
+      if (
+        vectorCross(
+          vector(a[2] - endCenterX, a[1] - endCenterY),
+          vector(a[0] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
+        return [
+          [first[0], first[1], cX, first[3]],
+          [cX, second[1], second[2], second[3]],
+        ];
+      }
+
+      return [
+        [first[0], cY, first[2], first[3]],
+        [second[0], second[1], second[2], cY],
+      ];
+    } else if (a[2] < b[0] && a[3] < b[1]) {
+      // TOP LEFT
+      const cX = first[2] + (second[0] - first[2]) / 2;
+      const cY = first[3] + (second[1] - first[3]) / 2;
+
+      if (
+        vectorCross(
+          vector(a[0] - endCenterX, a[1] - endCenterY),
+          vector(a[2] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
+        return [
+          [first[0], first[1], first[2], cY],
+          [second[0], cY, second[2], second[3]],
+        ];
+      }
+
+      return [
+        [first[0], first[1], cX, first[3]],
+        [cX, second[1], second[2], second[3]],
+      ];
+    } else if (a[0] > b[2] && a[3] < b[1]) {
+      // TOP RIGHT
+      const cX = second[2] + (first[0] - second[2]) / 2;
+      const cY = first[3] + (second[1] - first[3]) / 2;
+
+      if (
+        vectorCross(
+          vector(a[2] - endCenterX, a[1] - endCenterY),
+          vector(a[0] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
+        return [
+          [cX, first[1], first[2], first[3]],
+          [second[0], second[1], cX, second[3]],
+        ];
+      }
+
+      return [
+        [first[0], first[1], first[2], cY],
+        [second[0], cY, second[2], second[3]],
+      ];
+    } else if (a[0] > b[2] && a[1] > b[3]) {
+      // BOTTOM RIGHT
+      const cX = second[2] + (first[0] - second[2]) / 2;
+      const cY = second[3] + (first[1] - second[3]) / 2;
+
+      if (
+        vectorCross(
+          vector(a[0] - endCenterX, a[1] - endCenterY),
+          vector(a[2] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
+        return [
+          [cX, first[1], first[2], first[3]],
+          [second[0], second[1], cX, second[3]],
+        ];
+      }
+
+      return [
+        [first[0], cY, first[2], first[3]],
+        [second[0], second[1], second[2], cY],
+      ];
+    }
+  }
+
+  return [first, second];
+};
+
+/**
+ * Calculates the grid which is used as nodes at
+ * the grid line intersections by the A* algorithm.
+ *
+ * NOTE: This is not a uniform grid. It is built at
+ * various intersections of bounding boxes.
+ */
+const calculateGrid = (
+  aabbs: Bounds[],
+  start: GlobalPoint,
+  startHeading: Heading,
+  end: GlobalPoint,
+  endHeading: Heading,
+  common: Bounds,
+): Grid => {
+  const horizontal = new Set<number>();
+  const vertical = new Set<number>();
+
+  if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) {
+    vertical.add(start[1]);
+  } else {
+    horizontal.add(start[0]);
+  }
+  if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) {
+    vertical.add(end[1]);
+  } else {
+    horizontal.add(end[0]);
+  }
+
+  aabbs.forEach((aabb) => {
+    horizontal.add(aabb[0]);
+    horizontal.add(aabb[2]);
+    vertical.add(aabb[1]);
+    vertical.add(aabb[3]);
+  });
+
+  horizontal.add(common[0]);
+  horizontal.add(common[2]);
+  vertical.add(common[1]);
+  vertical.add(common[3]);
+
+  const _vertical = Array.from(vertical).sort((a, b) => a - b);
+  const _horizontal = Array.from(horizontal).sort((a, b) => a - b);
+
+  return {
+    row: _vertical.length,
+    col: _horizontal.length,
+    data: _vertical.flatMap((y, row) =>
+      _horizontal.map(
+        (x, col): Node => ({
+          f: 0,
+          g: 0,
+          h: 0,
+          closed: false,
+          visited: false,
+          parent: null,
+          addr: [col, row] as GridAddress,
+          pos: [x, y] as GlobalPoint,
+        }),
+      ),
+    ),
+  };
+};
+
+const getDonglePosition = (
+  bounds: Bounds,
+  heading: Heading,
+  p: GlobalPoint,
+): GlobalPoint => {
+  switch (heading) {
+    case HEADING_UP:
+      return pointFrom(p[0], bounds[1]);
+    case HEADING_RIGHT:
+      return pointFrom(bounds[2], p[1]);
+    case HEADING_DOWN:
+      return pointFrom(p[0], bounds[3]);
+  }
+  return pointFrom(bounds[0], p[1]);
+};
+
+const estimateSegmentCount = (
+  start: Node,
+  end: Node,
+  startHeading: Heading,
+  endHeading: Heading,
+) => {
+  if (endHeading === HEADING_RIGHT) {
+    switch (startHeading) {
+      case HEADING_RIGHT: {
+        if (start.pos[0] >= end.pos[0]) {
+          return 4;
+        }
+        if (start.pos[1] === end.pos[1]) {
+          return 0;
+        }
+        return 2;
+      }
+      case HEADING_UP:
+        if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_DOWN:
+        if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_LEFT:
+        if (start.pos[1] === end.pos[1]) {
+          return 4;
+        }
+        return 2;
+    }
+  } else if (endHeading === HEADING_LEFT) {
+    switch (startHeading) {
+      case HEADING_RIGHT:
+        if (start.pos[1] === end.pos[1]) {
+          return 4;
+        }
+        return 2;
+      case HEADING_UP:
+        if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_DOWN:
+        if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_LEFT:
+        if (start.pos[0] <= end.pos[0]) {
+          return 4;
+        }
+        if (start.pos[1] === end.pos[1]) {
+          return 0;
+        }
+        return 2;
+    }
+  } else if (endHeading === HEADING_UP) {
+    switch (startHeading) {
+      case HEADING_RIGHT:
+        if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_UP:
+        if (start.pos[1] >= end.pos[1]) {
+          return 4;
+        }
+        if (start.pos[0] === end.pos[0]) {
+          return 0;
+        }
+        return 2;
+      case HEADING_DOWN:
+        if (start.pos[0] === end.pos[0]) {
+          return 4;
+        }
+        return 2;
+      case HEADING_LEFT:
+        if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
+          return 1;
+        }
+        return 3;
+    }
+  } else if (endHeading === HEADING_DOWN) {
+    switch (startHeading) {
+      case HEADING_RIGHT:
+        if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
+          return 1;
+        }
+        return 3;
+      case HEADING_UP:
+        if (start.pos[0] === end.pos[0]) {
+          return 4;
+        }
+        return 2;
+      case HEADING_DOWN:
+        if (start.pos[1] <= end.pos[1]) {
+          return 4;
+        }
+        if (start.pos[0] === end.pos[0]) {
+          return 0;
+        }
+        return 2;
+      case HEADING_LEFT:
+        if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
+          return 1;
+        }
+        return 3;
+    }
+  }
+  return 0;
+};
+
+/**
+ * Get neighboring points for a gived grid address
+ */
+const getNeighbors = ([col, row]: [number, number], grid: Grid) =>
+  [
+    gridNodeFromAddr([col, row - 1], grid),
+    gridNodeFromAddr([col + 1, row], grid),
+    gridNodeFromAddr([col, row + 1], grid),
+    gridNodeFromAddr([col - 1, row], grid),
+  ] as [Node | null, Node | null, Node | null, Node | null];
+
+const gridNodeFromAddr = (
+  [col, row]: [col: number, row: number],
+  grid: Grid,
+): Node | null => {
+  if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) {
+    return null;
+  }
+
+  return grid.data[row * grid.col + col] ?? null;
+};
+
+/**
+ * Get node for global point on canvas (if exists)
+ */
+const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
+  for (let col = 0; col < grid.col; col++) {
+    for (let row = 0; row < grid.row; row++) {
+      const candidate = gridNodeFromAddr([col, row], grid);
+      if (
+        candidate &&
+        point[0] === candidate.pos[0] &&
+        point[1] === candidate.pos[1]
+      ) {
+        return candidate;
+      }
+    }
+  }
+
+  return null;
+};
+
+const commonAABB = (aabbs: Bounds[]): Bounds => [
+  Math.min(...aabbs.map((aabb) => aabb[0])),
+  Math.min(...aabbs.map((aabb) => aabb[1])),
+  Math.max(...aabbs.map((aabb) => aabb[2])),
+  Math.max(...aabbs.map((aabb) => aabb[3])),
+];
+
+/// #region Utils
+
+const getBindableElementForId = (
+  id: string,
+  elementsMap: ElementsMap,
+): ExcalidrawBindableElement | null => {
+  const element = elementsMap.get(id);
+  if (element && isBindableElement(element)) {
+    return element;
+  }
+
+  return null;
+};
+
+const normalizeArrowElementUpdate = (
+  global: GlobalPoint[],
+  nextFixedSegments: FixedSegment[] | null,
+  startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
+  endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
+): {
+  points: LocalPoint[];
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  fixedSegments: FixedSegment[] | null;
+  startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
+  endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
+} => {
+  const offsetX = global[0][0];
+  const offsetY = global[0][1];
+
+  const points = global.map((p) =>
+    pointTranslate<GlobalPoint, LocalPoint>(
+      p,
+      vectorScale(vectorFromPoint(global[0]), -1),
+    ),
+  );
+
+  return {
+    points,
+    x: offsetX,
+    y: offsetY,
+    fixedSegments:
+      (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
+    ...getSizeFromPoints(points),
+    startIsSpecial,
+    endIsSpecial,
+  };
+};
+
+const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => {
+  if (points.length > 1) {
+    let previousHorizontal =
+      Math.abs(points[0][1] - points[1][1]) <
+      Math.abs(points[0][0] - points[1][0]);
+
+    return points.filter((p, idx) => {
+      // The very first and last points are always kept
+      if (idx === 0 || idx === points.length - 1) {
+        return true;
+      }
+
+      const next = points[idx + 1];
+      const nextHorizontal =
+        Math.abs(p[1] - next[1]) < Math.abs(p[0] - next[0]);
+      if (previousHorizontal === nextHorizontal) {
+        previousHorizontal = nextHorizontal;
+        return false;
+      }
+
+      previousHorizontal = nextHorizontal;
+      return true;
+    });
+  }
+
+  return points;
+};
+
+const removeElbowArrowShortSegments = (
+  points: GlobalPoint[],
+): GlobalPoint[] => {
+  if (points.length >= 4) {
+    return points.filter((p, idx) => {
+      if (idx === 0 || idx === points.length - 1) {
+        return true;
+      }
+
+      const prev = points[idx - 1];
+      const prevDist = pointDistance(prev, p);
+      return prevDist > DEDUP_TRESHOLD;
+    });
+  }
+
+  return points;
+};
+
+const neighborIndexToHeading = (idx: number): Heading => {
+  switch (idx) {
+    case 0:
+      return HEADING_UP;
+    case 1:
+      return HEADING_RIGHT;
+    case 2:
+      return HEADING_DOWN;
+  }
+  return HEADING_LEFT;
+};
+
+const getGlobalPoint = (
+  fixedPointRatio: [number, number] | undefined | null,
+  initialPoint: GlobalPoint,
+  otherPoint: GlobalPoint,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  boundElement?: ExcalidrawBindableElement | null,
+  hoveredElement?: ExcalidrawBindableElement | null,
+  isDragging?: boolean,
+): GlobalPoint => {
+  if (isDragging) {
+    if (hoveredElement) {
+      const snapPoint = getSnapPoint(
+        initialPoint,
+        otherPoint,
+        hoveredElement,
+        elementsMap,
+      );
+
+      return snapToMid(hoveredElement, snapPoint);
+    }
+
+    return initialPoint;
+  }
+
+  if (boundElement) {
+    const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
+      fixedPointRatio || [0, 0],
+      boundElement,
+    );
+
+    // NOTE: Resize scales the binding position point too, so we need to update it
+    return Math.abs(
+      distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
+        FIXED_BINDING_DISTANCE,
+    ) > 0.01
+      ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
+      : fixedGlobalPoint;
+  }
+
+  return initialPoint;
+};
+
+const getSnapPoint = (
+  p: GlobalPoint,
+  otherPoint: GlobalPoint,
+  element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
+) =>
+  bindPointToSnapToElementOutline(
+    isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
+    otherPoint,
+    element,
+    elementsMap,
+  );
+
+const getBindPointHeading = (
+  p: GlobalPoint,
+  otherPoint: GlobalPoint,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  hoveredElement: ExcalidrawBindableElement | null | undefined,
+  origPoint: GlobalPoint,
+): Heading =>
+  getHeadingForElbowArrowSnap(
+    p,
+    otherPoint,
+    hoveredElement,
+    hoveredElement &&
+      aabbForElement(
+        hoveredElement,
+        Array(4).fill(
+          distanceToBindableElement(hoveredElement, p, elementsMap),
+        ) as [number, number, number, number],
+      ),
+    elementsMap,
+    origPoint,
+  );
+
+const getHoveredElements = (
+  origStartGlobalPoint: GlobalPoint,
+  origEndGlobalPoint: GlobalPoint,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  zoom?: AppState["zoom"],
+) => {
+  // TODO: Might be a performance bottleneck and the Map type
+  // remembers the insertion order anyway...
+  const nonDeletedSceneElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
+    new Map([...elementsMap].filter((el) => !el[1].isDeleted)),
+  );
+  const elements = Array.from(elementsMap.values());
+  return [
+    getHoveredElementForBinding(
+      tupleToCoors(origStartGlobalPoint),
+      elements,
+      nonDeletedSceneElementsMap,
+      zoom,
+      true,
+    ),
+    getHoveredElementForBinding(
+      tupleToCoors(origEndGlobalPoint),
+      elements,
+      nonDeletedSceneElementsMap,
+      zoom,
+      true,
+    ),
+  ];
+};
+
+const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
+  a[0] === b[0] && a[1] === b[1];

+ 4 - 12
packages/excalidraw/element/flowchart.ts

@@ -452,20 +452,12 @@ const createBindingArrow = (
     bindingArrow as OrderedExcalidrawElement,
   );
 
-  LinearElementEditor.movePoints(
-    bindingArrow,
-    [
-      {
-        index: 1,
-        point: bindingArrow.points[1],
-      },
-    ],
-    elementsMap as NonDeletedSceneElementsMap,
-    undefined,
+  LinearElementEditor.movePoints(bindingArrow, [
     {
-      changedElements,
+      index: 1,
+      point: bindingArrow.points[1],
     },
-  );
+  ]);
 
   return bindingArrow;
 };

+ 31 - 7
packages/excalidraw/element/heading.ts

@@ -11,6 +11,7 @@ import {
   pointScaleFromOrigin,
   radiansToDegrees,
   triangleIncludesPoint,
+  vectorFromPoint,
 } from "../../math";
 import { getCenterForBounds, type Bounds } from "./bounds";
 import type { ExcalidrawBindableElement } from "./types";
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
   return HEADING_UP;
 };
 
+export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
+  p: P,
+  o: P,
+) => vectorToHeading(vectorFromPoint<P>(p, o));
+
+export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
+  p: P,
+  o: P,
+) => headingIsHorizontal(headingForPoint<P>(p, o));
+
 export const compareHeading = (a: Heading, b: Heading) =>
   a[0] === b[0] && a[1] === b[1];
 
+export const headingIsHorizontal = (a: Heading) =>
+  compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
+
+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.
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
 >(
   element: Readonly<ExcalidrawBindableElement>,
   aabb: Readonly<Bounds>,
-  p: Readonly<LocalPoint | GlobalPoint>,
+  p: Readonly<Point>,
 ): Heading => {
   const SEARCH_CONE_MULTIPLIER = 2;
 
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
       element.angle,
     );
 
-    if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
+    if (
+      triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
+    ) {
       return headingForDiamond(top, right);
     } else if (
-      triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
+      triangleIncludesPoint<Point>(
+        [right, bottom, midPoint] as Triangle<Point>,
+        p,
+      )
     ) {
       return headingForDiamond(right, bottom);
     } else if (
-      triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
+      triangleIncludesPoint<Point>(
+        [bottom, left, midPoint] as Triangle<Point>,
+        p,
+      )
     ) {
       return headingForDiamond(bottom, left);
     }
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
     SEARCH_CONE_MULTIPLIER,
   ) as Point;
 
-  return triangleIncludesPoint(
+  return triangleIncludesPoint<Point>(
     [topLeft, topRight, midPoint] as Triangle<Point>,
     p,
   )
     ? HEADING_UP
-    : triangleIncludesPoint(
+    : triangleIncludesPoint<Point>(
         [topRight, bottomRight, midPoint] as Triangle<Point>,
         p,
       )
     ? HEADING_RIGHT
-    : triangleIncludesPoint(
+    : triangleIncludesPoint<Point>(
         [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
         p,
       )

+ 183 - 131
packages/excalidraw/element/linearElementEditor.ts

@@ -7,9 +7,10 @@ import type {
   ExcalidrawTextElementWithContainer,
   ElementsMap,
   NonDeletedSceneElementsMap,
-  OrderedExcalidrawElement,
   FixedPointBinding,
   SceneElementsMap,
+  FixedSegment,
+  ExcalidrawElbowArrowElement,
 } from "./types";
 import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
 import type { Bounds } from "./bounds";
@@ -24,6 +25,7 @@ import type {
   InteractiveCanvasAppState,
   AppClassProperties,
   NullableGridSize,
+  Zoom,
 } from "../types";
 import { mutateElement } from "./mutateElement";
 
@@ -32,7 +34,7 @@ import {
   getHoveredElementForBinding,
   isBindingEnabled,
 } from "./binding";
-import { invariant, toBrandedType, tupleToCoors } from "../utils";
+import { invariant, tupleToCoors } from "../utils";
 import {
   isBindingElement,
   isElbowArrow,
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
 import type { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
 import type { Store } from "../store";
-import { mutateElbowArrow } from "./routing";
 import type Scene from "../scene/Scene";
 import type { Radians } from "../../math";
 import {
@@ -56,6 +57,8 @@ import {
   type GlobalPoint,
   type LocalPoint,
   pointDistance,
+  pointTranslate,
+  vectorFromPoint,
 } from "../../math";
 import {
   getBezierCurveLength,
@@ -65,6 +68,7 @@ import {
   mapIntervalToBezierT,
 } from "../shapes";
 import { getGridPoint } from "../snapping";
+import { headingIsHorizontal, vectorToHeading } from "./heading";
 
 const editorMidPointsCache: {
   version: number | null;
@@ -144,13 +148,13 @@ export class LinearElementEditor {
    * @param id the `elementId` from the instance of this class (so that we can
    *  statically guarantee this method returns an ExcalidrawLinearElement)
    */
-  static getElement(
+  static getElement<T extends ExcalidrawLinearElement>(
     id: InstanceType<typeof LinearElementEditor>["elementId"],
     elementsMap: ElementsMap,
-  ) {
+  ): T | null {
     const element = elementsMap.get(id);
     if (element) {
-      return element as NonDeleted<ExcalidrawLinearElement>;
+      return element as NonDeleted<T>;
     }
     return null;
   }
@@ -291,20 +295,16 @@ export class LinearElementEditor {
           event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
         );
 
-        LinearElementEditor.movePoints(
-          element,
-          [
-            {
-              index: selectedIndex,
-              point: pointFrom(
-                width + referencePoint[0],
-                height + referencePoint[1],
-              ),
-              isDragging: selectedIndex === lastClickedPoint,
-            },
-          ],
-          elementsMap,
-        );
+        LinearElementEditor.movePoints(element, [
+          {
+            index: selectedIndex,
+            point: pointFrom(
+              width + referencePoint[0],
+              height + referencePoint[1],
+            ),
+            isDragging: selectedIndex === lastClickedPoint,
+          },
+        ]);
       } else {
         const newDraggingPointPosition = LinearElementEditor.createPointAt(
           element,
@@ -339,7 +339,6 @@ export class LinearElementEditor {
               isDragging: pointIndex === lastClickedPoint,
             };
           }),
-          elementsMap,
         );
       }
 
@@ -422,19 +421,15 @@ export class LinearElementEditor {
           selectedPoint === element.points.length - 1
         ) {
           if (isPathALoop(element.points, appState.zoom.value)) {
-            LinearElementEditor.movePoints(
-              element,
-              [
-                {
-                  index: selectedPoint,
-                  point:
-                    selectedPoint === 0
-                      ? element.points[element.points.length - 1]
-                      : element.points[0],
-                },
-              ],
-              elementsMap,
-            );
+            LinearElementEditor.movePoints(element, [
+              {
+                index: selectedPoint,
+                point:
+                  selectedPoint === 0
+                    ? element.points[element.points.length - 1]
+                    : element.points[0],
+              },
+            ]);
           }
 
           const bindingElement = isBindingEnabled(appState)
@@ -495,6 +490,7 @@ export class LinearElementEditor {
 
     // Since its not needed outside editor unless 2 pointer lines or bound text
     if (
+      !isElbowArrow(element) &&
       !appState.editingLinearElement &&
       element.points.length > 2 &&
       !boundText
@@ -533,6 +529,7 @@ export class LinearElementEditor {
           element,
           element.points[index],
           element.points[index + 1],
+          index,
           appState.zoom,
         )
       ) {
@@ -573,19 +570,23 @@ export class LinearElementEditor {
       scenePointer.x,
       scenePointer.y,
     );
-    if (clickedPointIndex >= 0) {
+    if (!isElbowArrow(element) && clickedPointIndex >= 0) {
       return null;
     }
     const points = LinearElementEditor.getPointsGlobalCoordinates(
       element,
       elementsMap,
     );
-    if (points.length >= 3 && !appState.editingLinearElement) {
+    if (
+      points.length >= 3 &&
+      !appState.editingLinearElement &&
+      !isElbowArrow(element)
+    ) {
       return null;
     }
 
     const threshold =
-      LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
+      (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
 
     const existingSegmentMidpointHitCoords =
       linearElementEditor.segmentMidPointHoveredCoords;
@@ -604,10 +605,11 @@ export class LinearElementEditor {
     let index = 0;
     const midPoints: typeof editorMidPointsCache["points"] =
       LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
+
     while (index < midPoints.length) {
       if (midPoints[index] !== null) {
         const distance = pointDistance(
-          pointFrom(midPoints[index]![0], midPoints[index]![1]),
+          midPoints[index]!,
           pointFrom(scenePointer.x, scenePointer.y),
         );
         if (distance <= threshold) {
@@ -620,16 +622,25 @@ export class LinearElementEditor {
     return null;
   };
 
-  static isSegmentTooShort(
+  static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
     element: NonDeleted<ExcalidrawLinearElement>,
-    startPoint: GlobalPoint | LocalPoint,
-    endPoint: GlobalPoint | LocalPoint,
-    zoom: AppState["zoom"],
+    startPoint: P,
+    endPoint: P,
+    index: number,
+    zoom: Zoom,
   ) {
-    let distance = pointDistance(
-      pointFrom(startPoint[0], startPoint[1]),
-      pointFrom(endPoint[0], endPoint[1]),
-    );
+    if (isElbowArrow(element)) {
+      if (index >= 0 && index < element.points.length) {
+        return (
+          pointDistance(startPoint, endPoint) * zoom.value <
+          LinearElementEditor.POINT_HANDLE_SIZE / 2
+        );
+      }
+
+      return false;
+    }
+
+    let distance = pointDistance(startPoint, endPoint);
     if (element.points.length > 2 && element.roundness) {
       distance = getBezierCurveLength(element, endPoint);
     }
@@ -748,12 +759,8 @@ export class LinearElementEditor {
         segmentMidpoint,
         elementsMap,
       );
-    }
-    if (event.altKey && appState.editingLinearElement) {
-      if (
-        linearElementEditor.lastUncommittedPoint == null &&
-        !isElbowArrow(element)
-      ) {
+    } else if (event.altKey && appState.editingLinearElement) {
+      if (linearElementEditor.lastUncommittedPoint == null) {
         mutateElement(element, {
           points: [
             ...element.points,
@@ -909,12 +916,7 @@ export class LinearElementEditor {
 
     if (!event.altKey) {
       if (lastPoint === lastUncommittedPoint) {
-        LinearElementEditor.deletePoints(
-          element,
-          [points.length - 1],
-          elementsMap,
-          app.state.zoom,
-        );
+        LinearElementEditor.deletePoints(element, [points.length - 1]);
       }
       return {
         ...appState.editingLinearElement,
@@ -952,23 +954,14 @@ export class LinearElementEditor {
     }
 
     if (lastPoint === lastUncommittedPoint) {
-      LinearElementEditor.movePoints(
-        element,
-        [
-          {
-            index: element.points.length - 1,
-            point: newPoint,
-          },
-        ],
-        elementsMap,
-      );
+      LinearElementEditor.movePoints(element, [
+        {
+          index: element.points.length - 1,
+          point: newPoint,
+        },
+      ]);
     } else {
-      LinearElementEditor.addPoints(
-        element,
-        [{ point: newPoint }],
-        elementsMap,
-        app.state.zoom,
-      );
+      LinearElementEditor.addPoints(element, [{ point: newPoint }]);
     }
     return {
       ...appState.editingLinearElement,
@@ -1197,16 +1190,12 @@ export class LinearElementEditor {
     // potentially expanding the bounding box
     if (pointAddedToEnd) {
       const lastPoint = element.points[element.points.length - 1];
-      LinearElementEditor.movePoints(
-        element,
-        [
-          {
-            index: element.points.length - 1,
-            point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
-          },
-        ],
-        elementsMap,
-      );
+      LinearElementEditor.movePoints(element, [
+        {
+          index: element.points.length - 1,
+          point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
+        },
+      ]);
     }
 
     return {
@@ -1221,8 +1210,6 @@ export class LinearElementEditor {
   static deletePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     pointIndices: readonly number[],
-    elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-    zoom: AppState["zoom"],
   ) {
     let offsetX = 0;
     let offsetY = 0;
@@ -1252,47 +1239,27 @@ export class LinearElementEditor {
       return acc;
     }, []);
 
-    LinearElementEditor._updatePoints(
-      element,
-      nextPoints,
-      offsetX,
-      offsetY,
-      elementsMap,
-    );
+    LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
   }
 
   static addPoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     targetPoints: { point: LocalPoint }[],
-    elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-    zoom: AppState["zoom"],
   ) {
     const offsetX = 0;
     const offsetY = 0;
 
     const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
-    LinearElementEditor._updatePoints(
-      element,
-      nextPoints,
-      offsetX,
-      offsetY,
-      elementsMap,
-    );
+    LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
   }
 
   static movePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
-    elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
     otherUpdates?: {
       startBinding?: PointBinding | null;
       endBinding?: PointBinding | null;
     },
-    options?: {
-      changedElements?: Map<string, OrderedExcalidrawElement>;
-      isDragging?: boolean;
-      zoom?: AppState["zoom"];
-    },
   ) {
     const { points } = element;
 
@@ -1335,7 +1302,6 @@ export class LinearElementEditor {
       nextPoints,
       offsetX,
       offsetY,
-      elementsMap,
       otherUpdates,
       {
         isDragging: targetPoints.reduce(
@@ -1343,8 +1309,6 @@ export class LinearElementEditor {
             dragging || targetPoint.isDragging === true,
           false,
         ),
-        changedElements: options?.changedElements,
-        zoom: options?.zoom,
       },
     );
   }
@@ -1451,54 +1415,49 @@ export class LinearElementEditor {
     nextPoints: readonly LocalPoint[],
     offsetX: number,
     offsetY: number,
-    elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
     otherUpdates?: {
       startBinding?: PointBinding | null;
       endBinding?: PointBinding | null;
     },
     options?: {
-      changedElements?: Map<string, OrderedExcalidrawElement>;
       isDragging?: boolean;
       zoom?: AppState["zoom"];
     },
   ) {
     if (isElbowArrow(element)) {
-      const bindings: {
+      const updates: {
         startBinding?: FixedPointBinding | null;
         endBinding?: FixedPointBinding | null;
+        points?: LocalPoint[];
       } = {};
       if (otherUpdates?.startBinding !== undefined) {
-        bindings.startBinding =
+        updates.startBinding =
           otherUpdates.startBinding !== null &&
           isFixedPointBinding(otherUpdates.startBinding)
             ? otherUpdates.startBinding
             : null;
       }
       if (otherUpdates?.endBinding !== undefined) {
-        bindings.endBinding =
+        updates.endBinding =
           otherUpdates.endBinding !== null &&
           isFixedPointBinding(otherUpdates.endBinding)
             ? otherUpdates.endBinding
             : null;
       }
 
-      const mergedElementsMap = options?.changedElements
-        ? toBrandedType<SceneElementsMap>(
-            new Map([...elementsMap, ...options.changedElements]),
-          )
-        : elementsMap;
-
-      mutateElbowArrow(
-        element,
-        mergedElementsMap,
-        nextPoints,
+      updates.points = Array.from(nextPoints);
+      updates.points[0] = pointTranslate(
+        updates.points[0],
+        vector(offsetX, offsetY),
+      );
+      updates.points[updates.points.length - 1] = pointTranslate(
+        updates.points[updates.points.length - 1],
         vector(offsetX, offsetY),
-        bindings,
-        {
-          isDragging: options?.isDragging,
-          zoom: options?.zoom,
-        },
       );
+
+      mutateElement(element, updates, true, {
+        isDragging: options?.isDragging,
+      });
     } else {
       const nextCoords = getElementPointsCoords(element, nextPoints);
       const prevCoords = getElementPointsCoords(element, element.points);
@@ -1773,6 +1732,99 @@ export class LinearElementEditor {
 
     return coords;
   };
+
+  static moveFixedSegment(
+    linearElement: LinearElementEditor,
+    index: number,
+    x: number,
+    y: number,
+    elementsMap: ElementsMap,
+  ): LinearElementEditor {
+    const element = LinearElementEditor.getElement(
+      linearElement.elementId,
+      elementsMap,
+    );
+
+    if (!element || !isElbowArrow(element)) {
+      return linearElement;
+    }
+
+    if (index && index > 0 && index < element.points.length) {
+      const isHorizontal = headingIsHorizontal(
+        vectorToHeading(
+          vectorFromPoint(element.points[index], element.points[index - 1]),
+        ),
+      );
+
+      const fixedSegments = (element.fixedSegments ?? []).reduce(
+        (segments, s) => {
+          segments[s.index] = s;
+          return segments;
+        },
+        {} as Record<number, FixedSegment>,
+      );
+      fixedSegments[index] = {
+        index,
+        start: pointFrom<LocalPoint>(
+          !isHorizontal ? x - element.x : element.points[index - 1][0],
+          isHorizontal ? y - element.y : element.points[index - 1][1],
+        ),
+        end: pointFrom<LocalPoint>(
+          !isHorizontal ? x - element.x : element.points[index][0],
+          isHorizontal ? y - element.y : element.points[index][1],
+        ),
+      };
+      const nextFixedSegments = Object.values(fixedSegments).sort(
+        (a, b) => a.index - b.index,
+      );
+
+      const offset = nextFixedSegments
+        .map((segment) => segment.index)
+        .reduce((count, idx) => (idx < index ? count + 1 : count), 0);
+
+      mutateElement(element, {
+        fixedSegments: nextFixedSegments,
+      });
+
+      const point = pointFrom<GlobalPoint>(
+        element.x +
+          (element.fixedSegments![offset].start[0] +
+            element.fixedSegments![offset].end[0]) /
+            2,
+        element.y +
+          (element.fixedSegments![offset].start[1] +
+            element.fixedSegments![offset].end[1]) /
+            2,
+      );
+
+      return {
+        ...linearElement,
+        segmentMidPointHoveredCoords: point,
+        pointerDownState: {
+          ...linearElement.pointerDownState,
+          segmentMidpoint: {
+            added: false,
+            index: element.fixedSegments![offset].index,
+            value: point,
+          },
+        },
+      };
+    }
+
+    return linearElement;
+  }
+
+  static deleteFixedSegment(
+    element: ExcalidrawElbowArrowElement,
+    index: number,
+  ): void {
+    mutateElement(element, {
+      fixedSegments: element.fixedSegments?.filter(
+        (segment) => segment.index !== index,
+      ),
+    });
+    mutateElement(element, {}, true);
+  }
 }
 
 const normalizeSelectedPoints = (

+ 42 - 4
packages/excalidraw/element/mutateElement.ts

@@ -1,10 +1,13 @@
-import type { ExcalidrawElement } from "./types";
+import type { ExcalidrawElement, SceneElementsMap } from "./types";
 import Scene from "../scene/Scene";
 import { getSizeFromPoints } from "../points";
 import { randomInteger } from "../random";
-import { getUpdatedTimestamp } from "../utils";
+import { getUpdatedTimestamp, toBrandedType } from "../utils";
 import type { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
+import { isElbowArrow } from "./typeChecks";
+import { updateElbowArrowPoints } from "./elbowArrow";
+import type { Radians } from "../../math";
 
 export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
   Partial<TElement>,
@@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
   element: TElement,
   updates: ElementUpdate<TElement>,
   informMutation = true,
+  options?: {
+    // Currently only for elbow arrows.
+    // If true, the elbow arrow tries to bind to the nearest element. If false
+    // it tries to keep the same bound element, if any.
+    isDragging?: boolean;
+  },
 ): TElement => {
   let didChange = false;
 
   // casting to any because can't use `in` operator
   // (see https://github.com/microsoft/TypeScript/issues/21732)
-  const { points, fileId } = updates as any;
+  const { points, fixedSegments, fileId } = updates as any;
 
-  if (typeof points !== "undefined") {
+  if (
+    isElbowArrow(element) &&
+    (Object.keys(updates).length === 0 || // normalization case
+      typeof points !== "undefined" || // repositioning
+      typeof fixedSegments !== "undefined") // segment fixing
+  ) {
+    const elementsMap = toBrandedType<SceneElementsMap>(
+      Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
+    );
+
+    updates = {
+      ...updates,
+      angle: 0 as Radians,
+      ...updateElbowArrowPoints(
+        {
+          ...element,
+          x: updates.x || element.x,
+          y: updates.y || element.y,
+        },
+        elementsMap,
+        {
+          fixedSegments,
+          points,
+        },
+        {
+          isDragging: options?.isDragging,
+        },
+      ),
+    };
+  } else if (typeof points !== "undefined") {
     updates = { ...getSizeFromPoints(points), ...updates };
   }
 

+ 28 - 5
packages/excalidraw/element/newElement.ts

@@ -18,6 +18,8 @@ import type {
   ExcalidrawIframeElement,
   ElementsMap,
   ExcalidrawArrowElement,
+  FixedSegment,
+  ExcalidrawElbowArrowElement,
 } from "./types";
 import {
   arrayToMap,
@@ -450,15 +452,34 @@ export const newLinearElement = (
   };
 };
 
-export const newArrowElement = (
+export const newArrowElement = <T extends boolean>(
   opts: {
     type: ExcalidrawArrowElement["type"];
     startArrowhead?: Arrowhead | null;
     endArrowhead?: Arrowhead | null;
     points?: ExcalidrawArrowElement["points"];
-    elbowed?: boolean;
+    elbowed?: T;
+    fixedSegments?: FixedSegment[] | null;
   } & ElementConstructorOpts,
-): NonDeleted<ExcalidrawArrowElement> => {
+): T extends true
+  ? NonDeleted<ExcalidrawElbowArrowElement>
+  : NonDeleted<ExcalidrawArrowElement> => {
+  if (opts.elbowed) {
+    return {
+      ..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
+      points: opts.points || [],
+      lastCommittedPoint: null,
+      startBinding: null,
+      endBinding: null,
+      startArrowhead: opts.startArrowhead || null,
+      endArrowhead: opts.endArrowhead || null,
+      elbowed: true,
+      fixedSegments: opts.fixedSegments || [],
+      startIsSpecial: false,
+      endIsSpecial: false,
+    } as NonDeleted<ExcalidrawElbowArrowElement>;
+  }
+
   return {
     ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
     points: opts.points || [],
@@ -467,8 +488,10 @@ export const newArrowElement = (
     endBinding: null,
     startArrowhead: opts.startArrowhead || null,
     endArrowhead: opts.endArrowhead || null,
-    elbowed: opts.elbowed || false,
-  };
+    elbowed: false,
+  } as T extends true
+    ? NonDeleted<ExcalidrawElbowArrowElement>
+    : NonDeleted<ExcalidrawArrowElement>;
 };
 
 export const newImageElement = (

+ 52 - 7
packages/excalidraw/element/resizeElements.ts

@@ -10,6 +10,7 @@ import type {
   ExcalidrawImageElement,
   ElementsMap,
   SceneElementsMap,
+  ExcalidrawElbowArrowElement,
 } from "./types";
 import type { Mutable } from "../utility-types";
 import {
@@ -53,7 +54,6 @@ import {
 import { wrapText } from "./textWrapping";
 import { LinearElementEditor } from "./linearElementEditor";
 import { isInGroup } from "../groups";
-import { mutateElbowArrow } from "./routing";
 import type { GlobalPoint } from "../../math";
 import {
   pointCenter,
@@ -177,10 +177,10 @@ export const transformElements = (
         elementsMap,
         transformHandleType,
         scene,
+        originalElements,
         {
           shouldResizeFromCenter,
           shouldMaintainAspectRatio,
-          originalElementsMap: originalElements,
           flipByX,
           flipByY,
           nextWidth,
@@ -531,8 +531,10 @@ const rotateMultipleElements = (
       );
 
       if (isElbowArrow(element)) {
-        const points = getArrowLocalFixedPoints(element, elementsMap);
-        mutateElbowArrow(element, elementsMap, points);
+        // Needed to re-route the arrow
+        mutateElement(element, {
+          points: getArrowLocalFixedPoints(element, elementsMap),
+        });
       } else {
         mutateElement(
           element,
@@ -1201,6 +1203,7 @@ export const resizeMultipleElements = (
   elementsMap: ElementsMap,
   handleDirection: TransformHandleDirection,
   scene: Scene,
+  originalElementsMap: ElementsMap,
   {
     shouldMaintainAspectRatio = false,
     shouldResizeFromCenter = false,
@@ -1208,7 +1211,6 @@ export const resizeMultipleElements = (
     flipByY = false,
     nextHeight,
     nextWidth,
-    originalElementsMap,
     originalBoundingBox,
   }: {
     nextWidth?: number;
@@ -1217,7 +1219,6 @@ export const resizeMultipleElements = (
     shouldResizeFromCenter?: boolean;
     flipByX?: boolean;
     flipByY?: boolean;
-    originalElementsMap?: ElementsMap;
     // added to improve performance
     originalBoundingBox?: BoundingBox;
   } = {},
@@ -1387,6 +1388,9 @@ export const resizeMultipleElements = (
         fontSize?: ExcalidrawTextElement["fontSize"];
         scale?: ExcalidrawImageElement["scale"];
         boundTextFontSize?: ExcalidrawTextElement["fontSize"];
+        startBinding?: ExcalidrawElbowArrowElement["startBinding"];
+        endBinding?: ExcalidrawElbowArrowElement["endBinding"];
+        fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
       };
     }[] = [];
 
@@ -1427,6 +1431,44 @@ export const resizeMultipleElements = (
         ...rescaledPoints,
       };
 
+      if (isElbowArrow(orig)) {
+        // Mirror fixed point binding for elbow arrows
+        // when resize goes into the negative direction
+        if (orig.startBinding) {
+          update.startBinding = {
+            ...orig.startBinding,
+            fixedPoint: [
+              flipByX
+                ? -orig.startBinding.fixedPoint[0] + 1
+                : orig.startBinding.fixedPoint[0],
+              flipByY
+                ? -orig.startBinding.fixedPoint[1] + 1
+                : orig.startBinding.fixedPoint[1],
+            ],
+          };
+        }
+        if (orig.endBinding) {
+          update.endBinding = {
+            ...orig.endBinding,
+            fixedPoint: [
+              flipByX
+                ? -orig.endBinding.fixedPoint[0] + 1
+                : orig.endBinding.fixedPoint[0],
+              flipByY
+                ? -orig.endBinding.fixedPoint[1] + 1
+                : orig.endBinding.fixedPoint[1],
+            ],
+          };
+        }
+        if (orig.fixedSegments && rescaledPoints.points) {
+          update.fixedSegments = orig.fixedSegments.map((segment) => ({
+            ...segment,
+            start: rescaledPoints.points[segment.index - 1],
+            end: rescaledPoints.points[segment.index],
+          }));
+        }
+      }
+
       if (isImageElement(orig)) {
         update.scale = [
           orig.scale[0] * flipFactorX,
@@ -1472,7 +1514,10 @@ export const resizeMultipleElements = (
     } of elementsAndUpdates) {
       const { width, height, angle } = update;
 
-      mutateElement(element, update, false);
+      mutateElement(element, update, false, {
+        // needed for the fixed binding point udpate to take effect
+        isDragging: true,
+      });
 
       updateBoundElements(element, elementsMap as SceneElementsMap, {
         simultaneouslyUpdated: elementsToUpdate,

+ 0 - 1110
packages/excalidraw/element/routing.ts

@@ -1,1110 +0,0 @@
-import type { Radians } from "../../math";
-import {
-  pointFrom,
-  pointScaleFromOrigin,
-  pointTranslate,
-  vector,
-  vectorCross,
-  vectorFromPoint,
-  vectorScale,
-  type GlobalPoint,
-  type LocalPoint,
-  type Vector,
-} from "../../math";
-import BinaryHeap from "../binaryheap";
-import { getSizeFromPoints } from "../points";
-import { aabbForElement, pointInsideBounds } from "../shapes";
-import type { AppState } from "../types";
-import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
-import {
-  bindPointToSnapToElementOutline,
-  distanceToBindableElement,
-  avoidRectangularCorner,
-  getHoveredElementForBinding,
-  FIXED_BINDING_DISTANCE,
-  getHeadingForElbowArrowSnap,
-  getGlobalFixedPointForBindableElement,
-  snapToMid,
-} from "./binding";
-import type { Bounds } from "./bounds";
-import type { Heading } from "./heading";
-import {
-  compareHeading,
-  flipHeading,
-  HEADING_DOWN,
-  HEADING_LEFT,
-  HEADING_RIGHT,
-  HEADING_UP,
-  vectorToHeading,
-} from "./heading";
-import type { ElementUpdate } from "./mutateElement";
-import { mutateElement } from "./mutateElement";
-import { isBindableElement, isRectanguloidElement } from "./typeChecks";
-import type {
-  ExcalidrawElbowArrowElement,
-  NonDeletedSceneElementsMap,
-  SceneElementsMap,
-} from "./types";
-import type { ElementsMap, ExcalidrawBindableElement } from "./types";
-
-type GridAddress = [number, number] & { _brand: "gridaddress" };
-
-type Node = {
-  f: number;
-  g: number;
-  h: number;
-  closed: boolean;
-  visited: boolean;
-  parent: Node | null;
-  pos: GlobalPoint;
-  addr: GridAddress;
-};
-
-type Grid = {
-  row: number;
-  col: number;
-  data: (Node | null)[];
-};
-
-const BASE_PADDING = 40;
-
-export const mutateElbowArrow = (
-  arrow: ExcalidrawElbowArrowElement,
-  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  nextPoints: readonly LocalPoint[],
-  offset?: Vector,
-  otherUpdates?: Omit<
-    ElementUpdate<ExcalidrawElbowArrowElement>,
-    "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
-  >,
-  options?: {
-    isDragging?: boolean;
-    informMutation?: boolean;
-    zoom?: AppState["zoom"];
-  },
-) => {
-  const update = updateElbowArrow(
-    arrow,
-    elementsMap,
-    nextPoints,
-    offset,
-    options,
-  );
-  if (update) {
-    mutateElement(
-      arrow,
-      {
-        ...otherUpdates,
-        ...update,
-        angle: 0 as Radians,
-      },
-      options?.informMutation,
-    );
-  } else {
-    console.error("Elbow arrow cannot find a route");
-  }
-};
-
-export const updateElbowArrow = (
-  arrow: ExcalidrawElbowArrowElement,
-  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  nextPoints: readonly LocalPoint[],
-  offset?: Vector,
-  options?: {
-    isDragging?: boolean;
-    disableBinding?: boolean;
-    informMutation?: boolean;
-    zoom?: AppState["zoom"];
-  },
-): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
-  const origStartGlobalPoint: GlobalPoint = pointTranslate(
-    pointTranslate<LocalPoint, GlobalPoint>(
-      nextPoints[0],
-      vector(arrow.x, arrow.y),
-    ),
-    offset,
-  );
-  const origEndGlobalPoint: GlobalPoint = pointTranslate(
-    pointTranslate<LocalPoint, GlobalPoint>(
-      nextPoints[nextPoints.length - 1],
-      vector(arrow.x, arrow.y),
-    ),
-    offset,
-  );
-
-  const startElement =
-    arrow.startBinding &&
-    getBindableElementForId(arrow.startBinding.elementId, elementsMap);
-  const endElement =
-    arrow.endBinding &&
-    getBindableElementForId(arrow.endBinding.elementId, elementsMap);
-  const [hoveredStartElement, hoveredEndElement] = options?.isDragging
-    ? getHoveredElements(
-        origStartGlobalPoint,
-        origEndGlobalPoint,
-        elementsMap,
-        options?.zoom,
-      )
-    : [startElement, endElement];
-  const startGlobalPoint = getGlobalPoint(
-    arrow.startBinding?.fixedPoint,
-    origStartGlobalPoint,
-    origEndGlobalPoint,
-    elementsMap,
-    startElement,
-    hoveredStartElement,
-    options?.isDragging,
-  );
-  const endGlobalPoint = getGlobalPoint(
-    arrow.endBinding?.fixedPoint,
-    origEndGlobalPoint,
-    origStartGlobalPoint,
-    elementsMap,
-    endElement,
-    hoveredEndElement,
-    options?.isDragging,
-  );
-  const startHeading = getBindPointHeading(
-    startGlobalPoint,
-    endGlobalPoint,
-    elementsMap,
-    hoveredStartElement,
-    origStartGlobalPoint,
-  );
-  const endHeading = getBindPointHeading(
-    endGlobalPoint,
-    startGlobalPoint,
-    elementsMap,
-    hoveredEndElement,
-    origEndGlobalPoint,
-  );
-  const startPointBounds = [
-    startGlobalPoint[0] - 2,
-    startGlobalPoint[1] - 2,
-    startGlobalPoint[0] + 2,
-    startGlobalPoint[1] + 2,
-  ] as Bounds;
-  const endPointBounds = [
-    endGlobalPoint[0] - 2,
-    endGlobalPoint[1] - 2,
-    endGlobalPoint[0] + 2,
-    endGlobalPoint[1] + 2,
-  ] as Bounds;
-  const startElementBounds = hoveredStartElement
-    ? aabbForElement(
-        hoveredStartElement,
-        offsetFromHeading(
-          startHeading,
-          arrow.startArrowhead
-            ? FIXED_BINDING_DISTANCE * 6
-            : FIXED_BINDING_DISTANCE * 2,
-          1,
-        ),
-      )
-    : startPointBounds;
-  const endElementBounds = hoveredEndElement
-    ? aabbForElement(
-        hoveredEndElement,
-        offsetFromHeading(
-          endHeading,
-          arrow.endArrowhead
-            ? FIXED_BINDING_DISTANCE * 6
-            : FIXED_BINDING_DISTANCE * 2,
-          1,
-        ),
-      )
-    : endPointBounds;
-  const boundsOverlap =
-    pointInsideBounds(
-      startGlobalPoint,
-      hoveredEndElement
-        ? aabbForElement(
-            hoveredEndElement,
-            offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
-          )
-        : endPointBounds,
-    ) ||
-    pointInsideBounds(
-      endGlobalPoint,
-      hoveredStartElement
-        ? aabbForElement(
-            hoveredStartElement,
-            offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
-          )
-        : startPointBounds,
-    );
-  const commonBounds = commonAABB(
-    boundsOverlap
-      ? [startPointBounds, endPointBounds]
-      : [startElementBounds, endElementBounds],
-  );
-  const dynamicAABBs = generateDynamicAABBs(
-    boundsOverlap ? startPointBounds : startElementBounds,
-    boundsOverlap ? endPointBounds : endElementBounds,
-    commonBounds,
-    boundsOverlap
-      ? offsetFromHeading(
-          startHeading,
-          !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
-          0,
-        )
-      : offsetFromHeading(
-          startHeading,
-          !hoveredStartElement && !hoveredEndElement
-            ? 0
-            : BASE_PADDING -
-                (arrow.startArrowhead
-                  ? FIXED_BINDING_DISTANCE * 6
-                  : FIXED_BINDING_DISTANCE * 2),
-          BASE_PADDING,
-        ),
-    boundsOverlap
-      ? offsetFromHeading(
-          endHeading,
-          !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
-          0,
-        )
-      : offsetFromHeading(
-          endHeading,
-          !hoveredStartElement && !hoveredEndElement
-            ? 0
-            : BASE_PADDING -
-                (arrow.endArrowhead
-                  ? FIXED_BINDING_DISTANCE * 6
-                  : FIXED_BINDING_DISTANCE * 2),
-          BASE_PADDING,
-        ),
-    boundsOverlap,
-    hoveredStartElement && aabbForElement(hoveredStartElement),
-    hoveredEndElement && aabbForElement(hoveredEndElement),
-  );
-  const startDonglePosition = getDonglePosition(
-    dynamicAABBs[0],
-    startHeading,
-    startGlobalPoint,
-  );
-  const endDonglePosition = getDonglePosition(
-    dynamicAABBs[1],
-    endHeading,
-    endGlobalPoint,
-  );
-
-  // Canculate Grid positions
-  const grid = calculateGrid(
-    dynamicAABBs,
-    startDonglePosition ? startDonglePosition : startGlobalPoint,
-    startHeading,
-    endDonglePosition ? endDonglePosition : endGlobalPoint,
-    endHeading,
-    commonBounds,
-  );
-
-  const startDongle =
-    startDonglePosition && pointToGridNode(startDonglePosition, grid);
-  const endDongle =
-    endDonglePosition && pointToGridNode(endDonglePosition, grid);
-
-  // Do not allow stepping on the true end or true start points
-  const endNode = pointToGridNode(endGlobalPoint, grid);
-  if (endNode && hoveredEndElement) {
-    endNode.closed = true;
-  }
-  const startNode = pointToGridNode(startGlobalPoint, grid);
-  if (startNode && arrow.startBinding) {
-    startNode.closed = true;
-  }
-  const dongleOverlap =
-    startDongle &&
-    endDongle &&
-    (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) ||
-      pointInsideBounds(endDongle.pos, dynamicAABBs[0]));
-
-  // Create path to end dongle from start dongle
-  const path = astar(
-    startDongle ? startDongle : startNode!,
-    endDongle ? endDongle : endNode!,
-    grid,
-    startHeading ? startHeading : HEADING_RIGHT,
-    endHeading ? endHeading : HEADING_RIGHT,
-    dongleOverlap ? [] : dynamicAABBs,
-  );
-
-  if (path) {
-    const points = path.map((node) => [
-      node.pos[0],
-      node.pos[1],
-    ]) as GlobalPoint[];
-    startDongle && points.unshift(startGlobalPoint);
-    endDongle && points.push(endGlobalPoint);
-
-    return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
-  }
-
-  return null;
-};
-
-const offsetFromHeading = (
-  heading: Heading,
-  head: number,
-  side: number,
-): [number, number, number, number] => {
-  switch (heading) {
-    case HEADING_UP:
-      return [head, side, side, side];
-    case HEADING_RIGHT:
-      return [side, head, side, side];
-    case HEADING_DOWN:
-      return [side, side, head, side];
-  }
-
-  return [side, side, side, head];
-};
-
-/**
- * Routing algorithm based on the A* path search algorithm.
- * @see https://www.geeksforgeeks.org/a-search-algorithm/
- *
- * Binary heap is used to optimize node lookup.
- * See {@link calculateGrid} for the grid calculation details.
- *
- * Additional modifications added due to aesthetic route reasons:
- * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier)
- * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment
- */
-const astar = (
-  start: Node,
-  end: Node,
-  grid: Grid,
-  startHeading: Heading,
-  endHeading: Heading,
-  aabbs: Bounds[],
-) => {
-  const bendMultiplier = m_dist(start.pos, end.pos);
-  const open = new BinaryHeap<Node>((node) => node.f);
-
-  open.push(start);
-
-  while (open.size() > 0) {
-    // Grab the lowest f(x) to process next.  Heap keeps this sorted for us.
-    const current = open.pop();
-
-    if (!current || current.closed) {
-      // Current is not passable, continue with next element
-      continue;
-    }
-
-    // End case -- result has been found, return the traced path.
-    if (current === end) {
-      return pathTo(start, current);
-    }
-
-    // Normal case -- move current from open to closed, process each of its neighbors.
-    current.closed = true;
-
-    // Find all neighbors for the current node.
-    const neighbors = getNeighbors(current.addr, grid);
-
-    for (let i = 0; i < 4; i++) {
-      const neighbor = neighbors[i];
-
-      if (!neighbor || neighbor.closed) {
-        // Not a valid node to process, skip to next neighbor.
-        continue;
-      }
-
-      // Intersect
-      const neighborHalfPoint = pointScaleFromOrigin(
-        neighbor.pos,
-        current.pos,
-        0.5,
-      );
-      if (
-        isAnyTrue(
-          ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)),
-        )
-      ) {
-        continue;
-      }
-
-      // The g score is the shortest distance from start to current node.
-      // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
-      const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
-      const previousDirection = current.parent
-        ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
-        : startHeading;
-
-      // Do not allow going in reverse
-      const reverseHeading = flipHeading(previousDirection);
-      const neighborIsReverseRoute =
-        compareHeading(reverseHeading, neighborHeading) ||
-        (gridAddressesEqual(start.addr, neighbor.addr) &&
-          compareHeading(neighborHeading, startHeading)) ||
-        (gridAddressesEqual(end.addr, neighbor.addr) &&
-          compareHeading(neighborHeading, endHeading));
-      if (neighborIsReverseRoute) {
-        continue;
-      }
-
-      const directionChange = previousDirection !== neighborHeading;
-      const gScore =
-        current.g +
-        m_dist(neighbor.pos, current.pos) +
-        (directionChange ? Math.pow(bendMultiplier, 3) : 0);
-
-      const beenVisited = neighbor.visited;
-
-      if (!beenVisited || gScore < neighbor.g) {
-        const estBendCount = estimateSegmentCount(
-          neighbor,
-          end,
-          neighborHeading,
-          endHeading,
-        );
-        // Found an optimal (so far) path to this node.  Take score for node to see how good it is.
-        neighbor.visited = true;
-        neighbor.parent = current;
-        neighbor.h =
-          m_dist(end.pos, neighbor.pos) +
-          estBendCount * Math.pow(bendMultiplier, 2);
-        neighbor.g = gScore;
-        neighbor.f = neighbor.g + neighbor.h;
-        if (!beenVisited) {
-          // Pushing to heap will put it in proper place based on the 'f' value.
-          open.push(neighbor);
-        } else {
-          // Already seen the node, but since it has been rescored we need to reorder it in the heap
-          open.rescoreElement(neighbor);
-        }
-      }
-    }
-  }
-
-  return null;
-};
-
-const pathTo = (start: Node, node: Node) => {
-  let curr = node;
-  const path = [];
-  while (curr.parent) {
-    path.unshift(curr);
-    curr = curr.parent;
-  }
-  path.unshift(start);
-
-  return path;
-};
-
-const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
-  Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
-
-/**
- * Create dynamically resizing, always touching
- * bounding boxes having a minimum extent represented
- * by the given static bounds.
- */
-const generateDynamicAABBs = (
-  a: Bounds,
-  b: Bounds,
-  common: Bounds,
-  startDifference?: [number, number, number, number],
-  endDifference?: [number, number, number, number],
-  disableSideHack?: boolean,
-  startElementBounds?: Bounds | null,
-  endElementBounds?: Bounds | null,
-): Bounds[] => {
-  const startEl = startElementBounds ?? a;
-  const endEl = endElementBounds ?? b;
-  const [startUp, startRight, startDown, startLeft] = startDifference ?? [
-    0, 0, 0, 0,
-  ];
-  const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0];
-
-  const first = [
-    a[0] > b[2]
-      ? a[1] > b[3] || a[3] < b[1]
-        ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
-        : (startEl[0] + endEl[2]) / 2
-      : a[0] > b[0]
-      ? a[0] - startLeft
-      : common[0] - startLeft,
-    a[1] > b[3]
-      ? a[0] > b[2] || a[2] < b[0]
-        ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
-        : (startEl[1] + endEl[3]) / 2
-      : a[1] > b[1]
-      ? a[1] - startUp
-      : common[1] - startUp,
-    a[2] < b[0]
-      ? a[1] > b[3] || a[3] < b[1]
-        ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
-        : (startEl[2] + endEl[0]) / 2
-      : a[2] < b[2]
-      ? a[2] + startRight
-      : common[2] + startRight,
-    a[3] < b[1]
-      ? a[0] > b[2] || a[2] < b[0]
-        ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
-        : (startEl[3] + endEl[1]) / 2
-      : a[3] < b[3]
-      ? a[3] + startDown
-      : common[3] + startDown,
-  ] as Bounds;
-  const second = [
-    b[0] > a[2]
-      ? b[1] > a[3] || b[3] < a[1]
-        ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
-        : (endEl[0] + startEl[2]) / 2
-      : b[0] > a[0]
-      ? b[0] - endLeft
-      : common[0] - endLeft,
-    b[1] > a[3]
-      ? b[0] > a[2] || b[2] < a[0]
-        ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
-        : (endEl[1] + startEl[3]) / 2
-      : b[1] > a[1]
-      ? b[1] - endUp
-      : common[1] - endUp,
-    b[2] < a[0]
-      ? b[1] > a[3] || b[3] < a[1]
-        ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
-        : (endEl[2] + startEl[0]) / 2
-      : b[2] < a[2]
-      ? b[2] + endRight
-      : common[2] + endRight,
-    b[3] < a[1]
-      ? b[0] > a[2] || b[2] < a[0]
-        ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
-        : (endEl[3] + startEl[1]) / 2
-      : b[3] < a[3]
-      ? b[3] + endDown
-      : common[3] + endDown,
-  ] as Bounds;
-
-  const c = commonAABB([first, second]);
-  if (
-    !disableSideHack &&
-    first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 &&
-    first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001
-  ) {
-    const [endCenterX, endCenterY] = [
-      (second[0] + second[2]) / 2,
-      (second[1] + second[3]) / 2,
-    ];
-    if (b[0] > a[2] && a[1] > b[3]) {
-      // BOTTOM LEFT
-      const cX = first[2] + (second[0] - first[2]) / 2;
-      const cY = second[3] + (first[1] - second[3]) / 2;
-
-      if (
-        vectorCross(
-          vector(a[2] - endCenterX, a[1] - endCenterY),
-          vector(a[0] - endCenterX, a[3] - endCenterY),
-        ) > 0
-      ) {
-        return [
-          [first[0], first[1], cX, first[3]],
-          [cX, second[1], second[2], second[3]],
-        ];
-      }
-
-      return [
-        [first[0], cY, first[2], first[3]],
-        [second[0], second[1], second[2], cY],
-      ];
-    } else if (a[2] < b[0] && a[3] < b[1]) {
-      // TOP LEFT
-      const cX = first[2] + (second[0] - first[2]) / 2;
-      const cY = first[3] + (second[1] - first[3]) / 2;
-
-      if (
-        vectorCross(
-          vector(a[0] - endCenterX, a[1] - endCenterY),
-          vector(a[2] - endCenterX, a[3] - endCenterY),
-        ) > 0
-      ) {
-        return [
-          [first[0], first[1], first[2], cY],
-          [second[0], cY, second[2], second[3]],
-        ];
-      }
-
-      return [
-        [first[0], first[1], cX, first[3]],
-        [cX, second[1], second[2], second[3]],
-      ];
-    } else if (a[0] > b[2] && a[3] < b[1]) {
-      // TOP RIGHT
-      const cX = second[2] + (first[0] - second[2]) / 2;
-      const cY = first[3] + (second[1] - first[3]) / 2;
-
-      if (
-        vectorCross(
-          vector(a[2] - endCenterX, a[1] - endCenterY),
-          vector(a[0] - endCenterX, a[3] - endCenterY),
-        ) > 0
-      ) {
-        return [
-          [cX, first[1], first[2], first[3]],
-          [second[0], second[1], cX, second[3]],
-        ];
-      }
-
-      return [
-        [first[0], first[1], first[2], cY],
-        [second[0], cY, second[2], second[3]],
-      ];
-    } else if (a[0] > b[2] && a[1] > b[3]) {
-      // BOTTOM RIGHT
-      const cX = second[2] + (first[0] - second[2]) / 2;
-      const cY = second[3] + (first[1] - second[3]) / 2;
-
-      if (
-        vectorCross(
-          vector(a[0] - endCenterX, a[1] - endCenterY),
-          vector(a[2] - endCenterX, a[3] - endCenterY),
-        ) > 0
-      ) {
-        return [
-          [cX, first[1], first[2], first[3]],
-          [second[0], second[1], cX, second[3]],
-        ];
-      }
-
-      return [
-        [first[0], cY, first[2], first[3]],
-        [second[0], second[1], second[2], cY],
-      ];
-    }
-  }
-
-  return [first, second];
-};
-
-/**
- * Calculates the grid which is used as nodes at
- * the grid line intersections by the A* algorithm.
- *
- * NOTE: This is not a uniform grid. It is built at
- * various intersections of bounding boxes.
- */
-const calculateGrid = (
-  aabbs: Bounds[],
-  start: GlobalPoint,
-  startHeading: Heading,
-  end: GlobalPoint,
-  endHeading: Heading,
-  common: Bounds,
-): Grid => {
-  const horizontal = new Set<number>();
-  const vertical = new Set<number>();
-
-  if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) {
-    vertical.add(start[1]);
-  } else {
-    horizontal.add(start[0]);
-  }
-  if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) {
-    vertical.add(end[1]);
-  } else {
-    horizontal.add(end[0]);
-  }
-
-  aabbs.forEach((aabb) => {
-    horizontal.add(aabb[0]);
-    horizontal.add(aabb[2]);
-    vertical.add(aabb[1]);
-    vertical.add(aabb[3]);
-  });
-
-  horizontal.add(common[0]);
-  horizontal.add(common[2]);
-  vertical.add(common[1]);
-  vertical.add(common[3]);
-
-  const _vertical = Array.from(vertical).sort((a, b) => a - b);
-  const _horizontal = Array.from(horizontal).sort((a, b) => a - b);
-
-  return {
-    row: _vertical.length,
-    col: _horizontal.length,
-    data: _vertical.flatMap((y, row) =>
-      _horizontal.map(
-        (x, col): Node => ({
-          f: 0,
-          g: 0,
-          h: 0,
-          closed: false,
-          visited: false,
-          parent: null,
-          addr: [col, row] as GridAddress,
-          pos: [x, y] as GlobalPoint,
-        }),
-      ),
-    ),
-  };
-};
-
-const getDonglePosition = (
-  bounds: Bounds,
-  heading: Heading,
-  p: GlobalPoint,
-): GlobalPoint => {
-  switch (heading) {
-    case HEADING_UP:
-      return pointFrom(p[0], bounds[1]);
-    case HEADING_RIGHT:
-      return pointFrom(bounds[2], p[1]);
-    case HEADING_DOWN:
-      return pointFrom(p[0], bounds[3]);
-  }
-  return pointFrom(bounds[0], p[1]);
-};
-
-const estimateSegmentCount = (
-  start: Node,
-  end: Node,
-  startHeading: Heading,
-  endHeading: Heading,
-) => {
-  if (endHeading === HEADING_RIGHT) {
-    switch (startHeading) {
-      case HEADING_RIGHT: {
-        if (start.pos[0] >= end.pos[0]) {
-          return 4;
-        }
-        if (start.pos[1] === end.pos[1]) {
-          return 0;
-        }
-        return 2;
-      }
-      case HEADING_UP:
-        if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_DOWN:
-        if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_LEFT:
-        if (start.pos[1] === end.pos[1]) {
-          return 4;
-        }
-        return 2;
-    }
-  } else if (endHeading === HEADING_LEFT) {
-    switch (startHeading) {
-      case HEADING_RIGHT:
-        if (start.pos[1] === end.pos[1]) {
-          return 4;
-        }
-        return 2;
-      case HEADING_UP:
-        if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_DOWN:
-        if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_LEFT:
-        if (start.pos[0] <= end.pos[0]) {
-          return 4;
-        }
-        if (start.pos[1] === end.pos[1]) {
-          return 0;
-        }
-        return 2;
-    }
-  } else if (endHeading === HEADING_UP) {
-    switch (startHeading) {
-      case HEADING_RIGHT:
-        if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_UP:
-        if (start.pos[1] >= end.pos[1]) {
-          return 4;
-        }
-        if (start.pos[0] === end.pos[0]) {
-          return 0;
-        }
-        return 2;
-      case HEADING_DOWN:
-        if (start.pos[0] === end.pos[0]) {
-          return 4;
-        }
-        return 2;
-      case HEADING_LEFT:
-        if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
-          return 1;
-        }
-        return 3;
-    }
-  } else if (endHeading === HEADING_DOWN) {
-    switch (startHeading) {
-      case HEADING_RIGHT:
-        if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
-          return 1;
-        }
-        return 3;
-      case HEADING_UP:
-        if (start.pos[0] === end.pos[0]) {
-          return 4;
-        }
-        return 2;
-      case HEADING_DOWN:
-        if (start.pos[1] <= end.pos[1]) {
-          return 4;
-        }
-        if (start.pos[0] === end.pos[0]) {
-          return 0;
-        }
-        return 2;
-      case HEADING_LEFT:
-        if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
-          return 1;
-        }
-        return 3;
-    }
-  }
-  return 0;
-};
-
-/**
- * Get neighboring points for a gived grid address
- */
-const getNeighbors = ([col, row]: [number, number], grid: Grid) =>
-  [
-    gridNodeFromAddr([col, row - 1], grid),
-    gridNodeFromAddr([col + 1, row], grid),
-    gridNodeFromAddr([col, row + 1], grid),
-    gridNodeFromAddr([col - 1, row], grid),
-  ] as [Node | null, Node | null, Node | null, Node | null];
-
-const gridNodeFromAddr = (
-  [col, row]: [col: number, row: number],
-  grid: Grid,
-): Node | null => {
-  if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) {
-    return null;
-  }
-
-  return grid.data[row * grid.col + col] ?? null;
-};
-
-/**
- * Get node for global point on canvas (if exists)
- */
-const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
-  for (let col = 0; col < grid.col; col++) {
-    for (let row = 0; row < grid.row; row++) {
-      const candidate = gridNodeFromAddr([col, row], grid);
-      if (
-        candidate &&
-        point[0] === candidate.pos[0] &&
-        point[1] === candidate.pos[1]
-      ) {
-        return candidate;
-      }
-    }
-  }
-
-  return null;
-};
-
-const commonAABB = (aabbs: Bounds[]): Bounds => [
-  Math.min(...aabbs.map((aabb) => aabb[0])),
-  Math.min(...aabbs.map((aabb) => aabb[1])),
-  Math.max(...aabbs.map((aabb) => aabb[2])),
-  Math.max(...aabbs.map((aabb) => aabb[3])),
-];
-
-/// #region Utils
-
-const getBindableElementForId = (
-  id: string,
-  elementsMap: ElementsMap,
-): ExcalidrawBindableElement | null => {
-  const element = elementsMap.get(id);
-  if (element && isBindableElement(element)) {
-    return element;
-  }
-
-  return null;
-};
-
-const normalizedArrowElementUpdate = (
-  global: GlobalPoint[],
-  externalOffsetX?: number,
-  externalOffsetY?: number,
-): {
-  points: LocalPoint[];
-  x: number;
-  y: number;
-  width: number;
-  height: number;
-} => {
-  const offsetX = global[0][0];
-  const offsetY = global[0][1];
-
-  const points = global.map((p) =>
-    pointTranslate<GlobalPoint, LocalPoint>(
-      p,
-      vectorScale(vectorFromPoint(global[0]), -1),
-    ),
-  );
-
-  return {
-    points,
-    x: offsetX + (externalOffsetX ?? 0),
-    y: offsetY + (externalOffsetY ?? 0),
-    ...getSizeFromPoints(points),
-  };
-};
-
-/// If last and current segments have the same heading, skip the middle point
-const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
-  points
-    .slice(2)
-    .reduce(
-      (result, p) =>
-        compareHeading(
-          vectorToHeading(
-            vectorFromPoint(
-              result[result.length - 1],
-              result[result.length - 2],
-            ),
-          ),
-          vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
-        )
-          ? [...result.slice(0, -1), p]
-          : [...result, p],
-      [points[0] ?? [0, 0], points[1] ?? [1, 0]],
-    );
-
-const neighborIndexToHeading = (idx: number): Heading => {
-  switch (idx) {
-    case 0:
-      return HEADING_UP;
-    case 1:
-      return HEADING_RIGHT;
-    case 2:
-      return HEADING_DOWN;
-  }
-  return HEADING_LEFT;
-};
-
-const getGlobalPoint = (
-  fixedPointRatio: [number, number] | undefined | null,
-  initialPoint: GlobalPoint,
-  otherPoint: GlobalPoint,
-  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  boundElement?: ExcalidrawBindableElement | null,
-  hoveredElement?: ExcalidrawBindableElement | null,
-  isDragging?: boolean,
-): GlobalPoint => {
-  if (isDragging) {
-    if (hoveredElement) {
-      const snapPoint = getSnapPoint(
-        initialPoint,
-        otherPoint,
-        hoveredElement,
-        elementsMap,
-      );
-
-      return snapToMid(hoveredElement, snapPoint);
-    }
-
-    return initialPoint;
-  }
-
-  if (boundElement) {
-    const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
-      fixedPointRatio || [0, 0],
-      boundElement,
-    );
-
-    // NOTE: Resize scales the binding position point too, so we need to update it
-    return Math.abs(
-      distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) -
-        FIXED_BINDING_DISTANCE,
-    ) > 0.01
-      ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap)
-      : fixedGlobalPoint;
-  }
-
-  return initialPoint;
-};
-
-const getSnapPoint = (
-  p: GlobalPoint,
-  otherPoint: GlobalPoint,
-  element: ExcalidrawBindableElement,
-  elementsMap: ElementsMap,
-) =>
-  bindPointToSnapToElementOutline(
-    isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
-    otherPoint,
-    element,
-    elementsMap,
-  );
-
-const getBindPointHeading = (
-  p: GlobalPoint,
-  otherPoint: GlobalPoint,
-  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  hoveredElement: ExcalidrawBindableElement | null | undefined,
-  origPoint: GlobalPoint,
-) =>
-  getHeadingForElbowArrowSnap(
-    p,
-    otherPoint,
-    hoveredElement,
-    hoveredElement &&
-      aabbForElement(
-        hoveredElement,
-        Array(4).fill(
-          distanceToBindableElement(hoveredElement, p, elementsMap),
-        ) as [number, number, number, number],
-      ),
-    elementsMap,
-    origPoint,
-  );
-
-const getHoveredElements = (
-  origStartGlobalPoint: GlobalPoint,
-  origEndGlobalPoint: GlobalPoint,
-  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  zoom?: AppState["zoom"],
-) => {
-  // TODO: Might be a performance bottleneck and the Map type
-  // remembers the insertion order anyway...
-  const nonDeletedSceneElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
-    new Map([...elementsMap].filter((el) => !el[1].isDeleted)),
-  );
-  const elements = Array.from(elementsMap.values());
-  return [
-    getHoveredElementForBinding(
-      tupleToCoors(origStartGlobalPoint),
-      elements,
-      nonDeletedSceneElementsMap,
-      zoom,
-      true,
-    ),
-    getHoveredElementForBinding(
-      tupleToCoors(origEndGlobalPoint),
-      elements,
-      nonDeletedSceneElementsMap,
-      zoom,
-      true,
-    ),
-  ];
-};
-
-const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
-  a[0] === b[0] && a[1] === b[1];

+ 23 - 0
packages/excalidraw/element/types.ts

@@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     endArrowhead: Arrowhead | null;
   }>;
 
+export type FixedSegment = {
+  start: LocalPoint;
+  end: LocalPoint;
+  index: number;
+};
+
 export type ExcalidrawArrowElement = ExcalidrawLinearElement &
   Readonly<{
     type: "arrow";
@@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
     elbowed: true;
     startBinding: FixedPointBinding | null;
     endBinding: FixedPointBinding | null;
+    fixedSegments: FixedSegment[] | null;
+    /**
+     * Marks that the 3rd point should be used as the 2nd point of the arrow in
+     * order to temporarily hide the first segment of the arrow without losing
+     * the data from the points array. It allows creating the expected arrow
+     * path when the arrow with fixed segments is bound on a horizontal side and
+     * moved to a vertical and vica versa.
+     */
+    startIsSpecial: boolean | null;
+    /**
+     * Marks that the 3rd point backwards from the end should be used as the 2nd
+     * point of the arrow in order to temporarily hide the last segment of the
+     * arrow without losing the data from the points array. It allows creating
+     * the expected arrow path when the arrow with fixed segments is bound on a
+     * horizontal side and moved to a vertical and vica versa.
+     */
+    endIsSpecial: boolean | null;
   }
 >;
 

+ 0 - 1
packages/excalidraw/fractionalIndex.ts

@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
 ): OrderedExcalidrawElement[] => {
   const indicesGroups = getInvalidIndicesGroups(elements);
   const elementsUpdates = generateIndices(elements, indicesGroups);
-
   for (const [element, update] of elementsUpdates) {
     mutateElement(element, update, false);
   }

+ 1 - 0
packages/excalidraw/package.json

@@ -117,6 +117,7 @@
     "fonteditor-core": "2.4.1",
     "harfbuzzjs": "0.3.6",
     "import-meta-loader": "1.1.0",
+    "jest-diff": "29.7.0",
     "mini-css-extract-plugin": "2.6.1",
     "postcss-loader": "7.0.1",
     "sass-loader": "13.0.2",

+ 72 - 40
packages/excalidraw/renderer/interactiveScene.ts

@@ -29,7 +29,7 @@ import {
   getOmitSidesForDevice,
   shouldShowBoundingBox,
 } from "../element/transformHandles";
-import { arrayToMap, throttleRAF } from "../utils";
+import { arrayToMap, invariant, throttleRAF } from "../utils";
 import {
   DEFAULT_TRANSFORM_HANDLE_SPACING,
   FRAME_STYLE,
@@ -78,9 +78,32 @@ import type {
   InteractiveSceneRenderConfig,
   RenderableElementsMap,
 } from "../scene/types";
-import type { GlobalPoint, LocalPoint, Radians } from "../../math";
+import {
+  pointFrom,
+  type GlobalPoint,
+  type LocalPoint,
+  type Radians,
+} from "../../math";
 import { getCornerRadius } from "../shapes";
 
+const renderElbowArrowMidPointHighlight = (
+  context: CanvasRenderingContext2D,
+  appState: InteractiveCanvasAppState,
+) => {
+  invariant(appState.selectedLinearElement, "selectedLinearElement is null");
+
+  const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
+
+  invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
+
+  context.save();
+  context.translate(appState.scrollX, appState.scrollY);
+
+  highlightPoint(segmentMidPointHoveredCoords, context, appState);
+
+  context.restore();
+};
+
 const renderLinearElementPointHighlight = (
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
@@ -490,7 +513,7 @@ const renderLinearPointHandles = (
   context.save();
   context.translate(appState.scrollX, appState.scrollY);
   context.lineWidth = 1 / appState.zoom.value;
-  const points = LinearElementEditor.getPointsGlobalCoordinates(
+  const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
     element,
     elementsMap,
   );
@@ -510,55 +533,57 @@ const renderLinearPointHandles = (
     renderSingleLinearPoint(context, appState, point, radius, isSelected);
   });
 
-  //Rendering segment mid points
-  const midPoints = LinearElementEditor.getEditorMidPoints(
-    element,
-    elementsMap,
-    appState,
-  ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
-
-  midPoints.forEach((segmentMidPoint) => {
-    if (
-      appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
-      LinearElementEditor.arePointsEqual(
-        segmentMidPoint,
-        appState.selectedLinearElement.segmentMidPointHoveredCoords,
-      )
-    ) {
-      // The order of renderingSingleLinearPoint and highLight points is different
-      // inside vs outside editor as hover states are different,
-      // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
-      // editor original point is visible and hover state is just an outer circle.
-      if (appState.editingLinearElement) {
+  // Rendering segment mid points
+  if (isElbowArrow(element)) {
+    const fixedSegments =
+      element.fixedSegments?.map((segment) => segment.index) || [];
+    points.slice(0, -1).forEach((p, idx) => {
+      if (
+        !LinearElementEditor.isSegmentTooShort(
+          element,
+          points[idx + 1],
+          points[idx],
+          idx,
+          appState.zoom,
+        )
+      ) {
         renderSingleLinearPoint(
           context,
           appState,
-          segmentMidPoint,
-          radius,
+          pointFrom<GlobalPoint>(
+            (p[0] + points[idx + 1][0]) / 2,
+            (p[1] + points[idx + 1][1]) / 2,
+          ),
+          POINT_HANDLE_SIZE / 2,
           false,
+          !fixedSegments.includes(idx + 1),
         );
-        highlightPoint(segmentMidPoint, context, appState);
-      } else {
-        highlightPoint(segmentMidPoint, context, appState);
+      }
+    });
+  } else {
+    const midPoints = LinearElementEditor.getEditorMidPoints(
+      element,
+      elementsMap,
+      appState,
+    ).filter(
+      (midPoint, idx, midPoints): midPoint is GlobalPoint =>
+        midPoint !== null &&
+        !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
+    );
+
+    midPoints.forEach((segmentMidPoint) => {
+      if (appState.editingLinearElement || points.length === 2) {
         renderSingleLinearPoint(
           context,
           appState,
           segmentMidPoint,
-          radius,
+          POINT_HANDLE_SIZE / 2,
           false,
+          true,
         );
       }
-    } else if (appState.editingLinearElement || points.length === 2) {
-      renderSingleLinearPoint(
-        context,
-        appState,
-        segmentMidPoint,
-        POINT_HANDLE_SIZE / 2,
-        false,
-        true,
-      );
-    }
-  });
+    });
+  }
 
   context.restore();
 };
@@ -864,6 +889,12 @@ const _renderInteractiveScene = ({
   }
 
   if (
+    isElbowArrow(selectedElements[0]) &&
+    appState.selectedLinearElement &&
+    appState.selectedLinearElement.segmentMidPointHoveredCoords
+  ) {
+    renderElbowArrowMidPointHighlight(context, appState);
+  } else if (
     appState.selectedLinearElement &&
     appState.selectedLinearElement.hoverPointIndex >= 0 &&
     !(
@@ -875,6 +906,7 @@ const _renderInteractiveScene = ({
   ) {
     renderLinearElementPointHighlight(context, appState, elementsMap);
   }
+
   // Paint selected elements
   if (!appState.multiElement && !appState.editingLinearElement) {
     const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);

+ 26 - 22
packages/excalidraw/scene/Shape.ts

@@ -23,13 +23,9 @@ import {
 } from "../element/typeChecks";
 import { canChangeRoundness } from "./comparisons";
 import type { EmbedsValidationStatus } from "../types";
-import {
-  pointFrom,
-  pointDistance,
-  type GlobalPoint,
-  type LocalPoint,
-} from "../../math";
+import { pointFrom, pointDistance, type LocalPoint } from "../../math";
 import { getCornerRadius, isPathALoop } from "../shapes";
+import { headingForPointIsHorizontal } from "../element/heading";
 
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
 
@@ -527,45 +523,53 @@ export const _generateElementShape = (
   }
 };
 
-const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
-  points: readonly Point[],
+const generateElbowArrowShape = (
+  points: readonly LocalPoint[],
   radius: number,
 ) => {
   const subpoints = [] as [number, number][];
   for (let i = 1; i < points.length - 1; i += 1) {
     const prev = points[i - 1];
     const next = points[i + 1];
+    const point = points[i];
+    const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
+    const nextIsHorizontal = headingForPointIsHorizontal(next, point);
     const corner = Math.min(
       radius,
       pointDistance(points[i], next) / 2,
       pointDistance(points[i], prev) / 2,
     );
 
-    if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
-      // LEFT
-      subpoints.push([points[i][0] - corner, points[i][1]]);
-    } else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
+    if (prevIsHorizontal) {
+      if (prev[0] < point[0]) {
+        // LEFT
+        subpoints.push([points[i][0] - corner, points[i][1]]);
+      } else {
+        // RIGHT
+        subpoints.push([points[i][0] + corner, points[i][1]]);
+      }
+    } else if (prev[1] < point[1]) {
       // UP
       subpoints.push([points[i][0], points[i][1] - corner]);
-    } else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
-      // RIGHT
-      subpoints.push([points[i][0] + corner, points[i][1]]);
     } else {
       subpoints.push([points[i][0], points[i][1] + corner]);
     }
 
     subpoints.push(points[i] as [number, number]);
 
-    if (next[0] < points[i][0] && next[1] === points[i][1]) {
-      // LEFT
-      subpoints.push([points[i][0] - corner, points[i][1]]);
-    } else if (next[0] === points[i][0] && next[1] < points[i][1]) {
+    if (nextIsHorizontal) {
+      if (next[0] < point[0]) {
+        // LEFT
+        subpoints.push([points[i][0] - corner, points[i][1]]);
+      } else {
+        // RIGHT
+        subpoints.push([points[i][0] + corner, points[i][1]]);
+      }
+    } else if (next[1] < point[1]) {
       // UP
       subpoints.push([points[i][0], points[i][1] - corner]);
-    } else if (next[0] > points[i][0] && next[1] === points[i][1]) {
-      // RIGHT
-      subpoints.push([points[i][0] + corner, points[i][1]]);
     } else {
+      // DOWN
       subpoints.push([points[i][0], points[i][1] + corner]);
     }
   }

+ 11 - 1
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -10983,7 +10983,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
     "focus": "-0.00161",
     "gap": "3.53708",
   },
+  "endIsSpecial": null,
   "fillStyle": "solid",
+  "fixedSegments": null,
   "frameId": null,
   "groupIds": [],
   "height": "448.10100",
@@ -11000,9 +11002,13 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
       0,
     ],
     [
-      "451.90000",
+      "225.95000",
       0,
     ],
+    [
+      "225.95000",
+      "448.10100",
+    ],
     [
       "451.90000",
       "448.10100",
@@ -11022,6 +11028,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
     "focus": "-0.00159",
     "gap": 5,
   },
+  "startIsSpecial": null,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
@@ -11147,7 +11154,9 @@ History {
                 "focus": "-0.00161",
                 "gap": "3.53708",
               },
+              "endIsSpecial": false,
               "fillStyle": "solid",
+              "fixedSegments": [],
               "frameId": null,
               "groupIds": [],
               "height": "236.10000",
@@ -11185,6 +11194,7 @@ History {
                 "focus": "-0.00159",
                 "gap": 5,
               },
+              "startIsSpecial": false,
               "strokeColor": "#1e1e1e",
               "strokeStyle": "solid",
               "strokeWidth": 2,

+ 3 - 3
packages/excalidraw/tests/cropElement.test.tsx

@@ -171,8 +171,8 @@ describe("Crop an image", () => {
     // test corner handle aspect ratio preserving
     UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
     expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
-    expect(image.width).toBeLessThanOrEqual(initialWidth);
-    expect(image.height).toBeLessThanOrEqual(initialHeight);
+    expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
+    expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
 
     // reset
     image = API.createElement({ type: "image", width: 200, height: 100 });
@@ -194,7 +194,7 @@ describe("Crop an image", () => {
     expect(image.width).toBeCloseTo(image.height);
     // max height should be reached
     expect(image.height).toBeCloseTo(initialHeight);
-    expect(image.width).toBe(initialHeight);
+    expect(image.width).toBeCloseTo(initialHeight);
   });
 });
 

+ 2 - 0
packages/excalidraw/tests/helpers/api.ts

@@ -11,6 +11,7 @@ import type {
   ExcalidrawMagicFrameElement,
   ExcalidrawElbowArrowElement,
   ExcalidrawArrowElement,
+  FixedSegment,
 } from "../../element/types";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -197,6 +198,7 @@ export class API {
       ? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
       : never;
     elbowed?: boolean;
+    fixedSegments?: FixedSegment[] | null;
   }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
     : T extends "freedraw"

+ 2 - 1
packages/excalidraw/tests/history.test.tsx

@@ -2084,7 +2084,8 @@ describe("history", () => {
       )[0] as ExcalidrawElbowArrowElement;
       expect(modifiedArrow.points).toEqual([
         [0, 0],
-        [451.9000000000001, 0],
+        [225.95000000000005, 0],
+        [225.95000000000005, 448.10100010002003],
         [451.9000000000001, 448.10100010002003],
       ]);
     });

+ 13 - 18
packages/excalidraw/tests/linearElementEditor.test.tsx

@@ -5,7 +5,6 @@ import type {
   ExcalidrawLinearElement,
   ExcalidrawTextElementWithContainer,
   FontString,
-  SceneElementsMap,
 } from "../element/types";
 import { Excalidraw, mutateElement } from "../index";
 import { reseed } from "../random";
@@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => {
       const [origStartX, origStartY] = [line.x, line.y];
 
       act(() => {
-        LinearElementEditor.movePoints(
-          line,
-          [
-            {
-              index: 0,
-              point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
-            },
-            {
-              index: line.points.length - 1,
-              point: pointFrom(
-                line.points[line.points.length - 1][0] - 10,
-                line.points[line.points.length - 1][1] - 10,
-              ),
-            },
-          ],
-          new Map() as SceneElementsMap,
-        );
+        LinearElementEditor.movePoints(line, [
+          {
+            index: 0,
+            point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
+          },
+          {
+            index: line.points.length - 1,
+            point: pointFrom(
+              line.points[line.points.length - 1][0] - 10,
+              line.points[line.points.length - 1][1] - 10,
+            ),
+          },
+        ]);
       });
       expect(line.x).toBe(origStartX + 10);
       expect(line.y).toBe(origStartY + 10);

+ 1 - 1
packages/excalidraw/tests/resize.test.tsx

@@ -535,7 +535,7 @@ describe("arrow element", () => {
 
     UI.resize([rectangle, arrow], "nw", [300, 350]);
 
-    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
+    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
   });
 });

+ 31 - 0
packages/excalidraw/tests/test-utils.ts

@@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
 import { getSelectedElements } from "../scene/selection";
 import type { ExcalidrawElement } from "../element/types";
 import { UI } from "./helpers/ui";
+import { diffStringsUnified } from "jest-diff";
 
 const customQueries = {
   ...queries,
@@ -246,6 +247,36 @@ expect.extend({
       pass: false,
     };
   },
+
+  toCloselyEqualPoints(received, expected, precision) {
+    if (!Array.isArray(received) || !Array.isArray(expected)) {
+      throw new Error("expected and received are not point arrays");
+    }
+
+    const COMPARE = 1 / Math.pow(10, precision || 2);
+    const pass = received.every(
+      (point, idx) =>
+        Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
+        Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
+    );
+
+    if (!pass) {
+      return {
+        message: () => ` The provided array of points are not close enough.
+
+${diffStringsUnified(
+  JSON.stringify(expected, undefined, 2),
+  JSON.stringify(received, undefined, 2),
+)}`,
+        pass: false,
+      };
+    }
+
+    return {
+      message: () => `expected ${received} to not be close to ${expected}`,
+      pass: true,
+    };
+  },
 });
 
 /**

+ 20 - 0
packages/excalidraw/visualdebug.ts

@@ -3,6 +3,7 @@ import {
   lineSegment,
   pointFrom,
   type GlobalPoint,
+  type LocalPoint,
 } from "../math";
 import type { LineSegment } from "../utils";
 import type { BoundingBox, Bounds } from "./element/bounds";
@@ -15,6 +16,8 @@ declare global {
       data: DebugElement[][];
       currentFrame?: number;
     };
+    debugDrawPoint: typeof debugDrawPoint;
+    debugDrawLine: typeof debugDrawLine;
   }
 }
 
@@ -147,6 +150,23 @@ export const debugDrawBounds = (
   );
 };
 
+export const debugDrawPoints = (
+  {
+    x,
+    y,
+    points,
+  }: {
+    x: number;
+    y: number;
+    points: LocalPoint[];
+  },
+  options?: any,
+) => {
+  points.forEach((p) =>
+    debugDrawPoint(pointFrom<GlobalPoint>(x + p[0], y + p[1]), options),
+  );
+};
+
 export const debugCloseFrame = () => {
   window.visualDebug?.data.push([]);
 };

+ 37 - 3
packages/math/line.ts

@@ -1,4 +1,4 @@
-import { pointCenter, pointRotateRads } from "./point";
+import { pointCenter, pointFrom, pointRotateRads } from "./point";
 import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
 
 /**
@@ -38,8 +38,16 @@ export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
     : undefined;
 }
 
-// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
-// note that when the origin is not given, the midpoint of the given line is used as the origin
+/**
+ * Return the coordinates resulting from rotating the given line about an
+ * origin by an angle in degrees note that when the origin is not given,
+ * the midpoint of the given line is used as the origin
+ *
+ * @param l
+ * @param angle
+ * @param origin
+ * @returns
+ */
 export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
   l: Line<Point>,
   angle: Radians,
@@ -50,3 +58,29 @@ export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
     pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
   );
 };
+
+/**
+ * Determines the intersection point (unless the lines are parallel) of two
+ * lines
+ *
+ * @param a
+ * @param b
+ * @returns
+ */
+export const linesIntersectAt = <Point extends GlobalPoint | LocalPoint>(
+  a: Line<Point>,
+  b: Line<Point>,
+): Point | null => {
+  const A1 = a[1][1] - a[0][1];
+  const B1 = a[0][0] - a[1][0];
+  const A2 = b[1][1] - b[0][1];
+  const B2 = b[0][0] - b[1][0];
+  const D = A1 * B2 - A2 * B1;
+  if (D !== 0) {
+    const C1 = A1 * a[0][0] + B1 * a[0][1];
+    const C2 = A2 * b[0][0] + B2 * b[0][1];
+    return pointFrom<Point>((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D);
+  }
+
+  return null;
+};

+ 20 - 1
packages/math/point.ts

@@ -61,6 +61,22 @@ export function pointFromVector<P extends GlobalPoint | LocalPoint>(
   return v as unknown as P;
 }
 
+/**
+ * Convert the coordiante object to a point.
+ *
+ * @param coords The coordinate object with x and y properties
+ * @returns
+ */
+export function pointFromCoords<Point extends GlobalPoint | LocalPoint>({
+  x,
+  y,
+}: {
+  x: number;
+  y: number;
+}) {
+  return [x, y] as Point;
+}
+
 /**
  * Checks if the provided value has the shape of a Point.
  *
@@ -217,7 +233,10 @@ export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
   a: P,
   b: P,
 ): number {
-  return Math.hypot(b[0] - a[0], b[1] - a[1]);
+  const xDiff = b[0] - a[0];
+  const yDiff = b[1] - a[1];
+
+  return xDiff * xDiff + yDiff * yDiff;
 }
 
 /**

+ 10 - 10
yarn.lock

@@ -7263,6 +7263,16 @@ jest-canvas-mock@~2.5.2:
     cssfontparser "^1.2.1"
     moo-color "^1.0.2"
 
[email protected], jest-diff@^29.7.0:
+  version "29.7.0"
+  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a"
+  integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==
+  dependencies:
+    chalk "^4.0.0"
+    diff-sequences "^29.6.3"
+    jest-get-type "^29.6.3"
+    pretty-format "^29.7.0"
+
 jest-diff@^27.0.0:
   version "27.5.1"
   resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
@@ -7273,16 +7283,6 @@ jest-diff@^27.0.0:
     jest-get-type "^27.5.1"
     pretty-format "^27.5.1"
 
-jest-diff@^29.7.0:
-  version "29.7.0"
-  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a"
-  integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==
-  dependencies:
-    chalk "^4.0.0"
-    diff-sequences "^29.6.3"
-    jest-get-type "^29.6.3"
-    pretty-format "^29.7.0"
-
 jest-get-type@^27.5.1:
   version "27.5.1"
   resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"