From 5403fa8a0d32bd265b4f7083c5a7828589f007e6 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 13 Jun 2025 17:50:06 +1000 Subject: [PATCH] feat: line snapping --- packages/element/src/linearElementEditor.ts | 40 +++- packages/excalidraw/components/App.tsx | 1 + packages/excalidraw/snapping.ts | 228 +++++++++++++++++++- 3 files changed, 264 insertions(+), 5 deletions(-) diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 447d1e3682..cb6b23dc89 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -33,6 +33,11 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; +import { + SnapLine, + snapLinearElementPoint, +} from "@excalidraw/excalidraw/snapping"; + import type { Mutable } from "@excalidraw/common/utility-types"; import { @@ -150,6 +155,7 @@ export class LinearElementEditor { public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; + public readonly snapLines: readonly SnapLine[]; constructor( element: NonDeleted, @@ -188,6 +194,7 @@ export class LinearElementEditor { this.segmentMidPointHoveredCoords = null; this.elbowed = isElbowArrow(element) && element.elbowed; this.customLineAngle = null; + this.snapLines = []; } // --------------------------------------------------------------------------- @@ -323,6 +330,8 @@ export class LinearElementEditor { // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; + let _snapLines: SnapLine[] = []; + if (selectedPointsIndices && draggingPoint) { if ( shouldRotateWithDiscreteAngle(event) && @@ -365,11 +374,33 @@ export class LinearElementEditor { ]), ); } else { + // Apply object snapping for the point being dragged + const originalPointerX = + scenePointerX - linearElementEditor.pointerOffset.x; + const originalPointerY = + scenePointerY - linearElementEditor.pointerOffset.y; + + const { snapOffset, snapLines } = snapLinearElementPoint( + scene.getNonDeletedElements(), + element, + lastClickedPoint, + { x: originalPointerX, y: originalPointerY }, + app, + event, + elementsMap, + ); + + _snapLines = snapLines; + + // Apply snap offset to get final coordinates + const snappedPointerX = originalPointerX + snapOffset.x; + const snappedPointerY = originalPointerY + snapOffset.y; + const newDraggingPointPosition = LinearElementEditor.createPointAt( element, elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, + snappedPointerX, + snappedPointerY, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); @@ -386,8 +417,8 @@ export class LinearElementEditor { ? LinearElementEditor.createPointAt( element, elementsMap, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, + snappedPointerX, + snappedPointerY, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), @@ -461,6 +492,7 @@ export class LinearElementEditor { elementsMap, ) : null, + snapLines: _snapLines, hoverPointIndex: lastClickedPoint === 0 || lastClickedPoint === element.points.length - 1 diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 15b5e5b6fd..6f027266d0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -8334,6 +8334,7 @@ class App extends React.Component { ? newLinearElementEditor : null, selectedLinearElement: newLinearElementEditor, + snapLines: newLinearElementEditor.snapLines, }); return; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 8dd1bd59ac..fccc8bc4e9 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -13,7 +13,7 @@ import { getDraggedElementsBounds, getElementAbsoluteCoords, } from "@excalidraw/element"; -import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element"; +import { isBoundToContainer, isFrameLikeElement, isElbowArrow } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element"; @@ -29,6 +29,7 @@ import type { MaybeTransformHandleType } from "@excalidraw/element"; import type { ElementsMap, ExcalidrawElement, + ExcalidrawLinearElement, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; @@ -189,6 +190,68 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { return Math.abs(a - b) <= precision; }; +export const getLinearElementPoints = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, + options: { + dragOffset?: Vector2D; + excludePointIndex?: number; + } = {}, +): GlobalPoint[] => { + const { dragOffset, excludePointIndex } = options; + + // Only process linear elements and freedraw + if ( + element.type !== "line" && + element.type !== "arrow" && + element.type !== "freedraw" + ) { + return []; + } + + const linearElement = element as ExcalidrawLinearElement; + if (!linearElement.points || linearElement.points.length === 0) { + return []; + } + + let elementX = element.x; + let elementY = element.y; + + if (dragOffset) { + elementX += dragOffset.x; + elementY += dragOffset.y; + } + + const globalPoints: GlobalPoint[] = []; + + for (let i = 0; i < linearElement.points.length; i++) { + // Skip the point being edited if specified + if (excludePointIndex !== undefined && i === excludePointIndex) { + continue; + } + + const localPoint = linearElement.points[i]; + const globalX = elementX + localPoint[0]; + const globalY = elementY + localPoint[1]; + + // Apply rotation if element is rotated + if (element.angle !== 0) { + const cx = elementX + element.width / 2; + const cy = elementY + element.height / 2; + const rotated = pointRotateRads( + pointFrom(globalX, globalY), + pointFrom(cx, cy), + element.angle, + ); + globalPoints.push(pointFrom(round(rotated[0]), round(rotated[1]))); + } else { + globalPoints.push(pointFrom(round(globalX), round(globalY))); + } + } + + return globalPoints; +}; + export const getElementsCorners = ( elements: ExcalidrawElement[], elementsMap: ElementsMap, @@ -229,6 +292,13 @@ export const getElementsCorners = ( const halfHeight = (y2 - y1) / 2; if ( + (element.type === "line" || element.type === "arrow" || element.type === "freedraw") && + !boundingBoxCorners + ) { + // For linear elements, use actual points instead of bounding box + const linearPoints = getLinearElementPoints(element, elementsMap, { dragOffset }); + result = linearPoints; + } else if ( (element.type === "diamond" || element.type === "ellipse") && !boundingBoxCorners ) { @@ -634,6 +704,162 @@ export const getReferenceSnapPoints = ( .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); }; +export const getReferenceSnapPointsForLinearElementPoint = ( + elements: readonly NonDeletedExcalidrawElement[], + editingElement: ExcalidrawLinearElement, + editingPointIndex: number, + appState: AppState, + elementsMap: ElementsMap, +) => { + // Get all reference elements (excluding the one being edited) + const referenceElements = getReferenceElements( + elements, + [editingElement], + appState, + elementsMap, + ); + + let allSnapPoints: GlobalPoint[] = []; + + // Add snap points from all reference elements + const referenceGroups = getMaximumGroups(referenceElements, elementsMap) + .filter( + (elementsGroup) => + !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), + ); + + for (const elementGroup of referenceGroups) { + allSnapPoints.push(...getElementsCorners(elementGroup, elementsMap)); + } + + // Note: We do not include other points from the same linear element + // as reference points when dragging a point, per user feedback + + return allSnapPoints; +}; + +export const snapLinearElementPoint = ( + elements: readonly NonDeletedExcalidrawElement[], + editingElement: ExcalidrawLinearElement, + editingPointIndex: number, + pointPosition: Vector2D, + app: AppClassProperties, + event: KeyboardModifiersObject, + elementsMap: ElementsMap, +) => { + if (!isSnappingEnabled({ app, event, selectedElements: [editingElement] }) || + isElbowArrow(editingElement)) { + return { + snapOffset: { x: 0, y: 0 }, + snapLines: [], + }; + } + + const snapDistance = getSnapDistance(app.state.zoom.value); + const minOffset = { + x: snapDistance, + y: snapDistance, + }; + + const nearestSnapsX: Snaps = []; + const nearestSnapsY: Snaps = []; + + // Get reference snap points (all elements except the current point) + const referenceSnapPoints = getReferenceSnapPointsForLinearElementPoint( + elements, + editingElement, + editingPointIndex, + app.state, + elementsMap, + ); + + // Create a snap point for the current point position + const currentPointGlobal = pointFrom(pointPosition.x, pointPosition.y); + + // Find nearest snaps + for (const referencePoint of referenceSnapPoints) { + const offsetX = referencePoint[0] - currentPointGlobal[0]; + const offsetY = referencePoint[1] - currentPointGlobal[1]; + + if (Math.abs(offsetX) <= minOffset.x) { + if (Math.abs(offsetX) < minOffset.x) { + nearestSnapsX.length = 0; + } + + nearestSnapsX.push({ + type: "point", + points: [currentPointGlobal, referencePoint], + offset: offsetX, + }); + + minOffset.x = Math.abs(offsetX); + } + + if (Math.abs(offsetY) <= minOffset.y) { + if (Math.abs(offsetY) < minOffset.y) { + nearestSnapsY.length = 0; + } + + nearestSnapsY.push({ + type: "point", + points: [currentPointGlobal, referencePoint], + offset: offsetY, + }); + + minOffset.y = Math.abs(offsetY); + } + } + + const snapOffset = { + x: nearestSnapsX[0]?.offset ?? 0, + y: nearestSnapsY[0]?.offset ?? 0, + }; + + // Create snap lines using the snapped position (fixed position) + let pointSnapLines: SnapLine[] = []; + + if (snapOffset.x !== 0 || snapOffset.y !== 0) { + // Recalculate snap lines with the snapped position + const snappedPosition = pointFrom( + pointPosition.x + snapOffset.x, + pointPosition.y + snapOffset.y + ); + + const snappedSnapsX: Snaps = []; + const snappedSnapsY: Snaps = []; + + // Find the reference points that we're snapping to + for (const referencePoint of referenceSnapPoints) { + const offsetX = referencePoint[0] - snappedPosition[0]; + const offsetY = referencePoint[1] - snappedPosition[1]; + + // Only include points that we're actually snapping to + if (Math.abs(offsetX) < 0.01) { // essentially zero after snapping + snappedSnapsX.push({ + type: "point", + points: [snappedPosition, referencePoint], + offset: 0, + }); + } + + if (Math.abs(offsetY) < 0.01) { // essentially zero after snapping + snappedSnapsY.push({ + type: "point", + points: [snappedPosition, referencePoint], + offset: 0, + }); + } + } + + pointSnapLines = createPointSnapLines(snappedSnapsX, snappedSnapsY); + } + + return { + snapOffset, + snapLines: pointSnapLines, + }; +}; + const getPointSnaps = ( selectedElements: ExcalidrawElement[], selectionSnapPoints: GlobalPoint[],