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
15 changed files with 241 additions and 77 deletions
+5 -4
View File
@@ -1,4 +1,5 @@
import {
pointFrom,
pointFromPair,
type GlobalPoint,
type LocalPoint,
@@ -69,12 +70,12 @@ export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
): GlobalPoint => {
if (gridSize) {
return [
return pointFrom<GlobalPoint>(
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
);
}
return [x, y];
return pointFrom<GlobalPoint>(x, y);
};
+4 -4
View File
@@ -125,7 +125,7 @@ const focusPointUpdate = (
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "fixed",
mode: "inside",
};
} else {
currentBinding = {
@@ -166,7 +166,7 @@ const focusPointUpdate = (
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "fixed",
mode: "inside",
};
} else {
adjacentBinding = {
@@ -256,8 +256,8 @@ export const handleFocusPointDrag = (
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "fixed"
: !switchToInsideBinding && arrow[bindingField]?.mode === "fixed"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
+151 -28
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(
@@ -170,12 +172,16 @@ export const bindOrUnbindBindingElement = (
},
);
const isMidpointSnappingEnabled =
appState.isMidpointSnappingEnabled && !appState.gridModeEnabled;
bindOrUnbindBindingElementEdge(
arrow,
start,
"start",
scene,
appState.isBindingEnabled,
isMidpointSnappingEnabled,
);
bindOrUnbindBindingElementEdge(
arrow,
@@ -183,6 +189,7 @@ export const bindOrUnbindBindingElement = (
"end",
scene,
appState.isBindingEnabled,
isMidpointSnappingEnabled,
);
if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then
@@ -227,6 +234,7 @@ const bindOrUnbindBindingElementEdge = (
startOrEnd: "start" | "end",
scene: Scene,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => {
if (mode === null) {
// null means break the binding
@@ -240,6 +248,7 @@ const bindOrUnbindBindingElementEdge = (
scene,
focusPoint,
shouldSnapToOutline,
isMidpointSnappingEnabled,
);
}
};
@@ -328,7 +337,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
if (hit) {
start = {
element: hit,
mode: "fixed",
mode: "inside",
focusPoint: point,
};
} else {
@@ -353,13 +362,13 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
start: isMultiPoint
? { mode: undefined }
: {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: origin ?? center,
},
end: isMultiPoint
? { mode: "orbit", element: hit, focusPoint: point }
: { mode: "fixed", element: hit, focusPoint: point },
: { mode: "inside", element: hit, focusPoint: point },
};
}
@@ -379,7 +388,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
start: isMultiPoint
? { mode: undefined }
: {
mode: otherElement.id !== hit.id ? "orbit" : "fixed",
mode: otherElement.id !== hit.id ? "orbit" : "inside",
element: otherElement,
focusPoint: origin ?? pointFrom<GlobalPoint>(arrow.x, arrow.y),
},
@@ -402,7 +411,7 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
const otherIsInsideBinding =
!!appState.selectedLinearElement?.initialState.arrowStartIsInside;
const other: BindingStrategy = {
mode: otherIsInsideBinding ? "fixed" : "orbit",
mode: otherIsInsideBinding ? "inside" : "orbit",
element: otherElement,
focusPoint: shiftKey
? elementCenterPoint(otherElement, elementsMap)
@@ -416,9 +425,9 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
let current: BindingStrategy;
if (hit) {
const isInsideBinding =
globalBindMode === "fixed" || globalBindMode === "skip";
globalBindMode === "inside" || globalBindMode === "skip";
current = {
mode: isInsideBinding && !isNested ? "fixed" : "orbit",
mode: isInsideBinding && !isNested ? "inside" : "orbit",
element: hit,
focusPoint: isInsideBinding || isNested ? point : point,
};
@@ -436,10 +445,10 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
if (!arrow.startBinding) {
if (hit) {
const isInsideBinding =
globalBindMode === "fixed" || globalBindMode === "skip";
globalBindMode === "inside" || globalBindMode === "skip";
end = {
mode: isInsideBinding ? "fixed" : "orbit",
mode: isInsideBinding ? "inside" : "orbit",
element: hit,
focusPoint: point,
};
@@ -488,7 +497,7 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
// If the global bind mode is in free binding mode, just bind
// where the pointer is and keep the other end intact
if (globalBindMode === "fixed" || globalBindMode === "skip") {
if (globalBindMode === "inside" || globalBindMode === "skip") {
current = hit
? {
element:
@@ -496,7 +505,7 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
? hit
: oppositeElement,
focusPoint: point,
mode: "fixed",
mode: "inside",
}
: { mode: null };
other =
@@ -514,9 +523,12 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
}
// Already inside binding over the same hit element should remain inside bound
if (hit.id === currentBinding?.elementId && currentBinding.mode === "fixed") {
if (
hit.id === currentBinding?.elementId &&
currentBinding.mode === "inside"
) {
return {
current: { mode: "fixed", focusPoint: point, element: hit },
current: { mode: "inside", focusPoint: point, element: hit },
other,
};
}
@@ -535,7 +547,7 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
// The opposite binding is inside the same element
// eslint-disable-next-line no-else-return
else {
current = { element: hit, mode: "fixed", focusPoint: point };
current = { element: hit, mode: "inside", focusPoint: point };
return { current, other: isMultiPoint ? { mode: undefined } : other };
}
@@ -547,7 +559,7 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
if (isOverlapping && oppositeElement && !otherIsTransparent) {
current = {
element: oppositeElement,
mode: "fixed",
mode: "inside",
focusPoint: point,
};
} else {
@@ -590,6 +602,7 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
finalize?: boolean;
initialBinding?: boolean;
zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
},
): { start: BindingStrategy; end: BindingStrategy } => {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
@@ -630,6 +643,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
finalize?: boolean;
initialBinding?: boolean;
zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
},
): { start: BindingStrategy; end: BindingStrategy } => {
const startIdx = 0;
@@ -692,7 +706,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap,
);
const hit = getHoveredElementForBinding(
globalPoint,
opts?.angleLocked || appState.gridModeEnabled
? pointFrom<GlobalPoint>(scenePointerX, scenePointerY)
: globalPoint,
elements,
elementsMap,
maxBindingDistance_simple(appState.zoom),
@@ -738,13 +754,17 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
) {
return {
start: {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: startDragged
? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow
? appState.selectedLinearElement!.initialState.origin!
? getGridPoint(
appState.selectedLinearElement!.initialState.origin![0],
appState.selectedLinearElement!.initialState.origin![1],
opts.gridSize as NullableGridSize,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
0,
@@ -752,7 +772,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
), // startFixedPoint,
},
end: {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: endDragged
? globalPoint
@@ -771,7 +791,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
start: startDragged
? hit
? {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: globalPoint,
}
@@ -780,7 +800,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
end: endDragged
? hit
? {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: globalPoint,
}
@@ -793,7 +813,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const current: BindingStrategy = hit
? pointInElement
? {
mode: "fixed",
mode: "inside",
element: hit,
focusPoint: globalPoint,
}
@@ -803,12 +823,27 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
focusPoint:
projectFixedPointOntoDiagonal(
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,
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
appState.isMidpointSnappingEnabled &&
!opts?.angleLocked &&
!appState.gridModeEnabled,
) || globalPoint,
}
: { mode: null };
@@ -830,7 +865,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
});
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "fixed";
: otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
!otherFocusPointIsInElement &&
@@ -853,7 +888,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
false,
) || otherEndpoint,
}
: { mode: undefined }
@@ -1018,6 +1053,7 @@ export const bindBindingElement = (
scene: Scene,
focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
@@ -1033,6 +1069,7 @@ export const bindBindingElement = (
startOrEnd,
elementsMap,
shouldSnapToOutline,
isMidpointSnappingEnabled,
),
};
} else {
@@ -1737,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) =>
element.width * element.height;
@@ -1769,7 +1892,7 @@ export const updateBoundPoint = (
// 0. Short-circuit for inside binding as it doesn't require any
// calculations and is not affected by other bindings
if (binding.mode === "fixed") {
if (binding.mode === "inside") {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
+1 -1
View File
@@ -762,7 +762,7 @@ export const isPointInElement = (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "fixed" for a non-looping path
// There isn't any "inside" for a non-looping path
return false;
}
+23 -9
View File
@@ -359,6 +359,7 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked = shouldRotateWithDiscreteAngle(event);
LinearElementEditor.movePoints(
element,
app.scene,
@@ -370,7 +371,10 @@ export class LinearElementEditor {
},
{
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
@@ -427,7 +431,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -554,6 +560,8 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked =
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
LinearElementEditor.movePoints(
element,
app.scene,
@@ -565,7 +573,10 @@ export class LinearElementEditor {
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
},
);
@@ -661,7 +672,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -1083,7 +1096,7 @@ export class LinearElementEditor {
},
arrowStartIsInside:
!!app.state.newElement &&
(app.state.bindMode === "fixed" || app.state.bindMode === "skip"),
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
},
selectedPointsIndices: [element.points.length - 1],
@@ -1145,7 +1158,7 @@ export class LinearElementEditor {
},
arrowStartIsInside:
!!app.state.newElement &&
(app.state.bindMode === "fixed" || app.state.bindMode === "skip"),
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
altFocusPoint: null,
},
selectedPointsIndices: nextSelectedPointsIndices,
@@ -2176,6 +2189,7 @@ const pointDraggingUpdates = (
newArrow: !!app.state.newElement,
angleLocked,
altKey,
gridSize: app.getEffectiveGridSize(),
},
);
@@ -2412,7 +2426,7 @@ const pointDraggingUpdates = (
const endLocalPoint = startIsDraggingOverEndElement
? nextArrow.points[nextArrow.points.length - 1]
: endIsDraggingOverStartElement &&
app.state.bindMode !== "fixed" &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[0]
: endBindable
@@ -2443,7 +2457,7 @@ const pointDraggingUpdates = (
endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS")
? nextArrow.points[0]
: startIsDraggingOverEndElement &&
app.state.bindMode !== "fixed" &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
? endLocalPoint
: startBindable
@@ -2461,7 +2475,7 @@ const pointDraggingUpdates = (
!startIsDraggingOverEndElement &&
!(
endIsDraggingOverStartElement &&
app.state.bindMode !== "fixed" &&
app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
+1 -1
View File
@@ -279,7 +279,7 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number];
export type BindMode = "fixed" | "orbit" | "skip";
export type BindMode = "inside" | "orbit" | "skip";
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"];
+2 -2
View File
@@ -72,14 +72,14 @@ describe("binding for simple arrows", () => {
expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
expect(startBinding.mode).toBe("fixed");
expect(startBinding.mode).toBe("inside");
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
expect(endBinding.mode).toBe("fixed");
expect(endBinding.mode).toBe("inside");
// Move the bindable
mouse.downAt(100, 150);
+20 -12
View File
@@ -27,7 +27,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -93,32 +93,40 @@ export const actionFinalize = register<FormData>({
? [element.points.length - 1] // New arrow creation
: appState.selectedLinearElement.selectedPointsIndices;
const angleLocked = shouldRotateWithDiscreteAngle(event);
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => {
map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords(
element,
pointFrom<GlobalPoint>(
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap,
),
point: angleLocked
? element.points[index]
: LinearElementEditor.createPointAt(
element,
elementsMap,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
effectiveGridSize,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
element,
draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
sceneCoords.x,
sceneCoords.y,
scene,
appState,
{
newArrow,
altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event),
angleLocked,
gridSize: app.getEffectiveGridSize(),
},
);
} else if (isLineElement(element)) {
@@ -1940,7 +1940,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
bindBindingElement(
newElement,
startElement,
appState.bindMode === "fixed" ? "fixed" : "orbit",
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
);
@@ -1954,7 +1954,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
bindBindingElement(
newElement,
endElement,
appState.bindMode === "fixed" ? "fixed" : "orbit",
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
);
+10 -8
View File
@@ -1095,7 +1095,7 @@ class App extends React.Component<AppProps, AppState> {
);
this.setState({
bindMode: "fixed",
bindMode: "inside",
selectedLinearElement: {
...this.state.selectedLinearElement,
initialState: {
@@ -1176,16 +1176,16 @@ class App extends React.Component<AppProps, AppState> {
: null;
const isAlreadyInsideBindingToSameElement =
(otherBinding &&
arrow[otherBinding]?.mode === "fixed" &&
arrow[otherBinding]?.mode === "inside" &&
arrow[otherBinding]?.elementId === hoveredElement?.id) ||
(currentBinding &&
arrow[currentBinding]?.mode === "fixed" &&
arrow[currentBinding]?.mode === "inside" &&
hoveredElement?.id === arrow[currentBinding]?.elementId);
if (
currentBinding &&
otherBinding &&
arrow[currentBinding]?.mode === "fixed" &&
arrow[currentBinding]?.mode === "inside" &&
hoveredElement?.id !== arrow[currentBinding]?.elementId &&
arrow[otherBinding]?.elementId !== arrow[currentBinding]?.elementId
) {
@@ -1217,7 +1217,7 @@ class App extends React.Component<AppProps, AppState> {
}
// Clear the inside binding mode too
if (this.state.bindMode === "fixed") {
if (this.state.bindMode === "inside") {
flushSync(() => {
this.setState({
bindMode: "orbit",
@@ -7263,14 +7263,16 @@ class App extends React.Component<AppProps, AppState> {
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(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
scenePointer,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom),
);
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap();
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
this.setState({
@@ -253,6 +253,7 @@ const getRelevantAppStateProps = (
newElement: appState.newElement,
isBindingEnabled: appState.isBindingEnabled,
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
gridModeEnabled: appState.gridModeEnabled,
suggestedBinding: appState.suggestedBinding,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
+4 -4
View File
@@ -239,8 +239,8 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
if (binding.elementId) {
return {
elementId: binding.elementId,
mode: (binding.mode as string) === "inside" ? "fixed" : binding.mode,
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
mode: binding.mode,
fixedPoint: normalizeFixedPoint(binding.fixedPoint),
} as FixedPointBinding | null;
}
return null;
@@ -269,7 +269,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
elementsMap,
);
const mode = isPointInElement(p, boundElement, elementsMap)
? "fixed"
? "inside"
: "orbit";
const safeElement = {
...element,
@@ -289,7 +289,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
: null,
};
const focusPoint =
mode === "fixed"
mode === "inside"
? p
: projectFixedPointOntoDiagonal(
safeElement,
@@ -17,6 +17,7 @@ import {
FRAME_STYLE,
getFeatureFlag,
invariant,
shouldRotateWithDiscreteAngle,
THEME,
} from "@excalidraw/common";
@@ -229,6 +230,7 @@ const renderBindingHighlightForBindableElement_simple = (
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
pointerCoords: GlobalPoint | null,
angleLocked = false,
) => {
const enclosingFrame =
suggestedBinding.element.frameId &&
@@ -415,6 +417,8 @@ const renderBindingHighlightForBindableElement_simple = (
if (
appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
!angleLocked &&
(isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element))
) {
@@ -807,7 +811,12 @@ const renderBindingHighlightForBindableElement_complex = (
context.restore();
if (appState.isMidpointSnappingEnabled) {
if (
appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
(!app.lastPointerMoveEvent ||
!shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent))
) {
// Draw midpoint indicators
context.save();
context.translate(
@@ -920,12 +929,16 @@ const renderBindingHighlightForBindableElement = (
app.lastPointerMoveCoords.y,
)
: null;
const angleLocked =
!!app.lastPointerMoveEvent &&
shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent);
renderBindingHighlightForBindableElement_simple(
context,
suggestedBinding,
allElementsMap,
appState,
pointerCoords,
angleLocked,
);
context.restore();
};
@@ -857,7 +857,7 @@ describe("repairing bindings", () => {
endBinding: {
elementId: container.id,
fixedPoint: [0.5, 0.5],
mode: "fixed",
mode: "inside",
},
});
+2
View File
@@ -224,6 +224,7 @@ export type InteractiveCanvasAppState = Readonly<
newElement: AppState["newElement"];
isBindingEnabled: AppState["isBindingEnabled"];
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
gridModeEnabled: AppState["gridModeEnabled"];
suggestedBinding: AppState["suggestedBinding"];
isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"];
@@ -845,6 +846,7 @@ export type AppClassProperties = {
onStateChange: App["onStateChange"];
lastPointerMoveCoords: App["lastPointerMoveCoords"];
lastPointerMoveEvent: App["lastPointerMoveEvent"];
bindModeHandler: App["bindModeHandler"];
setAppState: App["setAppState"];