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 { import {
arrayToMap, arrayToMap,
getFeatureFlag, getFeatureFlag,
getGridPoint,
invariant, invariant,
isTransparent, isTransparent,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -22,7 +23,7 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { LineSegment, LocalPoint, Radians } 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 { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
@@ -154,6 +155,7 @@ export const bindOrUnbindBindingElement = (
altKey?: boolean; altKey?: boolean;
angleLocked?: boolean; angleLocked?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
gridSize?: NullableGridSize;
}, },
) => { ) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
@@ -198,6 +200,8 @@ export const bindOrUnbindBindingElement = (
arrow.startBinding, arrow.startBinding,
start.element, start.element,
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
undefined,
opts?.gridSize,
) || arrow.points[0], ) || arrow.points[0],
}); });
} }
@@ -211,6 +215,8 @@ export const bindOrUnbindBindingElement = (
arrow.endBinding, arrow.endBinding,
end.element, end.element,
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
undefined,
opts?.gridSize,
) || arrow.points[arrow.points.length - 1], ) || arrow.points[arrow.points.length - 1],
}); });
} }
@@ -812,7 +818,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom, appState.zoom,
appState.isMidpointSnappingEnabled, appState.isMidpointSnappingEnabled &&
!opts?.angleLocked &&
!appState.gridModeEnabled,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -857,7 +865,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start", startDragged ? "end" : "start",
elementsMap, elementsMap,
appState.zoom, appState.zoom,
appState.isMidpointSnappingEnabled, false,
) || otherEndpoint, ) || otherEndpoint,
} }
: { mode: undefined } : { mode: undefined }
@@ -1744,6 +1752,54 @@ const extractBinding = (
const elementArea = (element: ExcalidrawBindableElement) => const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height; 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 = ( export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "startBinding" | "endBinding", startOrEnd: "startBinding" | "endBinding",
@@ -1751,6 +1807,7 @@ export const updateBoundPoint = (
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
dragging?: boolean, dragging?: boolean,
gridSize?: NullableGridSize,
): LocalPoint | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
@@ -1870,11 +1927,24 @@ export const updateBoundPoint = (
// and short-circuit to the focus point if the arrow is too short to // and short-circuit to the focus point if the arrow is too short to
// avoid inversion // avoid inversion
if (!otherBindable) { if (!otherBindable) {
const snapped =
!arrowTooShort && outlinePoint
? snapBoundPointToGrid(
outlinePoint,
bindableElement,
elementsMap,
gridSize ?? null,
)
: null;
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0], arrowTooShort
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1], ? focusPoint[0]
: snapped?.[0] ?? outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort
? focusPoint[1]
: snapped?.[1] ?? outlinePoint?.[1] ?? focusPoint[1],
null, null,
); );
} }
@@ -1893,11 +1963,19 @@ export const updateBoundPoint = (
} }
// 4. In the general case, snap to the outline if possible // 4. In the general case, snap to the outline if possible
const snappedOutline = outlinePoint
? snapBoundPointToGrid(
outlinePoint,
bindableElement,
elementsMap,
gridSize ?? null,
)
: null;
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
outlinePoint?.[0] || focusPoint[0], snappedOutline?.[0] ?? outlinePoint?.[0] ?? focusPoint[0],
outlinePoint?.[1] || focusPoint[1], snappedOutline?.[1] ?? outlinePoint?.[1] ?? focusPoint[1],
null, null,
); );
}; };
+32 -4
View File
@@ -344,6 +344,9 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const { positions, updates } = pointDraggingUpdates( const { positions, updates } = pointDraggingUpdates(
[idx], [idx],
deltaX, deltaX,
@@ -357,8 +360,10 @@ export class LinearElementEditor {
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
event.altKey, event.altKey,
linearElementEditor, linearElementEditor,
effectiveGridSize,
); );
const angleLocked = shouldRotateWithDiscreteAngle(event);
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
@@ -370,7 +375,10 @@ export class LinearElementEditor {
}, },
{ {
isBindingEnabled: app.state.isBindingEnabled, isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
}, },
); );
// Set the suggested binding from the updates if available // Set the suggested binding from the updates if available
@@ -427,7 +435,9 @@ export class LinearElementEditor {
"start", "start",
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
app.state.isMidpointSnappingEnabled, app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -533,6 +543,9 @@ export class LinearElementEditor {
// Apply the point movement if needed // Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null; let suggestedBinding: AppState["suggestedBinding"] = null;
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const { positions, updates } = pointDraggingUpdates( const { positions, updates } = pointDraggingUpdates(
selectedPointsIndices, selectedPointsIndices,
deltaX, deltaX,
@@ -546,8 +559,11 @@ export class LinearElementEditor {
shouldRotateWithDiscreteAngle(event) && singlePointDragged, shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey, event.altKey,
linearElementEditor, linearElementEditor,
effectiveGridSize,
); );
const angleLocked =
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
@@ -559,7 +575,10 @@ export class LinearElementEditor {
}, },
{ {
isBindingEnabled: app.state.isBindingEnabled, isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
}, },
); );
@@ -655,7 +674,9 @@ export class LinearElementEditor {
"start", "start",
elementsMap, elementsMap,
app.state.zoom, app.state.zoom,
app.state.isMidpointSnappingEnabled, app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -2116,6 +2137,7 @@ const pointDraggingUpdates = (
angleLocked: boolean, angleLocked: boolean,
altKey: boolean, altKey: boolean,
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
gridSize?: NullableGridSize,
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
@@ -2214,6 +2236,8 @@ const pointDraggingUpdates = (
element.startBinding, element.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
undefined,
gridSize,
) ?? null; ) ?? null;
if (startPoint) { if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true }); positions.set(0, { point: startPoint, isDragging: true });
@@ -2233,6 +2257,8 @@ const pointDraggingUpdates = (
element.endBinding, element.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
undefined,
gridSize,
) ?? null; ) ?? null;
if (endPoint) { if (endPoint) {
positions.set(element.points.length - 1, { positions.set(element.points.length - 1, {
@@ -2406,6 +2432,7 @@ const pointDraggingUpdates = (
endBindable, endBindable,
elementsMap, elementsMap,
endIsDragged, endIsDragged,
endIsDragged ? gridSize : null,
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2437,6 +2464,7 @@ const pointDraggingUpdates = (
startBindable, startBindable,
elementsMap, elementsMap,
startIsDragged, startIsDragged,
startIsDragged ? gridSize : null,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
+37 -12
View File
@@ -17,6 +17,7 @@ import {
import { import {
KEYS, KEYS,
arrayToMap, arrayToMap,
getGridPoint,
invariant, invariant,
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
updateActiveTool, updateActiveTool,
@@ -27,7 +28,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -93,32 +94,56 @@ export const actionFinalize = register<FormData>({
? [element.points.length - 1] // New arrow creation ? [element.points.length - 1] // New arrow creation
: appState.selectedLinearElement.selectedPointsIndices; : appState.selectedLinearElement.selectedPointsIndices;
const angleLocked = shouldRotateWithDiscreteAngle(event);
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const draggedPoints: PointsPositionUpdates = const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => { selectedPointsIndices.reduce((map, index) => {
map.set(index, { map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords( point: angleLocked
element, ? element.points[index]
pointFrom<GlobalPoint>( : LinearElementEditor.createPointAt(
sceneCoords.x - linearElementEditor.pointerOffset.x, element,
sceneCoords.y - linearElementEditor.pointerOffset.y, elementsMap,
), sceneCoords.x - linearElementEditor.pointerOffset.x,
elementsMap, sceneCoords.y - linearElementEditor.pointerOffset.y,
), effectiveGridSize,
),
}); });
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
const startIsDragged = selectedPointsIndices.includes(0);
const lockedGlobal = angleLocked
? LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startIsDragged ? 0 : -1,
elementsMap,
)
: null;
const [gridSnappedX, gridSnappedY] = getGridPoint(
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
effectiveGridSize,
);
const bindingSceneX = lockedGlobal ? lockedGlobal[0] : gridSnappedX;
const bindingSceneY = lockedGlobal ? lockedGlobal[1] : gridSnappedY;
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x, bindingSceneX,
sceneCoords.y - linearElementEditor.pointerOffset.y, bindingSceneY,
scene, scene,
appState, appState,
{ {
newArrow, newArrow,
altKey: event.altKey, altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event), angleLocked,
gridSize: effectiveGridSize,
}, },
); );
} else if (isLineElement(element)) { } else if (isLineElement(element)) {
@@ -251,6 +251,7 @@ const getRelevantAppStateProps = (
newElement: appState.newElement, newElement: appState.newElement,
isBindingEnabled: appState.isBindingEnabled, isBindingEnabled: appState.isBindingEnabled,
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled, isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
gridModeEnabled: appState.gridModeEnabled,
suggestedBinding: appState.suggestedBinding, suggestedBinding: appState.suggestedBinding,
isRotating: appState.isRotating, isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight, elementsToHighlight: appState.elementsToHighlight,
@@ -17,6 +17,7 @@ import {
FRAME_STYLE, FRAME_STYLE,
getFeatureFlag, getFeatureFlag,
invariant, invariant,
shouldRotateWithDiscreteAngle,
THEME, THEME,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -222,6 +223,7 @@ const renderBindingHighlightForBindableElement_simple = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
pointerCoords: GlobalPoint | null, pointerCoords: GlobalPoint | null,
angleLocked = false,
) => { ) => {
const enclosingFrame = const enclosingFrame =
suggestedBinding.element.frameId && suggestedBinding.element.frameId &&
@@ -408,6 +410,8 @@ const renderBindingHighlightForBindableElement_simple = (
if ( if (
appState.isMidpointSnappingEnabled && appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
!angleLocked &&
(isFrameLikeElement(suggestedBinding.element) || (isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element)) isBindableElement(suggestedBinding.element))
) { ) {
@@ -800,7 +804,12 @@ const renderBindingHighlightForBindableElement_complex = (
context.restore(); context.restore();
if (appState.isMidpointSnappingEnabled) { if (
appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
(!app.lastPointerMoveEvent ||
!shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent))
) {
// Draw midpoint indicators // Draw midpoint indicators
context.save(); context.save();
context.translate( context.translate(
@@ -913,12 +922,16 @@ const renderBindingHighlightForBindableElement = (
app.lastPointerMoveCoords.y, app.lastPointerMoveCoords.y,
) )
: null; : null;
const angleLocked =
!!app.lastPointerMoveEvent &&
shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent);
renderBindingHighlightForBindableElement_simple( renderBindingHighlightForBindableElement_simple(
context, context,
suggestedBinding, suggestedBinding,
allElementsMap, allElementsMap,
appState, appState,
pointerCoords, pointerCoords,
angleLocked,
); );
context.restore(); context.restore();
}; };
+2
View File
@@ -223,6 +223,7 @@ export type InteractiveCanvasAppState = Readonly<
newElement: AppState["newElement"]; newElement: AppState["newElement"];
isBindingEnabled: AppState["isBindingEnabled"]; isBindingEnabled: AppState["isBindingEnabled"];
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"]; isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
gridModeEnabled: AppState["gridModeEnabled"];
suggestedBinding: AppState["suggestedBinding"]; suggestedBinding: AppState["suggestedBinding"];
isRotating: AppState["isRotating"]; isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"]; elementsToHighlight: AppState["elementsToHighlight"];
@@ -824,6 +825,7 @@ export type AppClassProperties = {
onStateChange: App["onStateChange"]; onStateChange: App["onStateChange"];
lastPointerMoveCoords: App["lastPointerMoveCoords"]; lastPointerMoveCoords: App["lastPointerMoveCoords"];
lastPointerMoveEvent: App["lastPointerMoveEvent"];
bindModeHandler: App["bindModeHandler"]; bindModeHandler: App["bindModeHandler"];
setAppState: App["setAppState"]; setAppState: App["setAppState"];