Browse Source

Refactoring in pointer down event handler, step 2 (#1887)

* Refactor: introduce pointer down state to replace implicit closure state with an explicit object

* Refactor: use pointer down state for resize handle

* Refactor: use pointer down state for isResizing

* Refactor: use pointer down state for resizing offset

* Refactor: use pointer down state for hit element

* Refactor: move selection handling out of pointer down event handler

* Refactor: move text handling out of pointer down event handler

* Refactor: move linear tools handling out of pointer down event handler

* Refactor: move element creation out of pointer down handler
Michal Srb 5 years ago
parent
commit
5664de0459
2 changed files with 417 additions and 299 deletions
  1. 410 299
      src/components/App.tsx
  2. 7 0
      src/utils.ts

+ 410 - 299
src/components/App.tsx

@@ -61,6 +61,7 @@ import {
   ExcalidrawProps,
   ExcalidrawTextElement,
   NonDeleted,
+  ExcalidrawGenericElement,
 } from "../element/types";
 
 import { distance2d, isPathALoop, getGridPoint } from "../math";
@@ -75,6 +76,7 @@ import {
   viewportCoordsToSceneCoords,
   sceneCoordsToViewportCoords,
   setCursorForShape,
+  tupleToCoors,
 } from "../utils";
 import {
   KEYS,
@@ -186,6 +188,30 @@ const gesture: Gesture = {
   initialScale: null,
 };
 
+type PointerDownState = Readonly<{
+  // The first position at which pointerDown happened
+  origin: Readonly<{ x: number; y: number }>;
+  // The previous pointer position
+  lastCoords: { x: number; y: number };
+  resize: {
+    // Handle when resizing, might change during the pointer interaction
+    handle: ReturnType<typeof resizeTest>;
+    // This is determined on the initial pointer down event
+    isResizing: boolean;
+    // This is determined on the initial pointer down event
+    offset: { x: number; y: number };
+    // This is determined on the initial pointer down event
+    arrowDirection: "origin" | "end";
+  };
+  hit: {
+    // The element the pointer is "hitting", is determined on the initial
+    // pointer down event
+    element: ExcalidrawElement | null;
+    // This is determined on the initial pointer down event
+    wasAddedToSelection: boolean;
+  };
+}>;
+
 class App extends React.Component<ExcalidrawProps, AppState> {
   canvas: HTMLCanvasElement | null = null;
   rc: RoughCanvas | null = null;
@@ -1952,301 +1978,70 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       isOverVerticalScrollBar,
     } = isOverScrollBarsNow;
 
-    const { x, y } = viewportCoordsToSceneCoords(
+    const origin = viewportCoordsToSceneCoords(
       event,
       this.state,
       this.canvas,
       window.devicePixelRatio,
     );
-    const lastCoords = { x, y };
 
-    if (this.handleDraggingScrollBar(event, lastCoords, isOverScrollBarsNow)) {
+    // State for the duration of a pointer interaction, which starts with a
+    // pointerDown event, ends with a pointerUp event (or another pointerDown)
+    const pointerDownState: PointerDownState = {
+      origin,
+      // we need to duplicate because we'll be updating this state
+      lastCoords: { ...origin },
+      resize: {
+        handle: false as ReturnType<typeof resizeTest>,
+        isResizing: false,
+        offset: { x: 0, y: 0 },
+        arrowDirection: "origin",
+      },
+      hit: {
+        element: null,
+        wasAddedToSelection: false,
+      },
+    };
+
+    if (
+      this.handleDraggingScrollBar(event, pointerDownState, isOverScrollBarsNow)
+    ) {
       return;
     }
 
-    const originX = x;
-    const originY = y;
     const [originGridX, originGridY] = getGridPoint(
-      originX,
-      originY,
+      pointerDownState.origin.x,
+      pointerDownState.origin.y,
       this.state.gridSize,
     );
 
-    type ResizeTestType = ReturnType<typeof resizeTest>;
-    let resizeHandle: ResizeTestType = false;
-    const setResizeHandle = (nextResizeHandle: ResizeTestType) => {
-      resizeHandle = nextResizeHandle;
-    };
-    let resizeOffsetXY: [number, number] = [0, 0];
-    let resizeArrowDirection: "origin" | "end" = "origin";
-    let isResizingElements = false;
     let draggingOccurred = false;
     let dragOffsetXY: [number, number] | null = null;
-    let hitElement: ExcalidrawElement | null = null;
-    let hitElementWasAddedToSelection = false;
-
-    if (this.state.elementType !== "selection") {
-      this.setState({
-        selectedElementIds: {},
-        selectedGroupIds: {},
-        editingGroupId: null,
-      });
-    }
 
-    if (this.state.elementType === "selection") {
-      const elements = globalSceneState.getElements();
-      const selectedElements = getSelectedElements(elements, this.state);
-      if (selectedElements.length === 1 && !this.state.editingLinearElement) {
-        const elementWithResizeHandler = getElementWithResizeHandler(
-          elements,
-          this.state,
-          x,
-          y,
-          this.state.zoom,
-          event.pointerType,
-        );
-        if (elementWithResizeHandler != null) {
-          this.setState({ resizingElement: elementWithResizeHandler.element });
-          resizeHandle = elementWithResizeHandler.resizeHandle;
-        }
-      } else if (selectedElements.length > 1) {
-        resizeHandle = getResizeHandlerFromCoords(
-          getCommonBounds(selectedElements),
-          x,
-          y,
-          this.state.zoom,
-          event.pointerType,
-        );
-      }
-      if (resizeHandle) {
-        document.documentElement.style.cursor = getCursorForResizingElement({
-          resizeHandle,
-        });
-        isResizingElements = true;
-        resizeOffsetXY = getResizeOffsetXY(
-          resizeHandle,
-          selectedElements,
-          x,
-          y,
-        );
-        if (
-          selectedElements.length === 1 &&
-          isLinearElement(selectedElements[0]) &&
-          selectedElements[0].points.length === 2
-        ) {
-          resizeArrowDirection = getResizeArrowDirection(
-            resizeHandle,
-            selectedElements[0],
-          );
-        }
-      } else {
-        if (this.state.editingLinearElement) {
-          const ret = LinearElementEditor.handlePointerDown(
-            event,
-            this.state,
-            (appState) => this.setState(appState),
-            history,
-            x,
-            y,
-          );
-          if (ret.hitElement) {
-            hitElement = ret.hitElement;
-          }
-          if (ret.didAddPoint) {
-            return;
-          }
-        }
-
-        // hitElement may already be set above, so check first
-        hitElement =
-          hitElement ||
-          getElementAtPosition(elements, this.state, x, y, this.state.zoom);
-
-        this.maybeClearSelectionWhenHittingElement(event, hitElement);
-
-        // If we click on something
-        if (hitElement) {
-          // deselect if item is selected
-          // if shift is not clicked, this will always return true
-          // otherwise, it will trigger selection based on current
-          // state of the box
-          if (!this.state.selectedElementIds[hitElement.id]) {
-            // if we are currently editing a group, treat all selections outside of the group
-            // as exiting editing mode.
-            if (
-              this.state.editingGroupId &&
-              !isElementInGroup(hitElement, this.state.editingGroupId)
-            ) {
-              this.setState({
-                selectedElementIds: {},
-                selectedGroupIds: {},
-                editingGroupId: null,
-              });
-              return;
-            }
-            this.setState((prevState) => {
-              return selectGroupsForSelectedElements(
-                {
-                  ...prevState,
-                  selectedElementIds: {
-                    ...prevState.selectedElementIds,
-                    [hitElement!.id]: true,
-                  },
-                },
-                globalSceneState.getElements(),
-              );
-            });
-            // TODO: this is strange...
-            globalSceneState.replaceAllElements(
-              globalSceneState.getElementsIncludingDeleted(),
-            );
-            hitElementWasAddedToSelection = true;
-          }
-        }
+    this.clearSelectionIfNotUsingSelection();
 
-        const { selectedElementIds } = this.state;
-        this.setState({
-          previousSelectedElementIds: selectedElementIds,
-        });
-      }
+    if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
+      return;
     }
 
     if (this.state.elementType === "text") {
-      // if we're currently still editing text, clicking outside
-      //  should only finalize it, not create another (irrespective
-      //  of state.elementLocked)
-      if (this.state.editingElement?.type === "text") {
-        return;
-      }
-
-      this.startTextEditing({
-        sceneX: x,
-        sceneY: y,
-        insertAtParentCenter: !event.altKey,
-      });
-
-      resetCursor();
-      if (!this.state.elementLocked) {
-        this.setState({
-          elementType: "selection",
-        });
-      }
+      this.handleTextOnPointerDown(event, pointerDownState);
       return;
     } else if (
       this.state.elementType === "arrow" ||
       this.state.elementType === "draw" ||
       this.state.elementType === "line"
     ) {
-      if (this.state.multiElement) {
-        const { multiElement } = this.state;
-
-        // finalize if completing a loop
-        if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
-          mutateElement(multiElement, {
-            lastCommittedPoint:
-              multiElement.points[multiElement.points.length - 1],
-          });
-          this.actionManager.executeAction(actionFinalize);
-          return;
-        }
-
-        const { x: rx, y: ry, lastCommittedPoint } = multiElement;
-
-        // clicking inside commit zone → finalize arrow
-        if (
-          multiElement.points.length > 1 &&
-          lastCommittedPoint &&
-          distance2d(
-            x - rx,
-            y - ry,
-            lastCommittedPoint[0],
-            lastCommittedPoint[1],
-          ) < LINE_CONFIRM_THRESHOLD
-        ) {
-          this.actionManager.executeAction(actionFinalize);
-          return;
-        }
-
-        this.setState((prevState) => ({
-          selectedElementIds: {
-            ...prevState.selectedElementIds,
-            [multiElement.id]: true,
-          },
-        }));
-        // clicking outside commit zone → update reference for last committed
-        //  point
-        mutateElement(multiElement, {
-          lastCommittedPoint:
-            multiElement.points[multiElement.points.length - 1],
-        });
-        document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
-      } else {
-        const [gridX, gridY] = getGridPoint(
-          x,
-          y,
-          this.state.elementType === "draw" ? null : this.state.gridSize,
-        );
-        const element = newLinearElement({
-          type: this.state.elementType,
-          x: gridX,
-          y: gridY,
-          strokeColor: this.state.currentItemStrokeColor,
-          backgroundColor: this.state.currentItemBackgroundColor,
-          fillStyle: this.state.currentItemFillStyle,
-          strokeWidth: this.state.currentItemStrokeWidth,
-          strokeStyle: this.state.currentItemStrokeStyle,
-          roughness: this.state.currentItemRoughness,
-          opacity: this.state.currentItemOpacity,
-        });
-        this.setState((prevState) => ({
-          selectedElementIds: {
-            ...prevState.selectedElementIds,
-            [element.id]: false,
-          },
-        }));
-        mutateElement(element, {
-          points: [...element.points, [0, 0]],
-        });
-        globalSceneState.replaceAllElements([
-          ...globalSceneState.getElementsIncludingDeleted(),
-          element,
-        ]);
-        this.setState({
-          draggingElement: element,
-          editingElement: element,
-        });
-      }
+      this.handleLinearElementOnPointerDown(
+        event,
+        this.state.elementType,
+        pointerDownState,
+      );
     } else {
-      const [gridX, gridY] = getGridPoint(x, y, this.state.gridSize);
-      const element = newElement({
-        type: this.state.elementType,
-        x: gridX,
-        y: gridY,
-        strokeColor: this.state.currentItemStrokeColor,
-        backgroundColor: this.state.currentItemBackgroundColor,
-        fillStyle: this.state.currentItemFillStyle,
-        strokeWidth: this.state.currentItemStrokeWidth,
-        strokeStyle: this.state.currentItemStrokeStyle,
-        roughness: this.state.currentItemRoughness,
-        opacity: this.state.currentItemOpacity,
-      });
-
-      if (element.type === "selection") {
-        this.setState({
-          selectionElement: element,
-          draggingElement: element,
-        });
-      } else {
-        globalSceneState.replaceAllElements([
-          ...globalSceneState.getElementsIncludingDeleted(),
-          element,
-        ]);
-        this.setState({
-          multiElement: null,
-          draggingElement: element,
-          editingElement: element,
-        });
-      }
+      this.createGenericElementOnPointerDown(
+        this.state.elementType,
+        pointerDownState,
+      );
     }
 
     let selectedElementWasDuplicated = false;
@@ -2259,8 +2054,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       if (dragOffsetXY === null) {
         dragOffsetXY = getDragOffsetXY(
           getSelectedElements(globalSceneState.getElements(), this.state),
-          originX,
-          originY,
+          pointerDownState.origin.x,
+          pointerDownState.origin.y,
         );
       }
 
@@ -2271,21 +2066,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
       if (isOverHorizontalScrollBar) {
         const x = event.clientX;
-        const dx = x - lastCoords.x;
+        const dx = x - pointerDownState.lastCoords.x;
         this.setState({
           scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
         });
-        lastCoords.x = x;
+        pointerDownState.lastCoords.x = x;
         return;
       }
 
       if (isOverVerticalScrollBar) {
         const y = event.clientY;
-        const dy = y - lastCoords.y;
+        const dy = y - pointerDownState.lastCoords.y;
         this.setState({
           scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
         });
-        lastCoords.y = y;
+        pointerDownState.lastCoords.y = y;
         return;
       }
 
@@ -2306,31 +2101,44 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         (this.state.elementType === "arrow" ||
           this.state.elementType === "line")
       ) {
-        if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
+        if (
+          distance2d(
+            x,
+            y,
+            pointerDownState.origin.x,
+            pointerDownState.origin.y,
+          ) < DRAGGING_THRESHOLD
+        ) {
           return;
         }
       }
 
-      if (isResizingElements) {
+      if (pointerDownState.resize.isResizing) {
         const selectedElements = getSelectedElements(
           globalSceneState.getElements(),
           this.state,
         );
+        const resizeHandle = pointerDownState.resize.handle;
         this.setState({
+          // TODO: rename this state field to "isScaling" to distinguish
+          // it from the generic "isResizing" which includes scaling and
+          // rotating
           isResizing: resizeHandle && resizeHandle !== "rotation",
           isRotating: resizeHandle === "rotation",
         });
         const [resizeX, resizeY] = getGridPoint(
-          x - resizeOffsetXY[0],
-          y - resizeOffsetXY[1],
+          x - pointerDownState.resize.offset.x,
+          y - pointerDownState.resize.offset.y,
           this.state.gridSize,
         );
         if (
           resizeElements(
             resizeHandle,
-            setResizeHandle,
+            (newResizeHandle) => {
+              pointerDownState.resize.handle = newResizeHandle;
+            },
             selectedElements,
-            resizeArrowDirection,
+            pointerDownState.resize.arrowDirection,
             getRotateWithDiscreteAngleKey(event),
             getResizeWithSidesSameLengthKey(event),
             getResizeCenterPointKey(event),
@@ -2348,17 +2156,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           (appState) => this.setState(appState),
           x,
           y,
-          lastCoords.x,
-          lastCoords.y,
+          pointerDownState.lastCoords.x,
+          pointerDownState.lastCoords.y,
         );
 
         if (didDrag) {
-          lastCoords.x = x;
-          lastCoords.y = y;
+          pointerDownState.lastCoords.x = x;
+          pointerDownState.lastCoords.y = y;
           return;
         }
       }
 
+      const hitElement = pointerDownState.hit.element;
       if (hitElement && this.state.selectedElementIds[hitElement.id]) {
         // Marking that click was used for dragging to check
         // if elements should be deselected on pointerup
@@ -2391,7 +2200,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
                 this.state.selectedElementIds[element.id] ||
                 // case: the state.selectedElementIds might not have been
                 //  updated yet by the time this mousemove event is fired
-                (element.id === hitElement.id && hitElementWasAddedToSelection)
+                (element.id === hitElement.id &&
+                  pointerDownState.hit.wasAddedToSelection)
               ) {
                 const duplicatedElement = duplicateElement(
                   this.state.editingGroupId,
@@ -2399,8 +2209,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
                   element,
                 );
                 const [originDragX, originDragY] = getGridPoint(
-                  originX - dragOffsetXY[0],
-                  originY - dragOffsetXY[1],
+                  pointerDownState.origin.x - dragOffsetXY[0],
+                  pointerDownState.origin.y - dragOffsetXY[1],
                   this.state.gridSize,
                 );
                 mutateElement(duplicatedElement, {
@@ -2467,12 +2277,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         dragNewElement(
           draggingElement,
           this.state.elementType,
-          originX,
-          originY,
+          pointerDownState.origin.x,
+          pointerDownState.origin.y,
           x,
           y,
-          distance(originX, x),
-          distance(originY, y),
+          distance(pointerDownState.origin.x, x),
+          distance(pointerDownState.origin.y, y),
           getResizeWithSidesSameLengthKey(event),
           getResizeCenterPointKey(event),
         );
@@ -2654,11 +2464,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       // If click occurred and elements were dragged or some element
       // was added to selection (on pointerdown phase) we need to keep
       // selection unchanged
+      const hitElement = pointerDownState.hit.element;
       if (
         getSelectedGroupIds(this.state).length === 0 &&
         hitElement &&
         !draggingOccurred &&
-        !hitElementWasAddedToSelection
+        !pointerDownState.hit.wasAddedToSelection
       ) {
         if (childEvent.shiftKey) {
           this.setState((prevState) => ({
@@ -2859,7 +2670,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   // Returns whether the event is a dragging a scrollbar
   private handleDraggingScrollBar(
     event: React.PointerEvent<HTMLCanvasElement>,
-    lastCoords: { x: number; y: number },
+    pointerDownState: PointerDownState,
     {
       isOverHorizontalScrollBar,
       isOverVerticalScrollBar,
@@ -2877,8 +2688,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       return false;
     }
     isDraggingScrollBar = true;
-    lastCoords.x = event.clientX;
-    lastCoords.y = event.clientY;
+    pointerDownState.lastCoords.x = event.clientX;
+    pointerDownState.lastCoords.y = event.clientY;
     const onPointerMove = withBatchedUpdates((event: PointerEvent) => {
       const target = event.target;
       if (!(target instanceof HTMLElement)) {
@@ -2887,21 +2698,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
       if (isOverHorizontalScrollBar) {
         const x = event.clientX;
-        const dx = x - lastCoords.x;
+        const dx = x - pointerDownState.lastCoords.x;
         this.setState({
           scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom),
         });
-        lastCoords.x = x;
+        pointerDownState.lastCoords.x = x;
         return;
       }
 
       if (isOverVerticalScrollBar) {
         const y = event.clientY;
-        const dy = y - lastCoords.y;
+        const dy = y - pointerDownState.lastCoords.y;
         this.setState({
           scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom),
         });
-        lastCoords.y = y;
+        pointerDownState.lastCoords.y = y;
       }
     });
 
@@ -2924,6 +2735,306 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     return true;
   }
 
+  private clearSelectionIfNotUsingSelection = (): void => {
+    if (this.state.elementType !== "selection") {
+      this.setState({
+        selectedElementIds: {},
+        selectedGroupIds: {},
+        editingGroupId: null,
+      });
+    }
+  };
+
+  // Returns whether the pointer event has been completely handled
+  private handleSelectionOnPointerDown = (
+    event: React.PointerEvent<HTMLCanvasElement>,
+    pointerDownState: PointerDownState,
+  ): boolean => {
+    if (this.state.elementType === "selection") {
+      const elements = globalSceneState.getElements();
+      const selectedElements = getSelectedElements(elements, this.state);
+      if (selectedElements.length === 1 && !this.state.editingLinearElement) {
+        const elementWithResizeHandler = getElementWithResizeHandler(
+          elements,
+          this.state,
+          pointerDownState.origin.x,
+          pointerDownState.origin.y,
+          this.state.zoom,
+          event.pointerType,
+        );
+        if (elementWithResizeHandler != null) {
+          this.setState({
+            resizingElement: elementWithResizeHandler.element,
+          });
+          pointerDownState.resize.handle =
+            elementWithResizeHandler.resizeHandle;
+        }
+      } else if (selectedElements.length > 1) {
+        pointerDownState.resize.handle = getResizeHandlerFromCoords(
+          getCommonBounds(selectedElements),
+          pointerDownState.origin.x,
+          pointerDownState.origin.y,
+          this.state.zoom,
+          event.pointerType,
+        );
+      }
+      if (pointerDownState.resize.handle) {
+        document.documentElement.style.cursor = getCursorForResizingElement({
+          resizeHandle: pointerDownState.resize.handle,
+        });
+        pointerDownState.resize.isResizing = true;
+        pointerDownState.resize.offset = tupleToCoors(
+          getResizeOffsetXY(
+            pointerDownState.resize.handle,
+            selectedElements,
+            pointerDownState.origin.x,
+            pointerDownState.origin.y,
+          ),
+        );
+        if (
+          selectedElements.length === 1 &&
+          isLinearElement(selectedElements[0]) &&
+          selectedElements[0].points.length === 2
+        ) {
+          pointerDownState.resize.arrowDirection = getResizeArrowDirection(
+            pointerDownState.resize.handle,
+            selectedElements[0],
+          );
+        }
+      } else {
+        if (this.state.editingLinearElement) {
+          const ret = LinearElementEditor.handlePointerDown(
+            event,
+            this.state,
+            (appState) => this.setState(appState),
+            history,
+            pointerDownState.origin.x,
+            pointerDownState.origin.y,
+          );
+          if (ret.hitElement) {
+            pointerDownState.hit.element = ret.hitElement;
+          }
+          if (ret.didAddPoint) {
+            return true;
+          }
+        }
+
+        // hitElement may already be set above, so check first
+        pointerDownState.hit.element =
+          pointerDownState.hit.element ??
+          getElementAtPosition(
+            elements,
+            this.state,
+            pointerDownState.origin.x,
+            pointerDownState.origin.y,
+            this.state.zoom,
+          );
+
+        this.maybeClearSelectionWhenHittingElement(
+          event,
+          pointerDownState.hit.element,
+        );
+
+        // If we click on something
+        const hitElement = pointerDownState.hit.element;
+        if (hitElement != null) {
+          // deselect if item is selected
+          // if shift is not clicked, this will always return true
+          // otherwise, it will trigger selection based on current
+          // state of the box
+          if (!this.state.selectedElementIds[hitElement.id]) {
+            // if we are currently editing a group, treat all selections outside of the group
+            // as exiting editing mode.
+            if (
+              this.state.editingGroupId &&
+              !isElementInGroup(hitElement, this.state.editingGroupId)
+            ) {
+              this.setState({
+                selectedElementIds: {},
+                selectedGroupIds: {},
+                editingGroupId: null,
+              });
+              return true;
+            }
+            this.setState((prevState) => {
+              return selectGroupsForSelectedElements(
+                {
+                  ...prevState,
+                  selectedElementIds: {
+                    ...prevState.selectedElementIds,
+                    [hitElement!.id]: true,
+                  },
+                },
+                globalSceneState.getElements(),
+              );
+            });
+            // TODO: this is strange...
+            globalSceneState.replaceAllElements(
+              globalSceneState.getElementsIncludingDeleted(),
+            );
+            pointerDownState.hit.wasAddedToSelection = true;
+          }
+        }
+
+        const { selectedElementIds } = this.state;
+        this.setState({
+          previousSelectedElementIds: selectedElementIds,
+        });
+      }
+    }
+    return false;
+  };
+
+  private handleTextOnPointerDown = (
+    event: React.PointerEvent<HTMLCanvasElement>,
+    pointerDownState: PointerDownState,
+  ): void => {
+    // if we're currently still editing text, clicking outside
+    //  should only finalize it, not create another (irrespective
+    //  of state.elementLocked)
+    if (this.state.editingElement?.type === "text") {
+      return;
+    }
+
+    this.startTextEditing({
+      sceneX: pointerDownState.origin.x,
+      sceneY: pointerDownState.origin.y,
+      insertAtParentCenter: !event.altKey,
+    });
+
+    resetCursor();
+    if (!this.state.elementLocked) {
+      this.setState({
+        elementType: "selection",
+      });
+    }
+  };
+
+  private handleLinearElementOnPointerDown = (
+    event: React.PointerEvent<HTMLCanvasElement>,
+    elementType: "draw" | "line" | "arrow",
+    pointerDownState: PointerDownState,
+  ): void => {
+    if (this.state.multiElement) {
+      const { multiElement } = this.state;
+
+      // finalize if completing a loop
+      if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
+        mutateElement(multiElement, {
+          lastCommittedPoint:
+            multiElement.points[multiElement.points.length - 1],
+        });
+        this.actionManager.executeAction(actionFinalize);
+        return;
+      }
+
+      const { x: rx, y: ry, lastCommittedPoint } = multiElement;
+
+      // clicking inside commit zone → finalize arrow
+      if (
+        multiElement.points.length > 1 &&
+        lastCommittedPoint &&
+        distance2d(
+          pointerDownState.origin.x - rx,
+          pointerDownState.origin.y - ry,
+          lastCommittedPoint[0],
+          lastCommittedPoint[1],
+        ) < LINE_CONFIRM_THRESHOLD
+      ) {
+        this.actionManager.executeAction(actionFinalize);
+        return;
+      }
+
+      this.setState((prevState) => ({
+        selectedElementIds: {
+          ...prevState.selectedElementIds,
+          [multiElement.id]: true,
+        },
+      }));
+      // clicking outside commit zone → update reference for last committed
+      //  point
+      mutateElement(multiElement, {
+        lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
+      });
+      document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
+    } else {
+      const [gridX, gridY] = getGridPoint(
+        pointerDownState.origin.x,
+        pointerDownState.origin.y,
+        elementType === "draw" ? null : this.state.gridSize,
+      );
+      const element = newLinearElement({
+        type: elementType,
+        x: gridX,
+        y: gridY,
+        strokeColor: this.state.currentItemStrokeColor,
+        backgroundColor: this.state.currentItemBackgroundColor,
+        fillStyle: this.state.currentItemFillStyle,
+        strokeWidth: this.state.currentItemStrokeWidth,
+        strokeStyle: this.state.currentItemStrokeStyle,
+        roughness: this.state.currentItemRoughness,
+        opacity: this.state.currentItemOpacity,
+      });
+      this.setState((prevState) => ({
+        selectedElementIds: {
+          ...prevState.selectedElementIds,
+          [element.id]: false,
+        },
+      }));
+      mutateElement(element, {
+        points: [...element.points, [0, 0]],
+      });
+      globalSceneState.replaceAllElements([
+        ...globalSceneState.getElementsIncludingDeleted(),
+        element,
+      ]);
+      this.setState({
+        draggingElement: element,
+        editingElement: element,
+      });
+    }
+  };
+
+  private createGenericElementOnPointerDown = (
+    elementType: ExcalidrawGenericElement["type"],
+    pointerDownState: PointerDownState,
+  ): void => {
+    const [gridX, gridY] = getGridPoint(
+      pointerDownState.origin.x,
+      pointerDownState.origin.y,
+      this.state.gridSize,
+    );
+    const element = newElement({
+      type: elementType,
+      x: gridX,
+      y: gridY,
+      strokeColor: this.state.currentItemStrokeColor,
+      backgroundColor: this.state.currentItemBackgroundColor,
+      fillStyle: this.state.currentItemFillStyle,
+      strokeWidth: this.state.currentItemStrokeWidth,
+      strokeStyle: this.state.currentItemStrokeStyle,
+      roughness: this.state.currentItemRoughness,
+      opacity: this.state.currentItemOpacity,
+    });
+
+    if (element.type === "selection") {
+      this.setState({
+        selectionElement: element,
+        draggingElement: element,
+      });
+    } else {
+      globalSceneState.replaceAllElements([
+        ...globalSceneState.getElementsIncludingDeleted(),
+        element,
+      ]);
+      this.setState({
+        multiElement: null,
+        draggingElement: element,
+        editingElement: element,
+      });
+    }
+  };
+
   private maybeClearSelectionWhenHittingElement(
     event: React.PointerEvent<HTMLCanvasElement>,
     hitElement: ExcalidrawElement | null,

+ 7 - 0
src/utils.ts

@@ -241,3 +241,10 @@ const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
 export const isRTL = (text: string) => {
   return RE_RTL_CHECK.test(text);
 };
+
+export function tupleToCoors(
+  xyTuple: [number, number],
+): { x: number; y: number } {
+  const [x, y] = xyTuple;
+  return { x, y };
+}