feat: Overlap & distance based binding candidate selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-02-23 11:11:14 +01:00
parent 47c2fa9a39
commit 0598f945f6
4 changed files with 79 additions and 85 deletions
+45 -53
View File
@@ -291,54 +291,6 @@ const borderDistance = (
return distance > tolerance ? -Infinity : -distance;
};
// const bindingBorderTest = (
// element: NonDeleted<ExcalidrawBindableElement>,
// [x, y]: Readonly<GlobalPoint>,
// elementsMap: NonDeletedSceneElementsMap,
// tolerance: number = 0,
// ): boolean => {
// const p = pointFrom<GlobalPoint>(x, y);
// const shouldTestInside =
// // disable fullshape snapping for frame elements so we
// // can bind to frame children
// !isFrameLikeElement(element);
// // PERF: Run a cheap test to see if the binding element
// // is even close to the element
// const t = Math.max(1, tolerance);
// const bounds = [x - t, y - t, x + t, y + t] as Bounds;
// const elementBounds = getElementBounds(element, elementsMap);
// if (!doBoundsIntersect(bounds, elementBounds)) {
// return false;
// }
// // If the element is inside a frame, we should clip the element
// if (element.frameId) {
// const enclosingFrame = elementsMap.get(element.frameId);
// if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
// const enclosingFrameBounds = getElementBounds(
// enclosingFrame,
// elementsMap,
// );
// if (!pointInsideBounds(p, enclosingFrameBounds)) {
// return false;
// }
// }
// }
// // Do the intersection test against the element since it's close enough
// const intersections = intersectElementWithLineSegment(
// element,
// elementsMap,
// lineSegment(elementCenterPoint(element, elementsMap), p),
// );
// const distance = distanceToElement(element, elementsMap, p);
// return shouldTestInside
// ? intersections.length === 0 || distance <= tolerance
// : intersections.length > 0 && distance <= t;
// };
export const getAllHoveredElementAtPoint = (
arrow: { elbowed: boolean },
point: Readonly<GlobalPoint>,
@@ -347,7 +299,7 @@ export const getAllHoveredElementAtPoint = (
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// We need to do hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
@@ -400,7 +352,12 @@ export const getHoveredElementForBinding = (
continue;
}
const distance = borderDistance(element, point, elementsMap, tolerance);
const distance = borderDistance(
element,
point,
elementsMap,
tolerance ?? 0,
);
const bindingGap = getBindingGap(element, arrow);
if (distance > -(tolerance ?? bindingGap)) {
@@ -416,9 +373,44 @@ export const getHoveredElementForBinding = (
return candidateElements[0].element;
}
return candidateElements
.sort((a, b) => a.distance - b.distance)
.map((c) => c.element)[0];
const furthestDistance = candidateElements.sort(
(a, b) => a.distance - b.distance,
);
const candidate = furthestDistance[furthestDistance.length - 1].element;
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate, elementsMap);
const candidateArea = Math.max(
0.00001,
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
);
const overlaps = furthestDistance
.map((c) => {
if (c.element === candidate) {
return { ...c, overlapPercent: 0, relativeArea: 1 };
}
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
const overlapX1 = x1 > cx1 && x1 < cx2 ? x1 : cx1;
const overlapY1 = y1 > cy1 && y1 < cy2 ? y1 : cy1;
const overlapX2 = x2 < cx2 && x2 > cx1 ? x2 : cx2;
const overlapY2 = y2 < cy2 && y2 > cy1 ? y2 : cy2;
const overlapWdith =
overlapX1 !== cx1 || overlapX2 !== cx2 ? overlapX2 - overlapX1 : 0;
const overlapHeight =
overlapY1 !== cy1 || overlapY2 !== cy2 ? overlapY2 - overlapY1 : 0;
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
const overlapPercent = Math.abs(overlapHeight * overlapWdith) / area;
return {
...c,
overlapPercent,
relativeArea:
overlapPercent === 0 ? 1 : Math.min(area / candidateArea, 1),
};
})
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
return overlaps.length > 0 ? overlaps[0].element : candidate;
};
/**
+30 -22
View File
@@ -344,7 +344,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates(
const { positions, updates, hit } = pointDraggingUpdates(
[idx],
deltaX,
deltaY,
@@ -382,17 +382,20 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element)) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[element.points.length - 1],
elementsMap,
),
element,
element.points[element.points.length - 1],
elements,
elementsMap,
),
element,
elements,
elementsMap,
app.scene,
);
app.scene,
hit,
);
}
}
// PERF: Avoid state updates if not absolutely necessary
@@ -539,7 +542,7 @@ export class LinearElementEditor {
// Apply the point movement if needed
let suggestedBinding: AppState["suggestedBinding"] = null;
const { positions, updates } = pointDraggingUpdates(
const { positions, updates, hit } = pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
@@ -578,19 +581,22 @@ export class LinearElementEditor {
// Move the arrow over the bindable object in terms of z-index
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
if (hit) {
moveArrowAboveBindable(
LinearElementEditor.getPointGlobalCoordinates(
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elementsMap,
),
element,
startIsSelected
? element.points[0]
: element.points[element.points.length - 1],
elements,
elementsMap,
),
element,
elements,
elementsMap,
app.scene,
);
app.scene,
hit,
);
}
}
// Attached text might need to update if arrow dimensions change
@@ -2136,6 +2142,7 @@ const pointDraggingUpdates = (
): {
positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates;
hit?: ExcalidrawBindableElement | null;
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
@@ -2497,6 +2504,7 @@ const pointDraggingUpdates = (
];
}),
),
hit: startIsDragged ? start.element : end.element,
};
};
+1 -6
View File
@@ -8,7 +8,6 @@ import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene";
import type {
@@ -156,12 +155,8 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hit?: NonDeletedExcalidrawElement,
hoveredElement: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(arrow, point, elements, elementsMap);
if (!hoveredElement) {
return elements;
}
+3 -4
View File
@@ -9887,17 +9887,16 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (isBindingElement(element)) {
if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
const hoveredElement = getHoveredElementForBinding(
element,
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(this.state.zoom),
);
if (getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleDelayedBindModeChange(element, hoveredElement);
}
this.handleDelayedBindModeChange(element, hoveredElement);
}
if (