fix(line-snap): prefer bindable targets over external snaps

This commit is contained in:
Ryan Di
2026-04-28 21:16:32 +10:00
parent 8763bebb59
commit 8424d78254
3 changed files with 145 additions and 12 deletions
@@ -52,6 +52,7 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
maxBindingDistance_simple,
snapToMid, snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
@@ -60,6 +61,7 @@ import {
getElementPointsCoords, getElementPointsCoords,
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
} from "./bounds"; } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -1890,6 +1892,44 @@ export class LinearElementEditor {
: app.getEffectiveGridSize(); : app.getEffectiveGridSize();
} }
private static _shouldSkipExternalSnapForBindableTarget({
appState,
elements,
elementsMap,
element,
pointIndex,
scenePoint,
selectedPointsIndices,
}: {
appState: AppState;
elements: readonly Ordered<NonDeletedExcalidrawElement>[];
elementsMap: NonDeletedSceneElementsMap;
element: NonDeleted<ExcalidrawLinearElement>;
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({ private static _getSnappedPointForLinearElement({
app, app,
event, event,
@@ -1952,6 +1992,16 @@ export class LinearElementEditor {
const effectiveGridY = referencePointCoords[1] + dyFromReference; const effectiveGridY = referencePointCoords[1] + dyFromReference;
let snapLines: SnapLine[] = []; let snapLines: SnapLine[] = [];
const shouldSkipExternalSnap =
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
appState: app.state,
elements,
elementsMap,
element,
pointIndex,
scenePoint: pointFrom<GlobalPoint>(effectiveGridX, effectiveGridY),
selectedPointsIndices,
});
if (!isElbowArrow(element)) { if (!isElbowArrow(element)) {
const { snapOffset, snapLines: nextSnapLines } = snapLinearElementPoint( const { snapOffset, snapLines: nextSnapLines } = snapLinearElementPoint(
@@ -1962,6 +2012,7 @@ export class LinearElementEditor {
event, event,
elementsMap, elementsMap,
{ {
includeExternalPoints: !shouldSkipExternalSnap,
includeSelfPoints: true, includeSelfPoints: true,
selectedPointsIndices, selectedPointsIndices,
}, },
@@ -2010,6 +2061,16 @@ export class LinearElementEditor {
const originalPointerX = scenePointerX - pointerOffset.x; const originalPointerX = scenePointerX - pointerOffset.x;
const originalPointerY = scenePointerY - pointerOffset.y; const originalPointerY = scenePointerY - pointerOffset.y;
const shouldSkipExternalSnap =
LinearElementEditor._shouldSkipExternalSnapForBindableTarget({
appState: app.state,
elements,
elementsMap,
element,
pointIndex,
scenePoint: pointFrom<GlobalPoint>(originalPointerX, originalPointerY),
selectedPointsIndices,
});
const { snapOffset, snapLines } = snapLinearElementPoint( const { snapOffset, snapLines } = snapLinearElementPoint(
elements, elements,
@@ -2019,6 +2080,7 @@ export class LinearElementEditor {
event, event,
elementsMap, elementsMap,
{ {
includeExternalPoints: !shouldSkipExternalSnap,
includeSelfPoints: true, includeSelfPoints: true,
selectedPointsIndices, selectedPointsIndices,
}, },
+3
View File
@@ -804,6 +804,7 @@ export const snapLinearElementPoint = (
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: { options: {
includeExternalPoints?: boolean;
includeSelfPoints?: boolean; includeSelfPoints?: boolean;
selectedPointsIndices?: readonly number[]; selectedPointsIndices?: readonly number[];
} = {}, } = {},
@@ -827,6 +828,7 @@ export const snapLinearElementPoint = (
const nearestSnapsX: Snaps = []; const nearestSnapsX: Snaps = [];
const nearestSnapsY: Snaps = []; const nearestSnapsY: Snaps = [];
if (options.includeExternalPoints !== false) {
collectNearestAxisSnapCandidates( collectNearestAxisSnapCandidates(
getExternalAxisSnapTargets( getExternalAxisSnapTargets(
elements, elements,
@@ -839,6 +841,7 @@ export const snapLinearElementPoint = (
nearestSnapsY, nearestSnapsY,
minOffset, minOffset,
); );
}
if (options.includeSelfPoints) { if (options.includeSelfPoints) {
collectNearestAxisSnapCandidates( collectNearestAxisSnapCandidates(
@@ -313,6 +313,36 @@ describe("Test Linear Elements", () => {
expect(endpoint).toEqual(pointFrom<GlobalPoint>(100, 100)); expect(endpoint).toEqual(pointFrom<GlobalPoint>(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<GlobalPoint>(20, 20);
const pointerNearBindable = pointFrom<GlobalPoint>(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)", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false); 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<LocalPoint>(0, 0), pointFrom<LocalPoint>(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<GlobalPoint>(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 () => { it("should update the midpoints when element roundness changed", async () => {
createThreePointerLinearElement("line"); createThreePointerLinearElement("line");