From 8424d782547c3821263e904131b10a0a377494d2 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Tue, 28 Apr 2026 21:08:31 +1000 Subject: [PATCH] fix(line-snap): prefer bindable targets over external snaps --- packages/element/src/linearElementEditor.ts | 62 +++++++++++++++++ packages/element/src/snapping.ts | 27 ++++---- .../tests/linearElementEditor.test.tsx | 68 +++++++++++++++++++ 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e873cc7754..2a8f195f03 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -52,6 +52,7 @@ import { calculateFixedPointForNonElbowArrowBinding, getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, + maxBindingDistance_simple, snapToMid, updateBoundPoint, } from "./binding"; @@ -60,6 +61,7 @@ import { getElementPointsCoords, getMinMaxXYFromCurvePathOps, } from "./bounds"; +import { getHoveredElementForBinding } from "./collision"; import { headingIsHorizontal, vectorToHeading } from "./heading"; import { mutateElement } from "./mutateElement"; @@ -1890,6 +1892,44 @@ export class LinearElementEditor { : app.getEffectiveGridSize(); } + private static _shouldSkipExternalSnapForBindableTarget({ + appState, + elements, + elementsMap, + element, + pointIndex, + scenePoint, + selectedPointsIndices, + }: { + appState: AppState; + elements: readonly Ordered[]; + elementsMap: NonDeletedSceneElementsMap; + element: NonDeleted; + pointIndex: number; + scenePoint: GlobalPoint; + selectedPointsIndices?: readonly number[]; + }) { + if ( + isElbowArrow(element) || + !isBindingElement(element) || + !isBindingEnabled(appState) || + selectedPointsIndices?.length !== 1 + ) { + return false; + } + + if (pointIndex !== 0 && pointIndex !== element.points.length - 1) { + return false; + } + + return !!getHoveredElementForBinding( + scenePoint, + elements, + elementsMap, + maxBindingDistance_simple(appState.zoom), + ); + } + private static _getSnappedPointForLinearElement({ app, event, @@ -1952,6 +1992,16 @@ export class LinearElementEditor { const effectiveGridY = referencePointCoords[1] + dyFromReference; let snapLines: SnapLine[] = []; + const shouldSkipExternalSnap = + LinearElementEditor._shouldSkipExternalSnapForBindableTarget({ + appState: app.state, + elements, + elementsMap, + element, + pointIndex, + scenePoint: pointFrom(effectiveGridX, effectiveGridY), + selectedPointsIndices, + }); if (!isElbowArrow(element)) { const { snapOffset, snapLines: nextSnapLines } = snapLinearElementPoint( @@ -1962,6 +2012,7 @@ export class LinearElementEditor { event, elementsMap, { + includeExternalPoints: !shouldSkipExternalSnap, includeSelfPoints: true, selectedPointsIndices, }, @@ -2010,6 +2061,16 @@ export class LinearElementEditor { const originalPointerX = scenePointerX - pointerOffset.x; const originalPointerY = scenePointerY - pointerOffset.y; + const shouldSkipExternalSnap = + LinearElementEditor._shouldSkipExternalSnapForBindableTarget({ + appState: app.state, + elements, + elementsMap, + element, + pointIndex, + scenePoint: pointFrom(originalPointerX, originalPointerY), + selectedPointsIndices, + }); const { snapOffset, snapLines } = snapLinearElementPoint( elements, @@ -2019,6 +2080,7 @@ export class LinearElementEditor { event, elementsMap, { + includeExternalPoints: !shouldSkipExternalSnap, includeSelfPoints: true, selectedPointsIndices, }, diff --git a/packages/element/src/snapping.ts b/packages/element/src/snapping.ts index fe99ab456b..efd0154d3a 100644 --- a/packages/element/src/snapping.ts +++ b/packages/element/src/snapping.ts @@ -804,6 +804,7 @@ export const snapLinearElementPoint = ( event: KeyboardModifiersObject, elementsMap: ElementsMap, options: { + includeExternalPoints?: boolean; includeSelfPoints?: boolean; selectedPointsIndices?: readonly number[]; } = {}, @@ -827,18 +828,20 @@ export const snapLinearElementPoint = ( const nearestSnapsX: Snaps = []; const nearestSnapsY: Snaps = []; - collectNearestAxisSnapCandidates( - getExternalAxisSnapTargets( - elements, - editingElement, - app.state, - elementsMap, - ), - pointerPosition, - nearestSnapsX, - nearestSnapsY, - minOffset, - ); + if (options.includeExternalPoints !== false) { + collectNearestAxisSnapCandidates( + getExternalAxisSnapTargets( + elements, + editingElement, + app.state, + elementsMap, + ), + pointerPosition, + nearestSnapsX, + nearestSnapsY, + minOffset, + ); + } if (options.includeSelfPoints) { collectNearestAxisSnapCandidates( diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 95feeb933c..71f98a87e3 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -313,6 +313,36 @@ describe("Test Linear Elements", () => { expect(endpoint).toEqual(pointFrom(100, 100)); }); + it("prefers binding over external snaps when creating an arrow endpoint", () => { + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 40, + height: 40, + }); + API.setElements([rect]); + API.setAppState({ objectsSnapModeEnabled: true }); + + UI.clickTool("arrow"); + + const startPoint = pointFrom(20, 20); + const pointerNearBindable = pointFrom(96, 118); + + dragMove(startPoint, pointerNearBindable); + + expect(h.state.suggestedBinding?.element.id).toBe(rect.id); + expect(h.state.snapLines).toEqual([]); + + dragEnd(pointerNearBindable); + + const arrow = h.elements.find( + (element): element is ExcalidrawLinearElement => element.type === "arrow", + ); + + expect(arrow?.endBinding?.elementId).toBe(rect.id); + }); + it("should enter line editor via enter (line)", () => { createTwoPointerLinearElement("line"); expect(h.state.selectedLinearElement?.isEditing).toBe(false); @@ -489,6 +519,44 @@ describe("Test Linear Elements", () => { ); }); + it("prefers binding over external snaps when dragging an existing arrow endpoint", () => { + const rect = API.createElement({ + type: "rectangle", + x: 100, + y: 100, + width: 40, + height: 40, + }); + const arrow = API.createElement({ + type: "arrow", + x: 20, + y: 20, + width: 40, + height: 0, + points: [pointFrom(0, 0), pointFrom(40, 0)], + }); + + API.setElements([rect, arrow]); + API.setAppState({ objectsSnapModeEnabled: true }); + enterLineEditingMode(arrow); + + const endPoint = LinearElementEditor.getPointGlobalCoordinates( + arrow, + arrow.points[arrow.points.length - 1], + h.app.scene.getNonDeletedElementsMap(), + ); + const pointerNearBindable = pointFrom(96, 118); + + dragMove(endPoint, pointerNearBindable); + + expect(h.state.suggestedBinding?.element.id).toBe(rect.id); + expect(h.state.snapLines).toEqual([]); + + dragEnd(pointerNearBindable); + + expect(API.getElement(arrow).endBinding?.elementId).toBe(rect.id); + }); + it("should update the midpoints when element roundness changed", async () => { createThreePointerLinearElement("line");