diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 566ef3c4e4..6d25c625fc 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"; @@ -154,6 +155,7 @@ export const bindOrUnbindBindingElement = ( altKey?: boolean; angleLocked?: boolean; initialBinding?: boolean; + gridSize?: NullableGridSize; }, ) => { const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( @@ -198,6 +200,8 @@ export const bindOrUnbindBindingElement = ( arrow.startBinding, start.element, scene.getNonDeletedElementsMap(), + undefined, + opts?.gridSize, ) || arrow.points[0], }); } @@ -211,6 +215,8 @@ export const bindOrUnbindBindingElement = ( arrow.endBinding, end.element, scene.getNonDeletedElementsMap(), + undefined, + opts?.gridSize, ) || arrow.points[arrow.points.length - 1], }); } @@ -812,7 +818,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? "start" : "end", elementsMap, appState.zoom, - appState.isMidpointSnappingEnabled, + appState.isMidpointSnappingEnabled && + !opts?.angleLocked && + !appState.gridModeEnabled, ) || globalPoint, } : { mode: null }; @@ -857,7 +865,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? "end" : "start", elementsMap, appState.zoom, - appState.isMidpointSnappingEnabled, + false, ) || otherEndpoint, } : { mode: undefined } @@ -1744,6 +1752,54 @@ const extractBinding = ( const elementArea = (element: ExcalidrawBindableElement) => element.width * element.height; +/** + * 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, +): 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 absNX = Math.abs(normalGlobal[0]); + const absNY = Math.abs(normalGlobal[1]); + if (absNX >= absNY) { + // Global X is closest to the perpendicular → keep X, snap Y + const [, snappedY] = getGridPoint( + outlinePoint[0], + outlinePoint[1], + gridSize, + ); + return pointFrom(outlinePoint[0], snappedY); + } + + // Global Y is closest to the perpendicular → keep Y, snap X + const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize); + return pointFrom(snappedX, outlinePoint[1]); +}; + export const updateBoundPoint = ( arrow: NonDeleted, startOrEnd: "startBinding" | "endBinding", @@ -1751,6 +1807,7 @@ export const updateBoundPoint = ( bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, dragging?: boolean, + gridSize?: NullableGridSize, ): LocalPoint | null => { if ( binding == null || @@ -1870,11 +1927,24 @@ export const updateBoundPoint = ( // and short-circuit to the focus point if the arrow is too short to // avoid inversion if (!otherBindable) { + const snapped = + !arrowTooShort && outlinePoint + ? snapBoundPointToGrid( + outlinePoint, + bindableElement, + elementsMap, + gridSize ?? null, + ) + : null; return LinearElementEditor.createPointAt( arrow, elementsMap, - arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0], - arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1], + arrowTooShort + ? focusPoint[0] + : snapped?.[0] ?? outlinePoint?.[0] ?? focusPoint[0], + arrowTooShort + ? focusPoint[1] + : snapped?.[1] ?? outlinePoint?.[1] ?? focusPoint[1], null, ); } @@ -1893,11 +1963,19 @@ export const updateBoundPoint = ( } // 4. In the general case, snap to the outline if possible + const snappedOutline = outlinePoint + ? snapBoundPointToGrid( + outlinePoint, + bindableElement, + elementsMap, + gridSize ?? null, + ) + : null; return LinearElementEditor.createPointAt( arrow, elementsMap, - outlinePoint?.[0] || focusPoint[0], - outlinePoint?.[1] || focusPoint[1], + snappedOutline?.[0] ?? outlinePoint?.[0] ?? focusPoint[0], + snappedOutline?.[1] ?? outlinePoint?.[1] ?? focusPoint[1], null, ); }; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index e57211abbc..cf616eb690 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -344,6 +344,9 @@ export class LinearElementEditor { // Apply the point movement if needed let suggestedBinding: AppState["suggestedBinding"] = null; + const effectiveGridSize = event[KEYS.CTRL_OR_CMD] + ? null + : app.getEffectiveGridSize(); const { positions, updates } = pointDraggingUpdates( [idx], deltaX, @@ -357,8 +360,10 @@ export class LinearElementEditor { shouldRotateWithDiscreteAngle(event), event.altKey, linearElementEditor, + effectiveGridSize, ); + const angleLocked = shouldRotateWithDiscreteAngle(event); LinearElementEditor.movePoints( element, app.scene, @@ -370,7 +375,10 @@ export class LinearElementEditor { }, { isBindingEnabled: app.state.isBindingEnabled, - isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, + isMidpointSnappingEnabled: + app.state.isMidpointSnappingEnabled && + !angleLocked && + !app.state.gridModeEnabled, }, ); // Set the suggested binding from the updates if available @@ -427,7 +435,9 @@ export class LinearElementEditor { "start", elementsMap, app.state.zoom, - app.state.isMidpointSnappingEnabled, + app.state.isMidpointSnappingEnabled && + !angleLocked && + !app.state.gridModeEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -533,6 +543,9 @@ export class LinearElementEditor { // Apply the point movement if needed let suggestedBinding: AppState["suggestedBinding"] = null; + const effectiveGridSize = event[KEYS.CTRL_OR_CMD] + ? null + : app.getEffectiveGridSize(); const { positions, updates } = pointDraggingUpdates( selectedPointsIndices, deltaX, @@ -546,8 +559,11 @@ export class LinearElementEditor { shouldRotateWithDiscreteAngle(event) && singlePointDragged, event.altKey, linearElementEditor, + effectiveGridSize, ); + const angleLocked = + shouldRotateWithDiscreteAngle(event) && singlePointDragged; LinearElementEditor.movePoints( element, app.scene, @@ -559,7 +575,10 @@ export class LinearElementEditor { }, { isBindingEnabled: app.state.isBindingEnabled, - isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled, + isMidpointSnappingEnabled: + app.state.isMidpointSnappingEnabled && + !angleLocked && + !app.state.gridModeEnabled, }, ); @@ -655,7 +674,9 @@ export class LinearElementEditor { "start", elementsMap, app.state.zoom, - app.state.isMidpointSnappingEnabled, + app.state.isMidpointSnappingEnabled && + !angleLocked && + !app.state.gridModeEnabled, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -2116,6 +2137,7 @@ const pointDraggingUpdates = ( angleLocked: boolean, altKey: boolean, linearElementEditor: LinearElementEditor, + gridSize?: NullableGridSize, ): { positions: PointsPositionUpdates; updates?: PointMoveOtherUpdates; @@ -2214,6 +2236,8 @@ const pointDraggingUpdates = ( element.startBinding, startBindable, elementsMap, + undefined, + gridSize, ) ?? null; if (startPoint) { positions.set(0, { point: startPoint, isDragging: true }); @@ -2233,6 +2257,8 @@ const pointDraggingUpdates = ( element.endBinding, endBindable, elementsMap, + undefined, + gridSize, ) ?? null; if (endPoint) { positions.set(element.points.length - 1, { @@ -2406,6 +2432,7 @@ const pointDraggingUpdates = ( endBindable, elementsMap, endIsDragged, + endIsDragged ? gridSize : null, ) || nextArrow.points[nextArrow.points.length - 1] : nextArrow.points[nextArrow.points.length - 1]; @@ -2437,6 +2464,7 @@ const pointDraggingUpdates = ( startBindable, elementsMap, startIsDragged, + startIsDragged ? gridSize : null, ) || nextArrow.points[0] : nextArrow.points[0]; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9e529621a7..56336298fd 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -17,6 +17,7 @@ import { import { KEYS, arrayToMap, + getGridPoint, invariant, shouldRotateWithDiscreteAngle, updateActiveTool, @@ -27,7 +28,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element"; -import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; +import type { LocalPoint } from "@excalidraw/math"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -93,32 +94,56 @@ export const actionFinalize = register({ ? [element.points.length - 1] // New arrow creation : appState.selectedLinearElement.selectedPointsIndices; + const angleLocked = shouldRotateWithDiscreteAngle(event); + const effectiveGridSize = event[KEYS.CTRL_OR_CMD] + ? null + : app.getEffectiveGridSize(); + const draggedPoints: PointsPositionUpdates = selectedPointsIndices.reduce((map, index) => { map.set(index, { - point: LinearElementEditor.pointFromAbsoluteCoords( - element, - pointFrom( - sceneCoords.x - linearElementEditor.pointerOffset.x, - sceneCoords.y - linearElementEditor.pointerOffset.y, - ), - elementsMap, - ), + point: angleLocked + ? element.points[index] + : LinearElementEditor.createPointAt( + element, + elementsMap, + sceneCoords.x - linearElementEditor.pointerOffset.x, + sceneCoords.y - linearElementEditor.pointerOffset.y, + effectiveGridSize, + ), }); return map; }, new Map()) ?? new Map(); + + const startIsDragged = selectedPointsIndices.includes(0); + const lockedGlobal = angleLocked + ? LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + startIsDragged ? 0 : -1, + elementsMap, + ) + : null; + const [gridSnappedX, gridSnappedY] = getGridPoint( + sceneCoords.x - linearElementEditor.pointerOffset.x, + sceneCoords.y - linearElementEditor.pointerOffset.y, + effectiveGridSize, + ); + const bindingSceneX = lockedGlobal ? lockedGlobal[0] : gridSnappedX; + const bindingSceneY = lockedGlobal ? lockedGlobal[1] : gridSnappedY; + bindOrUnbindBindingElement( element, draggedPoints, - sceneCoords.x - linearElementEditor.pointerOffset.x, - sceneCoords.y - linearElementEditor.pointerOffset.y, + bindingSceneX, + bindingSceneY, scene, appState, { newArrow, altKey: event.altKey, - angleLocked: shouldRotateWithDiscreteAngle(event), + angleLocked, + gridSize: effectiveGridSize, }, ); } else if (isLineElement(element)) { diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index a30088f30a..7108c0eba8 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -251,6 +251,7 @@ const getRelevantAppStateProps = ( newElement: appState.newElement, isBindingEnabled: appState.isBindingEnabled, isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled, + gridModeEnabled: appState.gridModeEnabled, suggestedBinding: appState.suggestedBinding, isRotating: appState.isRotating, elementsToHighlight: appState.elementsToHighlight, diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 56c308713e..deb728d4a4 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -17,6 +17,7 @@ import { FRAME_STYLE, getFeatureFlag, invariant, + shouldRotateWithDiscreteAngle, THEME, } from "@excalidraw/common"; @@ -222,6 +223,7 @@ const renderBindingHighlightForBindableElement_simple = ( elementsMap: ElementsMap, appState: InteractiveCanvasAppState, pointerCoords: GlobalPoint | null, + angleLocked = false, ) => { const enclosingFrame = suggestedBinding.element.frameId && @@ -408,6 +410,8 @@ const renderBindingHighlightForBindableElement_simple = ( if ( appState.isMidpointSnappingEnabled && + !appState.gridModeEnabled && + !angleLocked && (isFrameLikeElement(suggestedBinding.element) || isBindableElement(suggestedBinding.element)) ) { @@ -800,7 +804,12 @@ const renderBindingHighlightForBindableElement_complex = ( context.restore(); - if (appState.isMidpointSnappingEnabled) { + if ( + appState.isMidpointSnappingEnabled && + !appState.gridModeEnabled && + (!app.lastPointerMoveEvent || + !shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent)) + ) { // Draw midpoint indicators context.save(); context.translate( @@ -913,12 +922,16 @@ const renderBindingHighlightForBindableElement = ( app.lastPointerMoveCoords.y, ) : null; + const angleLocked = + !!app.lastPointerMoveEvent && + shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent); renderBindingHighlightForBindableElement_simple( context, suggestedBinding, allElementsMap, appState, pointerCoords, + angleLocked, ); context.restore(); }; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 63397ea488..d01b21f186 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -223,6 +223,7 @@ export type InteractiveCanvasAppState = Readonly< newElement: AppState["newElement"]; isBindingEnabled: AppState["isBindingEnabled"]; isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"]; + gridModeEnabled: AppState["gridModeEnabled"]; suggestedBinding: AppState["suggestedBinding"]; isRotating: AppState["isRotating"]; elementsToHighlight: AppState["elementsToHighlight"]; @@ -824,6 +825,7 @@ export type AppClassProperties = { onStateChange: App["onStateChange"]; lastPointerMoveCoords: App["lastPointerMoveCoords"]; + lastPointerMoveEvent: App["lastPointerMoveEvent"]; bindModeHandler: App["bindModeHandler"]; setAppState: App["setAppState"];