diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 0b3fd52b0e..cd9a1e5d12 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -1,6 +1,7 @@ import { arrayToMap, getFeatureFlag, + getGridPoint, invariant, isTransparent, } from "@excalidraw/common"; @@ -22,7 +23,7 @@ import { } from "@excalidraw/math"; import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math"; -import type { AppState } from "@excalidraw/excalidraw/types"; +import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { Bounds } from "@excalidraw/common"; @@ -633,6 +634,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( finalize?: boolean; initialBinding?: boolean; zoom?: AppState["zoom"]; + gridSize?: NullableGridSize; }, ): { start: BindingStrategy; end: BindingStrategy } => { const startIdx = 0; @@ -695,7 +697,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( elementsMap, ); const hit = getHoveredElementForBinding( - globalPoint, + opts?.angleLocked || appState.gridModeEnabled + ? pointFrom(scenePointerX, scenePointerY) + : globalPoint, elements, elementsMap, maxBindingDistance_simple(appState.zoom), @@ -807,7 +811,15 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( focusPoint: projectFixedPointOntoDiagonal( arrow, - globalPoint, + opts?.angleLocked || appState.gridModeEnabled + ? snapBoundPointToGrid( + pointFrom(scenePointerX, scenePointerY), + hit, + elementsMap, + appState.gridSize as NullableGridSize, + arrow, + ) + : globalPoint, hit, startDragged ? "start" : "end", elementsMap, @@ -1743,6 +1755,88 @@ const extractBinding = ( }; }; +/** + * Snaps a bound arrow endpoint to the grid on the axis parallel to the + * bindable element's side, while preserving the binding gap distance on the + * perpendicular axis. In other words, the grid axis closest to the side's + * perpendicular (normal) is used as the snap axis and the other axis is kept at + * the binding gap distance. + */ +const snapBoundPointToGrid = ( + outlinePoint: GlobalPoint, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, + gridSize: NullableGridSize, + arrowElement: ExcalidrawArrowElement, +): GlobalPoint => { + if (!gridSize) { + return outlinePoint; + } + + const aabb = aabbForElement(bindableElement, elementsMap); + const heading = headingForPointFromElement( + bindableElement, + aabb, + outlinePoint, + ); + + const normalLocal = pointFrom(heading[0], heading[1]); + const normalGlobal = pointRotateRads( + normalLocal, + pointFrom(0, 0), + bindableElement.angle, + ); + + const bindingGap = getBindingGap(bindableElement, arrowElement); + const extent = + Math.max(bindableElement.width, bindableElement.height) + bindingGap * 2; + const center = getCenterForBounds(aabb); + + const absNX = Math.abs(normalGlobal[0]); + const absNY = Math.abs(normalGlobal[1]); + if (absNX >= absNY) { + // Global X is closest to the perpendicular → snap Y, intersect horizontal line + const [, snappedY] = getGridPoint( + outlinePoint[0], + outlinePoint[1], + gridSize, + ); + const intersector = lineSegment( + pointFrom(center[0] - extent, snappedY), + pointFrom(center[0] + extent, snappedY), + ); + const intersection = intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + bindingGap, + ).sort( + (a, b) => + pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint), + )[0]; + + return intersection ?? pointFrom(outlinePoint[0], snappedY); + } + + // Global Y is closest to the perpendicular → snap X, intersect vertical line + const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize); + const intersector = lineSegment( + pointFrom(snappedX, center[1] - extent), + pointFrom(snappedX, center[1] + extent), + ); + const intersection = intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + bindingGap, + ).sort( + (a, b) => + pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint), + )[0]; + + return intersection ?? pointFrom(snappedX, outlinePoint[1]); +}; + const elementArea = (element: ExcalidrawBindableElement) => element.width * element.height; diff --git a/yarn.lock b/yarn.lock index 2bc70c4afd..3b2d6cddc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1531,10 +1531,10 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358" - integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw== +"@excalidraw/mermaid-to-excalidraw@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.1.tgz#659c934a607dd2cf57f2a69282588ee2b0722959" + integrity sha512-jU+frqcxazsY+t5yOBf2mgrQy+WUrbrzA36if3SQB/Vwaf2qOJjnWxucNafgZZk/3+9xGmRotUeOviSOJG+wYA== dependencies: "@excalidraw/markdown-to-text" "0.1.2" "@mermaid-js/parser" "^0.6.3"