From 0598f945f642671d60cf90c36bdc93a9d97c3a83 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Mon, 23 Feb 2026 11:11:14 +0100 Subject: [PATCH] feat: Overlap & distance based binding candidate selection Signed-off-by: Mark Tolmacs --- packages/element/src/collision.ts | 98 ++++++++++----------- packages/element/src/linearElementEditor.ts | 52 ++++++----- packages/element/src/zindex.ts | 7 +- packages/excalidraw/components/App.tsx | 7 +- 4 files changed, 79 insertions(+), 85 deletions(-) diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 8289c5adbc..992ef91551 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -291,54 +291,6 @@ const borderDistance = ( return distance > tolerance ? -Infinity : -distance; }; -// const bindingBorderTest = ( -// element: NonDeleted, -// [x, y]: Readonly, -// elementsMap: NonDeletedSceneElementsMap, -// tolerance: number = 0, -// ): boolean => { -// const p = pointFrom(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, @@ -347,7 +299,7 @@ export const getAllHoveredElementAtPoint = ( tolerance?: number, ): NonDeleted[] => { const candidateElements: NonDeleted[] = []; - // 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; }; /** diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index d2f80a50b3..d4b03a8840 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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, }; }; diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index 3eda1eec90..2c23fb01b3 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -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[], elementsMap: NonDeletedSceneElementsMap, scene: Scene, - hit?: NonDeletedExcalidrawElement, + hoveredElement: NonDeletedExcalidrawElement, ): readonly OrderedExcalidrawElement[] => { - const hoveredElement = hit - ? hit - : getHoveredElementForBinding(arrow, point, elements, elementsMap); - if (!hoveredElement) { return elements; } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f412705a68..db7a342d41 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -9887,17 +9887,16 @@ class App extends React.Component { return; } - if (isBindingElement(element)) { + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { const hoveredElement = getHoveredElementForBinding( element, pointFrom(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 (