浏览代码

Allow binding linear elements to other elements (#1899)

* Refactor: simplify linear element type

* Refactor: dedupe scrollbar handling

* First step towards binding - establish relationship and basic test for dragged lines

* Refactor: use zoom from appstate

* Refactor: generalize getElementAtPosition

* Only consider bindable elements in hit test

* Refactor: pull out pieces of hit test for reuse later

* Refactor: pull out diamond from hit test for reuse later

* Refactor: pull out text from hit test for reuse later

* Suggest binding when hovering

* Give shapes in regression test real size

* Give shapes in undo/redo test real size

* Keep bound element highlighted

* Show binding suggestion for multi-point elements

* Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements

* Use Id instead of ID

* Improve boundary offset for non-squarish elements

* Fix localStorage for binding on linear elements

* Simplify dragging code and fix elements bound twice to the same shape

* Fix binding for rectangles

* Bind both ends at the end of the linear element creation, needed for focus points

* wip

* Refactor: Renames and reshapes for next commit

* Calculate and store focus points and gaps, but dont use them yet

* Focus points for rectangles

* Dont blow up when canceling linear element

* Stop suggesting binding when a non-compatible tool is selected

* Clean up collision code

* Using Geometric Algebra for hit tests

* Correct binding for all shapes

* Constant gap around polygon corners

* Fix rotation handling

* Generalize update and fix hit test for rotated elements

* Handle rotation realtime

* Handle scaling

* Remove vibration when moving bound and binding element together

* Handle simultenous scaling

* Allow binding and unbinding when editing linear elements

* Dont delete binding when the end point wasnt touched

* Bind on enter/escape when editing

* Support multiple suggested bindable elements in preparation for supporting linear elements dragging

* Update binding when moving linear elements

* Update binding when resizing linear elements

* Dont re-render UI on binding hints

* Update both ends when one is moved

* Use distance instead of focus point for binding

* Complicated approach for posterity, ignore this commit

* Revert the complicated approach

* Better focus point strategy, working for all shapes

* Update snapshots

* Dont break binding gap when mirroring shape

* Dont break binding gap when grid mode pushes it inside

* Dont bind draw elements

* Support alt duplication

* Fix alt duplication to

* Support cmd+D duplication

* All copy mechanisms are supported

* Allow binding shapes to arrows, having arrows created first

* Prevent arrows from disappearing for ellipses

* Better binding suggestion highlight for shapes

* Dont suggest second binding for simple elements when editing or moving them

* Dont steal already bound linear elements when moving shapes

* Fix highlighting diamonds and more precisely highlight other shapes

* Highlight linear element edges for binding

* Highlight text binding too

* Handle deletion

* Dont suggest second binding for simple linear elements when creating them

* Dont highlight bound element during creation

* Fix binding for rotated linear elements

* Fix collision check for ellipses

* Dont show suggested bindings for selected pairs

* Bind multi-point linear elements when the tool is switched - important for mobile

* Handle unbinding one of two bound edges correctly

* Rename boundElement in state to startBoundElement

* Dont double account for zoom when rendering binding highlight

* Fix rendering of edited linear element point handles

* Suggest binding when adding new point to a linear element

* Bind when adding a new point to a linear element and dont unbind when moving middle elements

* Handle deleting points

* Add cmd modifier key to disable binding

* Use state for enabling binding, fix not binding for linear elements during creation

* Drop support for binding lines, only arrows are bindable

* Reset binding mode on blur

* Fix not binding lines
Michal Srb 4 年之前
父节点
当前提交
26f67d27ec

+ 24 - 2
src/actions/actionDeleteSelected.tsx

@@ -11,6 +11,7 @@ import { AppState } from "../types";
 import { newElementWith } from "../element/mutateElement";
 import { getElementsInGroup } from "../groups";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { fixBindingsAfterDeletion } from "../element/binding";
 
 const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
@@ -53,7 +54,12 @@ export const actionDeleteSelected = register({
   name: "deleteSelectedElements",
   perform: (elements, appState) => {
     if (appState.editingLinearElement) {
-      const { elementId, activePointIndex } = appState.editingLinearElement;
+      const {
+        elementId,
+        activePointIndex,
+        startBindingElement,
+        endBindingElement,
+      } = appState.editingLinearElement;
       const element = LinearElementEditor.getElement(elementId);
       if (!element) {
         return false;
@@ -62,7 +68,7 @@ export const actionDeleteSelected = register({
         // case: no point selected → delete whole element
         activePointIndex == null ||
         activePointIndex === -1 ||
-        // case: deleting last point
+        // case: deleting last remaining point
         element.points.length < 2
       ) {
         const nextElements = elements.filter((el) => el.id !== element.id);
@@ -78,6 +84,17 @@ export const actionDeleteSelected = register({
         };
       }
 
+      // We cannot do this inside `movePoint` because it is also called
+      // when deleting the uncommitted point (which hasn't caused any binding)
+      const binding = {
+        startBindingElement:
+          activePointIndex === 0 ? null : startBindingElement,
+        endBindingElement:
+          activePointIndex === element.points.length - 1
+            ? null
+            : endBindingElement,
+      };
+
       LinearElementEditor.movePoint(element, activePointIndex, "delete");
 
       return {
@@ -86,6 +103,7 @@ export const actionDeleteSelected = register({
           ...appState,
           editingLinearElement: {
             ...appState.editingLinearElement,
+            ...binding,
             activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
           },
         },
@@ -97,6 +115,10 @@ export const actionDeleteSelected = register({
       elements: nextElements,
       appState: nextAppState,
     } = deleteSelectedElements(elements, appState);
+    fixBindingsAfterDeletion(
+      nextElements,
+      elements.filter(({ id }) => appState.selectedElementIds[id]),
+    );
 
     nextAppState = handleGroupEditingState(nextAppState, nextElements);
 

+ 50 - 33
src/actions/actionDuplicateSelection.tsx

@@ -11,6 +11,9 @@ import { getShortcutKey } from "../utils";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { mutateElement } from "../element/mutateElement";
 import { selectGroupsForSelectedElements } from "../groups";
+import { AppState } from "../types";
+import { fixBindingsAfterDuplication } from "../element/binding";
+import { ActionResult } from "./types";
 
 export const actionDuplicateSelection = register({
   name: "duplicateSelection",
@@ -50,40 +53,8 @@ export const actionDuplicateSelection = register({
       };
     }
 
-    const groupIdMap = new Map();
-    const newElements: ExcalidrawElement[] = [];
-    const finalElements = elements.reduce(
-      (acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
-        if (appState.selectedElementIds[element.id]) {
-          const newElement = duplicateElement(
-            appState.editingGroupId,
-            groupIdMap,
-            element,
-            {
-              x: element.x + 10,
-              y: element.y + 10,
-            },
-          );
-          newElements.push(newElement);
-          return acc.concat([element, newElement]);
-        }
-        return acc.concat(element);
-      },
-      [],
-    );
     return {
-      appState: selectGroupsForSelectedElements(
-        {
-          ...appState,
-          selectedGroupIds: {},
-          selectedElementIds: newElements.reduce((acc, element) => {
-            acc[element.id] = true;
-            return acc;
-          }, {} as any),
-        },
-        getNonDeletedElements(finalElements),
-      ),
-      elements: finalElements,
+      ...duplicateElements(elements, appState),
       commitToHistory: true,
     };
   },
@@ -102,3 +73,49 @@ export const actionDuplicateSelection = register({
     />
   ),
 });
+
+const duplicateElements = (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+): Partial<ActionResult> => {
+  const groupIdMap = new Map();
+  const newElements: ExcalidrawElement[] = [];
+  const oldElements: ExcalidrawElement[] = [];
+  const oldIdToDuplicatedId = new Map();
+  const finalElements = elements.reduce(
+    (acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
+      if (appState.selectedElementIds[element.id]) {
+        const newElement = duplicateElement(
+          appState.editingGroupId,
+          groupIdMap,
+          element,
+          {
+            x: element.x + 10,
+            y: element.y + 10,
+          },
+        );
+        oldIdToDuplicatedId.set(element.id, newElement.id);
+        oldElements.push(element);
+        newElements.push(newElement);
+        return acc.concat([element, newElement]);
+      }
+      return acc.concat(element);
+    },
+    [],
+  );
+  fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
+  return {
+    elements: finalElements,
+    appState: selectGroupsForSelectedElements(
+      {
+        ...appState,
+        selectedGroupIds: {},
+        selectedElementIds: newElements.reduce((acc, element) => {
+          acc[element.id] = true;
+          return acc;
+        }, {} as any),
+      },
+      getNonDeletedElements(finalElements),
+    ),
+  };
+};

+ 39 - 2
src/actions/actionFinalize.tsx

@@ -9,15 +9,32 @@ import { register } from "./register";
 import { mutateElement } from "../element/mutateElement";
 import { isPathALoop } from "../math";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import Scene from "../scene/Scene";
+import {
+  maybeBindLinearElement,
+  bindOrUnbindLinearElement,
+} from "../element/binding";
+import { isBindingElement } from "../element/typeChecks";
 
 export const actionFinalize = register({
   name: "finalize",
   perform: (elements, appState) => {
     if (appState.editingLinearElement) {
-      const { elementId } = appState.editingLinearElement;
+      const {
+        elementId,
+        startBindingElement,
+        endBindingElement,
+      } = appState.editingLinearElement;
       const element = LinearElementEditor.getElement(elementId);
 
       if (element) {
+        if (isBindingElement(element)) {
+          bindOrUnbindLinearElement(
+            element,
+            startBindingElement,
+            endBindingElement,
+          );
+        }
         return {
           elements:
             element.points.length < 2 || isInvisiblySmallElement(element)
@@ -66,11 +83,12 @@ export const actionFinalize = register({
       // If the multi point line closes the loop,
       // set the last point to first point.
       // This ensures that loop remains closed at different scales.
+      const isLoop = isPathALoop(multiPointElement.points);
       if (
         multiPointElement.type === "line" ||
         multiPointElement.type === "draw"
       ) {
-        if (isPathALoop(multiPointElement.points)) {
+        if (isLoop) {
           const linePoints = multiPointElement.points;
           const firstPoint = linePoints[0];
           mutateElement(multiPointElement, {
@@ -83,6 +101,23 @@ export const actionFinalize = register({
         }
       }
 
+      if (
+        isBindingElement(multiPointElement) &&
+        !isLoop &&
+        multiPointElement.points.length > 1
+      ) {
+        const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+          multiPointElement,
+          -1,
+        );
+        maybeBindLinearElement(
+          multiPointElement,
+          appState,
+          Scene.getScene(multiPointElement)!,
+          { x, y },
+        );
+      }
+
       if (!appState.elementLocked) {
         appState.selectedElementIds[multiPointElement.id] = true;
       }
@@ -101,6 +136,8 @@ export const actionFinalize = register({
         draggingElement: null,
         multiElement: null,
         editingElement: null,
+        startBoundElement: null,
+        suggestedBindings: [],
         selectedElementIds:
           multiPointElement && !appState.elementLocked
             ? {

+ 8 - 7
src/actions/actionHistory.tsx

@@ -9,6 +9,7 @@ import { AppState } from "../types";
 import { KEYS } from "../keys";
 import { getElementMap } from "../element";
 import { newElementWith } from "../element/mutateElement";
+import { fixBindingsAfterDeletion } from "../element/binding";
 
 const writeData = (
   prevElements: readonly ExcalidrawElement[],
@@ -31,6 +32,9 @@ const writeData = (
     const nextElements = data.elements;
     const nextElementMap = getElementMap(nextElements);
 
+    const deletedElements = prevElements.filter(
+      (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
+    );
     const elements = nextElements
       .map((nextElement) =>
         newElementWith(
@@ -39,14 +43,11 @@ const writeData = (
         ),
       )
       .concat(
-        prevElements
-          .filter(
-            (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
-          )
-          .map((prevElement) =>
-            newElementWith(prevElement, { isDeleted: true }),
-          ),
+        deletedElements.map((prevElement) =>
+          newElementWith(prevElement, { isDeleted: true }),
+        ),
       );
+    fixBindingsAfterDeletion(elements, deletedElements);
 
     return {
       elements,

+ 6 - 0
src/appState.ts

@@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit<
     resizingElement: null,
     multiElement: null,
     editingElement: null,
+    startBoundElement: null,
     editingLinearElement: null,
     elementType: "selection",
     elementLocked: false,
@@ -43,6 +44,7 @@ export const getDefaultAppState = (): Omit<
     scrolledOutside: false,
     name: `${t("labels.untitled")}-${getDateTime()}`,
     username: "",
+    isBindingEnabled: true,
     isCollaborating: false,
     isResizing: false,
     isRotating: false,
@@ -55,6 +57,7 @@ export const getDefaultAppState = (): Omit<
     collaborators: new Map(),
     shouldCacheIgnoreZoom: false,
     showShortcutsDialog: false,
+    suggestedBindings: [],
     zenModeEnabled: false,
     gridSize: null,
     editingGroupId: null,
@@ -96,6 +99,7 @@ const APP_STATE_STORAGE_CONF = (<
   cursorY: { browser: true, export: false },
   draggingElement: { browser: false, export: false },
   editingElement: { browser: false, export: false },
+  startBoundElement: { browser: false, export: false },
   editingGroupId: { browser: true, export: false },
   editingLinearElement: { browser: false, export: false },
   elementLocked: { browser: true, export: false },
@@ -104,6 +108,7 @@ const APP_STATE_STORAGE_CONF = (<
   exportBackground: { browser: true, export: false },
   gridSize: { browser: true, export: true },
   height: { browser: false, export: false },
+  isBindingEnabled: { browser: false, export: false },
   isCollaborating: { browser: false, export: false },
   isLibraryOpen: { browser: false, export: false },
   isLoading: { browser: false, export: false },
@@ -124,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
   shouldAddWatermark: { browser: true, export: false },
   shouldCacheIgnoreZoom: { browser: true, export: false },
   showShortcutsDialog: { browser: false, export: false },
+  suggestedBindings: { browser: false, export: false },
   username: { browser: true, export: false },
   viewBackgroundColor: { browser: true, export: true },
   width: { browser: false, export: false },

+ 301 - 117
src/components/App.tsx

@@ -31,6 +31,7 @@ import {
   dragSelectedElements,
   getDragOffsetXY,
   dragNewElement,
+  hitTest,
 } from "../element";
 import {
   getElementsWithinSelection,
@@ -60,6 +61,8 @@ import {
   ExcalidrawTextElement,
   NonDeleted,
   ExcalidrawGenericElement,
+  ExcalidrawLinearElement,
+  ExcalidrawBindableElement,
 } from "../element/types";
 
 import { distance2d, isPathALoop, getGridPoint } from "../math";
@@ -136,7 +139,13 @@ import { generateCollaborationLink, getCollaborationLinkData } from "../data";
 import { mutateElement, newElementWith } from "../element/mutateElement";
 import { invalidateShapeForElement } from "../renderer/renderElement";
 import { unstable_batchedUpdates } from "react-dom";
-import { isLinearElement } from "../element/typeChecks";
+import {
+  isLinearElement,
+  isLinearElementType,
+  isBindingElement,
+  isBindingElementType,
+  isBindableElement,
+} from "../element/typeChecks";
 import { actionFinalize, actionDeleteSelected } from "../actions";
 import {
   restoreUsernameFromLocalStorage,
@@ -154,6 +163,19 @@ import {
 } from "../groups";
 import { Library } from "../data/library";
 import Scene from "../scene/Scene";
+import {
+  getHoveredElementForBinding,
+  maybeBindLinearElement,
+  getEligibleElementsForBinding,
+  bindOrUnbindSelectedElements,
+  unbindLinearElements,
+  fixBindingsAfterDuplication,
+  maybeBindBindableElement,
+  getElligibleElementForBindingElementAtCoors,
+  fixBindingsAfterDeletion,
+  isLinearElementSimpleAndAlreadyBound,
+  isBindingEnabled,
+} from "../element/binding";
 
 /**
  * @param func handler taking at most single parameter (event).
@@ -407,6 +429,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
   private onBlur = withBatchedUpdates(() => {
     isHoldingSpace = false;
+    this.setState({ isBindingEnabled: true });
     this.saveDebounced();
     this.saveDebounced.flush();
   });
@@ -690,7 +713,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
   }, SYNC_FULL_SCENE_INTERVAL_MS);
 
-  componentDidUpdate(prevProps: ExcalidrawProps) {
+  componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
     const { width: prevWidth, height: prevHeight } = prevProps;
     const { width: currentWidth, height: currentHeight } = this.props;
     if (prevWidth !== currentWidth || prevHeight !== currentHeight) {
@@ -714,6 +737,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         this.actionManager.executeAction(actionFinalize);
       });
     }
+    const { multiElement } = prevState;
+    if (
+      prevState.elementType !== this.state.elementType &&
+      multiElement != null &&
+      isBindingEnabled(this.state) &&
+      isBindingElement(multiElement)
+    ) {
+      maybeBindLinearElement(
+        multiElement,
+        this.state,
+        this.scene,
+        tupleToCoors(
+          LinearElementEditor.getPointAtIndexGlobalCoordinates(
+            multiElement,
+            -1,
+          ),
+        ),
+      );
+    }
 
     const cursorButton: {
       [id: string]: string | undefined;
@@ -950,16 +992,31 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     const dy = y - elementsCenterY;
     const groupIdMap = new Map();
 
+    const oldIdToDuplicatedId = new Map();
     const newElements = clipboardElements.map((element) => {
-      return duplicateElement(this.state.editingGroupId, groupIdMap, element, {
-        x: element.x + dx - minX,
-        y: element.y + dy - minY,
-      });
+      const newElement = duplicateElement(
+        this.state.editingGroupId,
+        groupIdMap,
+        element,
+        {
+          x: element.x + dx - minX,
+          y: element.y + dy - minY,
+        },
+      );
+      oldIdToDuplicatedId.set(element.id, newElement.id);
+      return newElement;
     });
-    this.scene.replaceAllElements([
+    const nextElements = [
       ...this.scene.getElementsIncludingDeleted(),
       ...newElements,
-    ]);
+    ];
+    fixBindingsAfterDuplication(
+      nextElements,
+      clipboardElements,
+      oldIdToDuplicatedId,
+    );
+
+    this.scene.replaceAllElements(nextElements);
     history.resumeRecording();
     this.setState(
       selectGroupsForSelectedElements(
@@ -1403,6 +1460,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     if (event[KEYS.CTRL_OR_CMD] && event.keyCode === KEYS.GRID_KEY_CODE) {
       this.toggleGridMode();
     }
+    if (event[KEYS.CTRL_OR_CMD]) {
+      this.setState({ isBindingEnabled: false });
+    }
 
     if (event.code === "KeyC" && event.altKey && event.shiftKey) {
       this.copyToClipboardAsPng();
@@ -1511,6 +1571,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       }
       isHoldingSpace = false;
     }
+    if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
+      this.setState({ isBindingEnabled: true });
+    }
   });
 
   private selectShapeTool(elementType: AppState["elementType"]) {
@@ -1520,6 +1583,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     if (isToolIcon(document.activeElement)) {
       document.activeElement.blur();
     }
+    if (!isLinearElementType(elementType)) {
+      this.setState({ suggestedBindings: [] });
+    }
     if (elementType !== "selection") {
       this.setState({
         elementType,
@@ -1558,10 +1624,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     gesture.initialScale = null;
   });
 
-  private setElements = (elements: readonly ExcalidrawElement[]) => {
-    this.scene.replaceAllElements(elements);
-  };
-
   private handleTextWysiwyg(
     element: ExcalidrawTextElement,
     {
@@ -1612,6 +1674,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
               [element.id]: true,
             },
           }));
+        } else {
+          fixBindingsAfterDeletion(this.scene.getElements(), [element]);
         }
         if (!isDeleted || isExistingElement) {
           history.resumeRecording();
@@ -1643,13 +1707,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     x: number,
     y: number,
   ): NonDeleted<ExcalidrawTextElement> | null {
-    const element = getElementAtPosition(
-      this.scene.getElements(),
-      this.state,
-      x,
-      y,
-      this.state.zoom,
-    );
+    const element = this.getElementAtPosition(x, y);
 
     if (element && isTextElement(element) && !element.isDeleted) {
       return element;
@@ -1657,6 +1715,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     return null;
   }
 
+  private getElementAtPosition(
+    x: number,
+    y: number,
+  ): NonDeleted<ExcalidrawElement> | null {
+    return getElementAtPosition(this.scene.getElements(), (element) =>
+      hitTest(element, this.state, x, y),
+    );
+  }
+
   private startTextEditing = ({
     sceneX,
     sceneY,
@@ -1786,14 +1853,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     const selectedGroupIds = getSelectedGroupIds(this.state);
 
     if (selectedGroupIds.length > 0) {
-      const elements = this.scene.getElements();
-      const hitElement = getElementAtPosition(
-        elements,
-        this.state,
-        sceneX,
-        sceneY,
-        this.state.zoom,
-      );
+      const hitElement = this.getElementAtPosition(sceneX, sceneY);
 
       const selectedGroupId =
         hitElement &&
@@ -1873,12 +1933,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       }
     }
 
-    const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
+    const scenePointer = viewportCoordsToSceneCoords(
       event,
       this.state,
       this.canvas,
       window.devicePixelRatio,
     );
+    const { x: scenePointerX, y: scenePointerY } = scenePointer;
 
     if (
       this.state.editingLinearElement &&
@@ -1894,6 +1955,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       if (editingLinearElement !== this.state.editingLinearElement) {
         this.setState({ editingLinearElement });
       }
+      if (editingLinearElement.lastUncommittedPoint != null) {
+        this.maybeSuggestBindingAtCursor(scenePointer);
+      } else {
+        this.setState({ suggestedBindings: [] });
+      }
+    }
+
+    if (isBindingElementType(this.state.elementType)) {
+      // Hovering with a selected tool or creating new linear element via click
+      // and point
+      const { draggingElement } = this.state;
+      if (isBindingElement(draggingElement)) {
+        this.maybeSuggestBindingForLinearElementAtCursor(
+          draggingElement,
+          "end",
+          scenePointer,
+          this.state.startBoundElement,
+        );
+      } else {
+        this.maybeSuggestBindingAtCursor(scenePointer);
+      }
     }
 
     if (this.state.multiElement) {
@@ -1954,6 +2036,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           });
         }
       }
+
       return;
     }
 
@@ -2003,13 +2086,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         return;
       }
     }
-    const hitElement = getElementAtPosition(
-      elements,
-      this.state,
-      scenePointerX,
-      scenePointerY,
-      this.state.zoom,
-    );
+    const hitElement = this.getElementAtPosition(scenePointerX, scenePointerY);
     if (this.state.elementType === "text") {
       document.documentElement.style.cursor = isTextElement(hitElement)
         ? CURSOR_TYPE.TEXT
@@ -2328,24 +2405,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         return;
       }
 
-      if (pointerDownState.scrollbars.isOverHorizontal) {
-        const x = event.clientX;
-        const dx = x - pointerDownState.lastCoords.x;
-        this.setState({
-          scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
-        });
-        pointerDownState.lastCoords.x = x;
-        return;
-      }
-
-      if (pointerDownState.scrollbars.isOverVertical) {
-        const y = event.clientY;
-        const dy = y - pointerDownState.lastCoords.y;
-        this.setState({
-          scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
-        });
-        pointerDownState.lastCoords.y = y;
-      }
+      this.handlePointerMoveOverScrollbars(event, pointerDownState);
     });
 
     const onPointerUp = withBatchedUpdates(() => {
@@ -2440,8 +2500,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             this.state,
             (appState) => this.setState(appState),
             history,
-            pointerDownState.origin.x,
-            pointerDownState.origin.y,
+            pointerDownState.origin,
           );
           if (ret.hitElement) {
             pointerDownState.hit.element = ret.hitElement;
@@ -2454,12 +2513,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         // hitElement may already be set above, so check first
         pointerDownState.hit.element =
           pointerDownState.hit.element ??
-          getElementAtPosition(
-            elements,
-            this.state,
+          this.getElementAtPosition(
             pointerDownState.origin.x,
             pointerDownState.origin.y,
-            this.state.zoom,
           );
 
         this.maybeClearSelectionWhenHittingElement(
@@ -2544,7 +2600,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
   private handleLinearElementOnPointerDown = (
     event: React.PointerEvent<HTMLCanvasElement>,
-    elementType: "draw" | "line" | "arrow",
+    elementType: ExcalidrawLinearElement["type"],
     pointerDownState: PointerDownState,
   ): void => {
     if (this.state.multiElement) {
@@ -2616,6 +2672,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       mutateElement(element, {
         points: [...element.points, [0, 0]],
       });
+      const boundElement = getHoveredElementForBinding(
+        pointerDownState.origin,
+        this.scene,
+      );
       this.scene.replaceAllElements([
         ...this.scene.getElementsIncludingDeleted(),
         element,
@@ -2623,6 +2683,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.setState({
         draggingElement: element,
         editingElement: element,
+        startBoundElement: boundElement,
+        suggestedBindings: [],
       });
     }
   };
@@ -2690,33 +2752,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         return;
       }
 
-      if (pointerDownState.scrollbars.isOverHorizontal) {
-        const x = event.clientX;
-        const dx = x - pointerDownState.lastCoords.x;
-        this.setState({
-          scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
-        });
-        pointerDownState.lastCoords.x = x;
+      if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
         return;
       }
 
-      if (pointerDownState.scrollbars.isOverVertical) {
-        const y = event.clientY;
-        const dy = y - pointerDownState.lastCoords.y;
-        this.setState({
-          scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
-        });
-        pointerDownState.lastCoords.y = y;
-        return;
-      }
-
-      const { x, y } = viewportCoordsToSceneCoords(
+      const pointerCoords = viewportCoordsToSceneCoords(
         event,
         this.state,
         this.canvas,
         window.devicePixelRatio,
       );
-      const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
+      const [gridX, gridY] = getGridPoint(
+        pointerCoords.x,
+        pointerCoords.y,
+        this.state.gridSize,
+      );
 
       // for arrows/lines, don't start dragging until a given threshold
       //  to ensure we don't create a 2-point arrow by mistake when
@@ -2729,8 +2779,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       ) {
         if (
           distance2d(
-            x,
-            y,
+            pointerCoords.x,
+            pointerCoords.y,
             pointerDownState.origin.x,
             pointerDownState.origin.y,
           ) < DRAGGING_THRESHOLD
@@ -2753,8 +2803,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           isRotating: resizeHandle === "rotation",
         });
         const [resizeX, resizeY] = getGridPoint(
-          x - pointerDownState.resize.offset.x,
-          y - pointerDownState.resize.offset.y,
+          pointerCoords.x - pointerDownState.resize.offset.x,
+          pointerCoords.y - pointerDownState.resize.offset.y,
           this.state.gridSize,
         );
         if (
@@ -2775,6 +2825,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             pointerDownState.resize.originalElements,
           )
         ) {
+          this.maybeSuggestBindingForAll(selectedElements);
           return;
         }
       }
@@ -2783,13 +2834,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         const didDrag = LinearElementEditor.handlePointDragging(
           this.state,
           (appState) => this.setState(appState),
-          x,
-          y,
+          pointerCoords.x,
+          pointerCoords.y,
+          (element, startOrEnd) => {
+            this.maybeSuggestBindingForLinearElementAtCursor(
+              element,
+              startOrEnd,
+              pointerCoords,
+            );
+          },
         );
 
         if (didDrag) {
-          pointerDownState.lastCoords.x = x;
-          pointerDownState.lastCoords.y = y;
+          pointerDownState.lastCoords.x = pointerCoords.x;
+          pointerDownState.lastCoords.y = pointerCoords.y;
           return;
         }
       }
@@ -2805,11 +2863,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         );
         if (selectedElements.length > 0) {
           const [dragX, dragY] = getGridPoint(
-            x - pointerDownState.drag.offset.x,
-            y - pointerDownState.drag.offset.y,
+            pointerCoords.x - pointerDownState.drag.offset.x,
+            pointerCoords.y - pointerDownState.drag.offset.y,
             this.state.gridSize,
           );
-          dragSelectedElements(selectedElements, dragX, dragY);
+          dragSelectedElements(selectedElements, dragX, dragY, this.scene);
+          this.maybeSuggestBindingForAll(selectedElements);
 
           // We duplicate the selected element if alt is pressed on pointer move
           if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
@@ -2822,6 +2881,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             const nextElements = [];
             const elementsToAppend = [];
             const groupIdMap = new Map();
+            const oldIdToDuplicatedId = new Map();
             for (const element of this.scene.getElementsIncludingDeleted()) {
               if (
                 this.state.selectedElementIds[element.id] ||
@@ -2846,14 +2906,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
                 });
                 nextElements.push(duplicatedElement);
                 elementsToAppend.push(element);
+                oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
               } else {
                 nextElements.push(element);
               }
             }
-            this.scene.replaceAllElements([
-              ...nextElements,
-              ...elementsToAppend,
-            ]);
+            const nextSceneElements = [...nextElements, ...elementsToAppend];
+            fixBindingsAfterDuplication(
+              nextSceneElements,
+              elementsToAppend,
+              oldIdToDuplicatedId,
+              "duplicatesServeAsOld",
+            );
+            this.scene.replaceAllElements(nextSceneElements);
           }
           return;
         }
@@ -2872,8 +2937,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         let dx: number;
         let dy: number;
         if (draggingElement.type === "draw") {
-          dx = x - draggingElement.x;
-          dy = y - draggingElement.y;
+          dx = pointerCoords.x - draggingElement.x;
+          dy = pointerCoords.y - draggingElement.y;
         } else {
           dx = gridX - draggingElement.x;
           dy = gridY - draggingElement.y;
@@ -2903,16 +2968,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             });
           }
         }
+        if (isBindingElement(draggingElement)) {
+          // When creating a linear element by dragging
+          this.maybeSuggestBindingForLinearElementAtCursor(
+            draggingElement,
+            "end",
+            pointerCoords,
+            this.state.startBoundElement,
+          );
+        }
       } else if (draggingElement.type === "selection") {
         dragNewElement(
           draggingElement,
           this.state.elementType,
           pointerDownState.origin.x,
           pointerDownState.origin.y,
-          x,
-          y,
-          distance(pointerDownState.origin.x, x),
-          distance(pointerDownState.origin.y, y),
+          pointerCoords.x,
+          pointerCoords.y,
+          distance(pointerDownState.origin.x, pointerCoords.x),
+          distance(pointerDownState.origin.y, pointerCoords.y),
           getResizeWithSidesSameLengthKey(event),
           getResizeCenterPointKey(event),
         );
@@ -2929,6 +3003,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           getResizeWithSidesSameLengthKey(event),
           getResizeCenterPointKey(event),
         );
+        this.maybeSuggestBindingForAll([draggingElement]);
       }
 
       if (this.state.elementType === "selection") {
@@ -2963,6 +3038,33 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     });
   }
 
+  // Returns whether the pointer move happened over either scrollbar
+  private handlePointerMoveOverScrollbars(
+    event: PointerEvent,
+    pointerDownState: PointerDownState,
+  ): boolean {
+    if (pointerDownState.scrollbars.isOverHorizontal) {
+      const x = event.clientX;
+      const dx = x - pointerDownState.lastCoords.x;
+      this.setState({
+        scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
+      });
+      pointerDownState.lastCoords.x = x;
+      return true;
+    }
+
+    if (pointerDownState.scrollbars.isOverVertical) {
+      const y = event.clientY;
+      const dy = y - pointerDownState.lastCoords.y;
+      this.setState({
+        scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
+      });
+      pointerDownState.lastCoords.y = y;
+      return true;
+    }
+    return false;
+  }
+
   private onPointerUpFromPointerDownHandler(
     pointerDownState: PointerDownState,
   ): (event: PointerEvent) => void {
@@ -2973,6 +3075,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         multiElement,
         elementType,
         elementLocked,
+        isResizing,
       } = this.state;
 
       this.setState({
@@ -2991,14 +3094,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
 
-      // if moving start/end point towards start/end point within threshold,
-      //  close the loop
+      // Handle end of dragging a point of a linear element, might close a loop
+      // and sets binding element
       if (this.state.editingLinearElement) {
         const editingLinearElement = LinearElementEditor.handlePointerUp(
+          childEvent,
           this.state.editingLinearElement,
+          this.state,
         );
         if (editingLinearElement !== this.state.editingLinearElement) {
-          this.setState({ editingLinearElement });
+          this.setState({
+            editingLinearElement,
+            suggestedBindings: [],
+          });
         }
       }
 
@@ -3021,21 +3129,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         if (draggingElement!.points.length > 1) {
           history.resumeRecording();
         }
+        const pointerCoords = viewportCoordsToSceneCoords(
+          childEvent,
+          this.state,
+          this.canvas,
+          window.devicePixelRatio,
+        );
         if (
           !pointerDownState.drag.hasOccurred &&
           draggingElement &&
           !multiElement
         ) {
-          const { x, y } = viewportCoordsToSceneCoords(
-            childEvent,
-            this.state,
-            this.canvas,
-            window.devicePixelRatio,
-          );
           mutateElement(draggingElement, {
             points: [
               ...draggingElement.points,
-              [x - draggingElement.x, y - draggingElement.y],
+              [
+                pointerCoords.x - draggingElement.x,
+                pointerCoords.y - draggingElement.y,
+              ],
             ],
           });
           this.setState({
@@ -3043,6 +3154,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
             editingElement: this.state.draggingElement,
           });
         } else if (pointerDownState.drag.hasOccurred && !multiElement) {
+          if (
+            isBindingEnabled(this.state) &&
+            isBindingElement(draggingElement)
+          ) {
+            maybeBindLinearElement(
+              draggingElement,
+              this.state,
+              this.scene,
+              pointerCoords,
+            );
+          }
+          this.setState({ suggestedBindings: [], startBoundElement: null });
           if (!elementLocked) {
             resetCursor();
             this.setState((prevState) => ({
@@ -3086,6 +3209,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           draggingElement,
           getNormalizedDimensions(draggingElement),
         );
+
+        if (
+          isBindingEnabled(this.state) &&
+          isBindableElement(draggingElement)
+        ) {
+          maybeBindBindableElement(draggingElement);
+        }
       }
 
       if (resizingElement) {
@@ -3155,20 +3285,80 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         history.resumeRecording();
       }
 
+      if (pointerDownState.drag.hasOccurred || isResizing) {
+        (isBindingEnabled(this.state)
+          ? bindOrUnbindSelectedElements
+          : unbindLinearElements)(
+          getSelectedElements(this.scene.getElements(), this.state),
+        );
+      }
+
       if (!elementLocked) {
         resetCursor();
         this.setState({
           draggingElement: null,
+          suggestedBindings: [],
           elementType: "selection",
         });
       } else {
         this.setState({
           draggingElement: null,
+          suggestedBindings: [],
         });
       }
     });
   }
 
+  private maybeSuggestBindingAtCursor = (pointerCoords: {
+    x: number;
+    y: number;
+  }): void => {
+    const hoveredBindableElement = getHoveredElementForBinding(
+      pointerCoords,
+      this.scene,
+    );
+    this.setState({
+      suggestedBindings:
+        hoveredBindableElement != null ? [hoveredBindableElement] : [],
+    });
+  };
+
+  private maybeSuggestBindingForLinearElementAtCursor = (
+    linearElement: NonDeleted<ExcalidrawLinearElement>,
+    startOrEnd: "start" | "end",
+    pointerCoords: {
+      x: number;
+      y: number;
+    },
+    // During line creation the start binding hasn't been written yet
+    // into `linearElement`
+    oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
+  ): void => {
+    const hoveredBindableElement = getElligibleElementForBindingElementAtCoors(
+      linearElement,
+      startOrEnd,
+      pointerCoords,
+    );
+    this.setState({
+      suggestedBindings:
+        hoveredBindableElement != null &&
+        !isLinearElementSimpleAndAlreadyBound(
+          linearElement,
+          oppositeBindingBoundElement?.id,
+          hoveredBindableElement,
+        )
+          ? [hoveredBindableElement]
+          : [],
+    });
+  };
+
+  private maybeSuggestBindingForAll(
+    selectedElements: NonDeleted<ExcalidrawElement>[],
+  ): void {
+    const suggestedBindings = getEligibleElementsForBinding(selectedElements);
+    this.setState({ suggestedBindings });
+  }
+
   private maybeClearSelectionWhenHittingElement(
     event: React.PointerEvent<HTMLCanvasElement>,
     hitElement: ExcalidrawElement | null,
@@ -3291,13 +3481,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     );
 
     const elements = this.scene.getElements();
-    const element = getElementAtPosition(
-      elements,
-      this.state,
-      x,
-      y,
-      this.state.zoom,
-    );
+    const element = this.getElementAtPosition(x, y);
     if (!element) {
       ContextMenu.push({
         options: [

+ 7 - 1
src/components/LayerUI.tsx

@@ -590,7 +590,13 @@ const LayerUI = ({
 
 const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
   const getNecessaryObj = (appState: AppState): Partial<AppState> => {
-    const { cursorX, cursorY, ...ret } = appState;
+    const {
+      cursorX,
+      cursorY,
+      suggestedBindings,
+      startBoundElement: boundElement,
+      ...ret
+    } = appState;
     return ret;
   };
   const prevAppState = getNecessaryObj(prev.appState);

+ 6 - 1
src/data/restore.ts

@@ -48,7 +48,8 @@ function migrateElementWithProperties<T extends ExcalidrawElement>(
     width: element.width || 0,
     height: element.height || 0,
     seed: element.seed ?? 1,
-    groupIds: element.groupIds || [],
+    groupIds: element.groupIds ?? [],
+    boundElementIds: element.boundElementIds ?? [],
   };
 
   return {
@@ -85,6 +86,8 @@ const migrateElement = (
     case "line":
     case "arrow": {
       return migrateElementWithProperties(element, {
+        startBinding: element.startBinding,
+        endBinding: element.endBinding,
         points:
           // migrate old arrow model to new one
           !Array.isArray(element.points) || element.points.length < 2
@@ -98,7 +101,9 @@ const migrateElement = (
     }
     // generic elements
     case "ellipse":
+      return migrateElementWithProperties(element, {});
     case "rectangle":
+      return migrateElementWithProperties(element, {});
     case "diamond":
       return migrateElementWithProperties(element, {});
 

+ 674 - 0
src/element/binding.ts

@@ -0,0 +1,674 @@
+import {
+  ExcalidrawLinearElement,
+  ExcalidrawBindableElement,
+  NonDeleted,
+  NonDeletedExcalidrawElement,
+  PointBinding,
+  ExcalidrawElement,
+} from "./types";
+import { getElementAtPosition } from "../scene";
+import { AppState } from "../types";
+import { isBindableElement, isBindingElement } from "./typeChecks";
+import {
+  bindingBorderTest,
+  distanceToBindableElement,
+  maxBindingGap,
+  determineFocusDistance,
+  intersectElementWithLine,
+  determineFocusPoint,
+} from "./collision";
+import { mutateElement } from "./mutateElement";
+import Scene from "../scene/Scene";
+import { LinearElementEditor } from "./linearElementEditor";
+import { tupleToCoors } from "../utils";
+
+export type SuggestedBinding =
+  | NonDeleted<ExcalidrawBindableElement>
+  | SuggestedPointBinding;
+
+export type SuggestedPointBinding = [
+  NonDeleted<ExcalidrawLinearElement>,
+  "start" | "end" | "both",
+  NonDeleted<ExcalidrawBindableElement>,
+];
+
+export const isBindingEnabled = (appState: AppState): boolean => {
+  return appState.isBindingEnabled;
+};
+
+export const bindOrUnbindLinearElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startBindingElement: ExcalidrawBindableElement | null | "keep",
+  endBindingElement: ExcalidrawBindableElement | null | "keep",
+): void => {
+  const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
+  const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
+  bindOrUnbindLinearElementEdge(
+    linearElement,
+    startBindingElement,
+    "start",
+    boundToElementIds,
+    unboundFromElementIds,
+  );
+  bindOrUnbindLinearElementEdge(
+    linearElement,
+    endBindingElement,
+    "end",
+    boundToElementIds,
+    unboundFromElementIds,
+  );
+
+  const onlyUnbound = Array.from(unboundFromElementIds).filter(
+    (id) => !boundToElementIds.has(id),
+  );
+  Scene.getScene(linearElement)!
+    .getNonDeletedElements(onlyUnbound)
+    .forEach((element) => {
+      mutateElement(element, {
+        boundElementIds: element.boundElementIds?.filter(
+          (id) => id !== linearElement.id,
+        ),
+      });
+    });
+};
+
+const bindOrUnbindLinearElementEdge = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  bindableElement: ExcalidrawBindableElement | null | "keep",
+  startOrEnd: "start" | "end",
+  // Is mutated
+  boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
+  // Is mutated
+  unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
+): void => {
+  if (bindableElement !== "keep") {
+    if (bindableElement != null) {
+      bindLinearElement(linearElement, bindableElement, startOrEnd);
+      boundToElementIds.add(bindableElement.id);
+    } else {
+      const unbound = unbindLinearElement(linearElement, startOrEnd);
+      if (unbound != null) {
+        unboundFromElementIds.add(unbound);
+      }
+    }
+  }
+};
+
+export const bindOrUnbindSelectedElements = (
+  elements: NonDeleted<ExcalidrawElement>[],
+): void => {
+  elements.forEach((element) => {
+    if (isBindingElement(element)) {
+      bindOrUnbindLinearElement(
+        element,
+        getElligibleElementForBindingElement(element, "start"),
+        getElligibleElementForBindingElement(element, "end"),
+      );
+    } else if (isBindableElement(element)) {
+      maybeBindBindableElement(element);
+    }
+  });
+};
+
+export const maybeBindBindableElement = (
+  bindableElement: NonDeleted<ExcalidrawBindableElement>,
+): void => {
+  getElligibleElementsForBindableElementAndWhere(
+    bindableElement,
+  ).forEach(([linearElement, where]) =>
+    bindOrUnbindLinearElement(
+      linearElement,
+      where === "end" ? "keep" : bindableElement,
+      where === "start" ? "keep" : bindableElement,
+    ),
+  );
+};
+
+export const maybeBindLinearElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  appState: AppState,
+  scene: Scene,
+  pointerCoords: { x: number; y: number },
+): void => {
+  if (appState.startBoundElement != null) {
+    bindLinearElement(linearElement, appState.startBoundElement, "start");
+  }
+  const hoveredElement = getHoveredElementForBinding(pointerCoords, scene);
+  if (hoveredElement != null) {
+    bindLinearElement(linearElement, hoveredElement, "end");
+  }
+};
+
+const bindLinearElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  hoveredElement: ExcalidrawBindableElement,
+  startOrEnd: "start" | "end",
+): void => {
+  if (
+    isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+      linearElement,
+      hoveredElement,
+      startOrEnd,
+    )
+  ) {
+    return;
+  }
+  mutateElement(linearElement, {
+    [startOrEnd === "start" ? "startBinding" : "endBinding"]: {
+      elementId: hoveredElement.id,
+      ...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd),
+    } as PointBinding,
+  });
+  mutateElement(hoveredElement, {
+    boundElementIds: [
+      ...new Set([...(hoveredElement.boundElementIds ?? []), linearElement.id]),
+    ],
+  });
+};
+
+// Don't bind both ends of a simple segment
+const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  bindableElement: ExcalidrawBindableElement,
+  startOrEnd: "start" | "end",
+): boolean => {
+  const otherBinding =
+    linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
+  return isLinearElementSimpleAndAlreadyBound(
+    linearElement,
+    otherBinding?.elementId,
+    bindableElement,
+  );
+};
+
+export const isLinearElementSimpleAndAlreadyBound = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
+  bindableElement: ExcalidrawBindableElement,
+): boolean => {
+  return (
+    alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
+  );
+};
+
+export const unbindLinearElements = (
+  elements: NonDeleted<ExcalidrawElement>[],
+): void => {
+  elements.forEach((element) => {
+    if (isBindingElement(element)) {
+      bindOrUnbindLinearElement(element, null, null);
+    }
+  });
+};
+
+const unbindLinearElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+): ExcalidrawBindableElement["id"] | null => {
+  const field = startOrEnd === "start" ? "startBinding" : "endBinding";
+  const binding = linearElement[field];
+  if (binding == null) {
+    return null;
+  }
+  mutateElement(linearElement, { [field]: null });
+  return binding.elementId;
+};
+
+export const getHoveredElementForBinding = (
+  pointerCoords: {
+    x: number;
+    y: number;
+  },
+  scene: Scene,
+): NonDeleted<ExcalidrawBindableElement> | null => {
+  const hoveredElement = getElementAtPosition(
+    scene.getElements(),
+    (element) =>
+      isBindableElement(element) && bindingBorderTest(element, pointerCoords),
+  );
+  return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
+};
+
+const calculateFocusAndGap = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  hoveredElement: ExcalidrawBindableElement,
+  startOrEnd: "start" | "end",
+): { focus: number; gap: number } => {
+  const direction = startOrEnd === "start" ? -1 : 1;
+  const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
+  const adjacentPointIndex = edgePointIndex - direction;
+  const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+    linearElement,
+    edgePointIndex,
+  );
+  const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+    linearElement,
+    adjacentPointIndex,
+  );
+  return {
+    focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
+    gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
+  };
+};
+
+// Supports translating, rotating and scaling `changedElement` with bound
+// linear elements.
+// Because scaling involves moving the focus points as well, it is
+// done before the `changedElement` is updated, and the `newSize` is passed
+// in explicitly.
+export const updateBoundElements = (
+  changedElement: NonDeletedExcalidrawElement,
+  options?: {
+    simultaneouslyUpdated?: readonly ExcalidrawElement[];
+    newSize?: { width: number; height: number };
+  },
+) => {
+  const boundElementIds = changedElement.boundElementIds ?? [];
+  if (boundElementIds.length === 0) {
+    return;
+  }
+  const { newSize, simultaneouslyUpdated } = options ?? {};
+  const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
+    simultaneouslyUpdated,
+  );
+  (Scene.getScene(changedElement)!.getNonDeletedElements(
+    boundElementIds,
+  ) as NonDeleted<ExcalidrawLinearElement>[]).forEach((linearElement) => {
+    const bindableElement = changedElement as ExcalidrawBindableElement;
+    // In case the boundElementIds are stale
+    if (!doesNeedUpdate(linearElement, bindableElement)) {
+      return;
+    }
+    const startBinding = maybeCalculateNewGapWhenScaling(
+      bindableElement,
+      linearElement.startBinding,
+      newSize,
+    );
+    const endBinding = maybeCalculateNewGapWhenScaling(
+      bindableElement,
+      linearElement.endBinding,
+      newSize,
+    );
+    // `linearElement` is being moved/scaled already, just update the binding
+    if (simultaneouslyUpdatedElementIds.has(linearElement.id)) {
+      mutateElement(linearElement, { startBinding, endBinding });
+      return;
+    }
+    updateBoundPoint(
+      linearElement,
+      "start",
+      startBinding,
+      changedElement as ExcalidrawBindableElement,
+    );
+    updateBoundPoint(
+      linearElement,
+      "end",
+      endBinding,
+      changedElement as ExcalidrawBindableElement,
+    );
+  });
+};
+
+const doesNeedUpdate = (
+  boundElement: NonDeleted<ExcalidrawLinearElement>,
+  changedElement: ExcalidrawBindableElement,
+) => {
+  return (
+    boundElement.startBinding?.elementId === changedElement.id ||
+    boundElement.endBinding?.elementId === changedElement.id
+  );
+};
+
+const getSimultaneouslyUpdatedElementIds = (
+  simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
+): Set<ExcalidrawElement["id"]> => {
+  return new Set((simultaneouslyUpdated || []).map((element) => element.id));
+};
+
+const updateBoundPoint = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+  binding: PointBinding | null | undefined,
+  changedElement: ExcalidrawBindableElement,
+): void => {
+  if (
+    binding == null ||
+    // We only need to update the other end if this is a 2 point line element
+    (binding.elementId !== changedElement.id && linearElement.points.length > 2)
+  ) {
+    return;
+  }
+  const bindingElement = Scene.getScene(linearElement)!.getElement(
+    binding.elementId,
+  ) as ExcalidrawBindableElement | null;
+  if (bindingElement == null) {
+    // We're not cleaning up after deleted elements atm., so handle this case
+    return;
+  }
+  const direction = startOrEnd === "start" ? -1 : 1;
+  const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
+  const adjacentPointIndex = edgePointIndex - direction;
+  const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+    linearElement,
+    adjacentPointIndex,
+  );
+  const focusPointAbsolute = determineFocusPoint(
+    bindingElement,
+    binding.focus,
+    adjacentPoint,
+  );
+  let newEdgePoint;
+  // The linear element was not originally pointing inside the bound shape,
+  // we can point directly at the focus point
+  if (binding.gap === 0) {
+    newEdgePoint = focusPointAbsolute;
+  } else {
+    const intersections = intersectElementWithLine(
+      bindingElement,
+      adjacentPoint,
+      focusPointAbsolute,
+      binding.gap,
+    );
+    if (intersections.length === 0) {
+      // This should never happen, since focusPoint should always be
+      // inside the element, but just in case, bail out
+      newEdgePoint = focusPointAbsolute;
+    } else {
+      // Guaranteed to intersect because focusPoint is always inside the shape
+      newEdgePoint = intersections[0];
+    }
+  }
+  LinearElementEditor.movePoint(
+    linearElement,
+    edgePointIndex,
+    LinearElementEditor.pointFromAbsoluteCoords(linearElement, newEdgePoint),
+    { [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding },
+  );
+};
+
+const maybeCalculateNewGapWhenScaling = (
+  changedElement: ExcalidrawBindableElement,
+  currentBinding: PointBinding | null | undefined,
+  newSize: { width: number; height: number } | undefined,
+): PointBinding | null | undefined => {
+  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),
+    ),
+  );
+  return { elementId, gap: newGap, focus };
+};
+
+export const getEligibleElementsForBinding = (
+  elements: NonDeleted<ExcalidrawElement>[],
+): SuggestedBinding[] => {
+  const includedElementIds = new Set(elements.map(({ id }) => id));
+  return elements.flatMap((element) =>
+    isBindingElement(element)
+      ? (getElligibleElementsForBindingElement(
+          element as NonDeleted<ExcalidrawLinearElement>,
+        ).filter(
+          (element) => !includedElementIds.has(element.id),
+        ) as SuggestedBinding[])
+      : isBindableElement(element)
+      ? getElligibleElementsForBindableElementAndWhere(element).filter(
+          (binding) => !includedElementIds.has(binding[0].id),
+        )
+      : [],
+  );
+};
+
+const getElligibleElementsForBindingElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+): NonDeleted<ExcalidrawBindableElement>[] => {
+  return [
+    getElligibleElementForBindingElement(linearElement, "start"),
+    getElligibleElementForBindingElement(linearElement, "end"),
+  ].filter(
+    (element): element is NonDeleted<ExcalidrawBindableElement> =>
+      element != null,
+  );
+};
+
+const getElligibleElementForBindingElement = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+): NonDeleted<ExcalidrawBindableElement> | null => {
+  return getElligibleElementForBindingElementAtCoors(
+    linearElement,
+    startOrEnd,
+    getLinearElementEdgeCoors(linearElement, startOrEnd),
+  );
+};
+
+export const getElligibleElementForBindingElementAtCoors = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+  pointerCoords: {
+    x: number;
+    y: number;
+  },
+): NonDeleted<ExcalidrawBindableElement> | null => {
+  const bindableElement = getHoveredElementForBinding(
+    pointerCoords,
+    Scene.getScene(linearElement)!,
+  );
+  if (bindableElement == null) {
+    return null;
+  }
+  // Note: We could push this check inside a version of
+  // `getHoveredElementForBinding`, but it's unlikely this is needed.
+  if (
+    isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+      linearElement,
+      bindableElement,
+      startOrEnd,
+    )
+  ) {
+    return null;
+  }
+  return bindableElement;
+};
+
+const getLinearElementEdgeCoors = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+): { x: number; y: number } => {
+  const index = startOrEnd === "start" ? 0 : -1;
+  return tupleToCoors(
+    LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index),
+  );
+};
+
+const getElligibleElementsForBindableElementAndWhere = (
+  bindableElement: NonDeleted<ExcalidrawBindableElement>,
+): SuggestedPointBinding[] => {
+  return Scene.getScene(bindableElement)!
+    .getElements()
+    .map((element) => {
+      if (!isBindingElement(element)) {
+        return null;
+      }
+      const canBindStart = isLinearElementEligibleForNewBindingByBindable(
+        element,
+        "start",
+        bindableElement,
+      );
+      const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
+        element,
+        "end",
+        bindableElement,
+      );
+      if (!canBindStart && !canBindEnd) {
+        return null;
+      }
+      return [
+        element,
+        canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
+        bindableElement,
+      ];
+    })
+    .filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
+};
+
+const isLinearElementEligibleForNewBindingByBindable = (
+  linearElement: NonDeleted<ExcalidrawLinearElement>,
+  startOrEnd: "start" | "end",
+  bindableElement: NonDeleted<ExcalidrawBindableElement>,
+): boolean => {
+  const existingBinding =
+    linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
+  return (
+    existingBinding == null &&
+    !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+      linearElement,
+      bindableElement,
+      startOrEnd,
+    ) &&
+    bindingBorderTest(
+      bindableElement,
+      getLinearElementEdgeCoors(linearElement, startOrEnd),
+    )
+  );
+};
+
+// We need to:
+// 1: Update elements not selected to point to duplicated elements
+// 2: Update duplicated elements to point to other duplicated elements
+export const fixBindingsAfterDuplication = (
+  sceneElements: readonly ExcalidrawElement[],
+  oldElements: readonly ExcalidrawElement[],
+  oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+  // There are three copying mechanisms: Copy-paste, duplication and alt-drag.
+  // Only when alt-dragging the new "duplicates" act as the "old", while
+  // the "old" elements act as the "new copy" - essentially working reverse
+  // to the other two.
+  duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
+): void => {
+  // First collect all the binding/bindable elements, so we only update
+  // each once, regardless of whether they were duplicated or not.
+  const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
+  const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
+  const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
+  oldElements.forEach((oldElement) => {
+    const { boundElementIds } = oldElement;
+    if (boundElementIds != null && boundElementIds.length > 0) {
+      boundElementIds.forEach((boundElementId) => {
+        if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElementId)) {
+          allBoundElementIds.add(boundElementId);
+        }
+      });
+      allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
+    }
+    if (isBindingElement(oldElement)) {
+      if (oldElement.startBinding != null) {
+        const { elementId } = oldElement.startBinding;
+        if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
+          allBindableElementIds.add(elementId);
+        }
+      }
+      if (oldElement.endBinding != null) {
+        const { elementId } = oldElement.endBinding;
+        if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
+          allBindableElementIds.add(elementId);
+        }
+      }
+      if (oldElement.startBinding != null || oldElement.endBinding != null) {
+        allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
+      }
+    }
+  });
+
+  // Update the linear elements
+  (sceneElements.filter(({ id }) =>
+    allBoundElementIds.has(id),
+  ) as ExcalidrawLinearElement[]).forEach((element) => {
+    const { startBinding, endBinding } = element;
+    mutateElement(element, {
+      startBinding: newBindingAfterDuplication(
+        startBinding,
+        oldIdToDuplicatedId,
+      ),
+      endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
+    });
+  });
+
+  // Update the bindable shapes
+  sceneElements
+    .filter(({ id }) => allBindableElementIds.has(id))
+    .forEach((bindableElement) => {
+      const { boundElementIds } = bindableElement;
+      if (boundElementIds != null && boundElementIds.length > 0) {
+        mutateElement(bindableElement, {
+          boundElementIds: boundElementIds.map(
+            (boundElementId) =>
+              oldIdToDuplicatedId.get(boundElementId) ?? boundElementId,
+          ),
+        });
+      }
+    });
+};
+
+const newBindingAfterDuplication = (
+  binding: PointBinding | null,
+  oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+): PointBinding | null => {
+  if (binding == null) {
+    return null;
+  }
+  const { elementId, focus, gap } = binding;
+  return {
+    focus,
+    gap,
+    elementId: oldIdToDuplicatedId.get(elementId) ?? elementId,
+  };
+};
+
+export const fixBindingsAfterDeletion = (
+  sceneElements: readonly ExcalidrawElement[],
+  deletedElements: readonly ExcalidrawElement[],
+): void => {
+  const deletedElementIds = new Set(
+    deletedElements.map((element) => element.id),
+  );
+  // Non deleted and need an update
+  const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
+  deletedElements.forEach((deletedElement) => {
+    if (isBindableElement(deletedElement)) {
+      deletedElement.boundElementIds?.forEach((id) => {
+        if (!deletedElementIds.has(id)) {
+          boundElementIds.add(id);
+        }
+      });
+    }
+  });
+  (sceneElements.filter(({ id }) =>
+    boundElementIds.has(id),
+  ) as ExcalidrawLinearElement[]).forEach(
+    (element: ExcalidrawLinearElement) => {
+      const { startBinding, endBinding } = element;
+      mutateElement(element, {
+        startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
+        endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
+      });
+    },
+  );
+};
+
+const newBindingAfterDeletion = (
+  binding: PointBinding | null,
+  deletedElementIds: Set<ExcalidrawElement["id"]>,
+): PointBinding | null => {
+  if (binding == null || deletedElementIds.has(binding.elementId)) {
+    return null;
+  }
+  return binding;
+};

+ 12 - 2
src/element/bounds.ts

@@ -10,11 +10,14 @@ import {
 import { isLinearElement } from "./typeChecks";
 import { rescalePoints } from "../points";
 
+// x and y position of top left corner, x and y position of bottom right corner
+export type Bounds = readonly [number, number, number, number];
+
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
 export const getElementAbsoluteCoords = (
   element: ExcalidrawElement,
-): [number, number, number, number] => {
+): Bounds => {
   if (isLinearElement(element)) {
     return getLinearElementAbsoluteCoords(element);
   }
@@ -26,6 +29,13 @@ export const getElementAbsoluteCoords = (
   ];
 };
 
+export const pointRelativeTo = (
+  element: ExcalidrawElement,
+  absoluteCoords: Point,
+): Point => {
+  return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
+};
+
 export const getDiamondPoints = (element: ExcalidrawElement) => {
   // Here we add +1 to avoid these numbers to be 0
   // otherwise rough.js will throw an error complaining about it
@@ -35,7 +45,7 @@ export const getDiamondPoints = (element: ExcalidrawElement) => {
   const rightY = Math.floor(element.height / 2) + 1;
   const bottomX = topX;
   const bottomY = element.height;
-  const leftX = topY;
+  const leftX = 0;
   const leftY = rightY;
 
   return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];

+ 566 - 165
src/element/collision.ts

@@ -1,23 +1,28 @@
-import {
-  distanceBetweenPointAndSegment,
-  isPathALoop,
-  rotate,
-  isPointInPolygon,
-} from "../math";
-import { pointsOnBezierCurves } from "points-on-curve";
+import * as GA from "../ga";
+import * as GAPoint from "../gapoints";
+import * as GADirection from "../gadirections";
+import * as GALine from "../galines";
+import * as GATransform from "../gatransforms";
 
-import { NonDeletedExcalidrawElement } from "./types";
+import { isPathALoop, isPointInPolygon, rotate } from "../math";
+import { pointsOnBezierCurves } from "points-on-curve";
 
 import {
-  getDiamondPoints,
-  getElementAbsoluteCoords,
-  getCurvePathOps,
-} from "./bounds";
+  NonDeletedExcalidrawElement,
+  ExcalidrawBindableElement,
+  ExcalidrawElement,
+  ExcalidrawRectangleElement,
+  ExcalidrawDiamondElement,
+  ExcalidrawTextElement,
+  ExcalidrawEllipseElement,
+  NonDeleted,
+} from "./types";
+
+import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
 import { Point } from "../types";
 import { Drawable } from "roughjs/bin/core";
 import { AppState } from "../types";
 import { getShapeForElement } from "../renderer/renderElement";
-import { isLinearElement } from "./typeChecks";
 
 const isElementDraggableFromInside = (
   element: NonDeletedExcalidrawElement,
@@ -40,179 +45,575 @@ export const hitTest = (
   appState: AppState,
   x: number,
   y: number,
-  zoom: number,
 ): boolean => {
-  // For shapes that are composed of lines, we only enable point-selection when the distance
-  // of the click is less than x pixels of any of the lines that the shape is composed of
-  const lineThreshold = 10 / zoom;
+  // How many pixels off the shape boundary we still consider a hit
+  const threshold = 10 / appState.zoom;
+  const check = isElementDraggableFromInside(element, appState)
+    ? isInsideCheck
+    : isNearCheck;
+  const point: Point = [x, y];
+  return hitTestPointAgainstElement({ element, point, threshold, check });
+};
 
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  const cx = (x1 + x2) / 2;
-  const cy = (y1 + y2) / 2;
-  // reverse rotate the pointer
-  [x, y] = rotate(x, y, cx, cy, -element.angle);
+export const bindingBorderTest = (
+  element: NonDeleted<ExcalidrawBindableElement>,
+  { x, y }: { x: number; y: number },
+): boolean => {
+  const threshold = maxBindingGap(element, element.width, element.height);
+  const check = isOutsideCheck;
+  const point: Point = [x, y];
+  return hitTestPointAgainstElement({ element, point, threshold, check });
+};
 
-  if (element.type === "ellipse") {
-    // https://stackoverflow.com/a/46007540/232122
-    const px = Math.abs(x - element.x - element.width / 2);
-    const py = Math.abs(y - element.y - element.height / 2);
+export const maxBindingGap = (
+  element: ExcalidrawElement,
+  elementWidth: number,
+  elementHeight: number,
+): number => {
+  // Aligns diamonds with rectangles
+  const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
+  const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
+  // We make the bindable boundary bigger for bigger elements
+  return Math.max(15, Math.min(0.25 * smallerDimension, 80));
+};
 
-    let tx = 0.707;
-    let ty = 0.707;
+type HitTestArgs = {
+  element: NonDeletedExcalidrawElement;
+  point: Point;
+  threshold: number;
+  check: (distance: number, threshold: number) => boolean;
+};
 
-    const a = Math.abs(element.width) / 2;
-    const b = Math.abs(element.height) / 2;
+const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
+  switch (args.element.type) {
+    case "rectangle":
+    case "text":
+    case "diamond":
+    case "ellipse":
+      const distance = distanceToBindableElement(args.element, args.point);
+      return args.check(distance, args.threshold);
+    case "arrow":
+    case "line":
+    case "draw":
+      return hitTestLinear(args);
+    case "selection":
+      console.warn(
+        "This should not happen, we need to investigate why it does.",
+      );
+      return false;
+  }
+};
 
-    [0, 1, 2, 3].forEach((x) => {
-      const xx = a * tx;
-      const yy = b * ty;
+export const distanceToBindableElement = (
+  element: ExcalidrawBindableElement,
+  point: Point,
+): number => {
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+      return distanceToRectangle(element, point);
+    case "diamond":
+      return distanceToDiamond(element, point);
+    case "ellipse":
+      return distanceToEllipse(element, point);
+  }
+};
 
-      const ex = ((a * a - b * b) * tx ** 3) / a;
-      const ey = ((b * b - a * a) * ty ** 3) / b;
+const isInsideCheck = (distance: number, threshold: number): boolean => {
+  return distance < threshold;
+};
 
-      const rx = xx - ex;
-      const ry = yy - ey;
+const isNearCheck = (distance: number, threshold: number): boolean => {
+  return Math.abs(distance) < threshold;
+};
 
-      const qx = px - ex;
-      const qy = py - ey;
+const isOutsideCheck = (distance: number, threshold: number): boolean => {
+  return 0 <= distance && distance < threshold;
+};
 
-      const r = Math.hypot(ry, rx);
-      const q = Math.hypot(qy, qx);
+const distanceToRectangle = (
+  element: ExcalidrawRectangleElement | ExcalidrawTextElement,
+  point: Point,
+): number => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
+  const nearSide =
+    GAPoint.distanceToLine(pointRel, GALine.vector(hwidth, hheight)) > 0
+      ? GALine.equation(0, 1, -hheight)
+      : GALine.equation(1, 0, -hwidth);
+  return GAPoint.distanceToLine(pointRel, nearSide);
+};
 
-      tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
-      ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
-      const t = Math.hypot(ty, tx);
-      tx /= t;
-      ty /= t;
-    });
+const distanceToDiamond = (
+  element: ExcalidrawDiamondElement,
+  point: Point,
+): number => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
+  const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
+  return GAPoint.distanceToLine(pointRel, side);
+};
 
-    if (isElementDraggableFromInside(element, appState)) {
-      return (
-        a * tx - (px - lineThreshold) >= 0 && b * ty - (py - lineThreshold) >= 0
-      );
-    }
-    return Math.hypot(a * tx - px, b * ty - py) < lineThreshold;
-  } else if (element.type === "rectangle") {
-    if (isElementDraggableFromInside(element, appState)) {
-      return (
-        x > x1 - lineThreshold &&
-        x < x2 + lineThreshold &&
-        y > y1 - lineThreshold &&
-        y < y2 + lineThreshold
-      );
-    }
+const distanceToEllipse = (
+  element: ExcalidrawEllipseElement,
+  point: Point,
+): number => {
+  const [pointRel, tangent] = ellipseParamsForTest(element, point);
+  return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
+};
 
-    // (x1, y1) --A-- (x2, y1)
-    //    |D             |B
-    // (x1, y2) --C-- (x2, y2)
-    return (
-      distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A
-      distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B
-      distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C
-      distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D
-    );
-  } else if (element.type === "diamond") {
-    x -= element.x;
-    y -= element.y;
-    let [
-      topX,
-      topY,
-      rightX,
-      rightY,
-      bottomX,
-      bottomY,
-      leftX,
-      leftY,
-    ] = getDiamondPoints(element);
-
-    if (isElementDraggableFromInside(element, appState)) {
-      // TODO: remove this when we normalize coordinates globally
-      if (topY > bottomY) {
-        [bottomY, topY] = [topY, bottomY];
-      }
-      if (rightX < leftX) {
-        [leftX, rightX] = [rightX, leftX];
-      }
+const ellipseParamsForTest = (
+  element: ExcalidrawEllipseElement,
+  point: Point,
+): [GA.Point, GA.Line] => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
+  const [px, py] = GAPoint.toTuple(pointRel);
 
-      topY -= lineThreshold;
-      bottomY += lineThreshold;
-      leftX -= lineThreshold;
-      rightX += lineThreshold;
-
-      // all deltas should be < 0. Delta > 0 indicates it's on the outside side
-      //  of the line.
-      //
-      //          (topX, topY)
-      //     D  /             \ A
-      //      /               \
-      //  (leftX, leftY)  (rightX, rightY)
-      //    C \               / B
-      //      \              /
-      //      (bottomX, bottomY)
-      //
-      // https://stackoverflow.com/a/2752753/927631
-      return (
-        // delta from line D
-        (leftX - topX) * (y - leftY) - (leftX - x) * (topY - leftY) <= 0 &&
-        // delta from line A
-        (topX - rightX) * (y - rightY) - (x - rightX) * (topY - rightY) <= 0 &&
-        // delta from line B
-        (rightX - bottomX) * (y - bottomY) -
-          (x - bottomX) * (rightY - bottomY) <=
-          0 &&
-        // delta from line C
-        (bottomX - leftX) * (y - leftY) - (x - leftX) * (bottomY - leftY) <= 0
-      );
-    }
+  // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
+  let tx = 0.707;
+  let ty = 0.707;
+
+  const a = hwidth;
+  const b = hheight;
+
+  // This is a numerical method to find the params tx, ty at which
+  // the ellipse has the closest point to the given point
+  [0, 1, 2, 3].forEach((_) => {
+    const xx = a * tx;
+    const yy = b * ty;
+
+    const ex = ((a * a - b * b) * tx ** 3) / a;
+    const ey = ((b * b - a * a) * ty ** 3) / b;
+
+    const rx = xx - ex;
+    const ry = yy - ey;
+
+    const qx = px - ex;
+    const qy = py - ey;
+
+    const r = Math.hypot(ry, rx);
+    const q = Math.hypot(qy, qx);
+
+    tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
+    ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
+    const t = Math.hypot(ty, tx);
+    tx /= t;
+    ty /= t;
+  });
+
+  const closestPoint = GA.point(a * tx, b * ty);
+
+  const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
+  return [pointRel, tangent];
+};
+
+const hitTestLinear = (args: HitTestArgs): boolean => {
+  const { element, threshold } = args;
+  if (!getShapeForElement(element)) {
+    return false;
+  }
+  const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
+    args.element,
+    args.point,
+  );
+  const side1 = GALine.equation(0, 1, -hheight);
+  const side2 = GALine.equation(1, 0, -hwidth);
+  if (
+    !isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
+    !isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
+  ) {
+    return false;
+  }
+  const [relX, relY] = GAPoint.toTuple(point);
 
-    return (
-      distanceBetweenPointAndSegment(x, y, topX, topY, rightX, rightY) <
-        lineThreshold ||
-      distanceBetweenPointAndSegment(x, y, rightX, rightY, bottomX, bottomY) <
-        lineThreshold ||
-      distanceBetweenPointAndSegment(x, y, bottomX, bottomY, leftX, leftY) <
-        lineThreshold ||
-      distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
-        lineThreshold
+  const shape = getShapeForElement(element) as Drawable[];
+
+  if (args.check === isInsideCheck) {
+    const hit = shape.some((subshape) =>
+      hitTestCurveInside(subshape, relX, relY, threshold),
     );
-  } else if (isLinearElement(element)) {
-    if (!getShapeForElement(element)) {
-      return false;
-    }
-    const shape = getShapeForElement(element) as Drawable[];
-
-    if (
-      x < x1 - lineThreshold ||
-      y < y1 - lineThreshold ||
-      x > x2 + lineThreshold ||
-      y > y2 + lineThreshold
-    ) {
-      return false;
+    if (hit) {
+      return true;
     }
+  }
 
-    const relX = x - element.x;
-    const relY = y - element.y;
+  // hit test all "subshapes" of the linear element
+  return shape.some((subshape) =>
+    hitTestRoughShape(subshape, relX, relY, threshold),
+  );
+};
 
-    if (isElementDraggableFromInside(element, appState)) {
-      const hit = shape.some((subshape) =>
-        hitTestCurveInside(subshape, relX, relY, lineThreshold),
-      );
-      if (hit) {
-        return true;
-      }
-    }
+// Returns:
+//   1. the point relative to the elements (x, y) position
+//   2. the point relative to the element's center with positive (x, y)
+//   3. half element width
+//   4. half element height
+//
+// Note that for linear elements the (x, y) position is not at the
+// top right corner of their boundary.
+//
+// Rectangles, diamonds and ellipses are symmetrical over axes,
+// and other elements have a rectangular boundary,
+// so we only need to perform hit tests for the positive quadrant.
+const pointRelativeToElement = (
+  element: ExcalidrawElement,
+  pointTuple: Point,
+): [GA.Point, GA.Point, number, number] => {
+  const point = GAPoint.from(pointTuple);
+  const elementCoords = getElementAbsoluteCoords(element);
+  const center = coordsCenter(elementCoords);
+  // GA has angle orientation opposite to `rotate`
+  const rotate = GATransform.rotation(center, element.angle);
+  const pointRotated = GATransform.apply(rotate, point);
+  const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
+  const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
+  const elementPos = GA.offset(element.x, element.y);
+  const pointRelToPos = GA.sub(pointRotated, elementPos);
+  const [ax, ay, bx, by] = elementCoords;
+  const halfWidth = (bx - ax) / 2;
+  const halfHeight = (by - ay) / 2;
+  return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
+};
 
-    // hit thest all "subshapes" of the linear element
-    return shape.some((subshape) =>
-      hitTestRoughShape(subshape, relX, relY, lineThreshold),
-    );
-  } else if (element.type === "text") {
-    return x >= x1 && x <= x2 && y >= y1 && y <= y2;
-  } else if (element.type === "selection") {
-    console.warn("This should not happen, we need to investigate why it does.");
-    return false;
+// Returns point in absolute coordinates
+export const pointInAbsoluteCoords = (
+  element: ExcalidrawElement,
+  // Point relative to the element position
+  point: Point,
+): Point => {
+  const [x, y] = point;
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const cx = (x2 - x1) / 2;
+  const cy = (y2 - y1) / 2;
+  const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle);
+  return [element.x + rotatedX, element.y + rotatedY];
+};
+
+const relativizationToElementCenter = (
+  element: ExcalidrawElement,
+): GA.Transform => {
+  const elementCoords = getElementAbsoluteCoords(element);
+  const center = coordsCenter(elementCoords);
+  // GA has angle orientation opposite to `rotate`
+  const rotate = GATransform.rotation(center, element.angle);
+  const translate = GA.reverse(
+    GATransform.translation(GADirection.from(center)),
+  );
+  return GATransform.compose(rotate, translate);
+};
+
+const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
+  return GA.point((ax + bx) / 2, (ay + by) / 2);
+};
+
+// The focus distance is the oriented ratio between the size of
+// the `element` and the "focus image" of the element on which
+// all focus points lie, so it's a number between -1 and 1.
+// The line going through `a` and `b` is a tangent to the "focus image"
+// of the element.
+export const determineFocusDistance = (
+  element: ExcalidrawBindableElement,
+  // Point on the line, in absolute coordinates
+  a: Point,
+  // Another point on the line, in absolute coordinates (closer to element)
+  b: Point,
+): number => {
+  const relateToCenter = relativizationToElementCenter(element);
+  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
+  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
+  const line = GALine.through(aRel, bRel);
+  const q = element.height / element.width;
+  const hwidth = element.width / 2;
+  const hheight = element.height / 2;
+  const n = line[2];
+  const m = line[3];
+  const c = line[1];
+  const mabs = Math.abs(m);
+  const nabs = Math.abs(n);
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+      return c / (hwidth * (nabs + q * mabs));
+    case "diamond":
+      return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
+    case "ellipse":
+      return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
+  }
+};
+
+export const determineFocusPoint = (
+  element: ExcalidrawBindableElement,
+  // The oriented, relative distance from the center of `element` of the
+  // returned focusPoint
+  focus: number,
+  adjecentPoint: Point,
+): Point => {
+  if (focus === 0) {
+    const elementCoords = getElementAbsoluteCoords(element);
+    const center = coordsCenter(elementCoords);
+    return GAPoint.toTuple(center);
+  }
+  const relateToCenter = relativizationToElementCenter(element);
+  const adjecentPointRel = GATransform.apply(
+    relateToCenter,
+    GAPoint.from(adjecentPoint),
+  );
+  const reverseRelateToCenter = GA.reverse(relateToCenter);
+  let point;
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+    case "diamond":
+      point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
+      break;
+    case "ellipse":
+      point = findFocusPointForEllipse(element, focus, adjecentPointRel);
+      break;
+  }
+  return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
+};
+
+// Returns 2 or 0 intersection points between line going through `a` and `b`
+// and the `element`, in ascending order of distance from `a`.
+export const intersectElementWithLine = (
+  element: ExcalidrawBindableElement,
+  // Point on the line, in absolute coordinates
+  a: Point,
+  // Another point on the line, in absolute coordinates
+  b: Point,
+  // If given, the element is inflated by this value
+  gap: number = 0,
+): Point[] => {
+  const relateToCenter = relativizationToElementCenter(element);
+  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
+  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
+  const line = GALine.through(aRel, bRel);
+  const reverseRelateToCenter = GA.reverse(relateToCenter);
+  const intersections = getSortedElementLineIntersections(
+    element,
+    line,
+    aRel,
+    gap,
+  );
+  return intersections.map((point) =>
+    GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+  );
+};
+
+const getSortedElementLineIntersections = (
+  element: ExcalidrawBindableElement,
+  // Relative to element center
+  line: GA.Line,
+  // Relative to element center
+  nearPoint: GA.Point,
+  gap: number = 0,
+): GA.Point[] => {
+  let intersections: GA.Point[];
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+    case "diamond":
+      const corners = getCorners(element);
+      intersections = corners
+        .flatMap((point, i) => {
+          const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]];
+          return intersectSegment(line, offsetSegment(edge, gap));
+        })
+        .concat(
+          corners.flatMap((point) => getCircleIntersections(point, gap, line)),
+        );
+      break;
+    case "ellipse":
+      intersections = getEllipseIntersections(element, gap, line);
+      break;
+  }
+  if (intersections.length < 2) {
+    // Ignore the "edge" case of only intersecting with a single corner
+    return [];
+  }
+  const sortedIntersections = intersections.sort(
+    (i1, i2) =>
+      GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint),
+  );
+  return [
+    sortedIntersections[0],
+    sortedIntersections[sortedIntersections.length - 1],
+  ];
+};
+
+const getCorners = (
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawDiamondElement
+    | ExcalidrawTextElement,
+  scale: number = 1,
+): GA.Point[] => {
+  const hx = (scale * element.width) / 2;
+  const hy = (scale * element.height) / 2;
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+      return [
+        GA.point(hx, hy),
+        GA.point(hx, -hy),
+        GA.point(-hx, -hy),
+        GA.point(-hx, hy),
+      ];
+    case "diamond":
+      return [
+        GA.point(0, hy),
+        GA.point(hx, 0),
+        GA.point(0, -hy),
+        GA.point(-hx, 0),
+      ];
+  }
+};
+
+// Returns intersection of `line` with `segment`, with `segment` moved by
+// `gap` in its polar direction.
+// If intersection conincides with second segment point returns empty array.
+const intersectSegment = (
+  line: GA.Line,
+  segment: [GA.Point, GA.Point],
+): GA.Point[] => {
+  const [a, b] = segment;
+  const aDist = GAPoint.distanceToLine(a, line);
+  const bDist = GAPoint.distanceToLine(b, line);
+  if (aDist * bDist >= 0) {
+    // The intersection is outside segment `(a, b)`
+    return [];
+  }
+  return [GAPoint.intersect(line, GALine.through(a, b))];
+};
+
+const offsetSegment = (
+  segment: [GA.Point, GA.Point],
+  distance: number,
+): [GA.Point, GA.Point] => {
+  const [a, b] = segment;
+  const offset = GATransform.translationOrthogonal(
+    GADirection.fromTo(a, b),
+    distance,
+  );
+  return [GATransform.apply(offset, a), GATransform.apply(offset, b)];
+};
+
+const getEllipseIntersections = (
+  element: ExcalidrawEllipseElement,
+  gap: number,
+  line: GA.Line,
+): GA.Point[] => {
+  const a = element.width / 2 + gap;
+  const b = element.height / 2 + gap;
+  const m = line[2];
+  const n = line[3];
+  const c = line[1];
+  const squares = a * a * m * m + b * b * n * n;
+  const discr = squares - c * c;
+  if (squares === 0 || discr <= 0) {
+    return [];
+  }
+  const discrRoot = Math.sqrt(discr);
+  const xn = -a * a * m * c;
+  const yn = -b * b * n * c;
+  return [
+    GA.point(
+      (xn + a * b * n * discrRoot) / squares,
+      (yn - a * b * m * discrRoot) / squares,
+    ),
+    GA.point(
+      (xn - a * b * n * discrRoot) / squares,
+      (yn + a * b * m * discrRoot) / squares,
+    ),
+  ];
+};
+
+export const getCircleIntersections = (
+  center: GA.Point,
+  radius: number,
+  line: GA.Line,
+): GA.Point[] => {
+  if (radius === 0) {
+    return GAPoint.distanceToLine(line, center) === 0 ? [center] : [];
+  }
+  const m = line[2];
+  const n = line[3];
+  const c = line[1];
+  const [a, b] = GAPoint.toTuple(center);
+  const r = radius;
+  const squares = m * m + n * n;
+  const discr = r * r * squares - (m * a + n * b + c) ** 2;
+  if (squares === 0 || discr <= 0) {
+    return [];
   }
-  throw new Error(`Unimplemented type ${element.type}`);
+  const discrRoot = Math.sqrt(discr);
+  const xn = a * n * n - b * m * n - m * c;
+  const yn = b * m * m - a * m * n - n * c;
+
+  return [
+    GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares),
+    GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares),
+  ];
+};
+
+// The focus point is the tangent point of the "focus image" of the
+// `element`, where the tangent goes through `point`.
+export const findFocusPointForEllipse = (
+  ellipse: ExcalidrawEllipseElement,
+  // Between -1 and 1 (not 0) the relative size of the "focus image" of
+  // the element on which the focus point lies
+  relativeDistance: number,
+  // The point for which we're trying to find the focus point, relative
+  // to the ellipse center.
+  point: GA.Point,
+): GA.Point => {
+  const relativeDistanceAbs = Math.abs(relativeDistance);
+  const a = (ellipse.width * relativeDistanceAbs) / 2;
+  const b = (ellipse.height * relativeDistanceAbs) / 2;
+
+  const orientation = Math.sign(relativeDistance);
+  const [px, pyo] = GAPoint.toTuple(point);
+
+  // The calculation below can't handle py = 0
+  const py = pyo === 0 ? 0.0001 : pyo;
+
+  const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2;
+  // Tangent mx + ny + 1 = 0
+  const m =
+    (-px * b ** 2 +
+      orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
+    squares;
+
+  const n = (-m * px - 1) / py;
+
+  const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
+  return GA.point(x, (-m * x - 1) / n);
+};
+
+export const findFocusPointForRectangulars = (
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawDiamondElement
+    | ExcalidrawTextElement,
+  // Between -1 and 1 for how far away should the focus point be relative
+  // to the size of the element. Sign determines orientation.
+  relativeDistance: number,
+  // The point for which we're trying to find the focus point, relative
+  // to the element center.
+  point: GA.Point,
+): GA.Point => {
+  const relativeDistanceAbs = Math.abs(relativeDistance);
+  const orientation = Math.sign(relativeDistance);
+  const corners = getCorners(element, relativeDistanceAbs);
+
+  let maxDistance = 0;
+  let tangentPoint: null | GA.Point = null;
+  corners.forEach((corner) => {
+    const distance = orientation * GALine.through(point, corner)[1];
+    if (distance > maxDistance) {
+      maxDistance = distance;
+      tangentPoint = corner;
+    }
+  });
+  return tangentPoint!;
 };
 
 const pointInBezierEquation = (

+ 9 - 4
src/element/dragElements.ts

@@ -1,20 +1,25 @@
-import { NonDeletedExcalidrawElement } from "./types";
+import { SHAPES } from "../shapes";
+import { updateBoundElements } from "./binding";
 import { getCommonBounds } from "./bounds";
 import { mutateElement } from "./mutateElement";
-import { SHAPES } from "../shapes";
 import { getPerfectElementSize } from "./sizeHelpers";
+import Scene from "../scene/Scene";
+import { NonDeletedExcalidrawElement } from "./types";
 
 export const dragSelectedElements = (
   selectedElements: NonDeletedExcalidrawElement[],
   pointerX: number,
   pointerY: number,
+  scene: Scene,
 ) => {
   const [x1, y1] = getCommonBounds(selectedElements);
+  const offset = { x: pointerX - x1, y: pointerY - y1 };
   selectedElements.forEach((element) => {
     mutateElement(element, {
-      x: pointerX + element.x - x1,
-      y: pointerY + element.y - y1,
+      x: element.x + offset.x,
+      y: element.y + offset.y,
     });
+    updateBoundElements(element, { simultaneouslyUpdated: selectedElements });
   });
 };
 

+ 7 - 3
src/element/handlerRectangles.ts

@@ -1,10 +1,14 @@
 import { ExcalidrawElement, PointerType } from "./types";
 
-import { getElementAbsoluteCoords } from "./bounds";
+import { getElementAbsoluteCoords, Bounds } from "./bounds";
 import { rotate } from "../math";
 
 type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se" | "rotation";
 
+export type Handlers = Partial<
+  { [T in Sides]: [number, number, number, number] }
+>;
+
 const handleSizes: { [k in PointerType]: number } = {
   mouse: 8,
   pen: 16,
@@ -61,12 +65,12 @@ const generateHandler = (
 };
 
 export const handlerRectanglesFromCoords = (
-  [x1, y1, x2, y2]: [number, number, number, number],
+  [x1, y1, x2, y2]: Bounds,
   angle: number,
   zoom: number,
   pointerType: PointerType = "mouse",
   omitSides: { [T in Sides]?: boolean } = {},
-): Partial<{ [T in Sides]: [number, number, number, number] }> => {
+): Handlers => {
   const size = handleSizes[pointerType];
   const handlerWidth = size / zoom;
   const handlerHeight = size / zoom;

+ 128 - 20
src/element/linearElementEditor.ts

@@ -2,6 +2,8 @@ import {
   NonDeleted,
   ExcalidrawLinearElement,
   ExcalidrawElement,
+  PointBinding,
+  ExcalidrawBindableElement,
 } from "./types";
 import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
 import { getElementAbsoluteCoords } from ".";
@@ -11,6 +13,13 @@ import { mutateElement } from "./mutateElement";
 import { SceneHistory } from "../history";
 
 import Scene from "../scene/Scene";
+import {
+  bindOrUnbindLinearElement,
+  getHoveredElementForBinding,
+  isBindingEnabled,
+} from "./binding";
+import { tupleToCoors } from "../utils";
+import { isBindingElement } from "./typeChecks";
 
 export class LinearElementEditor {
   public elementId: ExcalidrawElement["id"] & {
@@ -21,6 +30,8 @@ export class LinearElementEditor {
   public isDragging: boolean;
   public lastUncommittedPoint: Point | null;
   public pointerOffset: { x: number; y: number };
+  public startBindingElement: ExcalidrawBindableElement | null | "keep";
+  public endBindingElement: ExcalidrawBindableElement | null | "keep";
 
   constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
     this.elementId = element.id as string & {
@@ -33,6 +44,8 @@ export class LinearElementEditor {
     this.lastUncommittedPoint = null;
     this.isDragging = false;
     this.pointerOffset = { x: 0, y: 0 };
+    this.startBindingElement = "keep";
+    this.endBindingElement = "keep";
   }
 
   // ---------------------------------------------------------------------------
@@ -59,6 +72,10 @@ export class LinearElementEditor {
     setState: React.Component<any, AppState>["setState"],
     scenePointerX: number,
     scenePointerY: number,
+    maybeSuggestBinding: (
+      element: NonDeleted<ExcalidrawLinearElement>,
+      startOrEnd: "start" | "end",
+    ) => void,
   ): boolean {
     if (!appState.editingLinearElement) {
       return false;
@@ -88,13 +105,18 @@ export class LinearElementEditor {
         appState.gridSize,
       );
       LinearElementEditor.movePoint(element, activePointIndex, newPoint);
+      if (isBindingElement(element)) {
+        maybeSuggestBinding(element, activePointIndex === 0 ? "start" : "end");
+      }
       return true;
     }
     return false;
   }
 
   static handlePointerUp(
+    event: PointerEvent,
     editingLinearElement: LinearElementEditor,
+    appState: AppState,
   ): LinearElementEditor {
     const { elementId, activePointIndex, isDragging } = editingLinearElement;
     const element = LinearElementEditor.getElement(elementId);
@@ -102,22 +124,40 @@ export class LinearElementEditor {
       return editingLinearElement;
     }
 
+    let binding = {};
     if (
       isDragging &&
-      (activePointIndex === 0 ||
-        activePointIndex === element.points.length - 1) &&
-      isPathALoop(element.points)
+      (activePointIndex === 0 || activePointIndex === element.points.length - 1)
     ) {
-      LinearElementEditor.movePoint(
-        element,
-        activePointIndex,
-        activePointIndex === 0
-          ? element.points[element.points.length - 1]
-          : element.points[0],
-      );
+      if (isPathALoop(element.points)) {
+        LinearElementEditor.movePoint(
+          element,
+          activePointIndex,
+          activePointIndex === 0
+            ? element.points[element.points.length - 1]
+            : element.points[0],
+        );
+      }
+      const bindingElement = isBindingEnabled(appState)
+        ? getHoveredElementForBinding(
+            tupleToCoors(
+              LinearElementEditor.getPointAtIndexGlobalCoordinates(
+                element,
+                activePointIndex!,
+              ),
+            ),
+            Scene.getScene(element)!,
+          )
+        : null;
+      binding = {
+        [activePointIndex === 0
+          ? "startBindingElement"
+          : "endBindingElement"]: bindingElement,
+      };
     }
     return {
       ...editingLinearElement,
+      ...binding,
       isDragging: false,
       pointerOffset: { x: 0, y: 0 },
     };
@@ -128,8 +168,7 @@ export class LinearElementEditor {
     appState: AppState,
     setState: React.Component<any, AppState>["setState"],
     history: SceneHistory,
-    scenePointerX: number,
-    scenePointerY: number,
+    scenePointer: { x: number; y: number },
   ): {
     didAddPoint: boolean;
     hitElement: ExcalidrawElement | null;
@@ -151,14 +190,14 @@ export class LinearElementEditor {
     }
 
     if (event.altKey) {
-      if (!appState.editingLinearElement.lastUncommittedPoint) {
+      if (appState.editingLinearElement.lastUncommittedPoint == null) {
         mutateElement(element, {
           points: [
             ...element.points,
             LinearElementEditor.createPointAt(
               element,
-              scenePointerX,
-              scenePointerY,
+              scenePointer.x,
+              scenePointer.y,
               appState.gridSize,
             ),
           ],
@@ -170,6 +209,10 @@ export class LinearElementEditor {
           ...appState.editingLinearElement,
           activePointIndex: element.points.length - 1,
           lastUncommittedPoint: null,
+          endBindingElement: getHoveredElementForBinding(
+            scenePointer,
+            Scene.getScene(element)!,
+          ),
         },
       });
       ret.didAddPoint = true;
@@ -179,14 +222,31 @@ export class LinearElementEditor {
     const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
       element,
       appState.zoom,
-      scenePointerX,
-      scenePointerY,
+      scenePointer.x,
+      scenePointer.y,
     );
 
     // if we clicked on a point, set the element as hitElement otherwise
     //  it would get deselected if the point is outside the hitbox area
     if (clickedPointIndex > -1) {
       ret.hitElement = element;
+    } else {
+      // You might be wandering why we are storing the binding elements on
+      // LinearElementEditor and passing them in, insted of calculating them
+      // from the end points of the `linearElement` - this is to allow disabling
+      // binding (which needs to happen at the point the user finishes moving
+      // the point).
+      const {
+        startBindingElement,
+        endBindingElement,
+      } = appState.editingLinearElement;
+      if (isBindingEnabled(appState) && isBindingElement(element)) {
+        bindOrUnbindLinearElement(
+          element,
+          startBindingElement,
+          endBindingElement,
+        );
+      }
     }
 
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@@ -208,8 +268,8 @@ export class LinearElementEditor {
         activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
         pointerOffset: targetPoint
           ? {
-              x: scenePointerX - targetPoint[0],
-              y: scenePointerY - targetPoint[1],
+              x: scenePointer.x - targetPoint[0],
+              y: scenePointer.y - targetPoint[1],
             }
           : { x: 0, y: 0 },
       },
@@ -237,7 +297,7 @@ export class LinearElementEditor {
       if (lastPoint === lastUncommittedPoint) {
         LinearElementEditor.movePoint(element, points.length - 1, "delete");
       }
-      return editingLinearElement;
+      return { ...editingLinearElement, lastUncommittedPoint: null };
     }
 
     const newPoint = LinearElementEditor.createPointAt(
@@ -276,6 +336,40 @@ export class LinearElementEditor {
     });
   }
 
+  static getPointAtIndexGlobalCoordinates(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    indexMaybeFromEnd: number, // -1 for last element
+  ): Point {
+    const index =
+      indexMaybeFromEnd < 0
+        ? element.points.length + indexMaybeFromEnd
+        : indexMaybeFromEnd;
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+
+    const point = element.points[index];
+    const { x, y } = element;
+    return rotate(x + point[0], y + point[1], cx, cy, element.angle);
+  }
+
+  static pointFromAbsoluteCoords(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    absoluteCoords: Point,
+  ): Point {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    const [x, y] = rotate(
+      absoluteCoords[0],
+      absoluteCoords[1],
+      cx,
+      cy,
+      -element.angle,
+    );
+    return [x - element.x, y - element.y];
+  }
+
   static getPointIndexUnderCursor(
     element: NonDeleted<ExcalidrawLinearElement>,
     zoom: AppState["zoom"],
@@ -343,10 +437,23 @@ export class LinearElementEditor {
     });
   }
 
+  static movePointByOffset(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    pointIndex: number,
+    offset: { x: number; y: number },
+  ) {
+    const [x, y] = element.points[pointIndex];
+    LinearElementEditor.movePoint(element, pointIndex, [
+      x + offset.x,
+      y + offset.y,
+    ]);
+  }
+
   static movePoint(
     element: NonDeleted<ExcalidrawLinearElement>,
     pointIndex: number | "new",
     targetPosition: Point | "delete",
+    otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
   ) {
     const { points } = element;
 
@@ -412,6 +519,7 @@ export class LinearElementEditor {
     const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
 
     mutateElement(element, {
+      ...otherUpdates,
       points: nextPoints,
       x: element.x + rotated[0],
       y: element.y + rotated[1],

+ 5 - 0
src/element/newElement.ts

@@ -24,6 +24,7 @@ type ElementConstructorOpts = MarkOptional<
   | "height"
   | "angle"
   | "groupIds"
+  | "boundElementIds"
   | "seed"
   | "version"
   | "versionNonce"
@@ -45,6 +46,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     height = 0,
     angle = 0,
     groupIds = [],
+    boundElementIds = null,
     ...rest
   }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
 ) => ({
@@ -67,6 +69,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
   version: rest.version || 1,
   versionNonce: rest.versionNonce ?? 0,
   isDeleted: false as false,
+  boundElementIds,
 });
 
 export const newElement = (
@@ -215,6 +218,8 @@ export const newLinearElement = (
     ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
     points: [],
     lastCommittedPoint: null,
+    startBinding: null,
+    endBinding: null,
   };
 };
 

+ 10 - 0
src/element/resizeElements.ts

@@ -22,6 +22,7 @@ import {
   normalizeResizeHandle,
 } from "./resizeTest";
 import { measureText, getFontString } from "../utils";
+import { updateBoundElements } from "./binding";
 
 const normalizeAngle = (angle: number): number => {
   if (angle >= 2 * Math.PI) {
@@ -32,6 +33,7 @@ const normalizeAngle = (angle: number): number => {
 
 type ResizeTestType = ReturnType<typeof resizeTest>;
 
+// Returns true when a resize (scaling/rotation) happened
 export const resizeElements = (
   resizeHandle: ResizeTestType,
   setResizeHandle: (nextResizeHandle: ResizeTestType) => void,
@@ -55,6 +57,7 @@ export const resizeElements = (
         pointerY,
         isRotateWithDiscreteAngle,
       );
+      updateBoundElements(element);
     } else if (
       isLinearElement(element) &&
       element.points.length === 2 &&
@@ -404,6 +407,9 @@ const resizeSingleElement = (
   const deltaX2 = (x2 - nextX2) / 2;
   const deltaY2 = (y2 - nextY2) / 2;
   const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
+  updateBoundElements(element, {
+    newSize: { width: nextWidth, height: nextHeight },
+  });
   const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
     {
       ...element,
@@ -530,6 +536,10 @@ const resizeMultipleElements = (
         }
         const origCoords = getElementAbsoluteCoords(element);
         const rescaledPoints = rescalePointsInElement(element, width, height);
+        updateBoundElements(element, {
+          newSize: { width, height },
+          simultaneouslyUpdated: elements,
+        });
         const finalCoords = getResizedElementAbsoluteCoords(
           {
             ...element,

+ 31 - 3
src/element/typeChecks.ts

@@ -2,6 +2,7 @@ import {
   ExcalidrawElement,
   ExcalidrawTextElement,
   ExcalidrawLinearElement,
+  ExcalidrawBindableElement,
 } from "./types";
 
 export const isTextElement = (
@@ -13,11 +14,38 @@ export const isTextElement = (
 export const isLinearElement = (
   element?: ExcalidrawElement | null,
 ): element is ExcalidrawLinearElement => {
+  return element != null && isLinearElementType(element.type);
+};
+
+export const isLinearElementType = (
+  elementType: ExcalidrawElement["type"],
+): boolean => {
+  return (
+    elementType === "arrow" || elementType === "line" || elementType === "draw"
+  );
+};
+
+export const isBindingElement = (
+  element?: ExcalidrawElement | null,
+): element is ExcalidrawLinearElement => {
+  return element != null && isBindingElementType(element.type);
+};
+
+export const isBindingElementType = (
+  elementType: ExcalidrawElement["type"],
+): boolean => {
+  return elementType === "arrow";
+};
+
+export const isBindableElement = (
+  element: ExcalidrawElement | null,
+): element is ExcalidrawBindableElement => {
   return (
     element != null &&
-    (element.type === "arrow" ||
-      element.type === "line" ||
-      element.type === "draw")
+    (element.type === "rectangle" ||
+      element.type === "diamond" ||
+      element.type === "ellipse" ||
+      element.type === "text")
   );
 };
 

+ 31 - 3
src/element/types.ts

@@ -22,19 +22,33 @@ type _ExcalidrawElementBase = Readonly<{
   versionNonce: number;
   isDeleted: boolean;
   groupIds: readonly GroupId[];
+  boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
 }>;
 
 export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
   type: "selection";
 };
+
+export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
+  type: "rectangle";
+};
+
+export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
+  type: "diamond";
+};
+
+export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
+  type: "ellipse";
+};
+
 /**
  * These are elements that don't have any additional properties.
  */
 export type ExcalidrawGenericElement =
   | ExcalidrawSelectionElement
-  | (_ExcalidrawElementBase & {
-      type: "rectangle" | "diamond" | "ellipse";
-    });
+  | ExcalidrawRectangleElement
+  | ExcalidrawDiamondElement
+  | ExcalidrawEllipseElement;
 
 /**
  * ExcalidrawElement should be JSON serializable and (eventually) contain
@@ -63,11 +77,25 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
     verticalAlign: VerticalAlign;
   }>;
 
+export type ExcalidrawBindableElement =
+  | ExcalidrawRectangleElement
+  | ExcalidrawDiamondElement
+  | ExcalidrawEllipseElement
+  | ExcalidrawTextElement;
+
+export type PointBinding = {
+  elementId: ExcalidrawBindableElement["id"];
+  focus: number;
+  gap: number;
+};
+
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{
     type: "arrow" | "line" | "draw";
     points: readonly Point[];
     lastCommittedPoint: Point | null;
+    startBinding: PointBinding | null;
+    endBinding: PointBinding | null;
   }>;
 
 export type PointerType = "mouse" | "pen" | "touch";

+ 340 - 0
src/ga.ts

@@ -0,0 +1,340 @@
+/**
+ * This is a 2D Projective Geometric Algebra implementation.
+ *
+ * For wider context on geometric algebra visit see https://bivector.net.
+ *
+ * For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf.
+ *
+ * Converted from generator written by enki, with a ton of added on top.
+ *
+ * This library uses 8-vectors to represent points, directions and lines
+ * in 2D space.
+ *
+ * An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector:
+ *   a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012
+ *
+ * See GAPoint, GALine, GADirection and GATransform modules for common
+ * operations.
+ */
+
+export type Point = NVector;
+export type Direction = NVector;
+export type Line = NVector;
+export type Transform = NVector;
+
+export function point(x: number, y: number): Point {
+  return [0, 0, 0, 0, y, x, 1, 0];
+}
+
+export function origin(): Point {
+  return [0, 0, 0, 0, 0, 0, 1, 0];
+}
+
+export function direction(x: number, y: number): Direction {
+  const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))`
+  return [0, 0, 0, 0, y / norm, x / norm, 0, 0];
+}
+
+export function offset(x: number, y: number): Direction {
+  return [0, 0, 0, 0, y, x, 0, 0];
+}
+
+/// This is the "implementation" part of the library
+
+type NVector = readonly [
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+];
+
+// These are labels for what each number in an nvector represents
+const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"];
+
+// Used to represent points, lines and transformations
+export function nvector(value: number = 0, index: number = 0): NVector {
+  const result = [0, 0, 0, 0, 0, 0, 0, 0];
+  if (index < 0 || index > 7) {
+    throw new Error(`Expected \`index\` betwen 0 and 7, got \`${index}\``);
+  }
+  if (value !== 0) {
+    result[index] = value;
+  }
+  return (result as unknown) as NVector;
+}
+
+const STRING_EPSILON = 0.000001;
+export function toString(nvector: NVector): string {
+  const result = nvector
+    .map((value, index) =>
+      Math.abs(value) > STRING_EPSILON
+        ? value.toFixed(7).replace(/(\.|0+)$/, "") +
+          (index > 0 ? NVECTOR_BASE[index] : "")
+        : null,
+    )
+    .filter((representation) => representation != null)
+    .join(" + ");
+  return result === "" ? "0" : result;
+}
+
+// Reverse the order of the basis blades.
+export function reverse(nvector: NVector): NVector {
+  return [
+    nvector[0],
+    nvector[1],
+    nvector[2],
+    nvector[3],
+    -nvector[4],
+    -nvector[5],
+    -nvector[6],
+    -nvector[7],
+  ];
+}
+
+// Poincare duality operator.
+export function dual(nvector: NVector): NVector {
+  return [
+    nvector[7],
+    nvector[6],
+    nvector[5],
+    nvector[4],
+    nvector[3],
+    nvector[2],
+    nvector[1],
+    nvector[0],
+  ];
+}
+
+// Clifford Conjugation
+export function conjugate(nvector: NVector): NVector {
+  return [
+    nvector[0],
+    -nvector[1],
+    -nvector[2],
+    -nvector[3],
+    -nvector[4],
+    -nvector[5],
+    -nvector[6],
+    nvector[7],
+  ];
+}
+
+// Main involution
+export function involute(nvector: NVector): NVector {
+  return [
+    nvector[0],
+    -nvector[1],
+    -nvector[2],
+    -nvector[3],
+    nvector[4],
+    nvector[5],
+    nvector[6],
+    -nvector[7],
+  ];
+}
+
+// Multivector addition
+export function add(a: NVector, b: NVector | number): NVector {
+  if (isNumber(b)) {
+    return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
+  }
+  return [
+    a[0] + b[0],
+    a[1] + b[1],
+    a[2] + b[2],
+    a[3] + b[3],
+    a[4] + b[4],
+    a[5] + b[5],
+    a[6] + b[6],
+    a[7] + b[7],
+  ];
+}
+
+// Multivector subtraction
+export function sub(a: NVector, b: NVector | number): NVector {
+  if (isNumber(b)) {
+    return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
+  }
+  return [
+    a[0] - b[0],
+    a[1] - b[1],
+    a[2] - b[2],
+    a[3] - b[3],
+    a[4] - b[4],
+    a[5] - b[5],
+    a[6] - b[6],
+    a[7] - b[7],
+  ];
+}
+
+// The geometric product.
+export function mul(a: NVector, b: NVector | number): NVector {
+  if (isNumber(b)) {
+    return [
+      a[0] * b,
+      a[1] * b,
+      a[2] * b,
+      a[3] * b,
+      a[4] * b,
+      a[5] * b,
+      a[6] * b,
+      a[7] * b,
+    ];
+  }
+  return [
+    mulScalar(a, b),
+    b[1] * a[0] +
+      b[0] * a[1] -
+      b[4] * a[2] +
+      b[5] * a[3] +
+      b[2] * a[4] -
+      b[3] * a[5] -
+      b[7] * a[6] -
+      b[6] * a[7],
+    b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
+    b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
+    b[4] * a[0] +
+      b[2] * a[1] -
+      b[1] * a[2] +
+      b[7] * a[3] +
+      b[0] * a[4] +
+      b[6] * a[5] -
+      b[5] * a[6] +
+      b[3] * a[7],
+    b[5] * a[0] -
+      b[3] * a[1] +
+      b[7] * a[2] +
+      b[1] * a[3] -
+      b[6] * a[4] +
+      b[0] * a[5] +
+      b[4] * a[6] +
+      b[2] * a[7],
+    b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
+    b[7] * a[0] +
+      b[6] * a[1] +
+      b[5] * a[2] +
+      b[4] * a[3] +
+      b[3] * a[4] +
+      b[2] * a[5] +
+      b[1] * a[6] +
+      b[0] * a[7],
+  ];
+}
+
+export function mulScalar(a: NVector, b: NVector): number {
+  return b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6];
+}
+
+// The outer/exterior/wedge product.
+export function meet(a: NVector, b: NVector): NVector {
+  return [
+    b[0] * a[0],
+    b[1] * a[0] + b[0] * a[1],
+    b[2] * a[0] + b[0] * a[2],
+    b[3] * a[0] + b[0] * a[3],
+    b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4],
+    b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5],
+    b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
+    b[7] * a[0] +
+      b[6] * a[1] +
+      b[5] * a[2] +
+      b[4] * a[3] +
+      b[3] * a[4] +
+      b[2] * a[5] +
+      b[1] * a[6],
+  ];
+}
+
+// The regressive product.
+export function join(a: NVector, b: NVector): NVector {
+  return [
+    joinScalar(a, b),
+    a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1],
+    a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2],
+    a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3],
+    a[4] * b[7] + a[7] * b[4],
+    a[5] * b[7] + a[7] * b[5],
+    a[6] * b[7] + a[7] * b[6],
+    a[7] * b[7],
+  ];
+}
+
+export function joinScalar(a: NVector, b: NVector): number {
+  return (
+    a[0] * b[7] +
+    a[1] * b[6] +
+    a[2] * b[5] +
+    a[3] * b[4] +
+    a[4] * b[3] +
+    a[5] * b[2] +
+    a[6] * b[1] +
+    a[7] * b[0]
+  );
+}
+
+// The inner product.
+export function dot(a: NVector, b: NVector): NVector {
+  return [
+    b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6],
+    b[1] * a[0] +
+      b[0] * a[1] -
+      b[4] * a[2] +
+      b[5] * a[3] +
+      b[2] * a[4] -
+      b[3] * a[5] -
+      b[7] * a[6] -
+      b[6] * a[7],
+    b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
+    b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
+    b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7],
+    b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7],
+    b[6] * a[0] + b[0] * a[6],
+    b[7] * a[0] + b[0] * a[7],
+  ];
+}
+
+export function norm(a: NVector): number {
+  return Math.sqrt(
+    Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6]),
+  );
+}
+
+export function inorm(a: NVector): number {
+  return Math.sqrt(
+    Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1]),
+  );
+}
+
+export function normalized(a: NVector): NVector {
+  const n = norm(a);
+  if (n === 0 || n === 1) {
+    return a;
+  }
+  const sign = a[6] < 0 ? -1 : 1;
+  return mul(a, sign / n);
+}
+
+export function inormalized(a: NVector): NVector {
+  const n = inorm(a);
+  if (n === 0 || n === 1) {
+    return a;
+  }
+  return mul(a, 1 / n);
+}
+
+function isNumber(a: any): a is number {
+  return typeof a === "number";
+}
+
+export const E0: NVector = nvector(1, 1);
+export const E1: NVector = nvector(1, 2);
+export const E2: NVector = nvector(1, 3);
+export const E01: NVector = nvector(1, 4);
+export const E20: NVector = nvector(1, 5);
+export const E12: NVector = nvector(1, 6);
+export const E012: NVector = nvector(1, 7);
+export const I = E012;

+ 23 - 0
src/gadirections.ts

@@ -0,0 +1,23 @@
+import * as GA from "./ga";
+import { Line, Direction, Point } from "./ga";
+
+/**
+ * A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
+ * vector `(x, y)`.
+ */
+
+export function from(point: Point): Point {
+  return [0, 0, 0, 0, point[4], point[5], 0, 0];
+}
+
+export function fromTo(from: Point, to: Point): Direction {
+  return GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
+}
+
+export function orthogonal(direction: Direction): Direction {
+  return GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
+}
+
+export function orthogonalToLine(line: Line): Direction {
+  return GA.mul(line, GA.I);
+}

+ 62 - 0
src/galines.ts

@@ -0,0 +1,62 @@
+import * as GA from "./ga";
+import { Line, Point } from "./ga";
+
+/**
+ * A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
+ *   c * e0 + a * e1 + b*e2
+ *
+ * This maps to a standard formula `a * x + b * y + c`.
+ *
+ * `(-b, a)` correponds to a 2D vector parallel to the line. The lines
+ * have a natural orientation, corresponding to that vector.
+ *
+ * The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
+ * `c / norm(line)` is the oriented distance from line to origin.
+ */
+
+// Returns line with direction (x, y) through origin
+export function vector(x: number, y: number): Line {
+  return GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
+}
+
+// For equation ax + by + c = 0.
+export function equation(a: number, b: number, c: number): Line {
+  return GA.normalized([0, c, a, b, 0, 0, 0, 0]);
+}
+
+export function through(from: Point, to: Point): Line {
+  return GA.normalized(GA.join(to, from));
+}
+
+export function orthogonal(line: Line, point: Point): Line {
+  return GA.dot(line, point);
+}
+
+// Returns a line perpendicular to the line through `against` and `intersection`
+// going through `intersection`.
+export function orthogonalThrough(against: Point, intersection: Point): Line {
+  return orthogonal(through(against, intersection), intersection);
+}
+
+export function parallel(line: Line, distance: number): Line {
+  const result = line.slice();
+  result[1] -= distance;
+  return (result as unknown) as Line;
+}
+
+export function parallelThrough(line: Line, point: Point): Line {
+  return orthogonal(orthogonal(point, line), point);
+}
+
+export function distance(line1: Line, line2: Line): number {
+  return GA.inorm(GA.meet(line1, line2));
+}
+
+export function angle(line1: Line, line2: Line): number {
+  return Math.acos(GA.dot(line1, line2)[0]);
+}
+
+// The orientation of the line
+export function sign(line: Line): number {
+  return Math.sign(line[1]);
+}

+ 37 - 0
src/gapoints.ts

@@ -0,0 +1,37 @@
+import * as GA from "./ga";
+import * as GALine from "./galines";
+import { Point, Line, join } from "./ga";
+
+/**
+ * TODO: docs
+ */
+
+export function from([x, y]: readonly [number, number]): Point {
+  return [0, 0, 0, 0, y, x, 1, 0];
+}
+
+export function toTuple(point: Point): [number, number] {
+  return [point[5], point[4]];
+}
+
+export function abs(point: Point): Point {
+  return [0, 0, 0, 0, Math.abs(point[4]), Math.abs(point[5]), 1, 0];
+}
+
+export function intersect(line1: Line, line2: Line): Point {
+  return GA.normalized(GA.meet(line1, line2));
+}
+
+// Projects `point` onto the `line`.
+// The returned point is the closest point on the `line` to the `point`.
+export function project(point: Point, line: Line): Point {
+  return intersect(GALine.orthogonal(line, point), line);
+}
+
+export function distance(point1: Point, point2: Point): number {
+  return GA.norm(join(point1, point2));
+}
+
+export function distanceToLine(point: Point, line: Line): number {
+  return GA.joinScalar(point, line);
+}

+ 38 - 0
src/gatransforms.ts

@@ -0,0 +1,38 @@
+import * as GA from "./ga";
+import { Line, Direction, Point, Transform } from "./ga";
+import * as GADirection from "./gadirections";
+
+/**
+ * TODO: docs
+ */
+
+export function rotation(pivot: Point, angle: number): Transform {
+  return GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
+}
+
+export function translation(direction: Direction): Transform {
+  return [1, 0, 0, 0, -(0.5 * direction[5]), 0.5 * direction[4], 0, 0];
+}
+
+export function translationOrthogonal(
+  direction: Direction,
+  distance: number,
+): Transform {
+  const scale = 0.5 * distance;
+  return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
+}
+
+export function translationAlong(line: Line, distance: number): Transform {
+  return GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
+}
+
+export function compose(motor1: Transform, motor2: Transform): Transform {
+  return GA.mul(motor2, motor1);
+}
+
+export function apply(
+  motor: Transform,
+  nvector: Point | Direction | Line,
+): Point | Direction | Line {
+  return GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));
+}

+ 21 - 55
src/math.ts

@@ -2,45 +2,6 @@ import { Point } from "./types";
 import { LINE_CONFIRM_THRESHOLD } from "./constants";
 import { ExcalidrawLinearElement } from "./element/types";
 
-// https://stackoverflow.com/a/6853926/232122
-export const distanceBetweenPointAndSegment = (
-  x: number,
-  y: number,
-  x1: number,
-  y1: number,
-  x2: number,
-  y2: number,
-) => {
-  const A = x - x1;
-  const B = y - y1;
-  const C = x2 - x1;
-  const D = y2 - y1;
-
-  const dot = A * C + B * D;
-  const lenSquare = C * C + D * D;
-  let param = -1;
-  if (lenSquare !== 0) {
-    // in case of 0 length line
-    param = dot / lenSquare;
-  }
-
-  let xx, yy;
-  if (param < 0) {
-    xx = x1;
-    yy = y1;
-  } else if (param > 1) {
-    xx = x2;
-    yy = y2;
-  } else {
-    xx = x1 + param * C;
-    yy = y1 + param * D;
-  }
-
-  const dx = x - xx;
-  const dy = y - yy;
-  return Math.hypot(dx, dy);
-};
-
 export const rotate = (
   x1: number,
   y1: number,
@@ -230,6 +191,10 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
   return Math.hypot(xd, yd);
 };
 
+export const centerPoint = (a: Point, b: Point): Point => {
+  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
+};
+
 // Checks if the first and last point are close enough
 // to be considered a loop
 export const isPathALoop = (
@@ -265,9 +230,9 @@ export const isPointInPolygon = (
   for (let i = 0; i < vertices; i++) {
     const current = points[i];
     const next = points[(i + 1) % vertices];
-    if (doIntersect(current, next, p, extreme)) {
-      if (orientation(current, p, next) === 0) {
-        return onSegment(current, p, next);
+    if (doSegmentsIntersect(current, next, p, extreme)) {
+      if (orderedColinearOrientation(current, p, next) === 0) {
+        return isPointWithinBounds(current, p, next);
       }
       count++;
     }
@@ -276,8 +241,9 @@ export const isPointInPolygon = (
   return count % 2 === 1;
 };
 
-// Check if q lies on the line segment pr
-const onSegment = (p: Point, q: Point, r: Point) => {
+// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
+// This is an approximation to "does `q` lie on a segment `pr`" check.
+const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
   return (
     q[0] <= Math.max(p[0], r[0]) &&
     q[0] >= Math.min(p[0], r[0]) &&
@@ -287,10 +253,10 @@ const onSegment = (p: Point, q: Point, r: Point) => {
 };
 
 // For the ordered points p, q, r, return
-// 0 if p, q, r are collinear
+// 0 if p, q, r are colinear
 // 1 if Clockwise
 // 2 if counterclickwise
-const orientation = (p: Point, q: Point, r: Point) => {
+const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
   const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
   if (val === 0) {
     return 0;
@@ -299,33 +265,33 @@ const orientation = (p: Point, q: Point, r: Point) => {
 };
 
 // Check is p1q1 intersects with p2q2
-const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
-  const o1 = orientation(p1, q1, p2);
-  const o2 = orientation(p1, q1, q2);
-  const o3 = orientation(p2, q2, p1);
-  const o4 = orientation(p2, q2, q1);
+const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
+  const o1 = orderedColinearOrientation(p1, q1, p2);
+  const o2 = orderedColinearOrientation(p1, q1, q2);
+  const o3 = orderedColinearOrientation(p2, q2, p1);
+  const o4 = orderedColinearOrientation(p2, q2, q1);
 
   if (o1 !== o2 && o3 !== o4) {
     return true;
   }
 
   // p1, q1 and p2 are colinear and p2 lies on segment p1q1
-  if (o1 === 0 && onSegment(p1, p2, q1)) {
+  if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
     return true;
   }
 
   // p1, q1 and p2 are colinear and q2 lies on segment p1q1
-  if (o2 === 0 && onSegment(p1, q2, q1)) {
+  if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
     return true;
   }
 
   // p2, q2 and p1 are colinear and p1 lies on segment p2q2
-  if (o3 === 0 && onSegment(p2, p1, q2)) {
+  if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
     return true;
   }
 
   // p2, q2 and q1 are colinear and q1 lies on segment p2q2
-  if (o4 === 0 && onSegment(p2, q1, q2)) {
+  if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
     return true;
   }
 

+ 270 - 122
src/renderer/renderScene.ts

@@ -9,6 +9,7 @@ import {
   ExcalidrawLinearElement,
   NonDeleted,
   GroupId,
+  ExcalidrawBindableElement,
 } from "../element/types";
 import {
   getElementAbsoluteCoords,
@@ -36,6 +37,13 @@ import {
   getSelectedGroupIds,
   getElementsInGroup,
 } from "../groups";
+import { maxBindingGap } from "../element/collision";
+import {
+  SuggestedBinding,
+  SuggestedPointBinding,
+  isBindingEnabled,
+} from "../element/binding";
+import { Handlers } from "../element/handlerRectangles";
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
@@ -48,7 +56,7 @@ const strokeRectWithRotation = (
   cx: number,
   cy: number,
   angle: number,
-  fill?: boolean,
+  fill: boolean = false,
 ) => {
   context.translate(cx, cy);
   context.rotate(angle);
@@ -60,15 +68,48 @@ const strokeRectWithRotation = (
   context.translate(-cx, -cy);
 };
 
-const strokeCircle = (
+const strokeDiamondWithRotation = (
+  context: CanvasRenderingContext2D,
+  width: number,
+  height: number,
+  cx: number,
+  cy: number,
+  angle: number,
+) => {
+  context.translate(cx, cy);
+  context.rotate(angle);
+  context.beginPath();
+  context.moveTo(0, height / 2);
+  context.lineTo(width / 2, 0);
+  context.lineTo(0, -height / 2);
+  context.lineTo(-width / 2, 0);
+  context.closePath();
+  context.stroke();
+  context.rotate(-angle);
+  context.translate(-cx, -cy);
+};
+
+const strokeEllipseWithRotation = (
   context: CanvasRenderingContext2D,
-  x: number,
-  y: number,
   width: number,
   height: number,
+  cx: number,
+  cy: number,
+  angle: number,
+) => {
+  context.beginPath();
+  context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
+  context.stroke();
+};
+
+const fillCircle = (
+  context: CanvasRenderingContext2D,
+  cx: number,
+  cy: number,
+  radius: number,
 ) => {
   context.beginPath();
-  context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
+  context.arc(cx, cy, radius, 0, Math.PI * 2);
   context.fill();
   context.stroke();
 };
@@ -116,12 +157,11 @@ const renderLinearPointHandles = (
           ? "rgba(255, 127, 127, 0.9)"
           : "rgba(255, 255, 255, 0.9)";
       const { POINT_HANDLE_SIZE } = LinearElementEditor;
-      strokeCircle(
+      fillCircle(
         context,
-        point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
-        point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
-        POINT_HANDLE_SIZE / sceneState.zoom,
-        POINT_HANDLE_SIZE / sceneState.zoom,
+        point[0],
+        point[1],
+        POINT_HANDLE_SIZE / 2 / sceneState.zoom,
       );
     },
   );
@@ -241,14 +281,20 @@ export const renderScene = (
     );
   }
 
+  if (isBindingEnabled(appState)) {
+    appState.suggestedBindings
+      .filter((binding) => binding != null)
+      .forEach((suggestedBinding) => {
+        renderBindingHighlight(context, sceneState, suggestedBinding!);
+      });
+  }
+
   // Paint selected elements
   if (
     renderSelection &&
     !appState.multiElement &&
     !appState.editingLinearElement
   ) {
-    context.translate(sceneState.scrollX, sceneState.scrollY);
-
     const selections = elements.reduce((acc, element) => {
       const selectionColors = [];
       // local user
@@ -310,99 +356,28 @@ export const renderScene = (
       addSelectionForGroupId(appState.editingGroupId);
     }
 
-    selections.forEach(
-      ({
-        angle,
-        elementX1,
-        elementY1,
-        elementX2,
-        elementY2,
-        selectionColors,
-      }) => {
-        const elementWidth = elementX2 - elementX1;
-        const elementHeight = elementY2 - elementY1;
-
-        const initialLineDash = context.getLineDash();
-        const lineWidth = context.lineWidth;
-        const lineDashOffset = context.lineDashOffset;
-        const strokeStyle = context.strokeStyle;
-
-        const dashedLinePadding = 4 / sceneState.zoom;
-        const dashWidth = 8 / sceneState.zoom;
-        const spaceWidth = 4 / sceneState.zoom;
-
-        context.lineWidth = 1 / sceneState.zoom;
-
-        const count = selectionColors.length;
-        for (var i = 0; i < count; ++i) {
-          context.strokeStyle = selectionColors[i];
-          context.setLineDash([
-            dashWidth,
-            spaceWidth + (dashWidth + spaceWidth) * (count - 1),
-          ]);
-          context.lineDashOffset = (dashWidth + spaceWidth) * i;
-          strokeRectWithRotation(
-            context,
-            elementX1 - dashedLinePadding,
-            elementY1 - dashedLinePadding,
-            elementWidth + dashedLinePadding * 2,
-            elementHeight + dashedLinePadding * 2,
-            elementX1 + elementWidth / 2,
-            elementY1 + elementHeight / 2,
-            angle,
-          );
-        }
-        context.lineDashOffset = lineDashOffset;
-        context.strokeStyle = strokeStyle;
-        context.lineWidth = lineWidth;
-        context.setLineDash(initialLineDash);
-      },
+    selections.forEach((selection) =>
+      renderSelectionBorder(context, sceneState, selection),
     );
-    context.translate(-sceneState.scrollX, -sceneState.scrollY);
 
     const locallySelectedElements = getSelectedElements(elements, appState);
 
     // Paint resize handlers
+    context.translate(sceneState.scrollX, sceneState.scrollY);
     if (locallySelectedElements.length === 1) {
-      context.translate(sceneState.scrollX, sceneState.scrollY);
       context.fillStyle = oc.white;
       const handlers = handlerRectangles(
         locallySelectedElements[0],
         sceneState.zoom,
       );
-      Object.keys(handlers).forEach((key) => {
-        const handler = handlers[key as HandlerRectanglesRet];
-        if (handler !== undefined) {
-          const lineWidth = context.lineWidth;
-          context.lineWidth = 1 / sceneState.zoom;
-          if (key === "rotation") {
-            strokeCircle(
-              context,
-              handler[0],
-              handler[1],
-              handler[2],
-              handler[3],
-            );
-          } else {
-            strokeRectWithRotation(
-              context,
-              handler[0],
-              handler[1],
-              handler[2],
-              handler[3],
-              handler[0] + handler[2] / 2,
-              handler[1] + handler[3] / 2,
-              locallySelectedElements[0].angle,
-              true, // fill before stroke
-            );
-          }
-          context.lineWidth = lineWidth;
-        }
-      });
-      context.translate(-sceneState.scrollX, -sceneState.scrollY);
+      renderHandlers(
+        context,
+        sceneState,
+        handlers,
+        locallySelectedElements[0].angle,
+      );
     } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
       const dashedLinePadding = 4 / sceneState.zoom;
-      context.translate(sceneState.scrollX, sceneState.scrollY);
       context.fillStyle = oc.white;
       const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
       const initialLineDash = context.getLineDash();
@@ -428,37 +403,9 @@ export const renderScene = (
         undefined,
         OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
       );
-      Object.keys(handlers).forEach((key) => {
-        const handler = handlers[key as HandlerRectanglesRet];
-        if (handler !== undefined) {
-          const lineWidth = context.lineWidth;
-          context.lineWidth = 1 / sceneState.zoom;
-          if (key === "rotation") {
-            strokeCircle(
-              context,
-              handler[0],
-              handler[1],
-              handler[2],
-              handler[3],
-            );
-          } else {
-            strokeRectWithRotation(
-              context,
-              handler[0],
-              handler[1],
-              handler[2],
-              handler[3],
-              handler[0] + handler[2] / 2,
-              handler[1] + handler[3] / 2,
-              0,
-              true, // fill before stroke
-            );
-          }
-          context.lineWidth = lineWidth;
-        }
-      });
-      context.translate(-sceneState.scrollX, -sceneState.scrollY);
+      renderHandlers(context, sceneState, handlers, 0);
     }
+    context.translate(-sceneState.scrollX, -sceneState.scrollY);
   }
 
   // Reset zoom
@@ -598,6 +545,207 @@ export const renderScene = (
   return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
 };
 
+const renderHandlers = (
+  context: CanvasRenderingContext2D,
+  sceneState: SceneState,
+  handlers: Handlers,
+  angle: number,
+): void => {
+  Object.keys(handlers).forEach((key) => {
+    const handler = handlers[key as HandlerRectanglesRet];
+    if (handler !== undefined) {
+      const lineWidth = context.lineWidth;
+      context.lineWidth = 1 / sceneState.zoom;
+      if (key === "rotation") {
+        fillCircle(
+          context,
+          handler[0] + handler[2] / 2,
+          handler[1] + handler[3] / 2,
+          handler[2] / 2,
+        );
+      } else {
+        strokeRectWithRotation(
+          context,
+          handler[0],
+          handler[1],
+          handler[2],
+          handler[3],
+          handler[0] + handler[2] / 2,
+          handler[1] + handler[3] / 2,
+          angle,
+          true, // fill before stroke
+        );
+      }
+      context.lineWidth = lineWidth;
+    }
+  });
+};
+
+const renderSelectionBorder = (
+  context: CanvasRenderingContext2D,
+  sceneState: SceneState,
+  elementProperties: {
+    angle: number;
+    elementX1: number;
+    elementY1: number;
+    elementX2: number;
+    elementY2: number;
+    selectionColors: string[];
+  },
+) => {
+  const {
+    angle,
+    elementX1,
+    elementY1,
+    elementX2,
+    elementY2,
+    selectionColors,
+  } = elementProperties;
+  const elementWidth = elementX2 - elementX1;
+  const elementHeight = elementY2 - elementY1;
+
+  const initialLineDash = context.getLineDash();
+  const lineWidth = context.lineWidth;
+  const lineDashOffset = context.lineDashOffset;
+  const strokeStyle = context.strokeStyle;
+
+  const dashedLinePadding = 4 / sceneState.zoom;
+  const dashWidth = 8 / sceneState.zoom;
+  const spaceWidth = 4 / sceneState.zoom;
+
+  context.lineWidth = 1 / sceneState.zoom;
+
+  context.translate(sceneState.scrollX, sceneState.scrollY);
+
+  const count = selectionColors.length;
+  for (var i = 0; i < count; ++i) {
+    context.strokeStyle = selectionColors[i];
+    context.setLineDash([
+      dashWidth,
+      spaceWidth + (dashWidth + spaceWidth) * (count - 1),
+    ]);
+    context.lineDashOffset = (dashWidth + spaceWidth) * i;
+    strokeRectWithRotation(
+      context,
+      elementX1 - dashedLinePadding,
+      elementY1 - dashedLinePadding,
+      elementWidth + dashedLinePadding * 2,
+      elementHeight + dashedLinePadding * 2,
+      elementX1 + elementWidth / 2,
+      elementY1 + elementHeight / 2,
+      angle,
+    );
+  }
+  context.lineDashOffset = lineDashOffset;
+  context.strokeStyle = strokeStyle;
+  context.lineWidth = lineWidth;
+  context.setLineDash(initialLineDash);
+  context.translate(-sceneState.scrollX, -sceneState.scrollY);
+};
+
+const renderBindingHighlight = (
+  context: CanvasRenderingContext2D,
+  sceneState: SceneState,
+  suggestedBinding: SuggestedBinding,
+) => {
+  // preserve context settings to restore later
+  const originalStrokeStyle = context.strokeStyle;
+  const originalLineWidth = context.lineWidth;
+
+  const renderHighlight = Array.isArray(suggestedBinding)
+    ? renderBindingHighlightForSuggestedPointBinding
+    : renderBindingHighlightForBindableElement;
+
+  context.translate(sceneState.scrollX, sceneState.scrollY);
+  renderHighlight(context, suggestedBinding as any);
+
+  // restore context settings
+  context.strokeStyle = originalStrokeStyle;
+  context.lineWidth = originalLineWidth;
+  context.translate(-sceneState.scrollX, -sceneState.scrollY);
+};
+
+const renderBindingHighlightForBindableElement = (
+  context: CanvasRenderingContext2D,
+  element: ExcalidrawBindableElement,
+) => {
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const width = x2 - x1;
+  const height = y2 - y1;
+  const threshold = maxBindingGap(element, width, height);
+
+  // 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 + threshold / 2;
+
+  switch (element.type) {
+    case "rectangle":
+    case "text":
+      strokeRectWithRotation(
+        context,
+        x1 - padding,
+        y1 - padding,
+        width + padding * 2,
+        height + padding * 2,
+        x1 + width / 2,
+        y1 + height / 2,
+        element.angle,
+      );
+      break;
+    case "diamond":
+      const side = Math.hypot(width, height);
+      const wPadding = (padding * side) / height;
+      const hPadding = (padding * side) / width;
+      strokeDiamondWithRotation(
+        context,
+        width + wPadding * 2,
+        height + hPadding * 2,
+        x1 + width / 2,
+        y1 + height / 2,
+        element.angle,
+      );
+      break;
+    case "ellipse":
+      strokeEllipseWithRotation(
+        context,
+        width + padding * 2,
+        height + padding * 2,
+        x1 + width / 2,
+        y1 + height / 2,
+        element.angle,
+      );
+      break;
+  }
+};
+
+const renderBindingHighlightForSuggestedPointBinding = (
+  context: CanvasRenderingContext2D,
+  suggestedBinding: SuggestedPointBinding,
+) => {
+  const [element, startOrEnd, bindableElement] = suggestedBinding;
+
+  const threshold = maxBindingGap(
+    bindableElement,
+    bindableElement.width,
+    bindableElement.height,
+  );
+
+  context.strokeStyle = "rgba(0,0,0,0)";
+  context.fillStyle = "rgba(0,0,0,.05)";
+
+  const pointIndices =
+    startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
+  pointIndices.forEach((index) => {
+    const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+      element,
+      index,
+    );
+    fillCircle(context, x, y, threshold);
+  });
+};
+
 const isVisibleElement = (
   element: ExcalidrawElement,
   viewportWidth: number,

+ 16 - 0
src/scene/Scene.ts

@@ -52,10 +52,12 @@ class Scene {
   private elements: readonly ExcalidrawElement[] = [];
   private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
 
+  // TODO: getAllElementsIncludingDeleted
   getElementsIncludingDeleted() {
     return this.elements;
   }
 
+  // TODO: getAllNonDeletedElements
   getElements(): readonly NonDeletedExcalidrawElement[] {
     return this.nonDeletedElements;
   }
@@ -74,6 +76,20 @@ class Scene {
     return null;
   }
 
+  // TODO: Rename methods here, this is confusing
+  getNonDeletedElements(
+    ids: readonly ExcalidrawElement["id"][],
+  ): NonDeleted<ExcalidrawElement>[] {
+    const result: NonDeleted<ExcalidrawElement>[] = [];
+    ids.forEach((id) => {
+      const element = this.getNonDeletedElement(id);
+      if (element != null) {
+        result.push(element);
+      }
+    });
+    return result;
+  }
+
   replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
     this.elements = nextElements;
     this.elementsMap.clear();

+ 6 - 9
src/scene/comparisons.ts

@@ -3,8 +3,7 @@ import {
   NonDeletedExcalidrawElement,
 } from "../element/types";
 
-import { getElementAbsoluteCoords, hitTest } from "../element";
-import { AppState } from "../types";
+import { getElementAbsoluteCoords } from "../element";
 
 export const hasBackground = (type: string) =>
   type === "rectangle" ||
@@ -25,19 +24,17 @@ export const hasText = (type: string) => type === "text";
 
 export const getElementAtPosition = (
   elements: readonly NonDeletedExcalidrawElement[],
-  appState: AppState,
-  x: number,
-  y: number,
-  zoom: number,
+  isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
 ) => {
   let hitElement = null;
   // We need to to hit testing from front (end of the array) to back (beginning of the array)
   for (let i = elements.length - 1; i >= 0; --i) {
-    if (elements[i].isDeleted) {
+    const element = elements[i];
+    if (element.isDeleted) {
       continue;
     }
-    if (hitTest(elements[i], appState, x, y, zoom)) {
-      hitElement = elements[i];
+    if (isAtPositionFn(element)) {
+      hitElement = element;
       break;
     }
   }

+ 9 - 0
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -6,6 +6,8 @@ exports[`add element to the scene when pointer dragging long enough arrow 2`] =
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -25,6 +27,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,
@@ -43,6 +46,7 @@ exports[`add element to the scene when pointer dragging long enough diamond 2`]
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -69,6 +73,7 @@ exports[`add element to the scene when pointer dragging long enough ellipse 2`]
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -93,6 +98,8 @@ exports[`add element to the scene when pointer dragging long enough line 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -112,6 +119,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,
@@ -130,6 +138,7 @@ exports[`add element to the scene when pointer dragging long enough rectangle 2`
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,

+ 3 - 0
src/tests/__snapshots__/move.test.tsx.snap

@@ -4,6 +4,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -28,6 +29,7 @@ exports[`duplicate element on move when ALT is clicked rectangle 2`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -52,6 +54,7 @@ exports[`move element rectangle 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,

+ 6 - 0
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -4,6 +4,8 @@ exports[`multi point mode in linear elements arrow 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 110,
@@ -30,6 +32,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,
@@ -46,6 +49,8 @@ exports[`multi point mode in linear elements line 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 110,
@@ -72,6 +77,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,

文件差异内容过多而无法显示
+ 156 - 44
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 2 - 0
src/tests/__snapshots__/resize.test.tsx.snap

@@ -4,6 +4,7 @@ exports[`resize element rectangle 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -28,6 +29,7 @@ exports[`resize element with aspect ratio when SHIFT is clicked rectangle 1`] =
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,

+ 9 - 0
src/tests/__snapshots__/selection.test.tsx.snap

@@ -4,6 +4,8 @@ exports[`select single element on the scene arrow 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -23,6 +25,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,
@@ -39,6 +42,8 @@ exports[`select single element on the scene arrow escape 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
+  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -58,6 +63,7 @@ Object {
   ],
   "roughness": 1,
   "seed": 337897,
+  "startBinding": null,
   "strokeColor": "#000000",
   "strokeStyle": "solid",
   "strokeWidth": 1,
@@ -74,6 +80,7 @@ exports[`select single element on the scene diamond 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -98,6 +105,7 @@ exports[`select single element on the scene ellipse 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,
@@ -122,6 +130,7 @@ exports[`select single element on the scene rectangle 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
+  "boundElementIds": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 50,

+ 70 - 0
src/tests/geometricAlgebra.test.ts

@@ -0,0 +1,70 @@
+import * as GA from "../ga";
+import { point, toString, direction, offset } from "../ga";
+import * as GAPoint from "../gapoints";
+import * as GALine from "../galines";
+import * as GATransform from "../gatransforms";
+
+describe("geometric algebra", () => {
+  describe("points", () => {
+    it("distanceToLine", () => {
+      const point = GA.point(3, 3);
+      const line = GALine.equation(0, 1, -1);
+      expect(GAPoint.distanceToLine(point, line)).toEqual(2);
+    });
+
+    it("distanceToLine neg", () => {
+      const point = GA.point(-3, -3);
+      const line = GALine.equation(0, 1, -1);
+      expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
+    });
+  });
+  describe("lines", () => {
+    it("through", () => {
+      const a = GA.point(0, 0);
+      const b = GA.point(2, 0);
+      expect(toString(GALine.through(a, b))).toEqual(
+        toString(GALine.equation(0, 2, 0)),
+      );
+    });
+    it("parallel", () => {
+      const point = GA.point(3, 3);
+      const line = GALine.equation(0, 1, -1);
+      const parallel = GALine.parallel(line, 2);
+      expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
+    });
+  });
+
+  describe("translation", () => {
+    it("points", () => {
+      const start = point(2, 2);
+      const move = GATransform.translation(direction(0, 1));
+      const end = GATransform.apply(move, start);
+      expect(toString(end)).toEqual(toString(point(2, 3)));
+    });
+
+    it("points 2", () => {
+      const start = point(2, 2);
+      const move = GATransform.translation(offset(3, 4));
+      const end = GATransform.apply(move, start);
+      expect(toString(end)).toEqual(toString(point(5, 6)));
+    });
+
+    it("lines", () => {
+      const original = GALine.through(point(2, 2), point(3, 4));
+      const move = GATransform.translation(offset(3, 4));
+      const parallel = GATransform.apply(move, original);
+      expect(toString(parallel)).toEqual(
+        toString(GALine.through(point(5, 6), point(6, 8))),
+      );
+    });
+  });
+  describe("rotation", () => {
+    it("points", () => {
+      const start = point(2, 2);
+      const pivot = point(1, 1);
+      const rotate = GATransform.rotation(pivot, Math.PI / 2);
+      const end = GATransform.apply(rotate, start);
+      expect(toString(end)).toEqual(toString(point(2, 0)));
+    });
+  });
+});

+ 3 - 1
src/tests/move.test.tsx

@@ -83,7 +83,9 @@ describe("duplicate element on move when ALT is clicked", () => {
     fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    // TODO: This used to be 4, but binding made it go up to 5. Do we need
+    // that additional render?
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(2);
 

+ 23 - 23
src/tests/regressionTests.test.tsx

@@ -259,40 +259,40 @@ afterEach(() => {
 describe("regression tests", () => {
   it("draw every type of shape", () => {
     clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(10, 10);
+    mouse.down(10, -10);
+    mouse.up(20, 10);
 
     clickTool("diamond");
     mouse.down(10, -10);
-    mouse.up(10, 10);
+    mouse.up(20, 10);
 
     clickTool("ellipse");
     mouse.down(10, -10);
-    mouse.up(10, 10);
+    mouse.up(20, 10);
 
     clickTool("arrow");
-    mouse.down(10, -10);
-    mouse.up(10, 10);
+    mouse.down(40, -10);
+    mouse.up(50, 10);
 
     clickTool("line");
-    mouse.down(10, -10);
-    mouse.up(10, 10);
+    mouse.down(40, -10);
+    mouse.up(50, 10);
 
     clickTool("arrow");
-    mouse.click(10, -10);
-    mouse.click(10, 10);
-    mouse.click(-10, 10);
+    mouse.click(40, -10);
+    mouse.click(50, 10);
+    mouse.click(30, 10);
     hotkeyPress("ENTER");
 
     clickTool("line");
-    mouse.click(10, -20);
-    mouse.click(10, 10);
-    mouse.click(-10, 10);
+    mouse.click(40, -20);
+    mouse.click(50, 10);
+    mouse.click(30, 10);
     hotkeyPress("ENTER");
 
     clickTool("draw");
-    mouse.down(10, -20);
-    mouse.up(10, 10);
+    mouse.down(40, -20);
+    mouse.up(50, 10);
 
     expect(h.elements.map((element) => element.type)).toEqual([
       "rectangle",
@@ -569,17 +569,17 @@ describe("regression tests", () => {
 
   it("undo/redo drawing an element", () => {
     clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(10, 10);
+    mouse.down(10, -10);
+    mouse.up(20, 10);
 
     clickTool("rectangle");
-    mouse.down(10, -10);
-    mouse.up(10, 10);
+    mouse.down(10, 0);
+    mouse.up(30, 20);
 
     clickTool("arrow");
-    mouse.click(10, -10);
-    mouse.click(10, 10);
-    mouse.click(-10, 10);
+    mouse.click(60, -10);
+    mouse.click(60, 10);
+    mouse.click(40, 10);
     hotkeyPress("ENTER");
 
     expect(h.elements.filter((element) => !element.isDeleted).length).toBe(3);

+ 5 - 0
src/types.ts

@@ -7,11 +7,13 @@ import {
   ExcalidrawElement,
   FontFamily,
   GroupId,
+  ExcalidrawBindableElement,
 } from "./element/types";
 import { SHAPES } from "./shapes";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
 import { SocketUpdateDataSource } from "./data";
 import { LinearElementEditor } from "./element/linearElementEditor";
+import { SuggestedBinding } from "./element/binding";
 
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
 export type Point = Readonly<RoughPoint>;
@@ -33,6 +35,9 @@ export type AppState = {
   resizingElement: NonDeletedExcalidrawElement | null;
   multiElement: NonDeleted<ExcalidrawLinearElement> | null;
   selectionElement: NonDeletedExcalidrawElement | null;
+  isBindingEnabled: boolean;
+  startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
+  suggestedBindings: SuggestedBinding[];
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: NonDeletedExcalidrawElement | null;

+ 1 - 1
src/utils.ts

@@ -241,7 +241,7 @@ export const isRTL = (text: string) => {
 };
 
 export function tupleToCoors(
-  xyTuple: [number, number],
+  xyTuple: readonly [number, number],
 ): { x: number; y: number } {
   const [x, y] = xyTuple;
   return { x, y };

部分文件因为文件数量过多而无法显示