Remove binding gap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2025-08-20 21:45:28 +02:00
parent e04e52f1bd
commit 771dfa3b0e
6 changed files with 67 additions and 272 deletions

View File

@@ -35,10 +35,8 @@ import {
import {
bindingBorderTest,
getHoveredElementForBinding,
getHoveredElementForBindingAndIfItsPrecise,
hitElementItself,
intersectElementWithLineSegment,
maxBindingDistanceFromOutline,
} from "./collision";
import { distanceToElement } from "./distance";
import {
@@ -114,7 +112,6 @@ export type BindingStrategy =
};
export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLElement>,
@@ -202,7 +199,6 @@ const bindOrUnbindBindingElementEdge = (
const getOriginalBindingsIfStillCloseToBindingEnds = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] =>
(["start", "end"] as const).map((edge) => {
const coors = tupleToCoors(
@@ -224,7 +220,6 @@ const getOriginalBindingsIfStillCloseToBindingEnds = (
element,
pointFrom<GlobalPoint>(coors.x, coors.y),
elementsMap,
zoom,
)
) {
return element;
@@ -328,30 +323,16 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
elementsMap,
);
const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise(
point,
elements,
elementsMap,
appState.zoom,
true,
);
const hit = getHoveredElementForBinding(point, elements, elementsMap);
// With new arrows this handles the binding at arrow creation
if (startDragged) {
if (hovered) {
if (hit) {
start = {
element: hovered,
mode: "inside",
focusPoint: point,
};
} else {
start = {
element: hovered,
mode: "orbit",
focusPoint: point,
};
}
if (hit) {
start = {
element: hit,
mode: "inside",
focusPoint: point,
};
} else {
start = { mode: null };
}
@@ -365,41 +346,24 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
appState?.selectedLinearElement?.pointerDownState.arrowOriginalStartPoint;
// Inside -> inside binding
if (hovered && hit && arrow.startBinding?.elementId === hovered.id) {
if (hit && arrow.startBinding?.elementId === hit.id) {
const center = pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
hit.x + hit.width / 2,
hit.y + hit.height / 2,
);
return {
start: {
mode: "inside",
element: hovered,
element: hit,
focusPoint: arrowOriginalStartPoint ?? center,
},
end: { mode: "inside", element: hovered, focusPoint: point },
};
}
// Inside -> orbit binding
if (hovered && !hit && arrow.startBinding?.elementId === hovered.id) {
const center = pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
);
return {
start: {
mode: globalBindMode === "inside" ? "inside" : "orbit",
element: hovered,
focusPoint: arrowOriginalStartPoint ?? center,
},
end: { mode: null },
end: { mode: "inside", element: hit, focusPoint: point },
};
}
// Inside -> outside binding
if (arrow.startBinding && arrow.startBinding.elementId !== hovered?.id) {
if (arrow.startBinding && arrow.startBinding.elementId !== hit?.id) {
const otherElement = elementsMap.get(
arrow.startBinding.elementId,
) as ExcalidrawBindableElement;
@@ -423,14 +387,14 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
// We are hovering another element with the end point
let current: BindingStrategy;
if (hovered) {
if (hit) {
const isInsideBinding = globalBindMode === "inside";
current = {
mode: isInsideBinding ? "inside" : "orbit",
element: hovered,
element: hit,
focusPoint: isInsideBinding
? point
: snapToCenter(hovered, elementsMap, point),
: snapToCenter(hit, elementsMap, point),
};
} else {
current = { mode: null };
@@ -444,13 +408,13 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
// No start binding
if (!arrow.startBinding) {
if (hovered) {
if (hit) {
const isInsideBinding =
globalBindMode === "inside" || isAlwaysInsideBinding(hovered);
globalBindMode === "inside" || isAlwaysInsideBinding(hit);
end = {
mode: isInsideBinding ? "inside" : "orbit",
element: hovered,
element: hit,
focusPoint: point,
};
} else {
@@ -471,7 +435,6 @@ const bindingStrategyForSimpleArrowEndpointDragging = (
oppositeBinding: FixedPointBinding | null,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom: AppState["zoom"],
globalBindMode?: AppState["bindMode"],
opts?: {
newArrow?: boolean;
@@ -481,23 +444,14 @@ const bindingStrategyForSimpleArrowEndpointDragging = (
let current: BindingStrategy = { mode: undefined };
let other: BindingStrategy = { mode: undefined };
const { hovered, hit } = getHoveredElementForBindingAndIfItsPrecise(
point,
elements,
elementsMap,
zoom,
true,
);
const hit = getHoveredElementForBinding(point, elements, elementsMap);
// If the global bind mode is in free binding mode, just bind
// where the pointer is and keep the other end intact
if (
globalBindMode === "inside" ||
(hovered && isAlwaysInsideBinding(hovered))
) {
current = hovered
if (globalBindMode === "inside" || (hit && isAlwaysInsideBinding(hit))) {
current = hit
? {
element: hovered,
element: hit,
focusPoint: point,
mode: "inside",
}
@@ -508,90 +462,58 @@ const bindingStrategyForSimpleArrowEndpointDragging = (
// Dragged point is outside of any bindable element
// so we break any existing binding
if (!hovered) {
if (!hit) {
return { current: { mode: null }, other };
}
// Dragged point is on the binding gap of a bindable element
if (!hit) {
// If the opposite binding (if exists) is on the same element
if (oppositeBinding) {
if (oppositeBinding.elementId === hovered.id) {
return { current: { mode: null }, other };
}
// The opposite binding is on a different element
// eslint-disable-next-line no-else-return
else {
current = {
element: hovered,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
)
: point,
};
return { current, other };
}
}
// No opposite binding or the opposite binding is on a different element
current = { element: hovered, mode: "orbit", focusPoint: point };
}
// The dragged point is inside the hovered bindable element
else {
// The opposite binding is on the same element
// eslint-disable-next-line no-lonely-if
if (oppositeBinding) {
if (oppositeBinding.elementId === hovered.id) {
// The opposite binding is on the binding gap of the same element
if (oppositeBinding.mode !== "inside") {
current = { element: hovered, mode: "orbit", focusPoint: point };
other = { mode: null };
return { current, other };
}
// The opposite binding is inside the same element
// eslint-disable-next-line no-else-return
else {
current = { element: hovered, mode: "inside", focusPoint: point };
// The opposite binding is on the same element
// eslint-disable-next-line no-lonely-if
if (oppositeBinding) {
if (oppositeBinding.elementId === hit.id) {
// The opposite binding is on the binding gap of the same element
if (oppositeBinding.mode !== "inside") {
current = { element: hit, mode: "orbit", focusPoint: point };
other = { mode: null };
return { current, other };
}
return { current, other };
}
// The opposite binding is on a different element
// The opposite binding is inside the same element
// eslint-disable-next-line no-else-return
else {
current = {
element: hovered,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
)
: point,
};
current = { element: hit, mode: "inside", focusPoint: point };
return { current, other };
}
}
// The opposite binding is on a different element or no binding
// The opposite binding is on a different element
// eslint-disable-next-line no-else-return
else {
current = {
element: hovered,
element: hit,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(
hovered.x + hovered.width / 2,
hovered.y + hovered.height / 2,
hit.x + hit.width / 2,
hit.y + hit.height / 2,
)
: point,
};
return { current, other };
}
}
// The opposite binding is on a different element or no binding
else {
current = {
element: hit,
mode: "orbit",
focusPoint: opts?.newArrow
? pointFrom<GlobalPoint>(hit.x + hit.width / 2, hit.y + hit.height / 2)
: point,
};
}
// Must return as only one endpoint is dragged, therefore
// the end binding strategy might accidentally gets overriden
@@ -654,7 +576,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
p,
elements,
elementsMap,
appState.zoom,
);
const current: BindingStrategy = hoveredElement
? {
@@ -702,7 +623,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
arrow.endBinding,
elementsMap,
elements,
appState.zoom,
globalBindMode,
opts,
);
@@ -724,7 +644,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
arrow.startBinding,
elementsMap,
elements,
appState.zoom,
globalBindMode,
opts,
);
@@ -753,7 +672,6 @@ export const bindOrUnbindBindingElements = (
export const getSuggestedBindingsForBindingElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): SuggestedBinding[] => {
// HOT PATH: Bail out if selected elements list is too large
if (selectedElements.length > 50) {
@@ -764,11 +682,7 @@ export const getSuggestedBindingsForBindingElements = (
selectedElements
.filter(isArrowElement)
.flatMap((element) =>
getOriginalBindingsIfStillCloseToBindingEnds(
element,
elementsMap,
zoom,
),
getOriginalBindingsIfStillCloseToBindingEnds(element, elementsMap),
)
.filter(
(element): element is NonDeleted<ExcalidrawBindableElement> =>
@@ -790,7 +704,6 @@ export const maybeSuggestBindingsForBindingElementAtCoords = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
startOrEndOrBoth: "start" | "end" | "both",
scene: Scene,
zoom: AppState["zoom"],
): ExcalidrawBindableElement[] => {
const startCoords = LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement,
@@ -806,13 +719,11 @@ export const maybeSuggestBindingsForBindingElementAtCoords = (
startCoords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
);
const endHovered = getHoveredElementForBinding(
endCoords,
scene.getNonDeletedElements(),
scene.getNonDeletedElementsMap(),
zoom,
);
const suggestedBindings = [];
@@ -1080,7 +991,6 @@ export const getHeadingForElbowArrowSnap = (
aabb: Bounds | undefined | null,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -1089,14 +999,8 @@ export const getHeadingForElbowArrowSnap = (
}
const d = distanceToElement(bindableElement, elementsMap, origPoint);
const bindDistance = maxBindingDistanceFromOutline(
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
const distance = d > bindDistance ? null : d;
const distance = d > 0 ? null : d;
if (!distance) {
return vectorToHeading(

View File

@@ -25,7 +25,7 @@ import type {
Radians,
} from "@excalidraw/math";
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
@@ -58,8 +58,6 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { BINDING_HIGHLIGHT_THICKNESS, FIXED_BINDING_DISTANCE } from "./binding";
import type {
ElementsMap,
ExcalidrawBindableElement,
@@ -206,40 +204,12 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap);
};
export const maxBindingDistanceFromOutline = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
);
};
export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingDistanceFromOutline(
element,
element.width,
element.height,
zoom,
);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
@@ -247,12 +217,7 @@ export const bindingBorderTest = (
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const bounds = [
x - threshold,
y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const bounds = [x - 1, y - 1, x + 1, y + 1] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
@@ -267,15 +232,14 @@ export const bindingBorderTest = (
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
? intersections.length === 0
: intersections.length > 0 && distance <= 1;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -291,7 +255,7 @@ export const getHoveredElementForBinding = (
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, zoom)
bindingBorderTest(element, point, elementsMap)
) {
candidateElements.push(element);
}
@@ -313,36 +277,6 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForBindingAndIfItsPrecise = (
point: GlobalPoint,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
shouldTestInside: boolean = true,
): {
hovered: NonDeleted<ExcalidrawBindableElement> | null;
hit: boolean;
} => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
zoom,
);
// TODO: Optimize this to avoid recalculating the point - element distance
const hit =
!!hoveredElement &&
hitElementItself({
element: hoveredElement,
elementsMap,
point,
threshold: 0,
overrideShouldTestInside: shouldTestInside,
});
return { hovered: hoveredElement, hit };
};
/**
* Intersect a line with an element for binding test
*

View File

@@ -1194,19 +1194,9 @@ const getElbowArrowData = (
if (options?.isDragging) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
origStartGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origStartGlobalPoint, elementsMap, elements) || null;
hoveredEndElement =
getHoveredElement(
origEndGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
getHoveredElement(origEndGlobalPoint, elementsMap, elements) || null;
} else {
hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@@ -2249,9 +2239,8 @@ const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"],
) => {
return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom);
return getHoveredElementForBinding(origPoint, elements, elementsMap);
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>

View File

@@ -465,7 +465,6 @@ export class LinearElementEditor {
? "start"
: "end",
app.scene,
app.state.zoom,
);
}
}
@@ -2001,7 +2000,6 @@ const pointDraggingUpdates = (
newGlobalPointPosition,
elements,
elementsMap,
app.state.zoom,
);
const otherGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
@@ -2011,12 +2009,7 @@ const pointDraggingUpdates = (
);
const otherPointInsideElement =
!!hoveredElement &&
!!bindingBorderTest(
hoveredElement,
otherGlobalPoint,
elementsMap,
app.state.zoom,
);
!!bindingBorderTest(hoveredElement, otherGlobalPoint, elementsMap);
if (
isBindingEnabled(app.state) &&

View File

@@ -4513,7 +4513,6 @@ class App extends React.Component<AppProps, AppState> {
(element) => element.id !== elbowArrow?.id || step !== 0,
),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
@@ -4742,7 +4741,6 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointer.x, scenePointer.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
if (hoveredElement && !this.bindModeHandler) {
@@ -6074,7 +6072,6 @@ class App extends React.Component<AppProps, AppState> {
newElement,
"end",
this.scene,
this.state.zoom,
),
});
} else {
@@ -6179,7 +6176,6 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
);
// Timed bind mode handler for arrow elements
@@ -7967,7 +7963,6 @@ class App extends React.Component<AppProps, AppState> {
),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
@@ -8173,7 +8168,6 @@ class App extends React.Component<AppProps, AppState> {
lastGlobalPoint,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
// clicking inside commit zone → finalize arrow
@@ -8294,7 +8288,6 @@ class App extends React.Component<AppProps, AppState> {
point,
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
);
this.scene.mutateElement(element, {
@@ -8740,7 +8733,6 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
elementsMap,
this.state.zoom,
);
// Timed bind mode handler for arrow elements
@@ -9117,7 +9109,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBindings: getSuggestedBindingsForBindingElements(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
),
});
}
@@ -9427,7 +9418,6 @@ class App extends React.Component<AppProps, AppState> {
newElement,
"end",
this.scene,
this.state.zoom,
),
});
}
@@ -10978,7 +10968,6 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(pointerCoords.x, pointerCoords.y),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
this.setState({
suggestedBindings:
@@ -11601,7 +11590,6 @@ class App extends React.Component<AppProps, AppState> {
const suggestedBindings = getSuggestedBindingsForBindingElements(
selectedElements,
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
);
const elementsToHighlight = new Set<ExcalidrawElement>();

View File

@@ -16,10 +16,7 @@ import {
throttleRAF,
} from "@excalidraw/common";
import {
FIXED_BINDING_DISTANCE,
maxBindingDistanceFromOutline,
} from "@excalidraw/element";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import {
getOmitSidesForDevice,
@@ -197,12 +194,7 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const padding = maxBindingDistanceFromOutline(
element,
element.width,
element.height,
zoom,
);
const padding = 5;
context.fillStyle = "rgba(0,0,0,.05)";
@@ -251,14 +243,9 @@ const renderBindingHighlightForSuggestedPointBinding = (
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
const [element, startOrEnd] = suggestedBinding;
const threshold = maxBindingDistanceFromOutline(
bindableElement,
bindableElement.width,
bindableElement.height,
zoom,
);
const threshold = 0;
context.strokeStyle = "rgba(0,0,0,0)";
context.fillStyle = "rgba(0,0,0,.05)";