Compare commits

...

10 Commits

Author SHA1 Message Date
Mark Tolmacs a7a3f9d82b Merge branch 'master' into mtolmacs/fix/grid-binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 15:54:48 +00:00
Mark Tolmacs 895c2b23c7 fix: Elbow midpoint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 19:46:56 +00:00
Mark Tolmacs 50099012c6 fix Suggested binding flicker
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 16:47:43 +00:00
Mark Tolmacs de2ad7cd3f fix: Inside binding grid respect
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 14:55:56 +00:00
Mark Tolmacs d7abb6a309 fix: Diamonds and ellipses
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 20:25:19 +00:00
Mark Tolmacs 9fd91d9a59 fix: False binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 16:29:09 +00:00
Mark Tolmacs ba087233cb fix: Grid is secondary to snap distance
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 12:04:23 +00:00
Mark Tolmacs 7c58d1f6f4 chore: Remove more non-needed grid snapping
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 20:28:29 +00:00
Mark Tolmacs d9ab298526 fix: Remove duplicated grid snapping
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 20:21:45 +00:00
Mark Tolmacs 7b2496bfd7 fix: Dragged arrow endpoint ignore grid and angle locks
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 19:59:33 +00:00
8 changed files with 191 additions and 30 deletions
+5 -4
View File
@@ -1,4 +1,5 @@
import { import {
pointFrom,
pointFromPair, pointFromPair,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
@@ -69,12 +70,12 @@ export const getGridPoint = (
x: number, x: number,
y: number, y: number,
gridSize: NullableGridSize, gridSize: NullableGridSize,
): [number, number] => { ): GlobalPoint => {
if (gridSize) { if (gridSize) {
return [ return pointFrom<GlobalPoint>(
Math.round(x / gridSize) * gridSize, Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize, Math.round(y / gridSize) * gridSize,
]; );
} }
return [x, y]; return pointFrom<GlobalPoint>(x, y);
}; };
+126 -6
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(
@@ -170,12 +172,16 @@ export const bindOrUnbindBindingElement = (
}, },
); );
const isMidpointSnappingEnabled =
appState.isMidpointSnappingEnabled && !appState.gridModeEnabled;
bindOrUnbindBindingElementEdge( bindOrUnbindBindingElementEdge(
arrow, arrow,
start, start,
"start", "start",
scene, scene,
appState.isBindingEnabled, appState.isBindingEnabled,
isMidpointSnappingEnabled,
); );
bindOrUnbindBindingElementEdge( bindOrUnbindBindingElementEdge(
arrow, arrow,
@@ -183,6 +189,7 @@ export const bindOrUnbindBindingElement = (
"end", "end",
scene, scene,
appState.isBindingEnabled, appState.isBindingEnabled,
isMidpointSnappingEnabled,
); );
if (start.focusPoint || end.focusPoint) { if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then // If the strategy dictates a focus point override, then
@@ -227,6 +234,7 @@ const bindOrUnbindBindingElementEdge = (
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene, scene: Scene,
shouldSnapToOutline = true, shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => { ): void => {
if (mode === null) { if (mode === null) {
// null means break the binding // null means break the binding
@@ -240,6 +248,7 @@ const bindOrUnbindBindingElementEdge = (
scene, scene,
focusPoint, focusPoint,
shouldSnapToOutline, shouldSnapToOutline,
isMidpointSnappingEnabled,
); );
} }
}; };
@@ -593,6 +602,7 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
finalize?: boolean; finalize?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
}, },
): { start: BindingStrategy; end: BindingStrategy } => { ): { start: BindingStrategy; end: BindingStrategy } => {
if (getFeatureFlag("COMPLEX_BINDINGS")) { if (getFeatureFlag("COMPLEX_BINDINGS")) {
@@ -633,6 +643,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
finalize?: boolean; finalize?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
}, },
): { start: BindingStrategy; end: BindingStrategy } => { ): { start: BindingStrategy; end: BindingStrategy } => {
const startIdx = 0; const startIdx = 0;
@@ -695,7 +706,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap, elementsMap,
); );
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
globalPoint, opts?.angleLocked || appState.gridModeEnabled
? pointFrom<GlobalPoint>(scenePointerX, scenePointerY)
: globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(appState.zoom), maxBindingDistance_simple(appState.zoom),
@@ -747,7 +760,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
? globalPoint ? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point : // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow opts?.newArrow
? appState.selectedLinearElement!.initialState.origin! ? getGridPoint(
appState.selectedLinearElement!.initialState.origin![0],
appState.selectedLinearElement!.initialState.origin![1],
opts.gridSize as NullableGridSize,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates( : LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
0, 0,
@@ -806,12 +823,27 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
focusPoint: focusPoint:
projectFixedPointOntoDiagonal( projectFixedPointOntoDiagonal(
arrow, arrow,
globalPoint, opts?.angleLocked || appState.gridModeEnabled
? snapBoundPointToGrid(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
elementsMap,
appState.gridSize as NullableGridSize,
arrow,
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startDragged ? 1 : -2,
elementsMap,
),
)
: globalPoint,
hit, hit,
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 };
@@ -856,7 +888,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 }
@@ -1021,6 +1053,7 @@ export const bindBindingElement = (
scene: Scene, scene: Scene,
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
shouldSnapToOutline = true, shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
@@ -1036,6 +1069,7 @@ export const bindBindingElement = (
startOrEnd, startOrEnd,
elementsMap, elementsMap,
shouldSnapToOutline, shouldSnapToOutline,
isMidpointSnappingEnabled,
), ),
}; };
} else { } else {
@@ -1740,6 +1774,92 @@ const extractBinding = (
}; };
}; };
/**
* 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,
arrowElement: ExcalidrawArrowElement,
adjacentPoint?: GlobalPoint,
): GlobalPoint => {
if (!gridSize) {
return outlinePoint;
}
const aabb = aabbForElement(bindableElement, elementsMap);
// For ellipses and diamonds use the arrow's incoming direction instead of
// the position-based heading, which can give the wrong axis when the
// outline point is near a cardinal zone or an angled diamond face.
const heading =
adjacentPoint &&
(bindableElement.type === "ellipse" || bindableElement.type === "diamond")
? vectorToHeading(vectorFromPoint(adjacentPoint, outlinePoint))
: headingForPointFromElement(bindableElement, aabb, outlinePoint);
const normalLocal = pointFrom<GlobalPoint>(heading[0], heading[1]);
const normalGlobal = pointRotateRads(
normalLocal,
pointFrom<GlobalPoint>(0, 0),
bindableElement.angle,
);
const bindingGap = getBindingGap(bindableElement, arrowElement);
const extent =
Math.max(bindableElement.width, bindableElement.height) + bindingGap * 2;
const center = getCenterForBounds(aabb);
const absNX = Math.abs(normalGlobal[0]);
const absNY = Math.abs(normalGlobal[1]);
if (absNX >= absNY) {
// Global X is closest to the perpendicular so snap Y, intersect horizontal line
const [, snappedY] = getGridPoint(
outlinePoint[0],
outlinePoint[1],
gridSize,
);
const intersector = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(center[0] - extent, snappedY),
pointFrom<GlobalPoint>(center[0] + extent, snappedY),
);
const intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
bindingGap,
).sort(
(a, b) =>
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
)[0];
return intersection ?? pointFrom<GlobalPoint>(outlinePoint[0], snappedY);
}
// Global Y is closest to the perpendicular so snap X, intersect vertical line
const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize);
const intersector = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(snappedX, center[1] - extent),
pointFrom<GlobalPoint>(snappedX, center[1] + extent),
);
const intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
bindingGap,
).sort(
(a, b) =>
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
)[0];
return intersection ?? pointFrom<GlobalPoint>(snappedX, outlinePoint[1]);
};
const elementArea = (element: ExcalidrawBindableElement) => const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height; element.width * element.height;
+18 -4
View File
@@ -359,6 +359,7 @@ export class LinearElementEditor {
linearElementEditor, linearElementEditor,
); );
const angleLocked = shouldRotateWithDiscreteAngle(event);
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
@@ -370,7 +371,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 +431,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,
}, },
@@ -554,6 +560,8 @@ export class LinearElementEditor {
linearElementEditor, linearElementEditor,
); );
const angleLocked =
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
app.scene, app.scene,
@@ -565,7 +573,10 @@ export class LinearElementEditor {
}, },
{ {
isBindingEnabled: app.state.isBindingEnabled, isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
}, },
); );
@@ -661,7 +672,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,
}, },
@@ -2176,6 +2189,7 @@ const pointDraggingUpdates = (
newArrow: !!app.state.newElement, newArrow: !!app.state.newElement,
angleLocked, angleLocked,
altKey, altKey,
gridSize: app.getEffectiveGridSize(),
}, },
); );
+20 -12
View File
@@ -27,7 +27,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 +93,40 @@ 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();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x, sceneCoords.x,
sceneCoords.y - linearElementEditor.pointerOffset.y, sceneCoords.y,
scene, scene,
appState, appState,
{ {
newArrow, newArrow,
altKey: event.altKey, altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event), angleLocked,
gridSize: app.getEffectiveGridSize(),
}, },
); );
} else if (isLineElement(element)) { } else if (isLineElement(element)) {
+5 -3
View File
@@ -7263,14 +7263,16 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (this.state.activeTool.type === "arrow") { // Set suggested binding if we're hovering with an arrow tool
// and not dragging out a new element
if (this.state.activeTool.type === "arrow" && !this.state.newElement) {
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const hit = getHoveredElementForBinding( const hit = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), scenePointer,
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), maxBindingDistance_simple(this.state.zoom),
); );
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
this.setState({ this.setState({
@@ -253,6 +253,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";
@@ -229,6 +230,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 &&
@@ -415,6 +417,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))
) { ) {
@@ -807,7 +811,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(
@@ -920,12 +929,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
@@ -224,6 +224,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"];
@@ -845,6 +846,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"];