revert binding gap increase for elbow arrows

This commit is contained in:
dwelle
2025-11-12 22:31:55 +01:00
parent 0441785552
commit 933cd5607a
4 changed files with 109 additions and 96 deletions

View File

@@ -103,11 +103,23 @@ export type BindingStrategy =
focusPoint?: undefined; focusPoint?: undefined;
}; };
/** excludes element strokeWidth */ /**
* gaps exclude element strokeWidth
*
* IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/
export const BASE_BINDING_GAP = 10; export const BASE_BINDING_GAP = 10;
export const BASE_BINDING_GAP_ELBOW = 5;
export const getBindingGap = (element: ExcalidrawBindableElement): number => export const getBindingGap = (
BASE_BINDING_GAP + element.strokeWidth / 2; bindTarget: ExcalidrawBindableElement,
opts: Pick<ExcalidrawArrowElement, "elbowed">,
): number => {
return (
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
bindTarget.strokeWidth / 2
);
};
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => { export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
const BASE_BINDING_DISTANCE = Math.max(BASE_BINDING_GAP, 15); const BASE_BINDING_DISTANCE = Math.max(BASE_BINDING_GAP, 15);
@@ -1183,7 +1195,7 @@ const getDistanceForBinding = (
}; };
export const bindPointToSnapToElementOutline = ( export const bindPointToSnapToElementOutline = (
linearElement: ExcalidrawArrowElement, arrowElement: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -1192,41 +1204,48 @@ export const bindPointToSnapToElementOutline = (
): GlobalPoint => { ): GlobalPoint => {
const aabb = aabbForElement(bindableElement, elementsMap); const aabb = aabbForElement(bindableElement, elementsMap);
const localPoint = const localPoint =
linearElement.points[ arrowElement.points[
startOrEnd === "start" ? 0 : linearElement.points.length - 1 startOrEnd === "start" ? 0 : arrowElement.points.length - 1
]; ];
const point = pointFrom<GlobalPoint>( const point = pointFrom<GlobalPoint>(
linearElement.x + localPoint[0], arrowElement.x + localPoint[0],
linearElement.y + localPoint[1], arrowElement.y + localPoint[1],
); );
if (linearElement.points.length < 2) { if (arrowElement.points.length < 2) {
// New arrow creation, so no snapping // New arrow creation, so no snapping
return point; return point;
} }
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, elementsMap, point) ? avoidRectangularCorner(arrowElement, bindableElement, elementsMap, point)
: point; : point;
const elbowed = isElbowArrow(linearElement); const elbowed = isElbowArrow(arrowElement);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
const adjacentPointIdx = const adjacentPointIdx =
startOrEnd === "start" ? 1 : linearElement.points.length - 2; startOrEnd === "start" ? 1 : arrowElement.points.length - 2;
const adjacentPoint = pointRotateRads( const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
linearElement.x + linearElement.points[adjacentPointIdx][0], arrowElement.x + arrowElement.points[adjacentPointIdx][0],
linearElement.y + linearElement.points[adjacentPointIdx][1], arrowElement.y + arrowElement.points[adjacentPointIdx][1],
), ),
center, center,
linearElement.angle ?? 0, arrowElement.angle ?? 0,
); );
const bindingGap = getBindingGap(bindableElement, arrowElement);
let intersection: GlobalPoint | null = null; let intersection: GlobalPoint | null = null;
if (elbowed) { if (elbowed) {
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint); const snapPoint = snapToMid(
arrowElement,
bindableElement,
elementsMap,
edgePoint,
);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : snapPoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : snapPoint[1], !isHorizontal ? center[1] : snapPoint[1],
@@ -1247,7 +1266,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement, bindableElement,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(bindableElement), bindingGap,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
if (!intersection) { if (!intersection) {
@@ -1269,7 +1288,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement, bindableElement,
elementsMap, elementsMap,
anotherIntersector, anotherIntersector,
BASE_BINDING_GAP, BASE_BINDING_GAP_ELBOW,
).sort(pointDistanceSq)[0]; ).sort(pointDistanceSq)[0];
} }
} else { } else {
@@ -1277,7 +1296,7 @@ export const bindPointToSnapToElementOutline = (
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) + pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) + Math.max(bindableElement.width, bindableElement.height) +
getBindingGap(bindableElement) * 2, bindingGap * 2,
); );
const intersector = const intersector =
customIntersector ?? customIntersector ??
@@ -1293,7 +1312,7 @@ export const bindPointToSnapToElementOutline = (
bindableElement, bindableElement,
elementsMap, elementsMap,
intersector, intersector,
getBindingGap(bindableElement), bindingGap,
).sort( ).sort(
(g, h) => (g, h) =>
pointDistanceSq(g, adjacentPoint) - pointDistanceSq(g, adjacentPoint) -
@@ -1313,95 +1332,90 @@ export const bindPointToSnapToElementOutline = (
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, arrowElement: ExcalidrawArrowElement,
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element, elementsMap); const center = elementCenterPoint(bindTarget, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(
p,
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -getBindingGap(element)) {
return pointRotateRads<GlobalPoint>(
pointFrom(element.x - getBindingGap(element), element.y),
center, center,
element.angle, -bindTarget.angle as Radians,
);
const bindingGap = getBindingGap(bindTarget, arrowElement);
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
// Top left
if (nonRotatedPoint[1] - bindTarget.y > -bindingGap) {
return pointRotateRads<GlobalPoint>(
pointFrom(bindTarget.x - bindingGap, bindTarget.y),
center,
bindTarget.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x, element.y - getBindingGap(element)), pointFrom(bindTarget.x, bindTarget.y - bindingGap),
center, center,
element.angle, bindTarget.angle,
); );
} else if ( } else if (
nonRotatedPoint[0] < element.x && nonRotatedPoint[0] < bindTarget.x &&
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > bindTarget.y + bindTarget.height
) { ) {
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -getBindingGap(element)) { if (nonRotatedPoint[0] - bindTarget.x > -bindingGap) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(bindTarget.x, bindTarget.y + bindTarget.height + bindingGap),
element.x,
element.y + element.height + getBindingGap(element),
),
center, center,
element.angle, bindTarget.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x - getBindingGap(element), element.y + element.height), pointFrom(bindTarget.x - bindingGap, bindTarget.y + bindTarget.height),
center, center,
element.angle, bindTarget.angle,
); );
} else if ( } else if (
nonRotatedPoint[0] > element.x + element.width && nonRotatedPoint[0] > bindTarget.x + bindTarget.width &&
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > bindTarget.y + bindTarget.height
) { ) {
// Bottom right // Bottom right
if ( if (nonRotatedPoint[0] - bindTarget.x < bindTarget.width + bindingGap) {
nonRotatedPoint[0] - element.x <
element.width + getBindingGap(element)
) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
element.x + element.width, bindTarget.x + bindTarget.width,
element.y + element.height + getBindingGap(element), bindTarget.y + bindTarget.height + bindingGap,
), ),
center, center,
element.angle, bindTarget.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
element.x + element.width + getBindingGap(element), bindTarget.x + bindTarget.width + bindingGap,
element.y + element.height, bindTarget.y + bindTarget.height,
), ),
center, center,
element.angle, bindTarget.angle,
); );
} else if ( } else if (
nonRotatedPoint[0] > element.x + element.width && nonRotatedPoint[0] > bindTarget.x + bindTarget.width &&
nonRotatedPoint[1] < element.y nonRotatedPoint[1] < bindTarget.y
) { ) {
// Top right // Top right
if ( if (nonRotatedPoint[0] - bindTarget.x < bindTarget.width + bindingGap) {
nonRotatedPoint[0] - element.x <
element.width + getBindingGap(element)
) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(bindTarget.x + bindTarget.width, bindTarget.y - bindingGap),
element.x + element.width,
element.y - getBindingGap(element),
),
center, center,
element.angle, bindTarget.angle,
); );
} }
return pointRotateRads( return pointRotateRads(
pointFrom(element.x + element.width + getBindingGap(element), element.y), pointFrom(bindTarget.x + bindTarget.width + bindingGap, bindTarget.y),
center, center,
element.angle, bindTarget.angle,
); );
} }
@@ -1409,22 +1423,25 @@ export const avoidRectangularCorner = (
}; };
const snapToMid = ( const snapToMid = (
element: ExcalidrawBindableElement, arrowElement: ExcalidrawArrowElement,
bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1); const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = getBindingGap(bindTarget, arrowElement);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThreshold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThreshold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
// Too close to the center makes it hard to resolve direction precisely // Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < getBindingGap(element)) { if (pointDistance(center, nonRotated) < bindingGap) {
return p; return p;
} }
@@ -1435,7 +1452,7 @@ const snapToMid = (
) { ) {
// LEFT // LEFT
return pointRotateRads<GlobalPoint>( return pointRotateRads<GlobalPoint>(
pointFrom(x - getBindingGap(element), center[1]), pointFrom(x - bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1445,11 +1462,7 @@ const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
pointFrom(center[0], y - getBindingGap(element)),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
@@ -1457,7 +1470,7 @@ const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom(x + width + getBindingGap(element), center[1]), pointFrom(x + width + bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1468,12 +1481,12 @@ const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom(center[0], y + height + getBindingGap(element)), pointFrom(center[0], y + height + bindingGap),
center, center,
angle, angle,
); );
} else if (element.type === "diamond") { } else if (bindTarget.type === "diamond") {
const distance = getBindingGap(element); const distance = bindingGap;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance, x + width / 4 - distance,
y + height / 4 - distance, y + height / 4 - distance,

View File

@@ -30,7 +30,7 @@ import {
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getBindingGap, getBindingGap,
maxBindingDistance_simple, maxBindingDistance_simple,
BASE_BINDING_GAP, BASE_BINDING_GAP_ELBOW,
} from "./binding"; } from "./binding";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
@@ -1304,8 +1304,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
? getBindingGap(hoveredStartElement) * 6 ? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
: getBindingGap(hoveredStartElement) * 2, : getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
1, 1,
), ),
) )
@@ -1317,8 +1317,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
? getBindingGap(hoveredEndElement) * 6 ? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
: getBindingGap(hoveredEndElement) * 2, : getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
1, 1,
), ),
) )
@@ -1365,8 +1365,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? BASE_BINDING_GAP * 6 ? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP * 2), : BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@@ -1381,8 +1381,8 @@ const getElbowArrowData = (
? 0 ? 0
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? BASE_BINDING_GAP * 6 ? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP * 2), : BASE_BINDING_GAP_ELBOW * 2),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,

View File

@@ -295,11 +295,11 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0], [0, 0],
[31, 0], [36, 0],
[31, 90], [36, 90],
[23, 90], [28, 90],
[23, 161], [28, 164],
[92, 161], [101, 164],
]); ]);
}); });

View File

@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.115); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.115); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.115); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.115); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });