From 6f9c6fc205c4f7d2013555458d6e5aa0c02736d2 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 19 Feb 2025 19:52:18 +0100 Subject: [PATCH] Binding updates --- packages/excalidraw/element/binding.ts | 235 +++++++++-------------- packages/excalidraw/element/collision.ts | 9 +- packages/excalidraw/element/utils.ts | 8 +- 3 files changed, 109 insertions(+), 143 deletions(-) diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 431cb6902f..392fbaf4a2 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -13,6 +13,7 @@ import type { ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, + FixedPointBinding, } from "./types"; import type { Bounds } from "./bounds"; @@ -64,18 +65,13 @@ import { line, linesIntersectAt, pointDistance, - pointOnLineSegment, pointFromVector, vectorScale, vectorNormalize, - vectorRotate, - lineClosestPoint, - vectorDot, + vectorCross, } from "../../math"; import { intersectElementWithLineSegment } from "./collision"; import { distanceToBindableElement } from "./distance"; -import { debugClear, debugDrawPoint } from "../visualdebug"; -import { ellipse } from "../../math/ellipse"; export type SuggestedBinding = | NonDeleted @@ -121,7 +117,6 @@ export const bindOrUnbindLinearElement = ( endBindingElement: ExcalidrawBindableElement | null | "keep", elementsMap: NonDeletedSceneElementsMap, scene: Scene, - zoom?: AppState["zoom"], ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -133,7 +128,6 @@ export const bindOrUnbindLinearElement = ( boundToElementIds, unboundFromElementIds, elementsMap, - zoom, ); bindOrUnbindLinearElementEdge( linearElement, @@ -143,7 +137,6 @@ export const bindOrUnbindLinearElement = ( boundToElementIds, unboundFromElementIds, elementsMap, - zoom, ); const onlyUnbound = Array.from(unboundFromElementIds).filter( @@ -170,7 +163,6 @@ const bindOrUnbindLinearElementEdge = ( // Is mutated unboundFromElementIds: Set, elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], ): void => { // "keep" is for method chaining convenience, a "no-op", so just bail out if (bindableElement === "keep") { @@ -207,18 +199,11 @@ const bindOrUnbindLinearElementEdge = ( bindableElement, startOrEnd, elementsMap, - zoom, ); boundToElementIds.add(bindableElement.id); } } else { - bindLinearElement( - linearElement, - bindableElement, - startOrEnd, - elementsMap, - zoom, - ); + bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap); boundToElementIds.add(bindableElement.id); } }; @@ -380,14 +365,7 @@ export const bindOrUnbindLinearElements = ( zoom, ); - bindOrUnbindLinearElement( - selectedElement, - start, - end, - elementsMap, - scene, - zoom, - ); + bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); }); }; @@ -436,7 +414,6 @@ export const maybeBindLinearElement = ( appState.startBoundElement, "start", elementsMap, - appState.zoom, ); } @@ -457,13 +434,7 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement( - linearElement, - hoveredElement, - "end", - elementsMap, - appState.zoom, - ); + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); } } }; @@ -493,31 +464,35 @@ export const bindLinearElement = ( hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], ): void => { if (!isArrowElement(linearElement)) { return; } - const binding: PointBinding = { + + const binding: PointBinding | FixedPointBinding = { elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - zoom, - ), - hoveredElement, - ), ...(isElbowArrow(linearElement) - ? calculateFixedPointForElbowArrowBinding( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ) - : { fixedPoint: null }), + ? { + ...calculateFixedPointForElbowArrowBinding( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + focus: 0, + gap: 0, + } + : { + ...normalizePointBinding( + calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + hoveredElement, + ), + }), }; mutateElement(linearElement, { @@ -713,7 +688,6 @@ const calculateFocusAndGap = ( hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: NonDeletedSceneElementsMap, - zoom?: AppState["zoom"], ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; @@ -730,28 +704,9 @@ const calculateFocusAndGap = ( elementsMap, ); - const focus = determineFocusDistance( - hoveredElement, - adjacentPoint, - edgePoint, - ); - const focusPointAbsolute = determineFocusPoint( - hoveredElement, - focus, - adjacentPoint, - ); - const intersection = - intersectElementWithLineSegment( - hoveredElement, - lineSegment(adjacentPoint, focusPointAbsolute), - ).sort( - (g, h) => pointDistanceSq(g, edgePoint) - pointDistanceSq(h, edgePoint), - )[0] ?? edgePoint; - const gap = Math.max(1, pointDistance(intersection, edgePoint)); - return { - focus, - gap, + focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), + gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), }; }; @@ -1264,27 +1219,16 @@ const updateBoundPoint = ( elementsMap, ); - const intersection = + newEdgePoint = intersectElementWithLineSegment( bindableElement, lineSegment(adjacentPoint, focusPointAbsolute), + binding.gap, ).sort( (g, h) => - pointDistanceSq(g!, edgePointAbsolute) - - pointDistanceSq(h!, edgePointAbsolute), + pointDistanceSq(g, edgePointAbsolute) - + pointDistanceSq(h, edgePointAbsolute), )[0] ?? edgePointAbsolute; - - const gapOffsetPoint = intersection - ? pointFromVector( - vectorScale( - vectorNormalize(vectorFromPoint(adjacentPoint, intersection)), - binding.gap, - ), - intersection, - ) - : edgePointAbsolute; - - newEdgePoint = gapOffsetPoint; } return LinearElementEditor.pointFromAbsoluteCoords( @@ -1579,12 +1523,10 @@ const determineFocusDistance = ( element.x + element.width / 2, element.y + element.height / 2, ); - const linear = vectorFromPoint(b, a); - const dot1 = Math.abs( - vectorDot( - linear, - // One of the diagonals - vectorFromPoint( + const intersection = [ + linesIntersectAt( + line(a, b), + line( pointFrom(element.x, element.y), pointFrom( element.x + element.width, @@ -1592,42 +1534,24 @@ const determineFocusDistance = ( ), ), ), - ); - const dot2 = Math.abs( - vectorDot( - linear, - // The other diagonal - vectorFromPoint( + linesIntersectAt( + line(a, b), + line( pointFrom(element.x + element.width, element.y), pointFrom(element.x, element.y + element.height), ), ), - ); - const intersect = - linesIntersectAt( - // The bigger inclination of the diagonal and the linear element is the one - // that determines the intersection point - dot1 > dot2 - ? line( - pointFrom(element.x + element.width, element.y), - pointFrom(element.x, element.y + element.height), - ) - : line( - pointFrom(element.x, element.y), - pointFrom( - element.x + element.width, - element.y + element.height, - ), - ), - line(a, b), - ) || center; + ] + .filter((p): p is GlobalPoint => p !== null) + .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b))[0]; const sign = - vectorDot(vectorFromPoint(center, a), vectorFromPoint(a, b)) < 0 ? -1 : 1; - const signedDist = sign * pointDistance(center, intersect); - const sdRatio = + Math.sign(vectorCross(vectorFromPoint(b, a), vectorFromPoint(b, center))) * + -1; + const signedDist = sign * pointDistance(center, intersection); + const signedDistanceRatio = signedDist / (Math.sqrt(element.width ** 2 + element.height ** 2) / 2); - return sdRatio; + return signedDistanceRatio; }; const determineFocusPoint = ( @@ -1645,22 +1569,55 @@ const determineFocusPoint = ( return center; } - return pointFromVector( - vectorScale( - vectorRotate( - vectorNormalize(vectorFromPoint(adjacentPoint, center)), - (Math.PI / 2) as Radians, - ), - Math.sign(focus) * - Math.min( - pointDistance(pointFrom(element.x, element.y), center) * - Math.abs(focus), - element.width / 2, - element.height / 2, - ), + const candidates = [ + pointFrom(element.x, element.y), + pointFrom(element.x + element.width, element.y), + pointFrom( + element.x + element.width, + element.y + element.height, + ), + pointFrom(element.x, element.y + element.height), + ].map((p) => + pointFromVector( + vectorScale(vectorFromPoint(p, center), Math.abs(focus)), + center, ), - center, ); + const selected = [ + adjacentPoint[1] < candidates[0][1] && // TOP + (focus > 0 + ? adjacentPoint[0] < candidates[1][0] + : adjacentPoint[0] > candidates[0][0]), + adjacentPoint[0] > candidates[1][0] && // RIGHT + (focus > 0 + ? adjacentPoint[1] < candidates[2][1] + : adjacentPoint[1] > candidates[1][1]), + adjacentPoint[1] > candidates[2][1] && // BOTTOM + (focus > 0 + ? adjacentPoint[0] > candidates[3][0] + : adjacentPoint[0] < candidates[2][0]), + adjacentPoint[0] < candidates[3][0] && // LEFT + (focus > 0 + ? adjacentPoint[1] < candidates[3][1] + : adjacentPoint[1] > candidates[0][1]), + ]; + const focusPoint = selected[0] + ? focus > 0 + ? candidates[1] + : candidates[0] + : selected[1] + ? focus > 0 + ? candidates[2] + : candidates[1] + : selected[2] + ? focus > 0 + ? candidates[3] + : candidates[2] + : focus > 0 + ? candidates[0] + : candidates[3]; + + return focusPoint; }; export const bindingProperties: Set = new Set([ diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 431323505d..968780baf4 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -156,6 +156,7 @@ export const hitElementBoundText = ( export const intersectElementWithLineSegment = ( element: ExcalidrawElement, line: LineSegment, + offset: number = 0, ): GlobalPoint[] => { switch (element.type) { case "rectangle": @@ -165,7 +166,7 @@ export const intersectElementWithLineSegment = ( case "embeddable": case "frame": case "magicframe": - return intersectRectanguloidWithLineSegment(element, line); + return intersectRectanguloidWithLineSegment(element, line, offset); case "diamond": return intersectDiamondWithLineSegment(element, line); case "ellipse": @@ -178,6 +179,7 @@ export const intersectElementWithLineSegment = ( const intersectRectanguloidWithLineSegment = ( element: ExcalidrawRectanguloidElement, l: LineSegment, + offset: number = 0, ): GlobalPoint[] => { const center = pointFrom( element.x + element.width / 2, @@ -197,7 +199,10 @@ const intersectRectanguloidWithLineSegment = ( ); // Get the element's building components we can test against - const [sides, corners] = deconstructRectanguloidElement(element); + const [sides, corners] = deconstructRectanguloidElement( + element, + offset, + ); return ( [ diff --git a/packages/excalidraw/element/utils.ts b/packages/excalidraw/element/utils.ts index 2958ac7dc9..1ab6b881eb 100644 --- a/packages/excalidraw/element/utils.ts +++ b/packages/excalidraw/element/utils.ts @@ -26,10 +26,14 @@ export function deconstructRectanguloidElement< Point extends GlobalPoint | LocalPoint, >( element: ExcalidrawRectanguloidElement, + offset: number = 0, ): [LineSegment[], Curve[]] { const r = rectangle( - pointFrom(element.x, element.y), - pointFrom(element.x + element.width, element.y + element.height), + pointFrom(element.x - offset, element.y - offset), + pointFrom( + element.x + element.width + offset, + element.y + element.height + offset, + ), ); const roundness = getCornerRadius( Math.min(element.width, element.height),