fix: Dragged arrow endpoint ignore grid and angle locks

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-03-17 19:59:33 +00:00
parent 2b0e4c9623
commit 7b2496bfd7
6 changed files with 171 additions and 24 deletions
+85 -7
View File
@@ -1,6 +1,7 @@
import {
arrayToMap,
getFeatureFlag,
getGridPoint,
invariant,
isTransparent,
} from "@excalidraw/common";
@@ -22,7 +23,7 @@ import {
} from "@excalidraw/math";
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common";
@@ -154,6 +155,7 @@ export const bindOrUnbindBindingElement = (
altKey?: boolean;
angleLocked?: boolean;
initialBinding?: boolean;
gridSize?: NullableGridSize;
},
) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
@@ -198,6 +200,8 @@ export const bindOrUnbindBindingElement = (
arrow.startBinding,
start.element,
scene.getNonDeletedElementsMap(),
undefined,
opts?.gridSize,
) || arrow.points[0],
});
}
@@ -211,6 +215,8 @@ export const bindOrUnbindBindingElement = (
arrow.endBinding,
end.element,
scene.getNonDeletedElementsMap(),
undefined,
opts?.gridSize,
) || arrow.points[arrow.points.length - 1],
});
}
@@ -812,7 +818,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
appState.isMidpointSnappingEnabled &&
!opts?.angleLocked &&
!appState.gridModeEnabled,
) || globalPoint,
}
: { mode: null };
@@ -857,7 +865,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
false,
) || otherEndpoint,
}
: { mode: undefined }
@@ -1744,6 +1752,54 @@ const extractBinding = (
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
/**
* Snaps a bound arrow endpoint to the grid on the axis parallel to the
* bindable element's side, while preserving the binding gap distance on the
* perpendicular axis. In other words, the grid axis closest to the side's
* perpendicular (normal) is used as the snap axis and the other axis is kept at
* the binding gap distance.
*/
const snapBoundPointToGrid = (
outlinePoint: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
gridSize: NullableGridSize,
): GlobalPoint => {
if (!gridSize) {
return outlinePoint;
}
const aabb = aabbForElement(bindableElement, elementsMap);
const heading = headingForPointFromElement(
bindableElement,
aabb,
outlinePoint,
);
const normalLocal = pointFrom<GlobalPoint>(heading[0], heading[1]);
const normalGlobal = pointRotateRads(
normalLocal,
pointFrom<GlobalPoint>(0, 0),
bindableElement.angle,
);
const absNX = Math.abs(normalGlobal[0]);
const absNY = Math.abs(normalGlobal[1]);
if (absNX >= absNY) {
// Global X is closest to the perpendicular → keep X, snap Y
const [, snappedY] = getGridPoint(
outlinePoint[0],
outlinePoint[1],
gridSize,
);
return pointFrom<GlobalPoint>(outlinePoint[0], snappedY);
}
// Global Y is closest to the perpendicular → keep Y, snap X
const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize);
return pointFrom<GlobalPoint>(snappedX, outlinePoint[1]);
};
export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "startBinding" | "endBinding",
@@ -1751,6 +1807,7 @@ export const updateBoundPoint = (
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
dragging?: boolean,
gridSize?: NullableGridSize,
): LocalPoint | null => {
if (
binding == null ||
@@ -1870,11 +1927,24 @@ export const updateBoundPoint = (
// and short-circuit to the focus point if the arrow is too short to
// avoid inversion
if (!otherBindable) {
const snapped =
!arrowTooShort && outlinePoint
? snapBoundPointToGrid(
outlinePoint,
bindableElement,
elementsMap,
gridSize ?? null,
)
: null;
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
arrowTooShort
? focusPoint[0]
: snapped?.[0] ?? outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort
? focusPoint[1]
: snapped?.[1] ?? outlinePoint?.[1] ?? focusPoint[1],
null,
);
}
@@ -1893,11 +1963,19 @@ export const updateBoundPoint = (
}
// 4. In the general case, snap to the outline if possible
const snappedOutline = outlinePoint
? snapBoundPointToGrid(
outlinePoint,
bindableElement,
elementsMap,
gridSize ?? null,
)
: null;
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
outlinePoint?.[0] || focusPoint[0],
outlinePoint?.[1] || focusPoint[1],
snappedOutline?.[0] ?? outlinePoint?.[0] ?? focusPoint[0],
snappedOutline?.[1] ?? outlinePoint?.[1] ?? focusPoint[1],
null,
);
};