Browse Source

feat: Orthogonal (elbow) arrows for diagramming (#8299)

Co-authored-by: dwelle <[email protected]>
Márk Tolmács 1 year ago
parent
commit
15e019706d
69 changed files with 4518 additions and 826 deletions
  1. 35 5
      packages/excalidraw/actions/actionDeleteSelected.tsx
  2. 1 2
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  3. 2 0
      packages/excalidraw/actions/actionFinalize.tsx
  4. 3 0
      packages/excalidraw/actions/actionFlip.ts
  5. 4 2
      packages/excalidraw/actions/actionHistory.tsx
  6. 3 2
      packages/excalidraw/actions/actionLinearEditor.tsx
  7. 248 7
      packages/excalidraw/actions/actionProperties.tsx
  8. 1 0
      packages/excalidraw/actions/types.ts
  9. 7 0
      packages/excalidraw/appState.ts
  10. 105 0
      packages/excalidraw/binaryheap.ts
  11. 7 2
      packages/excalidraw/change.ts
  12. 0 10
      packages/excalidraw/charts.ts
  13. 9 2
      packages/excalidraw/components/Actions.tsx
  14. 213 70
      packages/excalidraw/components/App.tsx
  15. 6 3
      packages/excalidraw/components/HintViewer.tsx
  16. 5 4
      packages/excalidraw/components/Stats/Angle.tsx
  17. 5 0
      packages/excalidraw/components/Stats/Dimension.tsx
  18. 2 2
      packages/excalidraw/components/Stats/DragInput.tsx
  19. 20 3
      packages/excalidraw/components/Stats/MultiDimension.tsx
  20. 12 0
      packages/excalidraw/components/Stats/MultiPosition.tsx
  21. 6 1
      packages/excalidraw/components/Stats/Position.tsx
  22. 9 6
      packages/excalidraw/components/Stats/index.tsx
  23. 24 4
      packages/excalidraw/components/Stats/utils.ts
  24. 29 0
      packages/excalidraw/components/icons.tsx
  25. 7 1
      packages/excalidraw/constants.ts
  26. 27 0
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  27. 47 9
      packages/excalidraw/data/restore.ts
  28. 1 0
      packages/excalidraw/data/transform.test.ts
  29. 6 3
      packages/excalidraw/data/transform.ts
  30. 606 75
      packages/excalidraw/element/binding.ts
  31. 29 5
      packages/excalidraw/element/dragElements.ts
  32. 146 0
      packages/excalidraw/element/heading.ts
  33. 1 0
      packages/excalidraw/element/index.ts
  34. 245 79
      packages/excalidraw/element/linearElementEditor.ts
  35. 7 0
      packages/excalidraw/element/newElement.test.ts
  36. 22 2
      packages/excalidraw/element/newElement.ts
  37. 62 25
      packages/excalidraw/element/resizeElements.ts
  38. 216 0
      packages/excalidraw/element/routing.test.tsx
  39. 1036 0
      packages/excalidraw/element/routing.ts
  40. 13 2
      packages/excalidraw/element/transformHandles.ts
  41. 31 0
      packages/excalidraw/element/typeChecks.ts
  42. 26 1
      packages/excalidraw/element/types.ts
  43. 9 2
      packages/excalidraw/history.ts
  44. 5 0
      packages/excalidraw/locales/en.json
  45. 45 1
      packages/excalidraw/math.test.ts
  46. 184 0
      packages/excalidraw/math.ts
  47. 54 22
      packages/excalidraw/renderer/interactiveScene.ts
  48. 69 4
      packages/excalidraw/scene/Shape.ts
  49. 2 1
      packages/excalidraw/scene/comparisons.ts
  50. 17 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  51. 1 0
      packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
  52. 180 114
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  53. 9 6
      packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
  54. 1 0
      packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap
  55. 61 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  56. 1 0
      packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
  57. 7 0
      packages/excalidraw/tests/binding.test.tsx
  58. 1 0
      packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap
  59. 0 4
      packages/excalidraw/tests/flip.test.tsx
  60. 14 2
      packages/excalidraw/tests/helpers/api.ts
  61. 504 317
      packages/excalidraw/tests/history.test.tsx
  62. 6 1
      packages/excalidraw/tests/library.test.tsx
  63. 26 20
      packages/excalidraw/tests/linearElementEditor.test.tsx
  64. 2 0
      packages/excalidraw/tests/move.test.tsx
  65. 14 3
      packages/excalidraw/tests/resize.test.tsx
  66. 2 0
      packages/excalidraw/types.ts
  67. 3 0
      packages/excalidraw/utils.ts
  68. 1 0
      packages/utils/__snapshots__/export.test.ts.snap
  69. 16 2
      packages/utils/geometry/geometry.ts

+ 35 - 5
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -5,19 +5,25 @@ import { t } from "../i18n";
 import { register } from "./register";
 import { getNonDeletedElements } from "../element";
 import type { ExcalidrawElement } from "../element/types";
-import type { AppState } from "../types";
-import { newElementWith } from "../element/mutateElement";
+import type { AppClassProperties, AppState } from "../types";
+import { mutateElement, newElementWith } from "../element/mutateElement";
 import { getElementsInGroup } from "../groups";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { fixBindingsAfterDeletion } from "../element/binding";
-import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
+import {
+  isBoundToContainer,
+  isElbowArrow,
+  isFrameLikeElement,
+} from "../element/typeChecks";
 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 framesToBeDeleted = new Set(
     getSelectedElements(
@@ -29,6 +35,26 @@ const deleteSelectedElements = (
   return {
     elements: elements.map((el) => {
       if (appState.selectedElementIds[el.id]) {
+        if (el.boundElements) {
+          el.boundElements.forEach((candidate) => {
+            const bound = app.scene
+              .getNonDeletedElementsMap()
+              .get(candidate.id);
+            if (bound && isElbowArrow(bound)) {
+              mutateElement(bound, {
+                startBinding:
+                  el.id === bound.startBinding?.elementId
+                    ? null
+                    : bound.startBinding,
+                endBinding:
+                  el.id === bound.endBinding?.elementId
+                    ? null
+                    : bound.endBinding,
+              });
+              mutateElbowArrow(bound, app.scene, bound.points);
+            }
+          });
+        }
         return newElementWith(el, { isDeleted: true });
       }
 
@@ -130,7 +156,11 @@ export const actionDeleteSelected = register({
           : endBindingElement,
       };
 
-      LinearElementEditor.deletePoints(element, selectedPointsIndices);
+      LinearElementEditor.deletePoints(
+        element,
+        selectedPointsIndices,
+        app.scene,
+      );
 
       return {
         elements,
@@ -149,7 +179,7 @@ export const actionDeleteSelected = register({
       };
     }
     let { elements: nextElements, appState: nextAppState } =
-      deleteSelectedElements(elements, appState);
+      deleteSelectedElements(elements, appState, app);
     fixBindingsAfterDeletion(
       nextElements,
       elements.filter(({ id }) => appState.selectedElementIds[id]),

+ 1 - 2
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -40,12 +40,11 @@ export const actionDuplicateSelection = register({
   icon: DuplicateIcon,
   trackEvent: { category: "element" },
   perform: (elements, appState, formData, app) => {
-    const elementsMap = app.scene.getNonDeletedElementsMap();
     // duplicate selected point(s) if editing a line
     if (appState.editingLinearElement) {
       const ret = LinearElementEditor.duplicateSelectedPoints(
         appState,
-        elementsMap,
+        app.scene,
       );
 
       if (!ret) {

+ 2 - 0
packages/excalidraw/actions/actionFinalize.tsx

@@ -38,6 +38,7 @@ export const actionFinalize = register({
             startBindingElement,
             endBindingElement,
             elementsMap,
+            scene,
           );
         }
         return {
@@ -136,6 +137,7 @@ export const actionFinalize = register({
           appState,
           { x, y },
           elementsMap,
+          elements,
         );
       }
     }

+ 3 - 0
packages/excalidraw/actions/actionFlip.ts

@@ -120,11 +120,14 @@ const flipElements = (
     true,
     flipDirection === "horizontal" ? maxX : minX,
     flipDirection === "horizontal" ? minY : maxY,
+    app.scene,
   );
 
   bindOrUnbindLinearElements(
     selectedElements.filter(isLinearElement),
     elementsMap,
+    app.scene.getNonDeletedElements(),
+    app.scene,
     isBindingEnabled(appState),
     [],
   );

+ 4 - 2
packages/excalidraw/actions/actionHistory.tsx

@@ -50,12 +50,13 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   icon: UndoIcon,
   trackEvent: { category: "history" },
   viewMode: false,
-  perform: (elements, appState) =>
+  perform: (elements, appState, value, app) =>
     writeData(appState, () =>
       history.undo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,
         store.snapshot,
+        app.scene,
       ),
     ),
   keyTest: (event) =>
@@ -91,12 +92,13 @@ export const createRedoAction: ActionCreator = (history, store) => ({
   icon: RedoIcon,
   trackEvent: { category: "history" },
   viewMode: false,
-  perform: (elements, appState) =>
+  perform: (elements, appState, _, app) =>
     writeData(appState, () =>
       history.redo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,
         store.snapshot,
+        app.scene,
       ),
     ),
   keyTest: (event) =>

+ 3 - 2
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -1,6 +1,6 @@
 import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import { isLinearElement } from "../element/typeChecks";
+import { isElbowArrow, isLinearElement } from "../element/typeChecks";
 import type { ExcalidrawLinearElement } from "../element/types";
 import { StoreAction } from "../store";
 import { register } from "./register";
@@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
     if (
       !appState.editingLinearElement &&
       selectedElements.length === 1 &&
-      isLinearElement(selectedElements[0])
+      isLinearElement(selectedElements[0]) &&
+      !isElbowArrow(selectedElements[0])
     ) {
       return true;
     }

+ 248 - 7
packages/excalidraw/actions/actionProperties.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useMemo, useRef, useState } from "react";
-import type { AppClassProperties, AppState, Primitive } from "../types";
+import type { AppClassProperties, AppState, Point, Primitive } from "../types";
 import type { StoreActionType } from "../store";
 import {
   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -50,8 +50,12 @@ import {
   ArrowheadDiamondIcon,
   ArrowheadDiamondOutlineIcon,
   fontSizeIcon,
+  sharpArrowIcon,
+  roundArrowIcon,
+  elbowArrowIcon,
 } from "../components/icons";
 import {
+  ARROW_TYPE,
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
   FONT_FAMILY,
@@ -67,12 +71,15 @@ import {
 import { mutateElement, newElementWith } from "../element/mutateElement";
 import { getBoundTextElement } from "../element/textElement";
 import {
+  isArrowElement,
   isBoundToContainer,
+  isElbowArrow,
   isLinearElement,
   isUsingAdaptiveRadius,
 } from "../element/typeChecks";
 import type {
   Arrowhead,
+  ExcalidrawBindableElement,
   ExcalidrawElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
@@ -91,10 +98,23 @@ import {
   isSomeElementSelected,
 } from "../scene";
 import { hasStrokeColor } from "../scene/comparisons";
-import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
+import {
+  arrayToMap,
+  getFontFamilyString,
+  getShortcutKey,
+  tupleToCoors,
+} from "../utils";
 import { register } from "./register";
 import { StoreAction } from "../store";
 import { Fonts, getLineHeight } from "../fonts";
+import {
+  bindLinearElement,
+  bindPointToSnapToElementOutline,
+  calculateFixedPointForElbowArrowBinding,
+  getHoveredElementForBinding,
+} from "../element/binding";
+import { mutateElbowArrow } from "../element/routing";
+import { LinearElementEditor } from "../element/linearElementEditor";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({
   trackEvent: false,
   perform: (elements, appState, value) => {
     return {
-      elements: changeProperty(elements, appState, (el) =>
-        newElementWith(el, {
+      elements: changeProperty(elements, appState, (el) => {
+        if (isElbowArrow(el)) {
+          return el;
+        }
+
+        return newElementWith(el, {
           roundness:
             value === "round"
               ? {
@@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({
                     : ROUNDNESS.PROPORTIONAL_RADIUS,
                 }
               : null,
-        }),
-      ),
+        });
+      }),
       appState: {
         ...appState,
         currentItemRoundness: value,
@@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({
             appState,
             (element) =>
               hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
-            (element) => element.hasOwnProperty("roundness"),
+            (element) =>
+              !isArrowElement(element) && element.hasOwnProperty("roundness"),
             (hasSelection) =>
               hasSelection ? null : appState.currentItemRoundness,
           )}
@@ -1518,3 +1543,219 @@ export const actionChangeArrowhead = register({
     );
   },
 });
+
+export const actionChangeArrowType = register({
+  name: "changeArrowType",
+  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();
+
+          app.dismissLinearEditor();
+
+          const startGlobalPoint =
+            LinearElementEditor.getPointAtIndexGlobalCoordinates(
+              newElement,
+              0,
+              elementsMap,
+            );
+          const endGlobalPoint =
+            LinearElementEditor.getPointAtIndexGlobalCoordinates(
+              newElement,
+              -1,
+              elementsMap,
+            );
+          const startHoveredElement =
+            !newElement.startBinding &&
+            getHoveredElementForBinding(
+              tupleToCoors(startGlobalPoint),
+              elements,
+              elementsMap,
+              true,
+            );
+          const endHoveredElement =
+            !newElement.endBinding &&
+            getHoveredElementForBinding(
+              tupleToCoors(endGlobalPoint),
+              elements,
+              elementsMap,
+              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;
+
+          startHoveredElement &&
+            bindLinearElement(
+              newElement,
+              startHoveredElement,
+              "start",
+              elementsMap,
+            );
+          endHoveredElement &&
+            bindLinearElement(
+              newElement,
+              endHoveredElement,
+              "end",
+              elementsMap,
+            );
+
+          mutateElbowArrow(
+            newElement,
+            app.scene,
+            [finalStartPoint, finalEndPoint].map(
+              (point) =>
+                [point[0] - newElement.x, point[1] - newElement.y] as Point,
+            ),
+            [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,
+                      ),
+                    },
+                  }
+                : {}),
+            },
+          );
+        } else {
+          mutateElement(
+            newElement,
+            {
+              startBinding: newElement.startBinding
+                ? { ...newElement.startBinding, fixedPoint: null }
+                : null,
+              endBinding: newElement.endBinding
+                ? { ...newElement.endBinding, fixedPoint: null }
+                : null,
+            },
+            false,
+          );
+        }
+
+        return newElement;
+      }),
+      appState: {
+        ...appState,
+        currentItemArrowType: value,
+      },
+      storeAction: StoreAction.CAPTURE,
+    };
+  },
+  PanelComponent: ({ elements, appState, updateData }) => {
+    return (
+      <fieldset>
+        <legend>{t("labels.arrowtypes")}</legend>
+        <ButtonIconSelect
+          group="arrowtypes"
+          options={[
+            {
+              value: ARROW_TYPE.sharp,
+              text: t("labels.arrowtype_sharp"),
+              icon: sharpArrowIcon,
+              testId: "sharp-arrow",
+            },
+            {
+              value: ARROW_TYPE.round,
+              text: t("labels.arrowtype_round"),
+              icon: roundArrowIcon,
+              testId: "round-arrow",
+            },
+            {
+              value: ARROW_TYPE.elbow,
+              text: t("labels.arrowtype_elbowed"),
+              icon: elbowArrowIcon,
+              testId: "elbow-arrow",
+            },
+          ]}
+          value={getFormValue(
+            elements,
+            appState,
+            (element) => {
+              if (isArrowElement(element)) {
+                return element.elbowed
+                  ? ARROW_TYPE.elbow
+                  : element.roundness
+                  ? ARROW_TYPE.round
+                  : ARROW_TYPE.sharp;
+              }
+
+              return null;
+            },
+            (element) => isArrowElement(element),
+            (hasSelection) =>
+              hasSelection ? null : appState.currentItemArrowType,
+          )}
+          onChange={(value) => updateData(value)}
+        />
+      </fieldset>
+    );
+  },
+});

+ 1 - 0
packages/excalidraw/actions/types.ts

@@ -70,6 +70,7 @@ export type ActionName =
   | "changeSloppiness"
   | "changeStrokeStyle"
   | "changeArrowhead"
+  | "changeArrowType"
   | "changeOpacity"
   | "changeFontSize"
   | "toggleCanvasMenu"

+ 7 - 0
packages/excalidraw/appState.ts

@@ -1,5 +1,6 @@
 import { COLOR_PALETTE } from "./colors";
 import {
+  ARROW_TYPE,
   DEFAULT_ELEMENT_PROPS,
   DEFAULT_FONT_FAMILY,
   DEFAULT_FONT_SIZE,
@@ -33,6 +34,7 @@ export const getDefaultAppState = (): Omit<
     currentItemStartArrowhead: null,
     currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
     currentItemRoundness: "round",
+    currentItemArrowType: ARROW_TYPE.round,
     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemTextAlign: DEFAULT_TEXT_ALIGN,
@@ -143,6 +145,11 @@ const APP_STATE_STORAGE_CONF = (<
     export: false,
     server: false,
   },
+  currentItemArrowType: {
+    browser: true,
+    export: false,
+    server: false,
+  },
   currentItemOpacity: { browser: true, export: false, server: false },
   currentItemRoughness: { browser: true, export: false, server: false },
   currentItemStartArrowhead: { browser: true, export: false, server: false },

+ 105 - 0
packages/excalidraw/binaryheap.ts

@@ -0,0 +1,105 @@
+export default class BinaryHeap<T> {
+  private content: T[] = [];
+
+  constructor(private scoreFunction: (node: T) => number) {}
+
+  sinkDown(idx: number) {
+    const node = this.content[idx];
+    while (idx > 0) {
+      const parentN = ((idx + 1) >> 1) - 1;
+      const parent = this.content[parentN];
+      if (this.scoreFunction(node) < this.scoreFunction(parent)) {
+        this.content[parentN] = node;
+        this.content[idx] = parent;
+        idx = parentN; // TODO: Optimize
+      } else {
+        break;
+      }
+    }
+  }
+
+  bubbleUp(idx: number) {
+    const length = this.content.length;
+    const node = this.content[idx];
+    const score = this.scoreFunction(node);
+
+    while (true) {
+      const child2N = (idx + 1) << 1;
+      const child1N = child2N - 1;
+      let swap = null;
+      let child1Score = 0;
+
+      if (child1N < length) {
+        const child1 = this.content[child1N];
+        child1Score = this.scoreFunction(child1);
+        if (child1Score < score) {
+          swap = child1N;
+        }
+      }
+
+      if (child2N < length) {
+        const child2 = this.content[child2N];
+        const child2Score = this.scoreFunction(child2);
+        if (child2Score < (swap === null ? score : child1Score)) {
+          swap = child2N;
+        }
+      }
+
+      if (swap !== null) {
+        this.content[idx] = this.content[swap];
+        this.content[swap] = node;
+        idx = swap; // TODO: Optimize
+      } else {
+        break;
+      }
+    }
+  }
+
+  push(node: T) {
+    this.content.push(node);
+    this.sinkDown(this.content.length - 1);
+  }
+
+  pop(): T | null {
+    if (this.content.length === 0) {
+      return null;
+    }
+
+    const result = this.content[0];
+    const end = this.content.pop()!;
+
+    if (this.content.length > 0) {
+      this.content[0] = end;
+      this.bubbleUp(0);
+    }
+
+    return result;
+  }
+
+  remove(node: T) {
+    if (this.content.length === 0) {
+      return;
+    }
+
+    const i = this.content.indexOf(node);
+    const end = this.content.pop()!;
+
+    if (i < this.content.length) {
+      this.content[i] = end;
+
+      if (this.scoreFunction(end) < this.scoreFunction(node)) {
+        this.sinkDown(i);
+      } else {
+        this.bubbleUp(i);
+      }
+    }
+  }
+
+  size(): number {
+    return this.content.length;
+  }
+
+  rescoreElement(node: T) {
+    this.sinkDown(this.content.indexOf(node));
+  }
+}

+ 7 - 2
packages/excalidraw/change.ts

@@ -29,6 +29,7 @@ import type {
 } from "./element/types";
 import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
 import { getNonDeletedGroupIds } from "./groups";
+import type Scene from "./scene/Scene";
 import { getObservedAppState } from "./store";
 import type {
   AppState,
@@ -1053,6 +1054,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
   public applyTo(
     elements: SceneElementsMap,
     snapshot: Map<string, OrderedExcalidrawElement>,
+    scene: Scene,
   ): [SceneElementsMap, boolean] {
     let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
     let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1100,7 +1102,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
     try {
       // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
       ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
-      ElementsChange.redrawBoundArrows(nextElements, changedElements);
+      ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
 
       // the following reorder performs also mutations, but only on new instances of changed elements
       // (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1457,10 +1459,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
   private static redrawBoundArrows(
     elements: SceneElementsMap,
     changed: Map<string, OrderedExcalidrawElement>,
+    scene: Scene,
   ) {
     for (const element of changed.values()) {
       if (!element.isDeleted && isBindableElement(element)) {
-        updateBoundElements(element, elements);
+        updateBoundElements(element, elements, scene, {
+          changedElements: changed,
+        });
       }
     }
   }

+ 0 - 10
packages/excalidraw/charts.ts

@@ -257,8 +257,6 @@ const chartLines = (
     type: "line",
     x,
     y,
-    startArrowhead: null,
-    endArrowhead: null,
     width: chartWidth,
     points: [
       [0, 0],
@@ -273,8 +271,6 @@ const chartLines = (
     type: "line",
     x,
     y,
-    startArrowhead: null,
-    endArrowhead: null,
     height: chartHeight,
     points: [
       [0, 0],
@@ -289,8 +285,6 @@ const chartLines = (
     type: "line",
     x,
     y: y - BAR_HEIGHT - BAR_GAP,
-    startArrowhead: null,
-    endArrowhead: null,
     strokeStyle: "dotted",
     width: chartWidth,
     opacity: GRID_OPACITY,
@@ -418,8 +412,6 @@ const chartTypeLine = (
     type: "line",
     x: x + BAR_GAP + BAR_WIDTH / 2,
     y: y - BAR_GAP,
-    startArrowhead: null,
-    endArrowhead: null,
     height: maxY - minY,
     width: maxX - minX,
     strokeWidth: 2,
@@ -453,8 +445,6 @@ const chartTypeLine = (
       type: "line",
       x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
       y: y - cy,
-      startArrowhead: null,
-      endArrowhead: null,
       height: cy,
       strokeStyle: "dotted",
       opacity: GRID_OPACITY,

+ 9 - 2
packages/excalidraw/components/Actions.tsx

@@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
 import { capitalizeString, isTransparent } from "../utils";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
-import { hasStrokeColor } from "../scene/comparisons";
+import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
 import { trackEvent } from "../analytics";
 import {
   hasBoundTextElement,
+  isElbowArrow,
   isLinearElement,
   isTextElement,
 } from "../element/typeChecks";
@@ -121,7 +122,8 @@ export const SelectedShapeActions = ({
   const showLineEditorAction =
     !appState.editingLinearElement &&
     targetElements.length === 1 &&
-    isLinearElement(targetElements[0]);
+    isLinearElement(targetElements[0]) &&
+    !isElbowArrow(targetElements[0]);
 
   return (
     <div className="panelColumn">
@@ -155,6 +157,11 @@ export const SelectedShapeActions = ({
         <>{renderAction("changeRoundness")}</>
       )}
 
+      {(toolIsArrow(appState.activeTool.type) ||
+        targetElements.some((element) => toolIsArrow(element.type))) && (
+        <>{renderAction("changeArrowType")}</>
+      )}
+
       {(appState.activeTool.type === "text" ||
         targetElements.some(isTextElement)) && (
         <>

+ 213 - 70
packages/excalidraw/components/App.tsx

@@ -48,7 +48,7 @@ import {
 } from "../appState";
 import type { PastedMixedContent } from "../clipboard";
 import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
-import type { EXPORT_IMAGE_TYPES } from "../constants";
+import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
 import {
   APP_NAME,
   CURSOR_TYPE,
@@ -142,6 +142,7 @@ import {
   newEmbeddableElement,
   newMagicFrameElement,
   newIframeElement,
+  newArrowElement,
 } from "../element/newElement";
 import {
   hasBoundTextElement,
@@ -160,6 +161,7 @@ import {
   isIframeLikeElement,
   isMagicFrameElement,
   isTextBindableContainer,
+  isElbowArrow,
 } from "../element/typeChecks";
 import type {
   ExcalidrawBindableElement,
@@ -181,6 +183,7 @@ import type {
   ExcalidrawIframeElement,
   ExcalidrawEmbeddableElement,
   Ordered,
+  ExcalidrawArrowElement,
 } from "../element/types";
 import { getCenter, getDistance } from "../gesture";
 import {
@@ -425,6 +428,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts";
 import { actionTextAutoResize } from "../actions/actionTextAutoResize";
 import { getVisibleSceneBounds } from "../element/bounds";
 import { isMaybeMermaidDefinition } from "../mermaid";
+import { mutateElbowArrow } from "../element/routing";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -2112,6 +2116,14 @@ class App extends React.Component<AppProps, AppState> {
     });
   };
 
+  public dismissLinearEditor = () => {
+    setTimeout(() => {
+      this.setState({
+        editingLinearElement: null,
+      });
+    });
+  };
+
   public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
     if (this.unmounted || actionResult === false) {
       return;
@@ -2803,6 +2815,7 @@ class App extends React.Component<AppProps, AppState> {
           ),
         ),
         this.scene.getNonDeletedElementsMap(),
+        this.scene.getNonDeletedElements(),
       );
     }
 
@@ -3947,14 +3960,27 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (isArrowKey(event.key)) {
-        const step =
-          (this.state.gridSize &&
+        const selectedElements = this.scene.getSelectedElements({
+          selectedElementIds: this.state.selectedElementIds,
+          includeBoundTextElement: true,
+          includeElementsInFrames: true,
+        });
+
+        const elbowArrow = selectedElements.find(isElbowArrow) as
+          | ExcalidrawArrowElement
+          | undefined;
+
+        const step = elbowArrow
+          ? elbowArrow.startBinding || elbowArrow.endBinding
+            ? 0
+            : ELEMENT_TRANSLATE_AMOUNT
+          : (this.state.gridSize &&
+              (event.shiftKey
+                ? ELEMENT_TRANSLATE_AMOUNT
+                : this.state.gridSize)) ||
             (event.shiftKey
-              ? ELEMENT_TRANSLATE_AMOUNT
-              : this.state.gridSize)) ||
-          (event.shiftKey
-            ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
-            : ELEMENT_TRANSLATE_AMOUNT);
+              ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
+              : ELEMENT_TRANSLATE_AMOUNT);
 
         let offsetX = 0;
         let offsetY = 0;
@@ -3969,26 +3995,27 @@ class App extends React.Component<AppProps, AppState> {
           offsetY = step;
         }
 
-        const selectedElements = this.scene.getSelectedElements({
-          selectedElementIds: this.state.selectedElementIds,
-          includeBoundTextElement: true,
-          includeElementsInFrames: true,
-        });
-
         selectedElements.forEach((element) => {
           mutateElement(element, {
             x: element.x + offsetX,
             y: element.y + offsetY,
           });
 
-          updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
-            simultaneouslyUpdated: selectedElements,
-          });
+          updateBoundElements(
+            element,
+            this.scene.getNonDeletedElementsMap(),
+            this.scene,
+            {
+              simultaneouslyUpdated: selectedElements,
+            },
+          );
         });
 
         this.setState({
           suggestedBindings: getSuggestedBindingsForArrows(
-            selectedElements,
+            selectedElements.filter(
+              (element) => element.id !== elbowArrow?.id || step !== 0,
+            ),
             this.scene.getNonDeletedElementsMap(),
           ),
         });
@@ -4006,11 +4033,13 @@ class App extends React.Component<AppProps, AppState> {
                   selectedElements[0].id
               ) {
                 this.store.shouldCaptureIncrement();
-                this.setState({
-                  editingLinearElement: new LinearElementEditor(
-                    selectedElement,
-                  ),
-                });
+                if (!isElbowArrow(selectedElement)) {
+                  this.setState({
+                    editingLinearElement: new LinearElementEditor(
+                      selectedElement,
+                    ),
+                  });
+                }
               }
             }
           } else if (
@@ -4058,6 +4087,16 @@ class App extends React.Component<AppProps, AppState> {
               })`,
             );
           }
+          if (shape === "arrow" && this.state.activeTool.type === "arrow") {
+            this.setState((prevState) => ({
+              currentItemArrowType:
+                prevState.currentItemArrowType === ARROW_TYPE.sharp
+                  ? ARROW_TYPE.round
+                  : prevState.currentItemArrowType === ARROW_TYPE.round
+                  ? ARROW_TYPE.elbow
+                  : ARROW_TYPE.sharp,
+            }));
+          }
           this.setActiveTool({ type: shape });
           event.stopPropagation();
         } else if (event.key === KEYS.Q) {
@@ -4191,6 +4230,8 @@ class App extends React.Component<AppProps, AppState> {
       bindOrUnbindLinearElements(
         this.scene.getSelectedElements(this.state).filter(isLinearElement),
         this.scene.getNonDeletedElementsMap(),
+        this.scene.getNonDeletedElements(),
+        this.scene,
         isBindingEnabled(this.state),
         this.state.selectedLinearElement?.selectedPointsIndices ?? [],
       );
@@ -4422,7 +4463,7 @@ class App extends React.Component<AppProps, AppState> {
       onChange: withBatchedUpdates((nextOriginalText) => {
         updateElement(nextOriginalText, false);
         if (isNonDeletedElement(element)) {
-          updateBoundElements(element, elementsMap);
+          updateBoundElements(element, elementsMap, this.scene);
         }
       }),
       onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
@@ -4871,7 +4912,9 @@ class App extends React.Component<AppProps, AppState> {
       if (
         event[KEYS.CTRL_OR_CMD] &&
         (!this.state.editingLinearElement ||
-          this.state.editingLinearElement.elementId !== selectedElements[0].id)
+          this.state.editingLinearElement.elementId !==
+            selectedElements[0].id) &&
+        !isElbowArrow(selectedElements[0])
       ) {
         this.store.shouldCaptureIncrement();
         this.setState({
@@ -5214,7 +5257,7 @@ class App extends React.Component<AppProps, AppState> {
         scenePointerX,
         scenePointerY,
         this.state,
-        this.scene.getNonDeletedElementsMap(),
+        this.scene,
       );
 
       if (
@@ -5301,7 +5344,9 @@ class App extends React.Component<AppProps, AppState> {
         const [gridX, gridY] = getGridPoint(
           scenePointerX,
           scenePointerY,
-          event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+          event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
+            ? null
+            : this.state.gridSize,
         );
 
         const [lastCommittedX, lastCommittedY] =
@@ -5325,16 +5370,35 @@ class App extends React.Component<AppProps, AppState> {
         if (isPathALoop(points, this.state.zoom.value)) {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
         }
-        // update last uncommitted point
-        mutateElement(multiElement, {
-          points: [
-            ...points.slice(0, -1),
+        if (isElbowArrow(multiElement)) {
+          mutateElbowArrow(
+            multiElement,
+            this.scene,
             [
-              lastCommittedX + dxFromLastCommitted,
-              lastCommittedY + dyFromLastCommitted,
+              ...points.slice(0, -1),
+              [
+                lastCommittedX + dxFromLastCommitted,
+                lastCommittedY + dyFromLastCommitted,
+              ],
             ],
-          ],
-        });
+            undefined,
+            undefined,
+            {
+              isDragging: true,
+            },
+          );
+        } else {
+          // update last uncommitted point
+          mutateElement(multiElement, {
+            points: [
+              ...points.slice(0, -1),
+              [
+                lastCommittedX + dxFromLastCommitted,
+                lastCommittedY + dyFromLastCommitted,
+              ],
+            ],
+          });
+        }
       }
 
       return;
@@ -5369,8 +5433,9 @@ class App extends React.Component<AppProps, AppState> {
       }
 
       if (
-        !this.state.selectedLinearElement ||
-        this.state.selectedLinearElement.hoverPointIndex === -1
+        (!this.state.selectedLinearElement ||
+          this.state.selectedLinearElement.hoverPointIndex === -1) &&
+        !(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
       ) {
         const elementWithTransformHandleType =
           getElementWithTransformHandleType(
@@ -5658,7 +5723,12 @@ class App extends React.Component<AppProps, AppState> {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
         }
       } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
-        setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
+        if (
+          !isElbowArrow(element) ||
+          !(element.startBinding || element.endBinding)
+        ) {
+          setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
+        }
       }
 
       if (
@@ -6232,6 +6302,7 @@ class App extends React.Component<AppProps, AppState> {
     const origin = viewportCoordsToSceneCoords(event, this.state);
     const selectedElements = this.scene.getSelectedElements(this.state);
     const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
+    const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
 
     return {
       origin,
@@ -6240,7 +6311,9 @@ class App extends React.Component<AppProps, AppState> {
         getGridPoint(
           origin.x,
           origin.y,
-          event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
+          event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
+            ? null
+            : this.state.gridSize,
         ),
       ),
       scrollbars: isOverScrollBars(
@@ -6421,7 +6494,7 @@ class App extends React.Component<AppProps, AppState> {
             this.store,
             pointerDownState.origin,
             linearElementEditor,
-            this,
+            this.scene,
           );
           if (ret.hitElement) {
             pointerDownState.hit.element = ret.hitElement;
@@ -6753,6 +6826,7 @@ class App extends React.Component<AppProps, AppState> {
 
     const boundElement = getHoveredElementForBinding(
       pointerDownState.origin,
+      this.scene.getNonDeletedElements(),
       this.scene.getNonDeletedElementsMap(),
     );
     this.scene.insertElement(element);
@@ -6923,6 +6997,17 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
+      // Elbow arrows cannot be created by putting down points
+      // only the start and end points can be defined
+      if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
+        mutateElement(multiElement, {
+          lastCommittedPoint:
+            multiElement.points[multiElement.points.length - 1],
+        });
+        this.actionManager.executeAction(actionFinalize);
+        return;
+      }
+
       const { x: rx, y: ry, lastCommittedPoint } = multiElement;
 
       // clicking inside commit zone → finalize arrow
@@ -6978,26 +7063,50 @@ class App extends React.Component<AppProps, AppState> {
           ? [currentItemStartArrowhead, currentItemEndArrowhead]
           : [null, null];
 
-      const element = newLinearElement({
-        type: elementType,
-        x: gridX,
-        y: gridY,
-        strokeColor: this.state.currentItemStrokeColor,
-        backgroundColor: this.state.currentItemBackgroundColor,
-        fillStyle: this.state.currentItemFillStyle,
-        strokeWidth: this.state.currentItemStrokeWidth,
-        strokeStyle: this.state.currentItemStrokeStyle,
-        roughness: this.state.currentItemRoughness,
-        opacity: this.state.currentItemOpacity,
-        roundness:
-          this.state.currentItemRoundness === "round"
-            ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
-            : null,
-        startArrowhead,
-        endArrowhead,
-        locked: false,
-        frameId: topLayerFrame ? topLayerFrame.id : null,
-      });
+      const element =
+        elementType === "arrow"
+          ? newArrowElement({
+              type: elementType,
+              x: gridX,
+              y: gridY,
+              strokeColor: this.state.currentItemStrokeColor,
+              backgroundColor: this.state.currentItemBackgroundColor,
+              fillStyle: this.state.currentItemFillStyle,
+              strokeWidth: this.state.currentItemStrokeWidth,
+              strokeStyle: this.state.currentItemStrokeStyle,
+              roughness: this.state.currentItemRoughness,
+              opacity: this.state.currentItemOpacity,
+              roundness:
+                this.state.currentItemArrowType === ARROW_TYPE.round
+                  ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+                  : // note, roundness doesn't have any effect for elbow arrows,
+                    // but it's best to set it to null as well
+                    null,
+              startArrowhead,
+              endArrowhead,
+              locked: false,
+              frameId: topLayerFrame ? topLayerFrame.id : null,
+              elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
+            })
+          : newLinearElement({
+              type: elementType,
+              x: gridX,
+              y: gridY,
+              strokeColor: this.state.currentItemStrokeColor,
+              backgroundColor: this.state.currentItemBackgroundColor,
+              fillStyle: this.state.currentItemFillStyle,
+              strokeWidth: this.state.currentItemStrokeWidth,
+              strokeStyle: this.state.currentItemStrokeStyle,
+              roughness: this.state.currentItemRoughness,
+              opacity: this.state.currentItemOpacity,
+              roundness:
+                this.state.currentItemRoundness === "round"
+                  ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+                  : null,
+              locked: false,
+              frameId: topLayerFrame ? topLayerFrame.id : null,
+            });
+
       this.setState((prevState) => {
         const nextSelectedElementIds = {
           ...prevState.selectedElementIds,
@@ -7015,7 +7124,9 @@ class App extends React.Component<AppProps, AppState> {
       });
       const boundElement = getHoveredElementForBinding(
         pointerDownState.origin,
+        this.scene.getNonDeletedElements(),
         this.scene.getNonDeletedElementsMap(),
+        isElbowArrow(element),
       );
 
       this.scene.insertElement(element);
@@ -7352,7 +7463,7 @@ class App extends React.Component<AppProps, AppState> {
             );
           },
           linearElementEditor,
-          this.scene.getNonDeletedElementsMap(),
+          this.scene,
         );
         if (didDrag) {
           pointerDownState.lastCoords.x = pointerCoords.x;
@@ -7476,18 +7587,24 @@ class App extends React.Component<AppProps, AppState> {
               pointerDownState,
               selectedElements,
               dragOffset,
-              this.state,
               this.scene,
               snapOffset,
               event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
             );
 
-          this.setState({
-            suggestedBindings: getSuggestedBindingsForArrows(
-              selectedElements,
-              this.scene.getNonDeletedElementsMap(),
-            ),
-          });
+          if (
+            selectedElements.length !== 1 ||
+            !isElbowArrow(selectedElements[0])
+          ) {
+            this.setState({
+              suggestedBindings: getSuggestedBindingsForArrows(
+                selectedElements,
+                this.scene.getNonDeletedElementsMap(),
+              ),
+            });
+          }
+
+          //}
 
           // We duplicate the selected element if alt is pressed on pointer move
           if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
@@ -7627,6 +7744,17 @@ class App extends React.Component<AppProps, AppState> {
           mutateElement(draggingElement, {
             points: [...points, [dx, dy]],
           });
+        } else if (points.length > 1 && isElbowArrow(draggingElement)) {
+          mutateElbowArrow(
+            draggingElement,
+            this.scene,
+            [...points.slice(0, -1), [dx, dy]],
+            [0, 0],
+            undefined,
+            {
+              isDragging: true,
+            },
+          );
         } else if (points.length === 2) {
           mutateElement(draggingElement, {
             points: [...points.slice(0, -1), [dx, dy]],
@@ -7832,7 +7960,7 @@ class App extends React.Component<AppProps, AppState> {
             childEvent,
             this.state.editingLinearElement,
             this.state,
-            this,
+            this.scene,
           );
           if (editingLinearElement !== this.state.editingLinearElement) {
             this.setState({
@@ -7856,7 +7984,7 @@ class App extends React.Component<AppProps, AppState> {
             childEvent,
             this.state.selectedLinearElement,
             this.state,
-            this,
+            this.scene,
           );
 
           const { startBindingElement, endBindingElement } =
@@ -7868,6 +7996,7 @@ class App extends React.Component<AppProps, AppState> {
               startBindingElement,
               endBindingElement,
               elementsMap,
+              this.scene,
             );
           }
 
@@ -8007,6 +8136,7 @@ class App extends React.Component<AppProps, AppState> {
               this.state,
               pointerCoords,
               this.scene.getNonDeletedElementsMap(),
+              this.scene.getNonDeletedElements(),
             );
           }
           this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -8568,6 +8698,8 @@ class App extends React.Component<AppProps, AppState> {
         bindOrUnbindLinearElements(
           linearElements,
           this.scene.getNonDeletedElementsMap(),
+          this.scene.getNonDeletedElements(),
+          this.scene,
           isBindingEnabled(this.state),
           this.state.selectedLinearElement?.selectedPointsIndices ?? [],
         );
@@ -9055,6 +9187,7 @@ class App extends React.Component<AppProps, AppState> {
   }): void => {
     const hoveredBindableElement = getHoveredElementForBinding(
       pointerCoords,
+      this.scene.getNonDeletedElements(),
       this.scene.getNonDeletedElementsMap(),
     );
     this.setState({
@@ -9082,7 +9215,9 @@ class App extends React.Component<AppProps, AppState> {
       (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
         const hoveredBindableElement = getHoveredElementForBinding(
           coords,
+          this.scene.getNonDeletedElements(),
           this.scene.getNonDeletedElementsMap(),
+          isArrowElement(linearElement) && isElbowArrow(linearElement),
         );
         if (
           hoveredBindableElement != null &&
@@ -9610,6 +9745,7 @@ class App extends React.Component<AppProps, AppState> {
         resizeY,
         pointerDownState.resize.center.x,
         pointerDownState.resize.center.y,
+        this.scene,
       )
     ) {
       const suggestedBindings = getSuggestedBindingsForArrows(
@@ -9926,6 +10062,7 @@ class App extends React.Component<AppProps, AppState> {
 declare global {
   interface Window {
     h: {
+      scene: Scene;
       elements: readonly ExcalidrawElement[];
       state: AppState;
       setState: React.Component<any, AppState>["setState"];
@@ -9952,6 +10089,12 @@ export const createTestHook = () => {
           );
         },
       },
+      scene: {
+        configurable: true,
+        get() {
+          return this.app?.scene;
+        },
+      },
     });
   }
 };

+ 6 - 3
packages/excalidraw/components/HintViewer.tsx

@@ -30,10 +30,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
     return t("hints.eraserRevert");
   }
   if (activeTool.type === "arrow" || activeTool.type === "line") {
-    if (!multiMode) {
-      return t("hints.linearElement");
+    if (multiMode) {
+      return t("hints.linearElementMulti");
     }
-    return t("hints.linearElementMulti");
+    if (activeTool.type === "arrow") {
+      return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
+    }
+    return t("hints.linearElement");
   }
 
   if (activeTool.type === "freedraw") {

+ 5 - 4
packages/excalidraw/components/Stats/Angle.tsx

@@ -1,6 +1,6 @@
 import { mutateElement } from "../../element/mutateElement";
 import { getBoundTextElement } from "../../element/textElement";
-import { isArrowElement } from "../../element/typeChecks";
+import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
 import type { ExcalidrawElement } from "../../element/types";
 import { degreeToRadian, radianToDegree } from "../../math";
 import { angleIcon } from "../icons";
@@ -27,8 +27,9 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
-  if (origElement) {
+  if (origElement && !isElbowArrow(origElement)) {
     const latestElement = elementsMap.get(origElement.id);
     if (!latestElement) {
       return;
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
       mutateElement(latestElement, {
         angle: nextAngle,
       });
-      updateBindings(latestElement, elementsMap);
+      updateBindings(latestElement, elementsMap, elements, scene);
 
       const boundTextElement = getBoundTextElement(latestElement, elementsMap);
       if (boundTextElement && !isArrowElement(latestElement)) {
@@ -65,7 +66,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     mutateElement(latestElement, {
       angle: nextAngle,
     });
-    updateBindings(latestElement, elementsMap);
+    updateBindings(latestElement, elementsMap, elements, scene);
 
     const boundTextElement = getBoundTextElement(latestElement, elementsMap);
     if (boundTextElement && !isArrowElement(latestElement)) {

+ 5 - 0
packages/excalidraw/components/Stats/Dimension.tsx

@@ -31,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType<
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
   if (origElement) {
     const keepAspectRatio =
@@ -61,6 +62,8 @@ const handleDimensionChange: DragInputCallbackType<
         keepAspectRatio,
         origElement,
         elementsMap,
+        elements,
+        scene,
       );
 
       return;
@@ -103,6 +106,8 @@ const handleDimensionChange: DragInputCallbackType<
       keepAspectRatio,
       origElement,
       elementsMap,
+      elements,
+      scene,
     );
   }
 };

+ 2 - 2
packages/excalidraw/components/Stats/DragInput.tsx

@@ -25,9 +25,9 @@ export type DragInputCallbackType<
   originalElementsMap: ElementsMap;
   shouldKeepAspectRatio: boolean;
   shouldChangeByStepSize: boolean;
+  scene: Scene;
   nextValue?: number;
   property: P;
-  scene: Scene;
   originalAppState: AppState;
 }) => void;
 
@@ -122,9 +122,9 @@ const StatsDragInput = <
         originalElementsMap: app.scene.getNonDeletedElementsMap(),
         shouldKeepAspectRatio: shouldKeepAspectRatio!!,
         shouldChangeByStepSize: false,
+        scene,
         nextValue: rounded,
         property,
-        scene,
         originalAppState: appState,
       });
       app.syncActionResult({ storeAction: StoreAction.CAPTURE });

+ 20 - 3
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -66,8 +66,10 @@ const resizeElementInGroup = (
   origElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
   originalElementsMap: ElementsMap,
+  scene: Scene,
 ) => {
   const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
+  const { width: oldWidth, height: oldHeight } = latestElement;
 
   mutateElement(latestElement, updates, false);
   const boundTextElement = getBoundTextElement(
@@ -76,8 +78,8 @@ const resizeElementInGroup = (
   );
   if (boundTextElement) {
     const newFontSize = boundTextElement.fontSize * scale;
-    updateBoundElements(latestElement, elementsMap, {
-      newSize: { width: updates.width, height: updates.height },
+    updateBoundElements(latestElement, elementsMap, scene, {
+      oldSize: { width: oldWidth, height: oldHeight },
     });
     const latestBoundTextElement = elementsMap.get(boundTextElement.id);
     if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -109,6 +111,7 @@ const resizeGroup = (
   originalElements: ExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
   originalElementsMap: ElementsMap,
+  scene: Scene,
 ) => {
   // keep aspect ratio for groups
   if (property === "width") {
@@ -132,6 +135,7 @@ const resizeGroup = (
       origElement,
       elementsMap,
       originalElementsMap,
+      scene,
     );
   }
 };
@@ -149,6 +153,7 @@ const handleDimensionChange: DragInputCallbackType<
   property,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const atomicUnits = getAtomicUnits(originalElements, originalAppState);
   if (nextValue !== undefined) {
     for (const atomicUnit of atomicUnits) {
@@ -185,6 +190,7 @@ const handleDimensionChange: DragInputCallbackType<
           originalElements,
           elementsMap,
           originalElementsMap,
+          scene,
         );
       } else {
         const [el] = elementsInUnit;
@@ -227,6 +233,8 @@ const handleDimensionChange: DragInputCallbackType<
             false,
             origElement,
             elementsMap,
+            elements,
+            scene,
             false,
           );
         }
@@ -288,6 +296,7 @@ const handleDimensionChange: DragInputCallbackType<
         originalElements,
         elementsMap,
         originalElementsMap,
+        scene,
       );
     } else {
       const [el] = elementsInUnit;
@@ -320,7 +329,15 @@ const handleDimensionChange: DragInputCallbackType<
         nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
         nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
 
-        resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
+        resizeElement(
+          nextWidth,
+          nextHeight,
+          false,
+          origElement,
+          elementsMap,
+          elements,
+          scene,
+        );
       }
     }
   }

+ 12 - 0
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -1,6 +1,7 @@
 import type {
   ElementsMap,
   ExcalidrawElement,
+  NonDeletedExcalidrawElement,
   NonDeletedSceneElementsMap,
 } from "../../element/types";
 import { rotate } from "../../math";
@@ -33,6 +34,7 @@ const moveElements = (
   originalElements: readonly ExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
   originalElementsMap: ElementsMap,
+  scene: Scene,
 ) => {
   for (let i = 0; i < elements.length; i++) {
     const origElement = originalElements[i];
@@ -60,6 +62,8 @@ const moveElements = (
       newTopLeftY,
       origElement,
       elementsMap,
+      elements,
+      scene,
       originalElementsMap,
       false,
     );
@@ -71,6 +75,7 @@ const moveGroupTo = (
   nextY: number,
   originalElements: ExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
   originalElementsMap: ElementsMap,
   scene: Scene,
 ) => {
@@ -106,6 +111,8 @@ const moveGroupTo = (
         topLeftY + offsetY,
         origElement,
         elementsMap,
+        elements,
+        scene,
         originalElementsMap,
         false,
       );
@@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType<
   originalAppState,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
 
   if (nextValue !== undefined) {
     for (const atomicUnit of getAtomicUnits(
@@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType<
           newTopLeftY,
           elementsInUnit.map((el) => el.original),
           elementsMap,
+          elements,
           originalElementsMap,
           scene,
         );
@@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType<
             newTopLeftY,
             origElement,
             elementsMap,
+            elements,
+            scene,
             originalElementsMap,
             false,
           );
@@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType<
     originalElements,
     elementsMap,
     originalElementsMap,
+    scene,
   );
 
   scene.triggerUpdate();

+ 6 - 1
packages/excalidraw/components/Stats/Position.tsx

@@ -26,6 +26,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
   scene,
 }) => {
   const elementsMap = scene.getNonDeletedElementsMap();
+  const elements = scene.getNonDeletedElements();
   const origElement = originalElements[0];
   const [cx, cy] = [
     origElement.x + origElement.width / 2,
@@ -47,6 +48,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
       newTopLeftY,
       origElement,
       elementsMap,
+      elements,
+      scene,
       originalElementsMap,
     );
     return;
@@ -78,6 +81,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
     newTopLeftY,
     origElement,
     elementsMap,
+    elements,
+    scene,
     originalElementsMap,
   );
 };
@@ -104,9 +109,9 @@ const Position = ({
       label={property === "x" ? "X" : "Y"}
       elements={[element]}
       dragInputCallback={handlePositionChange}
+      scene={scene}
       value={value}
       property={property}
-      scene={scene}
       appState={appState}
     />
   );

+ 9 - 6
packages/excalidraw/components/Stats/index.tsx

@@ -21,6 +21,7 @@ import type Scene from "../../scene/Scene";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { getAtomicUnits } from "./utils";
 import { STATS_PANELS } from "../../constants";
+import { isElbowArrow } from "../../element/typeChecks";
 
 interface StatsProps {
   scene: Scene;
@@ -209,12 +210,14 @@ export const StatsInner = memo(
                         scene={scene}
                         appState={appState}
                       />
-                      <Angle
-                        property="angle"
-                        element={singleElement}
-                        scene={scene}
-                        appState={appState}
-                      />
+                      {!isElbowArrow(singleElement) && (
+                        <Angle
+                          property="angle"
+                          element={singleElement}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      )}
                       <FontSize
                         property="fontSize"
                         element={singleElement}

+ 24 - 4
packages/excalidraw/components/Stats/utils.ts

@@ -31,6 +31,7 @@ import {
   isInGroup,
 } from "../../groups";
 import { rotate } from "../../math";
+import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
 import { getFontString } from "../../utils";
 
@@ -124,6 +125,8 @@ export const resizeElement = (
   keepAspectRatio: boolean,
   origElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   shouldInformMutation = true,
 ) => {
   const latestElement = elementsMap.get(origElement.id);
@@ -146,6 +149,8 @@ export const resizeElement = (
     nextHeight = Math.max(nextHeight, minHeight);
   }
 
+  const { width: oldWidth, height: oldHeight } = latestElement;
+
   mutateElement(
     latestElement,
     {
@@ -164,7 +169,7 @@ export const resizeElement = (
     },
     shouldInformMutation,
   );
-  updateBindings(latestElement, elementsMap, {
+  updateBindings(latestElement, elementsMap, elements, scene, {
     newSize: {
       width: nextWidth,
       height: nextHeight,
@@ -193,6 +198,10 @@ export const resizeElement = (
     }
   }
 
+  updateBoundElements(latestElement, elementsMap, scene, {
+    oldSize: { width: oldWidth, height: oldHeight },
+  });
+
   if (boundTextElement && boundTextFont) {
     mutateElement(boundTextElement, {
       fontSize: boundTextFont.fontSize,
@@ -206,6 +215,8 @@ export const moveElement = (
   newTopLeftY: number,
   originalElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   originalElementsMap: ElementsMap,
   shouldInformMutation = true,
 ) => {
@@ -244,7 +255,7 @@ export const moveElement = (
     },
     shouldInformMutation,
   );
-  updateBindings(latestElement, elementsMap);
+  updateBindings(latestElement, elementsMap, elements, scene);
 
   const boundTextElement = getBoundTextElement(
     originalElement,
@@ -288,14 +299,23 @@ export const getAtomicUnits = (
 export const updateBindings = (
   latestElement: ExcalidrawElement,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   options?: {
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
     newSize?: { width: number; height: number };
   },
 ) => {
   if (isLinearElement(latestElement)) {
-    bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
+    bindOrUnbindLinearElements(
+      [latestElement],
+      elementsMap,
+      elements,
+      scene,
+      true,
+      [],
+    );
   } else {
-    updateBoundElements(latestElement, elementsMap, options);
+    updateBoundElements(latestElement, elementsMap, scene, options);
   }
 };

+ 29 - 0
packages/excalidraw/components/icons.tsx

@@ -2095,6 +2095,35 @@ export const lineEditorIcon = createIcon(
   tablerIconProps,
 );
 
+// arrow-up-right (modified)
+export const sharpArrowIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 18l12 -12" />
+    <path d="M18 10v-4h-4" />
+  </g>,
+  tablerIconProps,
+);
+
+// arrow-guide (modified)
+export const elbowArrowIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
+    <path d="M18 4l3 3l-3 3" />
+  </g>,
+  tablerIconProps,
+);
+
+// arrow-ramp-right-2 (heavily modified)
+export const roundArrowIcon = createIcon(
+  <g>
+    <path d="M16,12L20,9L16,6" />
+    <path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
+  </g>,
+  tablerIconProps,
+);
+
 export const collapseDownIcon = createIcon(
   <g>
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />

+ 7 - 1
packages/excalidraw/constants.ts

@@ -1,5 +1,5 @@
 import cssVariables from "./css/variables.module.scss";
-import type { AppProps } from "./types";
+import type { AppProps, AppState } from "./types";
 import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
 import { COLOR_PALETTE } from "./colors";
 export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -421,3 +421,9 @@ export const DEFAULT_FILENAME = "Untitled";
 export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
 
 export const MIN_WIDTH_OR_HEIGHT = 1;
+
+export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
+  sharp: "sharp",
+  round: "round",
+  elbow: "elbow",
+};

+ 27 - 0
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -84,9 +84,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "ellipse-1",
+    "fixedPoint": null,
     "focus": -0.008153707962747813,
     "gap": 1,
   },
@@ -117,6 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id47",
+    "fixedPoint": null,
     "focus": -0.08139534883720931,
     "gap": 1,
   },
@@ -139,9 +142,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "ellipse-1",
+    "fixedPoint": null,
     "focus": 0.10666666666666667,
     "gap": 3.834326468444573,
   },
@@ -172,6 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "startArrowhead": null,
   "startBinding": {
     "elementId": "diamond-1",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -328,9 +334,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "text-2",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 205,
   },
@@ -361,6 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "startArrowhead": null,
   "startBinding": {
     "elementId": "text-1",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -429,9 +438,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "id40",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -462,6 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id39",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -604,9 +616,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "id44",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -637,6 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id43",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -824,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",
@@ -871,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "triangle",
   "endBinding": null,
   "fillStyle": "solid",
@@ -1463,9 +1480,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "Alice",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 5.299874999999986,
   },
@@ -1498,6 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "startArrowhead": null,
   "startBinding": {
     "elementId": "Bob",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -1525,9 +1545,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "B",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -1556,6 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "startArrowhead": null,
   "startBinding": {
     "elementId": "Bob",
+    "fixedPoint": null,
     "focus": 0,
     "gap": 1,
   },
@@ -1837,6 +1860,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",
@@ -1889,6 +1913,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",
@@ -1941,6 +1966,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",
@@ -1993,6 +2019,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
     },
   ],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",

+ 47 - 9
packages/excalidraw/data/restore.ts

@@ -1,4 +1,5 @@
 import type {
+  ExcalidrawArrowElement,
   ExcalidrawElement,
   ExcalidrawElementType,
   ExcalidrawLinearElement,
@@ -24,6 +25,7 @@ import {
 } from "../element";
 import {
   isArrowElement,
+  isElbowArrow,
   isLinearElement,
   isTextElement,
   isUsingAdaptiveRadius,
@@ -92,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
   return DEFAULT_FONT_FAMILY;
 };
 
-const repairBinding = (binding: PointBinding | null) => {
+const repairBinding = (
+  element: ExcalidrawLinearElement,
+  binding: PointBinding | null,
+): PointBinding | null => {
   if (!binding) {
     return null;
   }
-  return { ...binding, focus: binding.focus || 0 };
+
+  return {
+    ...binding,
+    focus: binding.focus || 0,
+    fixedPoint: isElbowArrow(element)
+      ? binding.fixedPoint ?? ([0, 0] as [number, number])
+      : null,
+  };
 };
 
 const restoreElementWithProperties = <
@@ -242,11 +254,7 @@ const restoreElement = (
     // @ts-ignore LEGACY type
     // eslint-disable-next-line no-fallthrough
     case "draw":
-    case "arrow": {
-      const {
-        startArrowhead = null,
-        endArrowhead = element.type === "arrow" ? "arrow" : null,
-      } = element;
+      const { startArrowhead = null, endArrowhead = null } = element;
       let x = element.x;
       let y = element.y;
       let points = // migrate old arrow model to new one
@@ -266,14 +274,44 @@ const restoreElement = (
           (element.type as ExcalidrawElementType | "draw") === "draw"
             ? "line"
             : element.type,
-        startBinding: repairBinding(element.startBinding),
-        endBinding: repairBinding(element.endBinding),
+        startBinding: repairBinding(element, element.startBinding),
+        endBinding: repairBinding(element, element.endBinding),
+        lastCommittedPoint: null,
+        startArrowhead,
+        endArrowhead,
+        points,
+        x,
+        y,
+        ...getSizeFromPoints(points),
+      });
+    case "arrow": {
+      const { startArrowhead = null, endArrowhead = "arrow" } = element;
+      let x = element.x;
+      let y = element.y;
+      let points = // migrate old arrow model to new one
+        !Array.isArray(element.points) || element.points.length < 2
+          ? [
+              [0, 0],
+              [element.width, element.height],
+            ]
+          : element.points;
+
+      if (points[0][0] !== 0 || points[0][1] !== 0) {
+        ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
+      }
+
+      // TODO: Separate arrow from linear element
+      return restoreElementWithProperties(element as ExcalidrawArrowElement, {
+        type: element.type,
+        startBinding: repairBinding(element, element.startBinding),
+        endBinding: repairBinding(element, element.endBinding),
         lastCommittedPoint: null,
         startArrowhead,
         endArrowhead,
         points,
         x,
         y,
+        elbowed: (element as ExcalidrawArrowElement).elbowed,
         ...getSizeFromPoints(points),
       });
     }

+ 1 - 0
packages/excalidraw/data/transform.test.ts

@@ -771,6 +771,7 @@ describe("Test Transform", () => {
       const [arrow, rect] = excalidrawElements;
       expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
         elementId: "rect-1",
+        fixedPoint: null,
         focus: 0,
         gap: 205,
       });

+ 6 - 3
packages/excalidraw/data/transform.ts

@@ -13,6 +13,7 @@ import {
 import { bindLinearElement } from "../element/binding";
 import type { ElementConstructorOpts } from "../element/newElement";
 import {
+  newArrowElement,
   newFrameElement,
   newImageElement,
   newMagicFrameElement,
@@ -51,6 +52,7 @@ import { getSizeFromPoints } from "../points";
 import { randomId } from "../random";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getLineHeight } from "../fonts";
+import { isArrowElement } from "../element/typeChecks";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";
@@ -545,7 +547,7 @@ export const convertToExcalidrawElements = (
       case "arrow": {
         const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
         const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
-        excalidrawElement = newLinearElement({
+        excalidrawElement = newArrowElement({
           width,
           height,
           endArrowhead: "arrow",
@@ -554,6 +556,7 @@ export const convertToExcalidrawElements = (
             [width, height],
           ],
           ...element,
+          type: "arrow",
         });
 
         Object.assign(
@@ -655,7 +658,7 @@ export const convertToExcalidrawElements = (
           elementStore.add(container);
           elementStore.add(text);
 
-          if (container.type === "arrow") {
+          if (isArrowElement(container)) {
             const originalStart =
               element.type === "arrow" ? element?.start : undefined;
             const originalEnd =
@@ -674,7 +677,7 @@ export const convertToExcalidrawElements = (
             }
             const { linearElement, startBoundElement, endBoundElement } =
               bindLinearElementToElement(
-                container as ExcalidrawArrowElement,
+                container,
                 originalStart,
                 originalEnd,
                 elementStore,

+ 606 - 75
packages/excalidraw/element/binding.ts

@@ -22,8 +22,12 @@ import type {
   NonDeletedSceneElementsMap,
   ExcalidrawTextElement,
   ExcalidrawArrowElement,
+  OrderedExcalidrawElement,
+  ExcalidrawElbowArrowElement,
+  FixedPoint,
 } from "./types";
 
+import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import type { AppState, Point } from "../types";
 import { isPointOnShape } from "../../utils/collision";
@@ -33,17 +37,38 @@ import {
   isBindableElement,
   isBindingElement,
   isBoundToContainer,
+  isElbowArrow,
   isLinearElement,
   isTextElement,
 } from "./typeChecks";
 import type { ElementUpdate } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
-import Scene from "../scene/Scene";
+import type Scene from "../scene/Scene";
 import { LinearElementEditor } from "./linearElementEditor";
 import { arrayToMap, tupleToCoors } from "../utils";
 import { KEYS } from "../keys";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
 import { getElementShape } from "../shapes";
+import {
+  aabbForElement,
+  clamp,
+  distanceSq2d,
+  getCenterForBounds,
+  getCenterForElement,
+  pointInsideBounds,
+  pointToVector,
+  rotatePoint,
+} from "../math";
+import {
+  compareHeading,
+  HEADING_DOWN,
+  HEADING_LEFT,
+  HEADING_RIGHT,
+  HEADING_UP,
+  headingForPointFromElement,
+  vectorToHeading,
+  type Heading,
+} from "./heading";
 
 export type SuggestedBinding =
   | NonDeleted<ExcalidrawBindableElement>
@@ -65,6 +90,8 @@ export const isBindingEnabled = (appState: AppState): boolean => {
   return appState.isBindingEnabled;
 };
 
+export const FIXED_BINDING_DISTANCE = 5;
+
 const getNonDeletedElements = (
   scene: Scene,
   ids: readonly ExcalidrawElement["id"][],
@@ -84,6 +111,7 @@ export const bindOrUnbindLinearElement = (
   startBindingElement: ExcalidrawBindableElement | null | "keep",
   endBindingElement: ExcalidrawBindableElement | null | "keep",
   elementsMap: NonDeletedSceneElementsMap,
+  scene: Scene,
 ): void => {
   const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
   const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@@ -95,6 +123,7 @@ export const bindOrUnbindLinearElement = (
     boundToElementIds,
     unboundFromElementIds,
     elementsMap,
+    scene,
   );
   bindOrUnbindLinearElementEdge(
     linearElement,
@@ -104,22 +133,21 @@ export const bindOrUnbindLinearElement = (
     boundToElementIds,
     unboundFromElementIds,
     elementsMap,
+    scene,
   );
 
   const onlyUnbound = Array.from(unboundFromElementIds).filter(
     (id) => !boundToElementIds.has(id),
   );
 
-  getNonDeletedElements(Scene.getScene(linearElement)!, onlyUnbound).forEach(
-    (element) => {
-      mutateElement(element, {
-        boundElements: element.boundElements?.filter(
-          (element) =>
-            element.type !== "arrow" || element.id !== linearElement.id,
-        ),
-      });
-    },
-  );
+  getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
+    mutateElement(element, {
+      boundElements: element.boundElements?.filter(
+        (element) =>
+          element.type !== "arrow" || element.id !== linearElement.id,
+      ),
+    });
+  });
 };
 
 const bindOrUnbindLinearElementEdge = (
@@ -132,6 +160,7 @@ const bindOrUnbindLinearElementEdge = (
   // Is mutated
   unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
   elementsMap: NonDeletedSceneElementsMap,
+  scene: Scene,
 ): void => {
   // "keep" is for method chaining convenience, a "no-op", so just bail out
   if (bindableElement === "keep") {
@@ -217,6 +246,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
   isBindingEnabled: boolean,
   draggingPoints: readonly number[],
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
 ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
   const startIdx = 0;
   const endIdx = selectedElement.points.length - 1;
@@ -228,6 +258,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
           selectedElement,
           "start",
           elementsMap,
+          elements,
         )
       : null // If binding is disabled and start is dragged, break all binds
     : // We have to update the focus and gap of the binding, so let's rebind
@@ -235,6 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
         selectedElement,
         "start",
         elementsMap,
+        elements,
       );
   const end = endDragged
     ? isBindingEnabled
@@ -242,10 +274,16 @@ const getBindingStrategyForDraggingArrowEndpoints = (
           selectedElement,
           "end",
           elementsMap,
+          elements,
         )
       : null // If binding is disabled and end is dragged, break all binds
     : // We have to update the focus and gap of the binding, so let's rebind
-      getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
+      getElligibleElementForBindingElement(
+        selectedElement,
+        "end",
+        elementsMap,
+        elements,
+      );
 
   return [start, end];
 };
@@ -253,6 +291,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
 const getBindingStrategyForDraggingArrowOrJoints = (
   selectedElement: NonDeleted<ExcalidrawLinearElement>,
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
   isBindingEnabled: boolean,
 ): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
   const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
@@ -265,6 +304,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
           selectedElement,
           "start",
           elementsMap,
+          elements,
         )
       : null
     : null;
@@ -274,6 +314,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
           selectedElement,
           "end",
           elementsMap,
+          elements,
         )
       : null
     : null;
@@ -284,6 +325,8 @@ const getBindingStrategyForDraggingArrowOrJoints = (
 export const bindOrUnbindLinearElements = (
   selectedElements: NonDeleted<ExcalidrawLinearElement>[],
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
+  scene: Scene,
   isBindingEnabled: boolean,
   draggingPoints: readonly number[] | null,
 ): void => {
@@ -295,15 +338,17 @@ export const bindOrUnbindLinearElements = (
           isBindingEnabled,
           draggingPoints ?? [],
           elementsMap,
+          elements,
         )
       : // The arrow itself (the shaft) or the inner joins are dragged
         getBindingStrategyForDraggingArrowOrJoints(
           selectedElement,
           elementsMap,
+          elements,
           isBindingEnabled,
         );
 
-    bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
+    bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
   });
 };
 
@@ -343,6 +388,7 @@ export const maybeBindLinearElement = (
   appState: AppState,
   pointerCoords: { x: number; y: number },
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
 ): void => {
   if (appState.startBoundElement != null) {
     bindLinearElement(
@@ -352,19 +398,24 @@ export const maybeBindLinearElement = (
       elementsMap,
     );
   }
+
   const hoveredElement = getHoveredElementForBinding(
     pointerCoords,
+    elements,
     elementsMap,
+    isElbowArrow(linearElement) && isElbowArrow(linearElement),
   );
-  if (
-    hoveredElement != null &&
-    !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
-      linearElement,
-      hoveredElement,
-      "end",
-    )
-  ) {
-    bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
+
+  if (hoveredElement !== null) {
+    if (
+      !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+        linearElement,
+        hoveredElement,
+        "end",
+      )
+    ) {
+      bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
+    }
   }
 };
 
@@ -377,16 +428,26 @@ export const bindLinearElement = (
   if (!isArrowElement(linearElement)) {
     return;
   }
+  const binding: PointBinding = {
+    elementId: hoveredElement.id,
+    ...calculateFocusAndGap(
+      linearElement,
+      hoveredElement,
+      startOrEnd,
+      elementsMap,
+    ),
+    ...(isElbowArrow(linearElement)
+      ? calculateFixedPointForElbowArrowBinding(
+          linearElement,
+          hoveredElement,
+          startOrEnd,
+          elementsMap,
+        )
+      : { fixedPoint: null }),
+  };
+
   mutateElement(linearElement, {
-    [startOrEnd === "start" ? "startBinding" : "endBinding"]: {
-      elementId: hoveredElement.id,
-      ...calculateFocusAndGap(
-        linearElement,
-        hoveredElement,
-        startOrEnd,
-        elementsMap,
-      ),
-    } as PointBinding,
+    [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
   });
 
   const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
@@ -448,13 +509,15 @@ export const getHoveredElementForBinding = (
     x: number;
     y: number;
   },
+  elements: readonly NonDeletedExcalidrawElement[],
   elementsMap: NonDeletedSceneElementsMap,
+  fullShape?: boolean,
 ): NonDeleted<ExcalidrawBindableElement> | null => {
   const hoveredElement = getElementAtPosition(
-    [...elementsMap].map(([_, value]) => value),
+    elements,
     (element) =>
       isBindableElement(element, false) &&
-      bindingBorderTest(element, pointerCoords, elementsMap),
+      bindingBorderTest(element, pointerCoords, elementsMap, fullShape),
   );
   return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
 };
@@ -501,12 +564,14 @@ const calculateFocusAndGap = (
 export const updateBoundElements = (
   changedElement: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,
+  scene: Scene,
   options?: {
     simultaneouslyUpdated?: readonly ExcalidrawElement[];
-    newSize?: { width: number; height: number };
+    oldSize?: { width: number; height: number };
+    changedElements?: Map<string, OrderedExcalidrawElement>;
   },
 ) => {
-  const { newSize, simultaneouslyUpdated } = options ?? {};
+  const { oldSize, simultaneouslyUpdated, changedElements } = options ?? {};
   const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
     simultaneouslyUpdated,
   );
@@ -524,16 +589,17 @@ export const updateBoundElements = (
     if (!doesNeedUpdate(element, changedElement)) {
       return;
     }
+
     const bindings = {
       startBinding: maybeCalculateNewGapWhenScaling(
         changedElement,
         element.startBinding,
-        newSize,
+        oldSize,
       ),
       endBinding: maybeCalculateNewGapWhenScaling(
         changedElement,
         element.endBinding,
-        newSize,
+        oldSize,
       ),
     };
 
@@ -543,23 +609,58 @@ export const updateBoundElements = (
       return;
     }
 
-    bindableElementsVisitor(
+    const updates = bindableElementsVisitor(
       elementsMap,
       element,
       (bindableElement, bindingProp) => {
         if (
           bindableElement &&
           isBindableElement(bindableElement) &&
-          (bindingProp === "startBinding" || bindingProp === "endBinding")
+          (bindingProp === "startBinding" || bindingProp === "endBinding") &&
+          changedElement.id === element[bindingProp]?.elementId
         ) {
-          updateBoundPoint(
+          const point = updateBoundPoint(
             element,
             bindingProp,
             bindings[bindingProp],
             bindableElement,
             elementsMap,
           );
+          if (point) {
+            return {
+              index:
+                bindingProp === "startBinding" ? 0 : element.points.length - 1,
+              point,
+            };
+          }
         }
+
+        return null;
+      },
+    ).filter(
+      (
+        update,
+      ): update is NonNullable<{
+        index: number;
+        point: Point;
+        isDragging?: boolean;
+      }> => update !== null,
+    );
+
+    LinearElementEditor.movePoints(
+      element,
+      updates,
+      scene,
+      {
+        ...(changedElement.id === element.startBinding?.elementId
+          ? { startBinding: bindings.startBinding }
+          : {}),
+        ...(changedElement.id === element.endBinding?.elementId
+          ? { endBinding: bindings.endBinding }
+          : {}),
+      },
+      {
+        changedElements,
       },
     );
 
@@ -586,24 +687,342 @@ const getSimultaneouslyUpdatedElementIds = (
   return new Set((simultaneouslyUpdated || []).map((element) => element.id));
 };
 
+export const getHeadingForElbowArrowSnap = (
+  point: Readonly<Point>,
+  otherPoint: Readonly<Point>,
+  bindableElement: ExcalidrawBindableElement | undefined | null,
+  aabb: Bounds | undefined | null,
+  elementsMap: ElementsMap,
+  origPoint: Point,
+): Heading => {
+  const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
+
+  if (!bindableElement || !aabb) {
+    return otherPointHeading;
+  }
+
+  const distance = getDistanceForBinding(
+    origPoint,
+    bindableElement,
+    elementsMap,
+  );
+
+  if (!distance) {
+    return vectorToHeading(
+      pointToVector(point, getCenterForElement(bindableElement)),
+    );
+  }
+
+  const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
+
+  return pointHeading;
+};
+
+const getDistanceForBinding = (
+  point: Readonly<Point>,
+  bindableElement: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
+) => {
+  const distance = distanceToBindableElement(
+    bindableElement,
+    point,
+    elementsMap,
+  );
+  const bindDistance = maxBindingGap(
+    bindableElement,
+    bindableElement.width,
+    bindableElement.height,
+  );
+
+  return distance > bindDistance ? null : distance;
+};
+
+export const bindPointToSnapToElementOutline = (
+  point: Readonly<Point>,
+  otherPoint: Readonly<Point>,
+  bindableElement: ExcalidrawBindableElement | undefined,
+  elementsMap: ElementsMap,
+): Point => {
+  const aabb = bindableElement && aabbForElement(bindableElement);
+
+  if (bindableElement && aabb) {
+    // TODO: Dirty hack until tangents are properly calculated
+    const intersections = [
+      ...intersectElementWithLine(
+        bindableElement,
+        [point[0], point[1] - 2 * bindableElement.height],
+        [point[0], point[1] + 2 * bindableElement.height],
+        FIXED_BINDING_DISTANCE,
+        elementsMap,
+      ),
+      ...intersectElementWithLine(
+        bindableElement,
+        [point[0] - 2 * bindableElement.width, point[1]],
+        [point[0] + 2 * bindableElement.width, point[1]],
+        FIXED_BINDING_DISTANCE,
+        elementsMap,
+      ),
+    ].map((i) =>
+      distanceToBindableElement(bindableElement, i, elementsMap) >
+      Math.min(bindableElement.width, bindableElement.height) / 2
+        ? ([-1 * i[0], -1 * i[1]] as Point)
+        : i,
+    );
+
+    const heading = headingForPointFromElement(bindableElement, aabb, point);
+    const isVertical =
+      compareHeading(heading, HEADING_LEFT) ||
+      compareHeading(heading, HEADING_RIGHT);
+    const dist = distanceToBindableElement(bindableElement, point, elementsMap);
+    const isInner = isVertical
+      ? dist < bindableElement.width * -0.1
+      : dist < bindableElement.height * -0.1;
+
+    intersections.sort(
+      (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
+    );
+
+    return isInner
+      ? headingToMidBindPoint(otherPoint, bindableElement, aabb)
+      : intersections.filter((i) =>
+          isVertical
+            ? Math.abs(point[1] - i[1]) < 0.1
+            : Math.abs(point[0] - i[0]) < 0.1,
+        )[0] ?? point;
+  }
+
+  return point;
+};
+
+const headingToMidBindPoint = (
+  point: Point,
+  bindableElement: ExcalidrawBindableElement,
+  aabb: Bounds,
+): Point => {
+  const center = getCenterForBounds(aabb);
+  const heading = vectorToHeading(pointToVector(point, center));
+
+  switch (true) {
+    case compareHeading(heading, HEADING_UP):
+      return rotatePoint(
+        [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
+        center,
+        bindableElement.angle,
+      );
+    case compareHeading(heading, HEADING_RIGHT):
+      return rotatePoint(
+        [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
+        center,
+        bindableElement.angle,
+      );
+    case compareHeading(heading, HEADING_DOWN):
+      return rotatePoint(
+        [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
+        center,
+        bindableElement.angle,
+      );
+    default:
+      return rotatePoint(
+        [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
+        center,
+        bindableElement.angle,
+      );
+  }
+};
+
+export const avoidRectangularCorner = (
+  element: ExcalidrawBindableElement,
+  p: Point,
+): Point => {
+  const center = getCenterForElement(element);
+  const nonRotatedPoint = rotatePoint(p, center, -element.angle);
+
+  if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
+    // Top left
+    if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
+      return rotatePoint(
+        [element.x - FIXED_BINDING_DISTANCE, element.y],
+        center,
+        element.angle,
+      );
+    }
+    return rotatePoint(
+      [element.x, element.y - FIXED_BINDING_DISTANCE],
+      center,
+      element.angle,
+    );
+  } else if (
+    nonRotatedPoint[0] < element.x &&
+    nonRotatedPoint[1] > element.y + element.height
+  ) {
+    // Bottom left
+    if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
+      return rotatePoint(
+        [element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
+        center,
+        element.angle,
+      );
+    }
+    return rotatePoint(
+      [element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
+      center,
+      element.angle,
+    );
+  } else if (
+    nonRotatedPoint[0] > element.x + element.width &&
+    nonRotatedPoint[1] > element.y + element.height
+  ) {
+    // Bottom right
+    if (
+      nonRotatedPoint[0] - element.x <
+      element.width + FIXED_BINDING_DISTANCE
+    ) {
+      return rotatePoint(
+        [
+          element.x + element.width,
+          element.y + element.height + FIXED_BINDING_DISTANCE,
+        ],
+        center,
+        element.angle,
+      );
+    }
+    return rotatePoint(
+      [
+        element.x + element.width + FIXED_BINDING_DISTANCE,
+        element.y + element.height,
+      ],
+      center,
+      element.angle,
+    );
+  } else if (
+    nonRotatedPoint[0] > element.x + element.width &&
+    nonRotatedPoint[1] < element.y
+  ) {
+    // Top right
+    if (
+      nonRotatedPoint[0] - element.x <
+      element.width + FIXED_BINDING_DISTANCE
+    ) {
+      return rotatePoint(
+        [element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
+        center,
+        element.angle,
+      );
+    }
+    return rotatePoint(
+      [element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
+      center,
+      element.angle,
+    );
+  }
+
+  return p;
+};
+
+export const snapToMid = (
+  element: ExcalidrawBindableElement,
+  p: Point,
+  tolerance: number = 0.05,
+): Point => {
+  const { x, y, width, height, angle } = element;
+  const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
+  const nonRotated = rotatePoint(p, center, -angle);
+
+  // snap-to-center point is adaptive to element size, but we don't want to go
+  // above and below certain px distance
+  const verticalThrehsold = clamp(tolerance * height, 5, 80);
+  const horizontalThrehsold = clamp(tolerance * width, 5, 80);
+
+  if (
+    nonRotated[0] <= x + width / 2 &&
+    nonRotated[1] > center[1] - verticalThrehsold &&
+    nonRotated[1] < center[1] + verticalThrehsold
+  ) {
+    // LEFT
+    return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
+  } else if (
+    nonRotated[1] <= y + height / 2 &&
+    nonRotated[0] > center[0] - horizontalThrehsold &&
+    nonRotated[0] < center[0] + horizontalThrehsold
+  ) {
+    // TOP
+    return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
+  } else if (
+    nonRotated[0] >= x + width / 2 &&
+    nonRotated[1] > center[1] - verticalThrehsold &&
+    nonRotated[1] < center[1] + verticalThrehsold
+  ) {
+    // RIGHT
+    return rotatePoint(
+      [x + width + FIXED_BINDING_DISTANCE, center[1]],
+      center,
+      angle,
+    );
+  } else if (
+    nonRotated[1] >= y + height / 2 &&
+    nonRotated[0] > center[0] - horizontalThrehsold &&
+    nonRotated[0] < center[0] + horizontalThrehsold
+  ) {
+    // DOWN
+    return rotatePoint(
+      [center[0], y + height + FIXED_BINDING_DISTANCE],
+      center,
+      angle,
+    );
+  }
+
+  return p;
+};
+
 const updateBoundPoint = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   startOrEnd: "startBinding" | "endBinding",
   binding: PointBinding | null | undefined,
   bindableElement: ExcalidrawBindableElement,
   elementsMap: ElementsMap,
-): void => {
+): Point | null => {
   if (
     binding == null ||
     // We only need to update the other end if this is a 2 point line element
     (binding.elementId !== bindableElement.id &&
       linearElement.points.length > 2)
   ) {
-    return;
+    return null;
   }
 
   const direction = startOrEnd === "startBinding" ? -1 : 1;
   const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
+
+  if (isElbowArrow(linearElement)) {
+    const fixedPoint =
+      binding.fixedPoint ??
+      calculateFixedPointForElbowArrowBinding(
+        linearElement,
+        bindableElement,
+        startOrEnd === "startBinding" ? "start" : "end",
+        elementsMap,
+      ).fixedPoint;
+    const globalMidPoint = [
+      bindableElement.x + bindableElement.width / 2,
+      bindableElement.y + bindableElement.height / 2,
+    ] as Point;
+    const global = [
+      bindableElement.x + fixedPoint[0] * bindableElement.width,
+      bindableElement.y + fixedPoint[1] * bindableElement.height,
+    ] as Point;
+    const rotatedGlobal = rotatePoint(
+      global,
+      globalMidPoint,
+      bindableElement.angle,
+    );
+
+    return LinearElementEditor.pointFromAbsoluteCoords(
+      linearElement,
+      rotatedGlobal,
+      elementsMap,
+    );
+  }
+
   const adjacentPointIndex = edgePointIndex - direction;
   const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
     linearElement,
@@ -616,7 +1035,9 @@ const updateBoundPoint = (
     adjacentPoint,
     elementsMap,
   );
-  let newEdgePoint;
+
+  let newEdgePoint: Point;
+
   // The linear element was not originally pointing inside the bound shape,
   // we can point directly at the focus point
   if (binding.gap === 0) {
@@ -638,20 +1059,62 @@ const updateBoundPoint = (
       newEdgePoint = intersections[0];
     }
   }
-  LinearElementEditor.movePoints(
+
+  return LinearElementEditor.pointFromAbsoluteCoords(
     linearElement,
-    [
-      {
-        index: edgePointIndex,
-        point: LinearElementEditor.pointFromAbsoluteCoords(
-          linearElement,
-          newEdgePoint,
-          elementsMap,
-        ),
-      },
-    ],
-    { [startOrEnd]: binding },
+    newEdgePoint,
+    elementsMap,
+  );
+};
+
+export const calculateFixedPointForElbowArrowBinding = (
+  linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
+  hoveredElement: ExcalidrawBindableElement,
+  startOrEnd: "start" | "end",
+  elementsMap: ElementsMap,
+): { fixedPoint: FixedPoint } => {
+  const bounds = [
+    hoveredElement.x,
+    hoveredElement.y,
+    hoveredElement.x + hoveredElement.width,
+    hoveredElement.y + hoveredElement.height,
+  ] as Bounds;
+  const edgePointIndex =
+    startOrEnd === "start" ? 0 : linearElement.points.length - 1;
+  const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+    linearElement,
+    edgePointIndex,
+    elementsMap,
+  );
+  const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+    linearElement,
+    edgePointIndex,
+    elementsMap,
+  );
+  const snappedPoint = bindPointToSnapToElementOutline(
+    globalPoint,
+    otherGlobalPoint,
+    hoveredElement,
+    elementsMap,
   );
+  const globalMidPoint = [
+    bounds[0] + (bounds[2] - bounds[0]) / 2,
+    bounds[1] + (bounds[3] - bounds[1]) / 2,
+  ] as Point;
+  const nonRotatedSnappedGlobalPoint = rotatePoint(
+    snappedPoint,
+    globalMidPoint,
+    -hoveredElement.angle,
+  ) as Point;
+
+  return {
+    fixedPoint: [
+      (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
+        hoveredElement.width,
+      (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
+        hoveredElement.height,
+    ] as [number, number],
+  };
 };
 
 const maybeCalculateNewGapWhenScaling = (
@@ -662,26 +1125,29 @@ const maybeCalculateNewGapWhenScaling = (
   if (currentBinding == null || newSize == null) {
     return currentBinding;
   }
-  const { gap, focus, elementId } = currentBinding;
   const { width: newWidth, height: newHeight } = newSize;
   const { width, height } = changedElement;
   const newGap = Math.max(
     1,
     Math.min(
       maxBindingGap(changedElement, newWidth, newHeight),
-      gap * (newWidth < newHeight ? newWidth / width : newHeight / height),
+      currentBinding.gap *
+        (newWidth < newHeight ? newWidth / width : newHeight / height),
     ),
   );
-  return { elementId, gap: newGap, focus };
+
+  return { ...currentBinding, gap: newGap };
 };
 
 const getElligibleElementForBindingElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   startOrEnd: "start" | "end",
   elementsMap: NonDeletedSceneElementsMap,
+  elements: readonly NonDeletedExcalidrawElement[],
 ): NonDeleted<ExcalidrawBindableElement> | null => {
   return getHoveredElementForBinding(
     getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
+    elements,
     elementsMap,
   );
 };
@@ -798,11 +1264,9 @@ const newBindingAfterDuplication = (
   if (binding == null) {
     return null;
   }
-  const { elementId, focus, gap } = binding;
   return {
-    focus,
-    gap,
-    elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
+    ...binding,
+    elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
   };
 };
 
@@ -843,14 +1307,18 @@ const newBoundElements = (
   return nextBoundElements;
 };
 
-const bindingBorderTest = (
+export const bindingBorderTest = (
   element: NonDeleted<ExcalidrawBindableElement>,
   { x, y }: { x: number; y: number },
-  elementsMap: ElementsMap,
+  elementsMap: NonDeletedSceneElementsMap,
+  fullShape?: boolean,
 ): boolean => {
   const threshold = maxBindingGap(element, element.width, element.height);
   const shape = getElementShape(element, elementsMap);
-  return isPointOnShape([x, y], shape, threshold);
+  return (
+    isPointOnShape([x, y], shape, threshold) ||
+    (fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
+  );
 };
 
 export const maxBindingGap = (
@@ -865,7 +1333,7 @@ export const maxBindingGap = (
   return Math.max(16, Math.min(0.25 * smallerDimension, 32));
 };
 
-const distanceToBindableElement = (
+export const distanceToBindableElement = (
   element: ExcalidrawBindableElement,
   point: Point,
   elementsMap: ElementsMap,
@@ -1408,11 +1876,11 @@ type BoundElementsVisitingFunc = (
   bindingId: string,
 ) => void;
 
-type BindableElementVisitingFunc = (
+type BindableElementVisitingFunc<T> = (
   bindableElement: ExcalidrawElement | undefined,
   bindingProp: BindingProp,
   bindingId: string,
-) => void;
+) => T;
 
 /**
  * Tries to visit each bound element (does not have to be found).
@@ -1436,32 +1904,36 @@ const boundElementsVisitor = (
 /**
  * Tries to visit each bindable element (does not have to be found).
  */
-const bindableElementsVisitor = (
+const bindableElementsVisitor = <T>(
   elements: ElementsMap,
   element: ExcalidrawElement,
-  visit: BindableElementVisitingFunc,
-) => {
+  visit: BindableElementVisitingFunc<T>,
+): T[] => {
+  const result: T[] = [];
+
   if (element.frameId) {
     const id = element.frameId;
-    visit(elements.get(id), "frameId", id);
+    result.push(visit(elements.get(id), "frameId", id));
   }
 
   if (isBoundToContainer(element)) {
     const id = element.containerId;
-    visit(elements.get(id), "containerId", id);
+    result.push(visit(elements.get(id), "containerId", id));
   }
 
   if (isArrowElement(element)) {
     if (element.startBinding) {
       const id = element.startBinding.elementId;
-      visit(elements.get(id), "startBinding", id);
+      result.push(visit(elements.get(id), "startBinding", id));
     }
 
     if (element.endBinding) {
       const id = element.endBinding.elementId;
-      visit(elements.get(id), "endBinding", id);
+      result.push(visit(elements.get(id), "endBinding", id));
     }
   }
+
+  return result;
 };
 
 /**
@@ -1689,3 +2161,62 @@ export class BindableElement {
     );
   };
 }
+
+export const getGlobalFixedPointForBindableElement = (
+  fixedPointRatio: [number, number],
+  element: ExcalidrawBindableElement,
+) => {
+  const [fixedX, fixedY] = fixedPointRatio;
+  return rotatePoint(
+    [element.x + element.width * fixedX, element.y + element.height * fixedY],
+    getCenterForElement(element),
+    element.angle,
+  );
+};
+
+const getGlobalFixedPoints = (
+  arrow: ExcalidrawElbowArrowElement,
+  elementsMap: ElementsMap,
+) => {
+  const startElement =
+    arrow.startBinding &&
+    (elementsMap.get(arrow.startBinding.elementId) as
+      | ExcalidrawBindableElement
+      | undefined);
+  const endElement =
+    arrow.endBinding &&
+    (elementsMap.get(arrow.endBinding.elementId) as
+      | ExcalidrawBindableElement
+      | undefined);
+  const startPoint: Point =
+    startElement && arrow.startBinding
+      ? getGlobalFixedPointForBindableElement(
+          arrow.startBinding.fixedPoint,
+          startElement as ExcalidrawBindableElement,
+        )
+      : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
+  const endPoint: Point =
+    endElement && arrow.endBinding
+      ? getGlobalFixedPointForBindableElement(
+          arrow.endBinding.fixedPoint,
+          endElement as ExcalidrawBindableElement,
+        )
+      : [
+          arrow.x + arrow.points[arrow.points.length - 1][0],
+          arrow.y + arrow.points[arrow.points.length - 1][1],
+        ];
+
+  return [startPoint, endPoint];
+};
+
+export const getArrowLocalFixedPoints = (
+  arrow: ExcalidrawElbowArrowElement,
+  elementsMap: ElementsMap,
+) => {
+  const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
+
+  return [
+    LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
+    LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
+  ];
+};

+ 29 - 5
packages/excalidraw/element/dragElements.ts

@@ -10,6 +10,7 @@ import { getGridPoint } from "../math";
 import type Scene from "../scene/Scene";
 import {
   isArrowElement,
+  isElbowArrow,
   isFrameLikeElement,
   isTextElement,
 } from "./typeChecks";
@@ -18,9 +19,8 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
 
 export const dragSelectedElements = (
   pointerDownState: PointerDownState,
-  selectedElements: NonDeletedExcalidrawElement[],
+  _selectedElements: NonDeletedExcalidrawElement[],
   offset: { x: number; y: number },
-  appState: AppState,
   scene: Scene,
   snapOffset: {
     x: number;
@@ -28,6 +28,25 @@ export const dragSelectedElements = (
   },
   gridSize: AppState["gridSize"],
 ) => {
+  if (
+    _selectedElements.length === 1 &&
+    isArrowElement(_selectedElements[0]) &&
+    isElbowArrow(_selectedElements[0]) &&
+    (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
+  ) {
+    return;
+  }
+
+  const selectedElements = _selectedElements.filter(
+    (el) =>
+      !(
+        isArrowElement(el) &&
+        isElbowArrow(el) &&
+        el.startBinding &&
+        el.endBinding
+      ),
+  );
+
   // 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
   // in the frame twice, hence the use of set
@@ -72,9 +91,14 @@ export const dragSelectedElements = (
         updateElementCoords(pointerDownState, textElement, adjustedOffset);
       }
     }
-    updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
-      simultaneouslyUpdated: Array.from(elementsToUpdate),
-    });
+    updateBoundElements(
+      element,
+      scene.getElementsMapIncludingDeleted(),
+      scene,
+      {
+        simultaneouslyUpdated: Array.from(elementsToUpdate),
+      },
+    );
   });
 };
 

+ 146 - 0
packages/excalidraw/element/heading.ts

@@ -0,0 +1,146 @@
+import { lineAngle } from "../../utils/geometry/geometry";
+import type { Point, Vector } from "../../utils/geometry/shape";
+import {
+  getCenterForBounds,
+  PointInTriangle,
+  rotatePoint,
+  scalePointFromOrigin,
+} from "../math";
+import type { Bounds } from "./bounds";
+import type { ExcalidrawBindableElement } from "./types";
+
+export const HEADING_RIGHT = [1, 0] as Heading;
+export const HEADING_DOWN = [0, 1] as Heading;
+export const HEADING_LEFT = [-1, 0] as Heading;
+export const HEADING_UP = [0, -1] as Heading;
+export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
+
+export const headingForDiamond = (a: Point, b: Point) => {
+  const angle = lineAngle([a, b]);
+  if (angle >= 315 || angle < 45) {
+    return HEADING_UP;
+  } else if (angle >= 45 && angle < 135) {
+    return HEADING_RIGHT;
+  } else if (angle >= 135 && angle < 225) {
+    return HEADING_DOWN;
+  }
+  return HEADING_LEFT;
+};
+
+export const vectorToHeading = (vec: Vector): Heading => {
+  const [x, y] = vec;
+  const absX = Math.abs(x);
+  const absY = Math.abs(y);
+  if (x > absY) {
+    return HEADING_RIGHT;
+  } else if (x <= -absY) {
+    return HEADING_LEFT;
+  } else if (y > absX) {
+    return HEADING_DOWN;
+  }
+  return HEADING_UP;
+};
+
+export const compareHeading = (a: Heading, b: Heading) =>
+  a[0] === b[0] && a[1] === b[1];
+
+// Gets the heading for the point by creating a bounding box around the rotated
+// close fitting bounding box, then creating 4 search cones around the center of
+// the external bbox.
+export const headingForPointFromElement = (
+  element: Readonly<ExcalidrawBindableElement>,
+  aabb: Readonly<Bounds>,
+  point: Readonly<Point>,
+): Heading => {
+  const SEARCH_CONE_MULTIPLIER = 2;
+
+  const midPoint = getCenterForBounds(aabb);
+
+  if (element.type === "diamond") {
+    if (point[0] < element.x) {
+      return HEADING_LEFT;
+    } else if (point[1] < element.y) {
+      return HEADING_UP;
+    } else if (point[0] > element.x + element.width) {
+      return HEADING_RIGHT;
+    } else if (point[1] > element.y + element.height) {
+      return HEADING_DOWN;
+    }
+
+    const top = rotatePoint(
+      scalePointFromOrigin(
+        [element.x + element.width / 2, element.y],
+        midPoint,
+        SEARCH_CONE_MULTIPLIER,
+      ),
+      midPoint,
+      element.angle,
+    );
+    const right = rotatePoint(
+      scalePointFromOrigin(
+        [element.x + element.width, element.y + element.height / 2],
+        midPoint,
+        SEARCH_CONE_MULTIPLIER,
+      ),
+      midPoint,
+      element.angle,
+    );
+    const bottom = rotatePoint(
+      scalePointFromOrigin(
+        [element.x + element.width / 2, element.y + element.height],
+        midPoint,
+        SEARCH_CONE_MULTIPLIER,
+      ),
+      midPoint,
+      element.angle,
+    );
+    const left = rotatePoint(
+      scalePointFromOrigin(
+        [element.x, element.y + element.height / 2],
+        midPoint,
+        SEARCH_CONE_MULTIPLIER,
+      ),
+      midPoint,
+      element.angle,
+    );
+
+    if (PointInTriangle(point, top, right, midPoint)) {
+      return headingForDiamond(top, right);
+    } else if (PointInTriangle(point, right, bottom, midPoint)) {
+      return headingForDiamond(right, bottom);
+    } else if (PointInTriangle(point, bottom, left, midPoint)) {
+      return headingForDiamond(bottom, left);
+    }
+
+    return headingForDiamond(left, top);
+  }
+
+  const topLeft = scalePointFromOrigin(
+    [aabb[0], aabb[1]],
+    midPoint,
+    SEARCH_CONE_MULTIPLIER,
+  );
+  const topRight = scalePointFromOrigin(
+    [aabb[2], aabb[1]],
+    midPoint,
+    SEARCH_CONE_MULTIPLIER,
+  );
+  const bottomLeft = scalePointFromOrigin(
+    [aabb[0], aabb[3]],
+    midPoint,
+    SEARCH_CONE_MULTIPLIER,
+  );
+  const bottomRight = scalePointFromOrigin(
+    [aabb[2], aabb[3]],
+    midPoint,
+    SEARCH_CONE_MULTIPLIER,
+  );
+
+  return PointInTriangle(point, topLeft, topRight, midPoint)
+    ? HEADING_UP
+    : PointInTriangle(point, topRight, bottomRight, midPoint)
+    ? HEADING_RIGHT
+    : PointInTriangle(point, bottomRight, bottomLeft, midPoint)
+    ? HEADING_DOWN
+    : HEADING_LEFT;
+};

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

@@ -11,6 +11,7 @@ export {
   newTextElement,
   refreshTextDimensions,
   newLinearElement,
+  newArrowElement,
   newImageElement,
   duplicateElement,
 } from "./newElement";

+ 245 - 79
packages/excalidraw/element/linearElementEditor.ts

@@ -7,6 +7,8 @@ import type {
   ExcalidrawTextElementWithContainer,
   ElementsMap,
   NonDeletedSceneElementsMap,
+  OrderedExcalidrawElement,
+  FixedPointBinding,
 } from "./types";
 import {
   distance2d,
@@ -33,7 +35,6 @@ import type {
   AppState,
   PointerCoords,
   InteractiveCanvasAppState,
-  AppClassProperties,
 } from "../types";
 import { mutateElement } from "./mutateElement";
 
@@ -43,13 +44,19 @@ import {
   isBindingEnabled,
 } from "./binding";
 import { tupleToCoors } from "../utils";
-import { isBindingElement } from "./typeChecks";
+import {
+  isBindingElement,
+  isElbowArrow,
+  isFixedPointBinding,
+} from "./typeChecks";
 import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
 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";
 
 const editorMidPointsCache: {
   version: number | null;
@@ -67,6 +74,7 @@ export class LinearElementEditor {
     prevSelectedPointsIndices: readonly number[] | null;
     /** index */
     lastClickedPoint: number;
+    lastClickedIsEndPoint: boolean;
     origin: Readonly<{ x: number; y: number }> | null;
     segmentMidpoint: {
       value: Point | null;
@@ -91,7 +99,9 @@ export class LinearElementEditor {
     this.elementId = element.id as string & {
       _brand: "excalidrawLinearElementId";
     };
-    LinearElementEditor.normalizePoints(element);
+    if (!arePointsEqual(element.points[0], [0, 0])) {
+      console.error("Linear element is not normalized", Error().stack);
+    }
 
     this.selectedPointsIndices = null;
     this.lastUncommittedPoint = null;
@@ -102,6 +112,7 @@ export class LinearElementEditor {
     this.pointerDownState = {
       prevSelectedPointsIndices: null,
       lastClickedPoint: -1,
+      lastClickedIsEndPoint: false,
       origin: null,
 
       segmentMidpoint: {
@@ -162,8 +173,8 @@ export class LinearElementEditor {
       elementsMap,
     );
 
-    const nextSelectedPoints = pointsSceneCoords.reduce(
-      (acc: number[], point, index) => {
+    const nextSelectedPoints = pointsSceneCoords
+      .reduce((acc: number[], point, index) => {
         if (
           (point[0] >= selectionX1 &&
             point[0] <= selectionX2 &&
@@ -175,9 +186,17 @@ export class LinearElementEditor {
         }
 
         return acc;
-      },
-      [],
-    );
+      }, [])
+      .filter((index) => {
+        if (
+          isElbowArrow(element) &&
+          index !== 0 &&
+          index !== element.points.length - 1
+        ) {
+          return false;
+        }
+        return true;
+      });
 
     setState({
       editingLinearElement: {
@@ -200,21 +219,52 @@ export class LinearElementEditor {
       pointSceneCoords: { x: number; y: number }[],
     ) => void,
     linearElementEditor: LinearElementEditor,
-    elementsMap: NonDeletedSceneElementsMap,
+    scene: Scene,
   ): boolean {
     if (!linearElementEditor) {
       return false;
     }
-    const { selectedPointsIndices, elementId } = linearElementEditor;
+    const { elementId } = linearElementEditor;
+    const elementsMap = scene.getNonDeletedElementsMap();
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     if (!element) {
       return false;
     }
 
+    if (
+      isElbowArrow(element) &&
+      !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
+      linearElementEditor.pointerDownState.lastClickedPoint !== 0
+    ) {
+      return false;
+    }
+
+    const selectedPointsIndices = isElbowArrow(element)
+      ? linearElementEditor.selectedPointsIndices
+          ?.reduce(
+            (startEnd, index) =>
+              (index === 0
+                ? [0, startEnd[1]]
+                : [startEnd[0], element.points.length - 1]) as [
+                boolean | number,
+                boolean | number,
+              ],
+            [false, false] as [number | boolean, number | boolean],
+          )
+          .filter(
+            (idx: number | boolean): idx is number => typeof idx === "number",
+          )
+      : linearElementEditor.selectedPointsIndices;
+    const lastClickedPoint = isElbowArrow(element)
+      ? linearElementEditor.pointerDownState.lastClickedPoint > 0
+        ? element.points.length - 1
+        : 0
+      : linearElementEditor.pointerDownState.lastClickedPoint;
+
     // point that's being dragged (out of all selected points)
-    const draggingPoint = element.points[
-      linearElementEditor.pointerDownState.lastClickedPoint
-    ] as [number, number] | undefined;
+    const draggingPoint = element.points[lastClickedPoint] as
+      | [number, number]
+      | undefined;
 
     if (selectedPointsIndices && draggingPoint) {
       if (
@@ -234,15 +284,17 @@ export class LinearElementEditor {
           event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
         );
 
-        LinearElementEditor.movePoints(element, [
-          {
-            index: selectedIndex,
-            point: [width + referencePoint[0], height + referencePoint[1]],
-            isDragging:
-              selectedIndex ===
-              linearElementEditor.pointerDownState.lastClickedPoint,
-          },
-        ]);
+        LinearElementEditor.movePoints(
+          element,
+          [
+            {
+              index: selectedIndex,
+              point: [width + referencePoint[0], height + referencePoint[1]],
+              isDragging: selectedIndex === lastClickedPoint,
+            },
+          ],
+          scene,
+        );
       } else {
         const newDraggingPointPosition = LinearElementEditor.createPointAt(
           element,
@@ -259,8 +311,7 @@ export class LinearElementEditor {
           element,
           selectedPointsIndices.map((pointIndex) => {
             const newPointPosition =
-              pointIndex ===
-              linearElementEditor.pointerDownState.lastClickedPoint
+              pointIndex === lastClickedPoint
                 ? LinearElementEditor.createPointAt(
                     element,
                     elementsMap,
@@ -275,11 +326,10 @@ export class LinearElementEditor {
             return {
               index: pointIndex,
               point: newPointPosition,
-              isDragging:
-                pointIndex ===
-                linearElementEditor.pointerDownState.lastClickedPoint,
+              isDragging: pointIndex === lastClickedPoint,
             };
           }),
+          scene,
         );
       }
 
@@ -334,9 +384,10 @@ export class LinearElementEditor {
     event: PointerEvent,
     editingLinearElement: LinearElementEditor,
     appState: AppState,
-    app: AppClassProperties,
+    scene: Scene,
   ): LinearElementEditor {
-    const elementsMap = app.scene.getNonDeletedElementsMap();
+    const elementsMap = scene.getNonDeletedElementsMap();
+    const elements = scene.getNonDeletedElements();
 
     const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
       editingLinearElement;
@@ -361,15 +412,19 @@ 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],
-              },
-            ]);
+            LinearElementEditor.movePoints(
+              element,
+              [
+                {
+                  index: selectedPoint,
+                  point:
+                    selectedPoint === 0
+                      ? element.points[element.points.length - 1]
+                      : element.points[0],
+                },
+              ],
+              scene,
+            );
           }
 
           const bindingElement = isBindingEnabled(appState)
@@ -381,6 +436,7 @@ export class LinearElementEditor {
                     elementsMap,
                   ),
                 ),
+                elements,
                 elementsMap,
               )
             : null;
@@ -645,13 +701,14 @@ export class LinearElementEditor {
     store: Store,
     scenePointer: { x: number; y: number },
     linearElementEditor: LinearElementEditor,
-    app: AppClassProperties,
+    scene: Scene,
   ): {
     didAddPoint: boolean;
     hitElement: NonDeleted<ExcalidrawElement> | null;
     linearElementEditor: LinearElementEditor | null;
   } {
-    const elementsMap = app.scene.getNonDeletedElementsMap();
+    const elementsMap = scene.getNonDeletedElementsMap();
+    const elements = scene.getNonDeletedElements();
 
     const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
       didAddPoint: false,
@@ -685,7 +742,10 @@ export class LinearElementEditor {
       );
     }
     if (event.altKey && appState.editingLinearElement) {
-      if (linearElementEditor.lastUncommittedPoint == null) {
+      if (
+        linearElementEditor.lastUncommittedPoint == null ||
+        !isElbowArrow(element)
+      ) {
         mutateElement(element, {
           points: [
             ...element.points,
@@ -706,6 +766,7 @@ export class LinearElementEditor {
         pointerDownState: {
           prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
           lastClickedPoint: -1,
+          lastClickedIsEndPoint: false,
           origin: { x: scenePointer.x, y: scenePointer.y },
           segmentMidpoint: {
             value: segmentMidpoint,
@@ -717,6 +778,7 @@ export class LinearElementEditor {
         lastUncommittedPoint: null,
         endBindingElement: getHoveredElementForBinding(
           scenePointer,
+          elements,
           elementsMap,
         ),
       };
@@ -749,6 +811,7 @@ export class LinearElementEditor {
           startBindingElement,
           endBindingElement,
           elementsMap,
+          scene,
         );
       }
     }
@@ -781,6 +844,7 @@ export class LinearElementEditor {
       pointerDownState: {
         prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
         lastClickedPoint: clickedPointIndex,
+        lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1,
         origin: { x: scenePointer.x, y: scenePointer.y },
         segmentMidpoint: {
           value: segmentMidpoint,
@@ -815,12 +879,13 @@ export class LinearElementEditor {
     scenePointerX: number,
     scenePointerY: number,
     appState: AppState,
-    elementsMap: ElementsMap,
+    scene: Scene,
   ): LinearElementEditor | null {
     if (!appState.editingLinearElement) {
       return null;
     }
     const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
+    const elementsMap = scene.getNonDeletedElementsMap();
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     if (!element) {
       return appState.editingLinearElement;
@@ -831,7 +896,7 @@ export class LinearElementEditor {
 
     if (!event.altKey) {
       if (lastPoint === lastUncommittedPoint) {
-        LinearElementEditor.deletePoints(element, [points.length - 1]);
+        LinearElementEditor.deletePoints(element, [points.length - 1], scene);
       }
       return {
         ...appState.editingLinearElement,
@@ -862,19 +927,30 @@ export class LinearElementEditor {
         elementsMap,
         scenePointerX - appState.editingLinearElement.pointerOffset.x,
         scenePointerY - appState.editingLinearElement.pointerOffset.y,
-        event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+        event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
+          ? null
+          : appState.gridSize,
       );
     }
 
     if (lastPoint === lastUncommittedPoint) {
-      LinearElementEditor.movePoints(element, [
-        {
-          index: element.points.length - 1,
-          point: newPoint,
-        },
-      ]);
+      LinearElementEditor.movePoints(
+        element,
+        [
+          {
+            index: element.points.length - 1,
+            point: newPoint,
+          },
+        ],
+        scene,
+      );
     } else {
-      LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
+      LinearElementEditor.addPoints(
+        element,
+        appState,
+        [{ point: newPoint }],
+        scene,
+      );
     }
     return {
       ...appState.editingLinearElement,
@@ -938,6 +1014,11 @@ export class LinearElementEditor {
     absoluteCoords: Point,
     elementsMap: ElementsMap,
   ): Point {
+    if (isElbowArrow(element)) {
+      // No rotation for elbow arrows
+      return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
+    }
+
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
@@ -1028,13 +1109,13 @@ export class LinearElementEditor {
     mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
   }
 
-  static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) {
+  static duplicateSelectedPoints(appState: AppState, scene: Scene) {
     if (!appState.editingLinearElement) {
       return false;
     }
 
     const { selectedPointsIndices, elementId } = appState.editingLinearElement;
-
+    const elementsMap = scene.getNonDeletedElementsMap();
     const element = LinearElementEditor.getElement(elementId, elementsMap);
 
     if (!element || selectedPointsIndices === null) {
@@ -1077,12 +1158,16 @@ 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: [lastPoint[0] + 30, lastPoint[1] + 30],
-        },
-      ]);
+      LinearElementEditor.movePoints(
+        element,
+        [
+          {
+            index: element.points.length - 1,
+            point: [lastPoint[0] + 30, lastPoint[1] + 30],
+          },
+        ],
+        scene,
+      );
     }
 
     return {
@@ -1099,6 +1184,7 @@ export class LinearElementEditor {
   static deletePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     pointIndices: readonly number[],
+    scene: Scene,
   ) {
     let offsetX = 0;
     let offsetY = 0;
@@ -1126,25 +1212,46 @@ export class LinearElementEditor {
       return acc;
     }, []);
 
-    LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
+    LinearElementEditor._updatePoints(
+      element,
+      nextPoints,
+      offsetX,
+      offsetY,
+      scene,
+    );
   }
 
   static addPoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     appState: AppState,
     targetPoints: { point: Point }[],
+    scene: Scene,
   ) {
     const offsetX = 0;
     const offsetY = 0;
 
     const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
-    LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
+    LinearElementEditor._updatePoints(
+      element,
+      nextPoints,
+      offsetX,
+      offsetY,
+      scene,
+    );
   }
 
   static movePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
     targetPoints: { index: number; point: Point; isDragging?: boolean }[],
-    otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
+    scene: Scene,
+    otherUpdates?: {
+      startBinding?: PointBinding | null;
+      endBinding?: PointBinding | null;
+    },
+    options?: {
+      changedElements?: Map<string, OrderedExcalidrawElement>;
+      isDragging?: boolean;
+    },
   ) {
     const { points } = element;
 
@@ -1192,7 +1299,16 @@ export class LinearElementEditor {
       nextPoints,
       offsetX,
       offsetY,
+      scene,
       otherUpdates,
+      {
+        isDragging: targetPoints.reduce(
+          (dragging, targetPoint): boolean =>
+            dragging || targetPoint.isDragging === true,
+          false,
+        ),
+        changedElements: options?.changedElements,
+      },
     );
   }
 
@@ -1207,6 +1323,11 @@ export class LinearElementEditor {
       elementsMap,
     );
 
+    // Elbow arrows don't allow midpoints
+    if (element && isElbowArrow(element)) {
+      return false;
+    }
+
     if (!element) {
       return false;
     }
@@ -1266,7 +1387,7 @@ export class LinearElementEditor {
       elementsMap,
       pointerCoords.x,
       pointerCoords.y,
-      snapToGrid ? appState.gridSize : null,
+      snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
     );
     const points = [
       ...element.points.slice(0, segmentMidpoint.index!),
@@ -1295,23 +1416,61 @@ export class LinearElementEditor {
     nextPoints: readonly Point[],
     offsetX: number,
     offsetY: number,
-    otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
+    scene: Scene,
+    otherUpdates?: {
+      startBinding?: PointBinding | null;
+      endBinding?: PointBinding | null;
+    },
+    options?: {
+      changedElements?: Map<string, OrderedExcalidrawElement>;
+      isDragging?: boolean;
+    },
   ) {
-    const nextCoords = getElementPointsCoords(element, nextPoints);
-    const prevCoords = getElementPointsCoords(element, element.points);
-    const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
-    const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
-    const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
-    const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
-    const dX = prevCenterX - nextCenterX;
-    const dY = prevCenterY - nextCenterY;
-    const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
-    mutateElement(element, {
-      ...otherUpdates,
-      points: nextPoints,
-      x: element.x + rotated[0],
-      y: element.y + rotated[1],
-    });
+    if (isElbowArrow(element)) {
+      const bindings: {
+        startBinding?: FixedPointBinding | null;
+        endBinding?: FixedPointBinding | null;
+      } = {};
+      if (otherUpdates?.startBinding !== undefined) {
+        bindings.startBinding =
+          otherUpdates.startBinding !== null &&
+          isFixedPointBinding(otherUpdates.startBinding)
+            ? otherUpdates.startBinding
+            : null;
+      }
+      if (otherUpdates?.endBinding !== undefined) {
+        bindings.endBinding =
+          otherUpdates.endBinding !== null &&
+          isFixedPointBinding(otherUpdates.endBinding)
+            ? otherUpdates.endBinding
+            : null;
+      }
+
+      mutateElbowArrow(
+        element,
+        scene,
+        nextPoints,
+        [offsetX, offsetY],
+        bindings,
+        options,
+      );
+    } else {
+      const nextCoords = getElementPointsCoords(element, nextPoints);
+      const prevCoords = getElementPointsCoords(element, element.points);
+      const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
+      const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
+      const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
+      const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
+      const dX = prevCenterX - nextCenterX;
+      const dY = prevCenterY - nextCenterY;
+      const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
+      mutateElement(element, {
+        ...otherUpdates,
+        points: nextPoints,
+        x: element.x + rotated[0],
+        y: element.y + rotated[1],
+      });
+    }
   }
 
   private static _getShiftLockedDelta(
@@ -1327,6 +1486,13 @@ export class LinearElementEditor {
       elementsMap,
     );
 
+    if (isElbowArrow(element)) {
+      return [
+        scenePointer[0] - referencePointCoords[0],
+        scenePointer[1] - referencePointCoords[1],
+      ];
+    }
+
     const [gridX, gridY] = getGridPoint(
       scenePointer[0],
       scenePointer[1],

+ 7 - 0
packages/excalidraw/element/newElement.test.ts

@@ -121,6 +121,7 @@ describe("duplicating multiple elements", () => {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
     });
 
@@ -131,6 +132,7 @@ describe("duplicating multiple elements", () => {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
       boundElements: [{ id: "text2", type: "text" }],
     });
@@ -247,6 +249,7 @@ describe("duplicating multiple elements", () => {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
     });
 
@@ -263,11 +266,13 @@ describe("duplicating multiple elements", () => {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
       endBinding: {
         elementId: "rectangle-not-exists",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
     });
 
@@ -278,11 +283,13 @@ describe("duplicating multiple elements", () => {
         elementId: "rectangle-not-exists",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
       endBinding: {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
     });
 

+ 22 - 2
packages/excalidraw/element/newElement.ts

@@ -17,6 +17,7 @@ import type {
   ExcalidrawMagicFrameElement,
   ExcalidrawIframeElement,
   ElementsMap,
+  ExcalidrawArrowElement,
 } from "./types";
 import {
   arrayToMap,
@@ -388,8 +389,6 @@ export const newFreeDrawElement = (
 export const newLinearElement = (
   opts: {
     type: ExcalidrawLinearElement["type"];
-    startArrowhead?: Arrowhead | null;
-    endArrowhead?: Arrowhead | null;
     points?: ExcalidrawLinearElement["points"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawLinearElement> => {
@@ -399,8 +398,29 @@ export const newLinearElement = (
     lastCommittedPoint: null,
     startBinding: null,
     endBinding: null,
+    startArrowhead: null,
+    endArrowhead: null,
+  };
+};
+
+export const newArrowElement = (
+  opts: {
+    type: ExcalidrawArrowElement["type"];
+    startArrowhead?: Arrowhead | null;
+    endArrowhead?: Arrowhead | null;
+    points?: ExcalidrawArrowElement["points"];
+    elbowed?: boolean;
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawArrowElement> => {
+  return {
+    ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
+    points: opts.points || [],
+    lastCommittedPoint: null,
+    startBinding: null,
+    endBinding: null,
     startArrowhead: opts.startArrowhead || null,
     endArrowhead: opts.endArrowhead || null,
+    elbowed: opts.elbowed || false,
   };
 };
 

+ 62 - 25
packages/excalidraw/element/resizeElements.ts

@@ -22,6 +22,7 @@ import {
 import {
   isArrowElement,
   isBoundToContainer,
+  isElbowArrow,
   isFrameLikeElement,
   isFreeDrawElement,
   isImageElement,
@@ -30,7 +31,7 @@ import {
 } from "./typeChecks";
 import { mutateElement } from "./mutateElement";
 import { getFontString } from "../utils";
-import { updateBoundElements } from "./binding";
+import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
 import type {
   MaybeTransformHandleType,
   TransformHandleDirection,
@@ -51,6 +52,7 @@ import {
 } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
 import { isInGroup } from "../groups";
+import { mutateElbowArrow } from "./routing";
 
 export const normalizeAngle = (angle: number): number => {
   if (angle < 0) {
@@ -75,18 +77,21 @@ export const transformElements = (
   pointerY: number,
   centerX: number,
   centerY: number,
+  scene: Scene,
 ) => {
   if (selectedElements.length === 1) {
     const [element] = selectedElements;
     if (transformHandleType === "rotation") {
-      rotateSingleElement(
-        element,
-        elementsMap,
-        pointerX,
-        pointerY,
-        shouldRotateWithDiscreteAngle,
-      );
-      updateBoundElements(element, elementsMap);
+      if (!isElbowArrow(element)) {
+        rotateSingleElement(
+          element,
+          elementsMap,
+          pointerX,
+          pointerY,
+          shouldRotateWithDiscreteAngle,
+        );
+        updateBoundElements(element, elementsMap, scene);
+      }
     } else if (isTextElement(element) && transformHandleType) {
       resizeSingleTextElement(
         originalElements,
@@ -97,7 +102,7 @@ export const transformElements = (
         pointerX,
         pointerY,
       );
-      updateBoundElements(element, elementsMap);
+      updateBoundElements(element, elementsMap, scene);
     } else if (transformHandleType) {
       resizeSingleElement(
         originalElements,
@@ -108,6 +113,7 @@ export const transformElements = (
         shouldResizeFromCenter,
         pointerX,
         pointerY,
+        scene,
       );
     }
 
@@ -123,6 +129,7 @@ export const transformElements = (
         shouldRotateWithDiscreteAngle,
         centerX,
         centerY,
+        scene,
       );
       return true;
     } else if (transformHandleType) {
@@ -135,6 +142,7 @@ export const transformElements = (
         shouldMaintainAspectRatio,
         pointerX,
         pointerY,
+        scene,
       );
       return true;
     }
@@ -431,7 +439,17 @@ export const resizeSingleElement = (
   shouldResizeFromCenter: boolean,
   pointerX: number,
   pointerY: number,
+  scene: Scene,
 ) => {
+  // Elbow arrows cannot be resized when bound on either end
+  if (
+    isArrowElement(element) &&
+    isElbowArrow(element) &&
+    (element.startBinding || element.endBinding)
+  ) {
+    return;
+  }
+
   const stateAtResizeStart = originalElements.get(element.id)!;
   // Gets bounds corners
   const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
@@ -701,8 +719,11 @@ export const resizeSingleElement = (
   ) {
     mutateElement(element, resizedElement);
 
-    updateBoundElements(element, elementsMap, {
-      newSize: { width: resizedElement.width, height: resizedElement.height },
+    updateBoundElements(element, elementsMap, scene, {
+      oldSize: {
+        width: stateAtResizeStart.width,
+        height: stateAtResizeStart.height,
+      },
     });
 
     if (boundTextElement && boundTextFont != null) {
@@ -728,6 +749,7 @@ export const resizeMultipleElements = (
   shouldMaintainAspectRatio: boolean,
   pointerX: number,
   pointerY: number,
+  scene: Scene,
 ) => {
   // map selected elements to the original elements. While it never should
   // happen that pointerDownState.originalElements won't contain the selected
@@ -955,13 +977,20 @@ export const resizeMultipleElements = (
     element,
     update: { boundTextFontSize, ...update },
   } of elementsAndUpdates) {
-    const { width, height, angle } = update;
+    const { angle } = update;
+    const { width: oldWidth, height: oldHeight } = element;
 
     mutateElement(element, update, false);
 
-    updateBoundElements(element, elementsMap, {
+    if (isArrowElement(element) && isElbowArrow(element)) {
+      mutateElbowArrow(element, scene, element.points, undefined, undefined, {
+        informMutation: false,
+      });
+    }
+
+    updateBoundElements(element, elementsMap, scene, {
       simultaneouslyUpdated: elementsToUpdate,
-      newSize: { width, height },
+      oldSize: { width: oldWidth, height: oldHeight },
     });
 
     const boundTextElement = getBoundTextElement(element, elementsMap);
@@ -990,6 +1019,7 @@ const rotateMultipleElements = (
   shouldRotateWithDiscreteAngle: boolean,
   centerX: number,
   centerY: number,
+  scene: Scene,
 ) => {
   let centerAngle =
     (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
@@ -1013,16 +1043,23 @@ const rotateMultipleElements = (
         centerY,
         centerAngle + origAngle - element.angle,
       );
-      mutateElement(
-        element,
-        {
-          x: element.x + (rotatedCX - cx),
-          y: element.y + (rotatedCY - cy),
-          angle: normalizeAngle(centerAngle + origAngle),
-        },
-        false,
-      );
-      updateBoundElements(element, elementsMap, {
+
+      if (isArrowElement(element) && isElbowArrow(element)) {
+        const points = getArrowLocalFixedPoints(element, elementsMap);
+        mutateElbowArrow(element, scene, points);
+      } else {
+        mutateElement(
+          element,
+          {
+            x: element.x + (rotatedCX - cx),
+            y: element.y + (rotatedCY - cy),
+            angle: normalizeAngle(centerAngle + origAngle),
+          },
+          false,
+        );
+      }
+
+      updateBoundElements(element, elementsMap, scene, {
         simultaneouslyUpdated: elements,
       });
 

+ 216 - 0
packages/excalidraw/element/routing.test.tsx

@@ -0,0 +1,216 @@
+import React from "react";
+import Scene from "../scene/Scene";
+import { API } from "../tests/helpers/api";
+import { Pointer, UI } from "../tests/helpers/ui";
+import {
+  fireEvent,
+  GlobalTestState,
+  queryByTestId,
+  render,
+} from "../tests/test-utils";
+import { bindLinearElement } from "./binding";
+import { Excalidraw } from "../index";
+import { mutateElbowArrow } from "./routing";
+import type {
+  ExcalidrawArrowElement,
+  ExcalidrawBindableElement,
+  ExcalidrawElbowArrowElement,
+} from "./types";
+import { ARROW_TYPE } from "../constants";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+const editInput = (input: HTMLInputElement, value: string) => {
+  input.focus();
+  fireEvent.change(input, { target: { value } });
+  input.blur();
+};
+
+const getStatsProperty = (label: string) => {
+  const elementStats = UI.queryStats()?.querySelector("#elementStats");
+
+  if (elementStats) {
+    const properties = elementStats?.querySelector(".statsItem");
+    return (
+      properties?.querySelector?.(
+        `.drag-input-container[data-testid="${label}"]`,
+      ) || null
+    );
+  }
+
+  return null;
+};
+
+describe("elbow arrow routing", () => {
+  it("can properly generate orthogonal arrow points", () => {
+    const scene = new Scene();
+    const arrow = API.createElement({
+      type: "arrow",
+      elbowed: true,
+    }) as ExcalidrawElbowArrowElement;
+    scene.insertElement(arrow);
+    mutateElbowArrow(arrow, scene, [
+      [-45 - arrow.x, -100.1 - arrow.y],
+      [45 - arrow.x, 99.9 - arrow.y],
+    ]);
+    expect(arrow.points).toEqual([
+      [0, 0],
+      [0, 100],
+      [90, 100],
+      [90, 200],
+    ]);
+    expect(arrow.x).toEqual(-45);
+    expect(arrow.y).toEqual(-100.1);
+    expect(arrow.width).toEqual(90);
+    expect(arrow.height).toEqual(200);
+  });
+  it("can generate proper points for bound elbow arrow", () => {
+    const scene = new Scene();
+    const rectangle1 = API.createElement({
+      type: "rectangle",
+      x: -150,
+      y: -150,
+      width: 100,
+      height: 100,
+    }) as ExcalidrawBindableElement;
+    const rectangle2 = API.createElement({
+      type: "rectangle",
+      x: 50,
+      y: 50,
+      width: 100,
+      height: 100,
+    }) as ExcalidrawBindableElement;
+    const arrow = API.createElement({
+      type: "arrow",
+      elbowed: true,
+      x: -45,
+      y: -100.1,
+      width: 90,
+      height: 200,
+      points: [
+        [0, 0],
+        [90, 200],
+      ],
+    }) as ExcalidrawElbowArrowElement;
+    scene.insertElement(rectangle1);
+    scene.insertElement(rectangle2);
+    scene.insertElement(arrow);
+    const elementsMap = scene.getNonDeletedElementsMap();
+    bindLinearElement(arrow, rectangle1, "start", elementsMap);
+    bindLinearElement(arrow, rectangle2, "end", elementsMap);
+
+    expect(arrow.startBinding).not.toBe(null);
+    expect(arrow.endBinding).not.toBe(null);
+
+    mutateElbowArrow(arrow, scene, [
+      [0, 0],
+      [90, 200],
+    ]);
+
+    expect(arrow.points).toEqual([
+      [0, 0],
+      [45, 0],
+      [45, 200],
+      [90, 200],
+    ]);
+  });
+});
+
+describe("elbow arrow ui", () => {
+  beforeEach(async () => {
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+  });
+
+  it("can follow bound shapes", async () => {
+    UI.createElement("rectangle", {
+      x: -150,
+      y: -150,
+      width: 100,
+      height: 100,
+    });
+    UI.createElement("rectangle", {
+      x: 50,
+      y: 50,
+      width: 100,
+      height: 100,
+    });
+
+    UI.clickTool("arrow");
+    UI.clickOnTestId("elbow-arrow");
+
+    expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
+
+    mouse.reset();
+    mouse.moveTo(-43, -99);
+    mouse.click();
+    mouse.moveTo(43, 99);
+    mouse.click();
+
+    const arrow = h.scene.getSelectedElements(
+      h.state,
+    )[0] as ExcalidrawArrowElement;
+
+    expect(arrow.type).toBe("arrow");
+    expect(arrow.elbowed).toBe(true);
+    expect(arrow.points).toEqual([
+      [0, 0],
+      [35, 0],
+      [35, 200],
+      [90, 200],
+    ]);
+  });
+
+  it("can follow bound rotated shapes", async () => {
+    UI.createElement("rectangle", {
+      x: -150,
+      y: -150,
+      width: 100,
+      height: 100,
+    });
+    UI.createElement("rectangle", {
+      x: 50,
+      y: 50,
+      width: 100,
+      height: 100,
+    });
+
+    UI.clickTool("arrow");
+    UI.clickOnTestId("elbow-arrow");
+
+    mouse.reset();
+    mouse.moveTo(-43, -99);
+    mouse.click();
+    mouse.moveTo(43, 99);
+    mouse.click();
+
+    const arrow = h.scene.getSelectedElements(
+      h.state,
+    )[0] as ExcalidrawArrowElement;
+
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+      button: 2,
+      clientX: 1,
+      clientY: 1,
+    });
+    const contextMenu = UI.queryContextMenu();
+    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+
+    mouse.click(51, 51);
+
+    const inputAngle = getStatsProperty("A")?.querySelector(
+      ".drag-input",
+    ) as HTMLInputElement;
+    editInput(inputAngle, String("40"));
+
+    expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
+      [0, 0],
+      [35, 0],
+      [35, 90],
+      [25, 90],
+      [25, 165],
+      [103, 165],
+    ]);
+  });
+});

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

@@ -0,0 +1,1036 @@
+import { cross } from "../../utils/geometry/geometry";
+import BinaryHeap from "../binaryheap";
+import {
+  aabbForElement,
+  arePointsEqual,
+  pointInsideBounds,
+  pointToVector,
+  scalePointFromOrigin,
+  scaleVector,
+  translatePoint,
+} from "../math";
+import { getSizeFromPoints } from "../points";
+import type Scene from "../scene/Scene";
+import type { Point } 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 {
+  HEADING_DOWN,
+  HEADING_LEFT,
+  HEADING_RIGHT,
+  HEADING_UP,
+  vectorToHeading,
+} from "./heading";
+import { mutateElement } from "./mutateElement";
+import { isBindableElement, isRectanguloidElement } from "./typeChecks";
+import type {
+  ExcalidrawElbowArrowElement,
+  FixedPointBinding,
+  NonDeletedExcalidrawElement,
+  NonDeletedSceneElementsMap,
+} from "./types";
+import type {
+  ElementsMap,
+  ExcalidrawBindableElement,
+  OrderedExcalidrawElement,
+} from "./types";
+
+type Node = {
+  f: number;
+  g: number;
+  h: number;
+  closed: boolean;
+  visited: boolean;
+  parent: Node | null;
+  pos: Point;
+  addr: [number, number];
+};
+
+type Grid = {
+  row: number;
+  col: number;
+  data: (Node | null)[];
+};
+
+const BASE_PADDING = 40;
+
+export const mutateElbowArrow = (
+  arrow: ExcalidrawElbowArrowElement,
+  scene: Scene,
+  nextPoints: readonly Point[],
+  offset?: Point,
+  otherUpdates?: {
+    startBinding?: FixedPointBinding | null;
+    endBinding?: FixedPointBinding | null;
+  },
+  options?: {
+    changedElements?: Map<string, OrderedExcalidrawElement>;
+    isDragging?: boolean;
+    disableBinding?: boolean;
+    informMutation?: boolean;
+  },
+) => {
+  const elements = getAllElements(scene, options?.changedElements);
+  const elementsMap = getAllElementsMap(scene, options?.changedElements);
+
+  const origStartGlobalPoint = translatePoint(nextPoints[0], [
+    arrow.x + (offset ? offset[0] : 0),
+    arrow.y + (offset ? offset[1] : 0),
+  ]);
+  const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
+    arrow.x + (offset ? offset[0] : 0),
+    arrow.y + (offset ? offset[1] : 0),
+  ]);
+
+  const startElement =
+    arrow.startBinding &&
+    getBindableElementForId(arrow.startBinding.elementId, elementsMap);
+  const endElement =
+    arrow.endBinding &&
+    getBindableElementForId(arrow.endBinding.elementId, elementsMap);
+  const hoveredStartElement = options?.isDragging
+    ? getHoveredElementForBinding(
+        tupleToCoors(origStartGlobalPoint),
+        elements,
+        elementsMap,
+        true,
+      )
+    : startElement;
+  const hoveredEndElement = options?.isDragging
+    ? getHoveredElementForBinding(
+        tupleToCoors(origEndGlobalPoint),
+        elements,
+        elementsMap,
+        true,
+      )
+    : 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,
+  );
+  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 Point[];
+    startDongle && points.unshift(startGlobalPoint);
+    endDongle && points.push(endGlobalPoint);
+
+    mutateElement(
+      arrow,
+      {
+        ...otherUpdates,
+        ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
+        angle: 0,
+      },
+      options?.informMutation,
+    );
+  } else {
+    console.error("Elbow arrow cannot find a route");
+  }
+};
+
+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 = scalePointFromOrigin(
+        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(pointToVector(current.pos, current.parent.pos))
+        : startHeading;
+
+      // Do not allow going in reverse
+      const reverseHeading = scaleVector(previousDirection, -1);
+      const neighborIsReverseRoute =
+        arePointsEqual(reverseHeading, neighborHeading) ||
+        (arePointsEqual(start.addr, neighbor.addr) &&
+          arePointsEqual(neighborHeading, startHeading)) ||
+        (arePointsEqual(end.addr, neighbor.addr) &&
+          arePointsEqual(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: Point, b: Point) =>
+  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,
+): Bounds[] => {
+  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((a[0] + b[2]) / 2, a[0] - startLeft)
+        : (a[0] + b[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((a[1] + b[3]) / 2, a[1] - startUp)
+        : (a[1] + b[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((a[2] + b[0]) / 2, a[2] + startRight)
+        : (a[2] + b[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((a[3] + b[1]) / 2, a[3] + startDown)
+        : (a[3] + b[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((b[0] + a[2]) / 2, b[0] - endLeft)
+        : (b[0] + a[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((b[1] + a[3]) / 2, b[1] - endUp)
+        : (b[1] + a[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((b[2] + a[0]) / 2, b[2] + endRight)
+        : (b[2] + a[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((b[3] + a[1]) / 2, b[3] + endDown)
+        : (b[3] + a[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 (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, 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 (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, 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 (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, 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 (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, 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: Point,
+  startHeading: Heading,
+  end: Point,
+  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 [number, number],
+          pos: [x, y] as Point,
+        }),
+      ),
+    ),
+  };
+};
+
+const getDonglePosition = (
+  bounds: Bounds,
+  heading: Heading,
+  point: Point,
+): Point => {
+  switch (heading) {
+    case HEADING_UP:
+      return [point[0], bounds[1]];
+    case HEADING_RIGHT:
+      return [bounds[2], point[1]];
+    case HEADING_DOWN:
+      return [point[0], bounds[3]];
+  }
+  return [bounds[0], point[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: Point, 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: Point[],
+  externalOffsetX?: number,
+  externalOffsetY?: number,
+) => {
+  const offsetX = global[0][0];
+  const offsetY = global[0][1];
+
+  const points = global.map(
+    (point, _idx) => [point[0] - offsetX, point[1] - offsetY] as const,
+  );
+
+  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: Point[]): Point[] =>
+  points
+    .slice(2)
+    .reduce(
+      (result, point) =>
+        arePointsEqual(
+          vectorToHeading(
+            pointToVector(result[result.length - 1], result[result.length - 2]),
+          ),
+          vectorToHeading(pointToVector(point, result[result.length - 1])),
+        )
+          ? [...result.slice(0, -1), point]
+          : [...result, point],
+      [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 getAllElementsMap = (
+  scene: Scene,
+  changedElements?: Map<string, OrderedExcalidrawElement>,
+): NonDeletedSceneElementsMap =>
+  changedElements
+    ? toBrandedType<NonDeletedSceneElementsMap>(
+        new Map([...scene.getNonDeletedElementsMap(), ...changedElements]),
+      )
+    : scene.getNonDeletedElementsMap();
+
+const getAllElements = (
+  scene: Scene,
+  changedElements?: Map<string, OrderedExcalidrawElement>,
+): readonly NonDeletedExcalidrawElement[] =>
+  changedElements
+    ? ([
+        ...scene.getNonDeletedElements(),
+        ...[...changedElements].map(([_, value]) => value),
+      ] as NonDeletedExcalidrawElement[])
+    : scene.getNonDeletedElements();
+
+const getGlobalPoint = (
+  fixedPointRatio: [number, number] | undefined | null,
+  initialPoint: Point,
+  otherPoint: Point,
+  elementsMap: NonDeletedSceneElementsMap,
+  boundElement?: ExcalidrawBindableElement | null,
+  hoveredElement?: ExcalidrawBindableElement | null,
+  isDragging?: boolean,
+): Point => {
+  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 = (
+  point: Point,
+  otherPoint: Point,
+  element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
+) =>
+  bindPointToSnapToElementOutline(
+    isRectanguloidElement(element)
+      ? avoidRectangularCorner(element, point)
+      : point,
+    otherPoint,
+    element,
+    elementsMap,
+  );
+
+const getBindPointHeading = (
+  point: Point,
+  otherPoint: Point,
+  elementsMap: NonDeletedSceneElementsMap,
+  hoveredElement: ExcalidrawBindableElement | null | undefined,
+  origPoint: Point,
+) =>
+  getHeadingForElbowArrowSnap(
+    point,
+    otherPoint,
+    hoveredElement,
+    hoveredElement &&
+      aabbForElement(
+        hoveredElement,
+        Array(4).fill(
+          distanceToBindableElement(hoveredElement, point, elementsMap),
+        ) as [number, number, number, number],
+      ),
+    elementsMap,
+    origPoint,
+  );

+ 13 - 2
packages/excalidraw/element/transformHandles.ts

@@ -9,7 +9,11 @@ import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import { rotate } from "../math";
 import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
-import { isFrameLikeElement, isLinearElement } from "./typeChecks";
+import {
+  isElbowArrow,
+  isFrameLikeElement,
+  isLinearElement,
+} from "./typeChecks";
 import {
   DEFAULT_TRANSFORM_HANDLE_SPACING,
   isAndroid,
@@ -262,7 +266,11 @@ export const getTransformHandles = (
   // so that when locked element is selected (especially when you toggle lock
   // via keyboard) the locked element is visually distinct, indicating
   // you can't move/resize
-  if (element.locked) {
+  if (
+    element.locked ||
+    // Elbow arrows cannot be rotated
+    isElbowArrow(element)
+  ) {
     return {};
   }
 
@@ -312,6 +320,9 @@ export const shouldShowBoundingBox = (
     return true;
   }
   const element = elements[0];
+  if (isElbowArrow(element)) {
+    return false;
+  }
   if (!isLinearElement(element)) {
     return true;
   }

+ 31 - 0
packages/excalidraw/element/typeChecks.ts

@@ -21,6 +21,9 @@ import type {
   ExcalidrawIframeLikeElement,
   ExcalidrawMagicFrameElement,
   ExcalidrawArrowElement,
+  ExcalidrawElbowArrowElement,
+  PointBinding,
+  FixedPointBinding,
 } from "./types";
 
 export const isInitializedImageElement = (
@@ -106,6 +109,12 @@ export const isArrowElement = (
   return element != null && element.type === "arrow";
 };
 
+export const isElbowArrow = (
+  element?: ExcalidrawElement,
+): element is ExcalidrawElbowArrowElement => {
+  return isArrowElement(element) && element.elbowed;
+};
+
 export const isLinearElementType = (
   elementType: ElementOrToolType,
 ): boolean => {
@@ -150,6 +159,22 @@ export const isBindableElement = (
   );
 };
 
+export const isRectanguloidElement = (
+  element?: ExcalidrawElement | null,
+): element is ExcalidrawBindableElement => {
+  return (
+    element != null &&
+    (element.type === "rectangle" ||
+      element.type === "diamond" ||
+      element.type === "image" ||
+      element.type === "iframe" ||
+      element.type === "embeddable" ||
+      element.type === "frame" ||
+      element.type === "magicframe" ||
+      (element.type === "text" && !element.containerId))
+  );
+};
+
 export const isTextBindableContainer = (
   element: ExcalidrawElement | null,
   includeLocked = true,
@@ -263,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
 
   return null;
 };
+
+export const isFixedPointBinding = (
+  binding: PointBinding,
+): binding is FixedPointBinding => {
+  return binding.fixedPoint != null;
+};

+ 26 - 1
packages/excalidraw/element/types.ts

@@ -6,7 +6,12 @@ import type {
   THEME,
   VERTICAL_ALIGN,
 } from "../constants";
-import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
+import type {
+  MakeBrand,
+  MarkNonNullable,
+  Merge,
+  ValueOf,
+} from "../utility-types";
 import type { MagicCacheData } from "../data/magic";
 
 export type ChartType = "bar" | "line";
@@ -228,12 +233,22 @@ export type ExcalidrawTextElementWithContainer = {
   containerId: ExcalidrawTextContainer["id"];
 } & ExcalidrawTextElement;
 
+export type FixedPoint = [number, number];
+
 export type PointBinding = {
   elementId: ExcalidrawBindableElement["id"];
   focus: number;
   gap: number;
+  // Represents the fixed point binding information in form of a vertical and
+  // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
+  // gives the user selected fixed point by multiplying the bound element width
+  // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
+  // bound element-local point coordinate.
+  fixedPoint: FixedPoint | null;
 };
 
+export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
+
 export type Arrowhead =
   | "arrow"
   | "bar"
@@ -259,8 +274,18 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
 export type ExcalidrawArrowElement = ExcalidrawLinearElement &
   Readonly<{
     type: "arrow";
+    elbowed: boolean;
   }>;
 
+export type ExcalidrawElbowArrowElement = Merge<
+  ExcalidrawArrowElement,
+  {
+    elbowed: true;
+    startBinding: FixedPointBinding | null;
+    endBinding: FixedPointBinding | null;
+  }
+>;
+
 export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
   Readonly<{
     type: "freedraw";

+ 9 - 2
packages/excalidraw/history.ts

@@ -1,6 +1,7 @@
 import type { AppStateChange, ElementsChange } from "./change";
 import type { SceneElementsMap } from "./element/types";
 import { Emitter } from "./emitter";
+import type Scene from "./scene/Scene";
 import type { Snapshot } from "./store";
 import type { AppState } from "./types";
 
@@ -64,6 +65,7 @@ export class History {
     elements: SceneElementsMap,
     appState: AppState,
     snapshot: Readonly<Snapshot>,
+    scene: Scene,
   ) {
     return this.perform(
       elements,
@@ -71,6 +73,7 @@ export class History {
       snapshot,
       () => History.pop(this.undoStack),
       (entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
+      scene,
     );
   }
 
@@ -78,6 +81,7 @@ export class History {
     elements: SceneElementsMap,
     appState: AppState,
     snapshot: Readonly<Snapshot>,
+    scene: Scene,
   ) {
     return this.perform(
       elements,
@@ -85,6 +89,7 @@ export class History {
       snapshot,
       () => History.pop(this.redoStack),
       (entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
+      scene,
     );
   }
 
@@ -94,6 +99,7 @@ export class History {
     snapshot: Readonly<Snapshot>,
     pop: () => HistoryEntry | null,
     push: (entry: HistoryEntry) => void,
+    scene: Scene,
   ): [SceneElementsMap, AppState] | void {
     try {
       let historyEntry = pop();
@@ -110,7 +116,7 @@ export class History {
       while (historyEntry) {
         try {
           [nextElements, nextAppState, containsVisibleChange] =
-            historyEntry.applyTo(nextElements, nextAppState, snapshot);
+            historyEntry.applyTo(nextElements, nextAppState, snapshot, scene);
         } finally {
           // make sure to always push / pop, even if the increment is corrupted
           push(historyEntry);
@@ -181,9 +187,10 @@ export class HistoryEntry {
     elements: SceneElementsMap,
     appState: AppState,
     snapshot: Readonly<Snapshot>,
+    scene: Scene,
   ): [SceneElementsMap, AppState, boolean] {
     const [nextElements, elementsContainVisibleChange] =
-      this.elementsChange.applyTo(elements, snapshot.elements);
+      this.elementsChange.applyTo(elements, snapshot.elements, scene);
 
     const [nextAppState, appStateContainsVisibleChange] =
       this.appStateChange.applyTo(appState, nextElements);

+ 5 - 0
packages/excalidraw/locales/en.json

@@ -46,6 +46,10 @@
     "arrowhead_triangle_outline": "Triangle (outline)",
     "arrowhead_diamond": "Diamond",
     "arrowhead_diamond_outline": "Diamond (outline)",
+    "arrowtypes": "Arrow type",
+    "arrowtype_sharp": "Sharp arrow",
+    "arrowtype_round": "Curved arrow",
+    "arrowtype_elbowed": "Elbow arrow",
     "fontSize": "Font size",
     "fontFamily": "Font family",
     "addWatermark": "Add \"Made with Excalidraw\"",
@@ -295,6 +299,7 @@
   "hints": {
     "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
     "linearElement": "Click to start multiple points, drag for single line",
+    "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
     "freeDraw": "Click and drag, release when you're finished",
     "text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
     "embeddable": "Click-drag to create a website embed",

+ 45 - 1
packages/excalidraw/math.test.ts

@@ -1,4 +1,9 @@
-import { rangeIntersection, rangesOverlap, rotate } from "./math";
+import {
+  isPointOnSymmetricArc,
+  rangeIntersection,
+  rangesOverlap,
+  rotate,
+} from "./math";
 
 describe("rotate", () => {
   it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -53,3 +58,42 @@ describe("range intersection", () => {
     expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
   });
 });
+
+describe("point on arc", () => {
+  it("should detect point on simple arc", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        [0.92291667, 0.385],
+      ),
+    ).toBe(true);
+  });
+  it("should not detect point outside of a simple arc", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        [-0.92291667, 0.385],
+      ),
+    ).toBe(false);
+  });
+  it("should not detect point with good angle but incorrect radius", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        [-0.5, 0.5],
+      ),
+    ).toBe(false);
+  });
+});

+ 184 - 0
packages/excalidraw/math.ts

@@ -10,9 +10,11 @@ import type {
   ExcalidrawLinearElement,
   NonDeleted,
 } from "./element/types";
+import type { Bounds } from "./element/bounds";
 import { getCurvePathOps } from "./element/bounds";
 import type { Mutable } from "./utility-types";
 import { ShapeCache } from "./scene/ShapeCache";
+import type { Vector } from "../utils/geometry/shape";
 
 export const rotate = (
   // target point to rotate
@@ -153,6 +155,12 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
   return Math.hypot(xd, yd);
 };
 
+export const distanceSq2d = (p1: Point, p2: Point) => {
+  const xd = p2[0] - p1[0];
+  const yd = p2[1] - p1[1];
+  return xd * xd + yd * yd;
+};
+
 export const centerPoint = (a: Point, b: Point): Point => {
   return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
 };
@@ -519,3 +527,179 @@ export const rangeIntersection = (
 export const isValueInRange = (value: number, min: number, max: number) => {
   return value >= min && value <= max;
 };
+
+export const translatePoint = (p: Point, v: Vector): Point => [
+  p[0] + v[0],
+  p[1] + v[1],
+];
+
+export const scaleVector = (v: Vector, scalar: number): Vector => [
+  v[0] * scalar,
+  v[1] * scalar,
+];
+
+export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
+  p[0] - origin[0],
+  p[1] - origin[1],
+];
+
+export const scalePointFromOrigin = (
+  p: Point,
+  mid: Point,
+  multiplier: number,
+) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
+
+const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
+  (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
+
+export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
+  const d1 = triangleSign(pt, v1, v2);
+  const d2 = triangleSign(pt, v2, v3);
+  const d3 = triangleSign(pt, v3, v1);
+
+  const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+  const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+
+  return !(has_neg && has_pos);
+};
+
+export const magnitudeSq = (vector: Vector) =>
+  vector[0] * vector[0] + vector[1] * vector[1];
+
+export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
+
+export const normalize = (vector: Vector): Vector => {
+  const m = magnitude(vector);
+
+  return [vector[0] / m, vector[1] / m];
+};
+
+export const addVectors = (
+  vec1: Readonly<Vector>,
+  vec2: Readonly<Vector>,
+): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
+
+export const subtractVectors = (
+  vec1: Readonly<Vector>,
+  vec2: Readonly<Vector>,
+): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
+
+export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
+  p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
+
+/**
+ * Get the axis-aligned bounding box for a given element
+ */
+export const aabbForElement = (
+  element: Readonly<ExcalidrawElement>,
+  offset?: [number, number, number, number],
+) => {
+  const bbox = {
+    minX: element.x,
+    minY: element.y,
+    maxX: element.x + element.width,
+    maxY: element.y + element.height,
+    midX: element.x + element.width / 2,
+    midY: element.y + element.height / 2,
+  };
+
+  const center = [bbox.midX, bbox.midY] as Point;
+  const [topLeftX, topLeftY] = rotatePoint(
+    [bbox.minX, bbox.minY],
+    center,
+    element.angle,
+  );
+  const [topRightX, topRightY] = rotatePoint(
+    [bbox.maxX, bbox.minY],
+    center,
+    element.angle,
+  );
+  const [bottomRightX, bottomRightY] = rotatePoint(
+    [bbox.maxX, bbox.maxY],
+    center,
+    element.angle,
+  );
+  const [bottomLeftX, bottomLeftY] = rotatePoint(
+    [bbox.minX, bbox.maxY],
+    center,
+    element.angle,
+  );
+
+  const bounds = [
+    Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
+    Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
+    Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
+    Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
+  ] as Bounds;
+
+  if (offset) {
+    const [topOffset, rightOffset, downOffset, leftOffset] = offset;
+    return [
+      bounds[0] - leftOffset,
+      bounds[1] - topOffset,
+      bounds[2] + rightOffset,
+      bounds[3] + downOffset,
+    ] as Bounds;
+  }
+
+  return bounds;
+};
+
+type PolarCoords = [number, number];
+
+/**
+ * Return the polar coordinates for the given carthesian point represented by
+ * (x, y) for the center point 0,0 where the first number returned is the radius,
+ * the second is the angle in radians.
+ */
+export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
+  Math.hypot(x, y),
+  Math.atan2(y, x),
+];
+
+/**
+ * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
+ * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
+ */
+type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
+
+/**
+ * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
+ * is part of a circle contour centered on 0, 0.
+ */
+export const isPointOnSymmetricArc = (
+  { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
+  point: Point,
+): boolean => {
+  const [radius, angle] = carthesian2Polar(point);
+
+  return startAngle < endAngle
+    ? Math.abs(radius - arcRadius) < 0.0000001 &&
+        startAngle <= angle &&
+        endAngle >= angle
+    : startAngle <= angle || endAngle >= angle;
+};
+
+export const getCenterForBounds = (bounds: Bounds): Point => [
+  bounds[0] + (bounds[2] - bounds[0]) / 2,
+  bounds[1] + (bounds[3] - bounds[1]) / 2,
+];
+
+export const getCenterForElement = (element: ExcalidrawElement): Point => [
+  element.x + element.width / 2,
+  element.y + element.height / 2,
+];
+
+export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
+  pointInsideBounds([a[0], a[1]], b) ||
+  pointInsideBounds([a[2], a[1]], b) ||
+  pointInsideBounds([a[2], a[3]], b) ||
+  pointInsideBounds([a[0], a[3]], b) ||
+  pointInsideBounds([b[0], b[1]], a) ||
+  pointInsideBounds([b[2], b[1]], a) ||
+  pointInsideBounds([b[2], b[3]], a) ||
+  pointInsideBounds([b[0], b[3]], a);
+
+export const clamp = (value: number, min: number, max: number) => {
+  return Math.min(Math.max(value, min), max);
+};

+ 54 - 22
packages/excalidraw/renderer/interactiveScene.ts

@@ -48,6 +48,8 @@ import {
 } from "./helpers";
 import oc from "open-color";
 import {
+  isArrowElement,
+  isElbowArrow,
   isFrameLikeElement,
   isLinearElement,
   isTextElement,
@@ -67,6 +69,7 @@ import type {
   InteractiveSceneRenderConfig,
   RenderableElementsMap,
 } from "../scene/types";
+import { getCornerRadius } from "../math";
 
 const renderLinearElementPointHighlight = (
   context: CanvasRenderingContext2D,
@@ -212,13 +215,18 @@ const renderBindingHighlightForBindableElement = (
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
   const width = x2 - x1;
   const height = y2 - y1;
-  const threshold = maxBindingGap(element, width, height);
+  const thickness = 10;
 
   // So that we don't overlap the element itself
   const strokeOffset = 4;
   context.strokeStyle = "rgba(0,0,0,.05)";
-  context.lineWidth = threshold - strokeOffset;
-  const padding = strokeOffset / 2 + threshold / 2;
+  context.lineWidth = thickness - strokeOffset;
+  const padding = strokeOffset / 2 + thickness / 2;
+
+  const radius = getCornerRadius(
+    Math.min(element.width, element.height),
+    element,
+  );
 
   switch (element.type) {
     case "rectangle":
@@ -237,6 +245,8 @@ const renderBindingHighlightForBindableElement = (
         x1 + width / 2,
         y1 + height / 2,
         element.angle,
+        undefined,
+        radius,
       );
       break;
     case "diamond":
@@ -474,6 +484,10 @@ const renderLinearPointHandles = (
     ? POINT_HANDLE_SIZE
     : POINT_HANDLE_SIZE / 2;
   points.forEach((point, idx) => {
+    if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
+      return;
+    }
+
     const isSelected =
       !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
 
@@ -727,7 +741,13 @@ const _renderInteractiveScene = ({
 
   if (
     appState.selectedLinearElement &&
-    appState.selectedLinearElement.hoverPointIndex >= 0
+    appState.selectedLinearElement.hoverPointIndex >= 0 &&
+    !(
+      isElbowArrow(selectedElements[0]) &&
+      appState.selectedLinearElement.hoverPointIndex > 0 &&
+      appState.selectedLinearElement.hoverPointIndex <
+        selectedElements[0].points.length - 1
+    )
   ) {
     renderLinearElementPointHighlight(context, appState, elementsMap);
   }
@@ -771,27 +791,39 @@ const _renderInteractiveScene = ({
 
       for (const element of elementsMap.values()) {
         const selectionColors = [];
-        // local user
-        if (
-          locallySelectedIds.has(element.id) &&
-          !isSelectedViaGroup(appState, element)
-        ) {
-          selectionColors.push(selectionColor);
-        }
-        // remote users
         const remoteClients = renderConfig.remoteSelectedElementIds.get(
           element.id,
         );
-        if (remoteClients) {
-          selectionColors.push(
-            ...remoteClients.map((socketId) => {
-              const background = getClientColor(
-                socketId,
-                appState.collaborators.get(socketId),
-              );
-              return background;
-            }),
-          );
+        if (
+          !(
+            // Elbow arrow elements cannot be selected when bound on either end
+            (
+              isSingleLinearElementSelected &&
+              isArrowElement(element) &&
+              isElbowArrow(element) &&
+              (element.startBinding || element.endBinding)
+            )
+          )
+        ) {
+          // local user
+          if (
+            locallySelectedIds.has(element.id) &&
+            !isSelectedViaGroup(appState, element)
+          ) {
+            selectionColors.push(selectionColor);
+          }
+          // remote users
+          if (remoteClients) {
+            selectionColors.push(
+              ...remoteClients.map((socketId) => {
+                const background = getClientColor(
+                  socketId,
+                  appState.collaborators.get(socketId),
+                );
+                return background;
+              }),
+            );
+          }
         }
 
         if (selectionColors.length) {

+ 69 - 4
packages/excalidraw/scene/Shape.ts

@@ -9,12 +9,13 @@ import type {
   ExcalidrawLinearElement,
   Arrowhead,
 } from "../element/types";
-import { isPathALoop, getCornerRadius } from "../math";
+import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
 import { generateFreeDrawShape } from "../renderer/renderElement";
 import { isTransparent, assertNever } from "../utils";
 import { simplify } from "points-on-curve";
 import { ROUGHNESS } from "../constants";
 import {
+  isElbowArrow,
   isEmbeddableElement,
   isIframeElement,
   isIframeLikeElement,
@@ -400,9 +401,16 @@ export const _generateElementShape = (
       // initial position to it
       const points = element.points.length ? element.points : [[0, 0]];
 
-      // curve is always the first element
-      // this simplifies finding the curve for an element
-      if (!element.roundness) {
+      if (isElbowArrow(element)) {
+        shape = [
+          generator.path(
+            generateElbowArrowShape(points as [number, number][], 16),
+            generateRoughOptions(element, true),
+          ),
+        ];
+      } else if (!element.roundness) {
+        // curve is always the first element
+        // this simplifies finding the curve for an element
         if (options.fill) {
           shape = [generator.polygon(points as [number, number][], options)];
         } else {
@@ -482,3 +490,60 @@ export const _generateElementShape = (
     }
   }
 };
+
+const generateElbowArrowShape = (
+  points: [number, number][],
+  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 corner = Math.min(
+      radius,
+      Math.sqrt(distanceSq2d(points[i], next)) / 2,
+      Math.sqrt(distanceSq2d(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]) {
+      // 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]) {
+      // 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 {
+      subpoints.push([points[i][0], points[i][1] + corner]);
+    }
+  }
+
+  const d = [`M ${points[0][0]} ${points[0][1]}`];
+  for (let i = 0; i < subpoints.length; i += 3) {
+    d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
+    d.push(
+      `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
+        subpoints[i + 2][0]
+      } ${subpoints[i + 2][1]}`,
+    );
+  }
+  d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
+
+  return d.join(" ");
+};

+ 2 - 1
packages/excalidraw/scene/comparisons.ts

@@ -40,11 +40,12 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
   type === "rectangle" ||
   type === "iframe" ||
   type === "embeddable" ||
-  type === "arrow" ||
   type === "line" ||
   type === "diamond" ||
   type === "image";
 
+export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
+
 export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
 
 export const getElementAtPosition = (

+ 17 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -796,6 +796,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -998,6 +999,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1210,6 +1212,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1537,6 +1540,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1864,6 +1868,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2076,6 +2081,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2312,6 +2318,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2609,6 +2616,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2974,6 +2982,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#a5d8ff",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "cross-hatch",
@@ -3445,6 +3454,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -3764,6 +3774,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -4083,6 +4094,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -5265,6 +5277,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -6388,6 +6401,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7319,6 +7333,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8227,6 +8242,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9117,6 +9133,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   },
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",

+ 1 - 0
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -8,6 +8,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",

File diff suppressed because it is too large
+ 180 - 114
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


+ 9 - 6
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap

@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "type": "rectangle",
   "updated": 1,
   "version": 7,
-  "versionNonce": 1984422985,
+  "versionNonce": 745419401,
   "width": 300,
   "x": 201,
   "y": 2,
@@ -186,16 +186,18 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
     "elementId": "id1",
+    "fixedPoint": null,
     "focus": "-0.46667",
     "gap": 10,
   },
   "fillStyle": "solid",
   "frameId": null,
   "groupIds": [],
-  "height": "81.48231",
+  "height": "81.47368",
   "id": "id2",
   "index": "a2",
   "isDeleted": false,
@@ -210,7 +212,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
     ],
     [
       81,
-      "81.48231",
+      "81.47368",
     ],
   ],
   "roughness": 1,
@@ -221,6 +223,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "startArrowhead": null,
   "startBinding": {
     "elementId": "id0",
+    "fixedPoint": null,
     "focus": "-0.60000",
     "gap": 10,
   },
@@ -229,10 +232,10 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "strokeWidth": 2,
   "type": "arrow",
   "updated": 1,
-  "version": 14,
-  "versionNonce": 2066753033,
+  "version": 11,
+  "versionNonce": 1996028265,
   "width": 81,
   "x": 110,
-  "y": "49.98179",
+  "y": 50,
 }
 `;

+ 1 - 0
packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -6,6 +6,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",

+ 61 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -421,6 +422,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -820,6 +822,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1358,6 +1361,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1555,6 +1559,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -1923,6 +1928,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2156,6 +2162,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2329,6 +2336,7 @@ exports[`regression tests > can drag element that covers another element, while
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2642,6 +2650,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -2881,6 +2890,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -3117,6 +3127,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -3340,6 +3351,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -3589,6 +3601,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -3893,6 +3906,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -4300,6 +4314,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -4606,6 +4621,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -4882,6 +4898,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -5115,6 +5132,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -5307,6 +5325,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -5682,6 +5701,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -5965,6 +5985,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -6247,6 +6268,7 @@ History {
               "backgroundColor": "transparent",
               "boundElements": null,
               "customData": undefined,
+              "elbowed": false,
               "endArrowhead": "arrow",
               "endBinding": null,
               "fillStyle": "solid",
@@ -6387,6 +6409,7 @@ History {
               "backgroundColor": "transparent",
               "boundElements": null,
               "customData": undefined,
+              "elbowed": false,
               "endArrowhead": "arrow",
               "endBinding": null,
               "fillStyle": "solid",
@@ -6764,6 +6787,7 @@ exports[`regression tests > given a group of selected elements with an element t
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7087,6 +7111,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "#ffc9c9",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7356,6 +7381,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7583,6 +7609,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7813,6 +7840,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -7986,6 +8014,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8159,6 +8188,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8332,6 +8362,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8408,6 +8439,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
     "isDragging": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
+      "lastClickedIsEndPoint": false,
       "lastClickedPoint": -1,
       "origin": null,
       "prevSelectedPointsIndices": null,
@@ -8480,6 +8512,7 @@ History {
               "backgroundColor": "transparent",
               "boundElements": null,
               "customData": undefined,
+              "elbowed": false,
               "endArrowhead": "arrow",
               "endBinding": null,
               "fillStyle": "solid",
@@ -8545,6 +8578,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8621,6 +8655,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
     "isDragging": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
+      "lastClickedIsEndPoint": false,
       "lastClickedPoint": -1,
       "origin": null,
       "prevSelectedPointsIndices": null,
@@ -8758,6 +8793,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -8945,6 +8981,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9021,6 +9058,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
     "isDragging": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
+      "lastClickedIsEndPoint": false,
       "lastClickedPoint": -1,
       "origin": null,
       "prevSelectedPointsIndices": null,
@@ -9093,6 +9131,7 @@ History {
               "backgroundColor": "transparent",
               "boundElements": null,
               "customData": undefined,
+              "elbowed": false,
               "endArrowhead": "arrow",
               "endBinding": null,
               "fillStyle": "solid",
@@ -9158,6 +9197,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9331,6 +9371,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9407,6 +9448,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
     "isDragging": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
+      "lastClickedIsEndPoint": false,
       "lastClickedPoint": -1,
       "origin": null,
       "prevSelectedPointsIndices": null,
@@ -9544,6 +9586,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9717,6 +9760,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -9904,6 +9948,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -10077,6 +10122,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -10584,6 +10630,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -10854,6 +10901,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -10973,6 +11021,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -11165,6 +11214,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -11469,6 +11519,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -11874,6 +11925,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -12480,6 +12532,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -12602,6 +12655,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -13179,6 +13233,7 @@ exports[`regression tests > switches from group of selected elements to another
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -13540,6 +13595,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -13828,6 +13884,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -13947,6 +14004,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -14146,6 +14204,7 @@ History {
               "backgroundColor": "transparent",
               "boundElements": null,
               "customData": undefined,
+              "elbowed": false,
               "endArrowhead": "arrow",
               "endBinding": null,
               "fillStyle": "solid",
@@ -14318,6 +14377,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",
@@ -14437,6 +14497,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",

+ 1 - 0
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap

@@ -6,6 +6,7 @@ exports[`select single element on the scene > arrow 1`] = `
   "backgroundColor": "transparent",
   "boundElements": null,
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": null,
   "fillStyle": "solid",

+ 7 - 0
packages/excalidraw/tests/binding.test.tsx

@@ -62,6 +62,7 @@ describe("element binding", () => {
 
     expect(arrow.startBinding).toEqual({
       elementId: rect.id,
+      fixedPoint: null,
       focus: expect.toBeNonNaNNumber(),
       gap: expect.toBeNonNaNNumber(),
     });
@@ -74,11 +75,13 @@ describe("element binding", () => {
     // Both the start and the end points should be bound
     expect(arrow.startBinding).toEqual({
       elementId: rect.id,
+      fixedPoint: null,
       focus: expect.toBeNonNaNNumber(),
       gap: expect.toBeNonNaNNumber(),
     });
     expect(arrow.endBinding).toEqual({
       elementId: rect.id,
+      fixedPoint: null,
       focus: expect.toBeNonNaNNumber(),
       gap: expect.toBeNonNaNNumber(),
     });
@@ -318,11 +321,13 @@ describe("element binding", () => {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
       endBinding: {
         elementId: "text1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [1, 0.5],
       },
     });
 
@@ -337,11 +342,13 @@ describe("element binding", () => {
         elementId: "text1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [0.5, 1],
       },
       endBinding: {
         elementId: "rectangle1",
         focus: 0.2,
         gap: 7,
+        fixedPoint: [1, 0.5],
       },
     });
 

+ 1 - 0
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -6,6 +6,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
   "backgroundColor": "transparent",
   "boundElements": [],
   "customData": undefined,
+  "elbowed": false,
   "endArrowhead": null,
   "endBinding": null,
   "fillStyle": "solid",

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

@@ -149,8 +149,6 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
       [-922.4761962890625, 300.3277587890625],
       [828.0126953125, 410.51605224609375],
     ],
-    startArrowhead: null,
-    endArrowhead: null,
   });
 };
 
@@ -183,8 +181,6 @@ const createLinearElementsWithCurveOutsideMinMaxPoints = (
       [-591.2804897585779, 36.09360810181511],
       [-148.56510566829502, 53.96308359105342],
     ],
-    startArrowhead: null,
-    endArrowhead: null,
     ...extraProps,
   });
 };

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

@@ -19,6 +19,7 @@ import util from "util";
 import path from "path";
 import { getMimeType } from "../../data/blob";
 import {
+  newArrowElement,
   newEmbeddableElement,
   newFrameElement,
   newFreeDrawElement,
@@ -146,6 +147,7 @@ export class API {
     endBinding?: T extends "arrow"
       ? ExcalidrawLinearElement["endBinding"]
       : never;
+    elbowed?: boolean;
   }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
     : T extends "freedraw"
@@ -250,14 +252,24 @@ export class API {
         });
         break;
       case "arrow":
+        element = newArrowElement({
+          ...base,
+          width,
+          height,
+          type,
+          points: rest.points ?? [
+            [0, 0],
+            [100, 100],
+          ],
+          elbowed: rest.elbowed ?? false,
+        });
+        break;
       case "line":
         element = newLinearElement({
           ...base,
           width,
           height,
           type,
-          startArrowhead: null,
-          endArrowhead: null,
           points: rest.points ?? [
             [0, 0],
             [100, 100],

File diff suppressed because it is too large
+ 504 - 317
packages/excalidraw/tests/history.test.tsx


+ 6 - 1
packages/excalidraw/tests/library.test.tsx

@@ -95,7 +95,12 @@ describe("library", () => {
     const arrow = API.createElement({
       id: "arrow1",
       type: "arrow",
-      endBinding: { elementId: "rectangle1", focus: -1, gap: 0 },
+      endBinding: {
+        elementId: "rectangle1",
+        focus: -1,
+        gap: 0,
+        fixedPoint: [0.5, 1],
+      },
     });
 
     await API.drop(

+ 26 - 20
packages/excalidraw/tests/linearElementEditor.test.tsx

@@ -5,7 +5,7 @@ import type {
   ExcalidrawTextElementWithContainer,
   FontString,
 } from "../element/types";
-import { Excalidraw } from "../index";
+import { Excalidraw, mutateElement } from "../index";
 import { centerPoint } from "../math";
 import { reseed } from "../random";
 import * as StaticScene from "../renderer/staticScene";
@@ -107,6 +107,7 @@ describe("Test Linear Elements", () => {
       ],
       roundness,
     });
+    mutateElement(line, { points: line.points });
     h.elements = [line];
     mouse.clickAt(p1[0], p1[1]);
     return line;
@@ -307,7 +308,7 @@ describe("Test Linear Elements", () => {
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         `9`,
       );
-      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
+      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
       const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
         h.elements[0] as ExcalidrawLinearElement,
@@ -365,7 +366,7 @@ describe("Test Linear Elements", () => {
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         `12`,
       );
-      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
       expect([line.x, line.y]).toEqual([
         points[0][0] + deltaX,
@@ -427,7 +428,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `16`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
         expect(line.points.length).toEqual(5);
 
@@ -478,7 +479,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
@@ -519,7 +520,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
@@ -567,7 +568,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `18`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
         const newMidPoints = LinearElementEditor.getEditorMidPoints(
           line,
@@ -617,7 +618,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `16`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
         expect(line.points.length).toEqual(5);
 
         expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -715,7 +716,7 @@ describe("Test Linear Elements", () => {
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
@@ -843,6 +844,7 @@ describe("Test Linear Elements", () => {
           id: textElement.id,
         }),
       };
+
       const elements: ExcalidrawElement[] = [];
       h.elements.forEach((element) => {
         if (element.id === container.id) {
@@ -1235,7 +1237,7 @@ describe("Test Linear Elements", () => {
       mouse.moveTo(200, 0);
       mouse.upAt(200, 0);
 
-      expect(arrow.width).toBe(200);
+      expect(arrow.width).toBe(205);
       expect(rect.x).toBe(200);
       expect(rect.y).toBe(0);
       expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@@ -1356,16 +1358,20 @@ describe("Test Linear Elements", () => {
       const line = createThreePointerLinearElement("arrow");
       const [origStartX, origStartY] = [line.x, line.y];
 
-      LinearElementEditor.movePoints(line, [
-        { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
-        {
-          index: line.points.length - 1,
-          point: [
-            line.points[line.points.length - 1][0] - 10,
-            line.points[line.points.length - 1][1] - 10,
-          ],
-        },
-      ]);
+      LinearElementEditor.movePoints(
+        line,
+        [
+          { index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
+          {
+            index: line.points.length - 1,
+            point: [
+              line.points[line.points.length - 1][0] - 10,
+              line.points[line.points.length - 1][1] - 10,
+            ],
+          },
+        ],
+        h.scene,
+      );
       expect(line.x).toBe(origStartX + 10);
       expect(line.y).toBe(origStartY + 10);
 

+ 2 - 0
packages/excalidraw/tests/move.test.tsx

@@ -13,6 +13,7 @@ import type {
 import { UI, Pointer, Keyboard } from "./helpers/ui";
 import { KEYS } from "../keys";
 import { vi } from "vitest";
+import type Scene from "../scene/Scene";
 
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -85,6 +86,7 @@ describe("move element", () => {
       rectA.get() as ExcalidrawRectangleElement,
       rectB.get() as ExcalidrawRectangleElement,
       elementsMap,
+      {} as Scene,
     );
 
     // select the second rectangle

+ 14 - 3
packages/excalidraw/tests/resize.test.tsx

@@ -798,6 +798,7 @@ describe("multiple selection", () => {
       width: 100,
       height: 0,
     });
+
     const rightBoundArrow = UI.createElement("arrow", {
       x: 210,
       y: 50,
@@ -822,11 +823,16 @@ describe("multiple selection", () => {
 
     expect(leftBoundArrow.x).toBeCloseTo(-110);
     expect(leftBoundArrow.y).toBeCloseTo(50);
-    expect(leftBoundArrow.width).toBeCloseTo(140, 0);
+    expect(leftBoundArrow.width).toBeCloseTo(137.5, 0);
     expect(leftBoundArrow.height).toBeCloseTo(7, 0);
     expect(leftBoundArrow.angle).toEqual(0);
     expect(leftBoundArrow.startBinding).toBeNull();
-    expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding);
+    expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352);
+    expect(leftBoundArrow.endBinding?.elementId).toBe(
+      leftArrowBinding.elementId,
+    );
+    expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
+    expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
 
     expect(rightBoundArrow.x).toBeCloseTo(210);
     expect(rightBoundArrow.y).toBeCloseTo(
@@ -836,7 +842,12 @@ describe("multiple selection", () => {
     expect(rightBoundArrow.height).toBeCloseTo(0);
     expect(rightBoundArrow.angle).toEqual(0);
     expect(rightBoundArrow.startBinding).toBeNull();
-    expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding);
+    expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
+    expect(rightBoundArrow.endBinding?.elementId).toBe(
+      rightArrowBinding.elementId,
+    );
+    expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
+    expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
   });
 
   it("resizes with labeled arrows", async () => {

+ 2 - 0
packages/excalidraw/types.ts

@@ -281,6 +281,7 @@ export interface AppState {
   currentItemEndArrowhead: Arrowhead | null;
   currentHoveredFontFamily: FontFamilyValues | null;
   currentItemRoundness: StrokeRoundness;
+  currentItemArrowType: "sharp" | "round" | "elbow";
   viewBackgroundColor: string;
   scrollX: number;
   scrollY: number;
@@ -624,6 +625,7 @@ export type AppClassProperties = {
   insertEmbeddableElement: App["insertEmbeddableElement"];
   onMagicframeToolSelect: App["onMagicframeToolSelect"];
   getName: App["getName"];
+  dismissLinearEditor: App["dismissLinearEditor"];
 };
 
 export type PointerDownState = Readonly<{

+ 3 - 0
packages/excalidraw/utils.ts

@@ -1157,3 +1157,6 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
     resolve(fn(...args));
   });
 };
+
+export const isAnyTrue = (...args: boolean[]): boolean =>
+  Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;

+ 1 - 0
packages/utils/__snapshots__/export.test.ts.snap

@@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "contextMenu": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
+  "currentItemArrowType": "round",
   "currentItemBackgroundColor": "transparent",
   "currentItemEndArrowhead": "arrow",
   "currentItemFillStyle": "solid",

+ 16 - 2
packages/utils/geometry/geometry.ts

@@ -16,10 +16,22 @@ const DEFAULT_THRESHOLD = 10e-5;
  */
 
 // the two vectors are ao and bo
-export const cross = (a: Point, b: Point, o: Point) => {
+export const cross = (
+  a: Readonly<Point>,
+  b: Readonly<Point>,
+  o: Readonly<Point>,
+) => {
   return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
 };
 
+export const dot = (
+  a: Readonly<Point>,
+  b: Readonly<Point>,
+  o: Readonly<Point>,
+) => {
+  return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]);
+};
+
 export const isClosed = (polygon: Polygon) => {
   const first = polygon[0];
   const last = polygon[polygon.length - 1];
@@ -36,7 +48,9 @@ export const close = (polygon: Polygon) => {
 
 // convert radians to degress
 export const angleToDegrees = (angle: number) => {
-  return (angle * 180) / Math.PI;
+  const theta = (angle * 180) / Math.PI;
+
+  return theta < 0 ? 360 + theta : theta;
 };
 
 // convert degrees to radians

Some files were not shown because too many files changed in this diff