diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 93024f994..b62fc9834 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -107,6 +107,7 @@ export * from "./ShapeCache"; export * from "./shapes"; export * from "./showSelectedShapeActions"; export * from "./sizeHelpers"; +export * from "./snapping"; export * from "./sortElements"; export * from "./store"; export * from "./textElement"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index cb6b23dc8..5d1dec477 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -20,6 +20,11 @@ import { tupleToCoors, } from "@excalidraw/common"; +import { + type SnapLine, + snapLinearElementPoint, +} from "@excalidraw/element/snapping"; + import type { Store } from "@excalidraw/element"; import type { Radians } from "@excalidraw/math"; @@ -33,11 +38,6 @@ import type { Zoom, } from "@excalidraw/excalidraw/types"; -import { - SnapLine, - snapLinearElementPoint, -} from "@excalidraw/excalidraw/snapping"; - import type { Mutable } from "@excalidraw/common/utility-types"; import { @@ -388,6 +388,7 @@ export class LinearElementEditor { app, event, elementsMap, + { includeSelfPoints: true }, // Include element's own points for snapping when editing ); _snapLines = snapLines; @@ -1045,10 +1046,12 @@ export class LinearElementEditor { return { ...appState.editingLinearElement, lastUncommittedPoint: null, + snapLines: [], }; } let newPoint: LocalPoint; + let snapLines: SnapLine[] = []; if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { const lastCommittedPoint = points[points.length - 2]; @@ -1066,11 +1069,32 @@ export class LinearElementEditor { height + lastCommittedPoint[1], ); } else { + const originalPointerX = + scenePointerX - appState.editingLinearElement.pointerOffset.x; + const originalPointerY = + scenePointerY - appState.editingLinearElement.pointerOffset.y; + + const { snapOffset, snapLines: snappingLines } = snapLinearElementPoint( + app.scene.getNonDeletedElements(), + element, + points.length - 1, + { x: originalPointerX, y: originalPointerY }, + app, + event, + elementsMap, + { includeSelfPoints: true }, + ); + + snapLines = snappingLines; + + const snappedPointerX = originalPointerX + snapOffset.x; + const snappedPointerY = originalPointerY + snapOffset.y; + newPoint = LinearElementEditor.createPointAt( element, elementsMap, - scenePointerX - appState.editingLinearElement.pointerOffset.x, - scenePointerY - appState.editingLinearElement.pointerOffset.y, + snappedPointerX, + snappedPointerY, event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) ? null : app.getEffectiveGridSize(), @@ -1096,6 +1120,7 @@ export class LinearElementEditor { return { ...appState.editingLinearElement, lastUncommittedPoint: element.points[element.points.length - 1], + snapLines, }; } diff --git a/packages/excalidraw/snapping.ts b/packages/element/src/snapping.ts similarity index 96% rename from packages/excalidraw/snapping.ts rename to packages/element/src/snapping.ts index fccc8bc4e..337312d61 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/element/src/snapping.ts @@ -1,4 +1,5 @@ import { + isCloseTo, pointFrom, pointRotateRads, rangeInclusive, @@ -13,7 +14,11 @@ import { getDraggedElementsBounds, getElementAbsoluteCoords, } from "@excalidraw/element"; -import { isBoundToContainer, isFrameLikeElement, isElbowArrow } from "@excalidraw/element"; +import { + isBoundToContainer, + isFrameLikeElement, + isElbowArrow, +} from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element"; @@ -37,7 +42,7 @@ import type { AppClassProperties, AppState, KeyboardModifiersObject, -} from "./types"; +} from "@excalidraw/excalidraw/types"; const SNAP_DISTANCE = 8; @@ -200,12 +205,8 @@ export const getLinearElementPoints = ( ): GlobalPoint[] => { const { dragOffset, excludePointIndex } = options; - // Only process linear elements and freedraw - if ( - element.type !== "line" && - element.type !== "arrow" && - element.type !== "freedraw" - ) { + // Only process linear elements + if (element.type !== "line" && element.type !== "arrow") { return []; } @@ -292,11 +293,13 @@ export const getElementsCorners = ( const halfHeight = (y2 - y1) / 2; if ( - (element.type === "line" || element.type === "arrow" || element.type === "freedraw") && + (element.type === "line" || element.type === "arrow") && !boundingBoxCorners ) { // For linear elements, use actual points instead of bounding box - const linearPoints = getLinearElementPoints(element, elementsMap, { dragOffset }); + const linearPoints = getLinearElementPoints(element, elementsMap, { + dragOffset, + }); result = linearPoints; } else if ( (element.type === "diamond" || element.type === "ellipse") && @@ -710,7 +713,12 @@ export const getReferenceSnapPointsForLinearElementPoint = ( editingPointIndex: number, appState: AppState, elementsMap: ElementsMap, + options: { + includeSelfPoints?: boolean; + } = {}, ) => { + const { includeSelfPoints = false } = options; + // Get all reference elements (excluding the one being edited) const referenceElements = getReferenceElements( elements, @@ -719,21 +727,28 @@ export const getReferenceSnapPointsForLinearElementPoint = ( elementsMap, ); - let allSnapPoints: GlobalPoint[] = []; + const allSnapPoints: GlobalPoint[] = []; // Add snap points from all reference elements - const referenceGroups = getMaximumGroups(referenceElements, elementsMap) - .filter( - (elementsGroup) => - !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), - ); + 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 + // Include other points from the same linear element when creating new points or in editing mode + if (includeSelfPoints) { + const elementPoints = getLinearElementPoints(editingElement, elementsMap, { + excludePointIndex: editingPointIndex >= 0 ? editingPointIndex : undefined, + }); + allSnapPoints.push(...elementPoints); + } return allSnapPoints; }; @@ -746,9 +761,14 @@ export const snapLinearElementPoint = ( app: AppClassProperties, event: KeyboardModifiersObject, elementsMap: ElementsMap, + options: { + includeSelfPoints?: boolean; + } = {}, ) => { - if (!isSnappingEnabled({ app, event, selectedElements: [editingElement] }) || - isElbowArrow(editingElement)) { + if ( + !isSnappingEnabled({ app, event, selectedElements: [editingElement] }) || + isElbowArrow(editingElement) + ) { return { snapOffset: { x: 0, y: 0 }, snapLines: [], @@ -771,10 +791,14 @@ export const snapLinearElementPoint = ( editingPointIndex, app.state, elementsMap, + options, ); // Create a snap point for the current point position - const currentPointGlobal = pointFrom(pointPosition.x, pointPosition.y); + const currentPointGlobal = pointFrom( + pointPosition.x, + pointPosition.y, + ); // Find nearest snaps for (const referencePoint of referenceSnapPoints) { @@ -817,40 +841,40 @@ export const snapLinearElementPoint = ( // 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 + 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 + if (isCloseTo(offsetX, 0, 0.01)) { snappedSnapsX.push({ type: "point", points: [snappedPosition, referencePoint], offset: 0, }); } - - if (Math.abs(offsetY) < 0.01) { // essentially zero after snapping + + if (isCloseTo(offsetY, 0, 0.01)) { snappedSnapsY.push({ - type: "point", + type: "point", points: [snappedPosition, referencePoint], offset: 0, }); } } - + pointSnapLines = createPointSnapLines(snappedSnapsX, snappedSnapsY); } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6f027266d..dd826cff0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -292,6 +292,20 @@ import { Scene } from "@excalidraw/element"; import { Store, CaptureUpdateAction } from "@excalidraw/element"; +import { + getSnapLinesAtPointer, + snapDraggedElements, + isActiveToolNonLinearSnappable, + snapNewElement, + snapResizingElements, + isSnappingEnabled, + getVisibleGaps, + getReferenceSnapPoints, + SnapCache, + isGridModeEnabled, + snapLinearElementPoint, +} from "@excalidraw/element"; + import type { ElementUpdate } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -424,18 +438,6 @@ import { import { Fonts } from "../fonts"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai"; import { ImageSceneDataError } from "../errors"; -import { - getSnapLinesAtPointer, - snapDraggedElements, - isActiveToolNonLinearSnappable, - snapNewElement, - snapResizingElements, - isSnappingEnabled, - getVisibleGaps, - getReferenceSnapPoints, - SnapCache, - isGridModeEnabled, -} from "../snapping"; import { convertToExcalidrawElements } from "../data/transform"; import { Renderer } from "../scene/Renderer"; import { @@ -5874,9 +5876,13 @@ class App extends React.Component { const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; + // snap origin of the new element that's to be created if ( !this.state.newElement && - isActiveToolNonLinearSnappable(this.state.activeTool.type) + (isActiveToolNonLinearSnappable(this.state.activeTool.type) || + ((this.state.activeTool.type === "line" || + this.state.activeTool.type === "arrow") && + this.state.currentItemArrowType !== ARROW_TYPE.elbow)) ) { const { originOffset, snapLines } = getSnapLinesAtPointer( this.scene.getNonDeletedElements(), @@ -6047,12 +6053,32 @@ class App extends React.Component { gridX, gridY, )); + } else if (!isElbowArrow(multiElement)) { + const { snapOffset, snapLines } = snapLinearElementPoint( + this.scene.getNonDeletedElements(), + multiElement, + points.length - 1, + { x: gridX, y: gridY }, + this, + event, + this.scene.getNonDeletedElementsMap(), + { includeSelfPoints: true }, + ); + + const snappedGridX = gridX + snapOffset.x; + const snappedGridY = gridY + snapOffset.y; + + dxFromLastCommitted = snappedGridX - rx - lastCommittedX; + dyFromLastCommitted = snappedGridY - ry - lastCommittedY; + + this.setState({ + snapLines, + }); } if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - // update last uncommitted point this.scene.mutateElement( multiElement, @@ -8778,6 +8804,26 @@ class App extends React.Component { let dx = gridX - newElement.x; let dy = gridY - newElement.y; + // snap a two-point line/arrow as well + if (!isElbowArrow(newElement)) { + const { snapOffset, snapLines } = snapLinearElementPoint( + this.scene.getNonDeletedElements(), + newElement, + points.length - 1, + { x: gridX, y: gridY }, + this, + event, + this.scene.getNonDeletedElementsMap(), + { includeSelfPoints: true }, + ); + const snappedGridX = gridX + snapOffset.x; + const snappedGridY = gridY + snapOffset.y; + dx = snappedGridX - newElement.x; + dy = snappedGridY - newElement.y; + + this.setState({ snapLines }); + } + if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { ({ width: dx, height: dy } = getLockedLinearCursorAlignSize( newElement.x, diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 017fccf8e..ae491cecf 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -10,11 +10,10 @@ import { import { getShortcutKey } from "@excalidraw/common"; -import { isNodeInFlowchart } from "@excalidraw/element"; +import { isNodeInFlowchart, isGridModeEnabled } from "@excalidraw/element"; import { t } from "../i18n"; import { isEraserActive } from "../appState"; -import { isGridModeEnabled } from "../snapping"; import "./HintViewer.scss"; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index bcfab8520..4b8754e88 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -12,10 +12,11 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; import { elementsAreInSameGroup } from "@excalidraw/element"; +import { isGridModeEnabled } from "@excalidraw/element"; + import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { t } from "../../i18n"; -import { isGridModeEnabled } from "../../snapping"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { Island } from "../Island"; import { CloseIcon } from "../icons"; diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts index dd131f779..58f6aa0c5 100644 --- a/packages/excalidraw/renderer/renderSnaps.ts +++ b/packages/excalidraw/renderer/renderSnaps.ts @@ -2,7 +2,8 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math"; import { THEME } from "@excalidraw/common"; -import type { PointSnapLine, PointerSnapLine } from "../snapping"; +import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element"; + import type { InteractiveCanvasAppState } from "../types"; const SNAP_COLOR_LIGHT = "#ff6b6b"; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 6f3fd0efa..24d80261e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -11,6 +11,8 @@ import type { LinearElementEditor } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element"; +import type { SnapLine } from "@excalidraw/element"; + import type { PointerType, ExcalidrawLinearElement, @@ -56,7 +58,6 @@ import type App from "./components/App"; import type Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; import type { ContextMenuItems } from "./components/ContextMenu"; -import type { SnapLine } from "./snapping"; import type { ImportedDataState } from "./data/types"; import type { Language } from "./i18n";