feat: extend line snapping to creation

This commit is contained in:
Ryan Di 2025-06-16 20:55:27 +10:00
parent 5403fa8a0d
commit 07640dd756
8 changed files with 155 additions and 57 deletions

View File

@ -107,6 +107,7 @@ export * from "./ShapeCache";
export * from "./shapes"; export * from "./shapes";
export * from "./showSelectedShapeActions"; export * from "./showSelectedShapeActions";
export * from "./sizeHelpers"; export * from "./sizeHelpers";
export * from "./snapping";
export * from "./sortElements"; export * from "./sortElements";
export * from "./store"; export * from "./store";
export * from "./textElement"; export * from "./textElement";

View File

@ -20,6 +20,11 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
type SnapLine,
snapLinearElementPoint,
} from "@excalidraw/element/snapping";
import type { Store } from "@excalidraw/element"; import type { Store } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@ -33,11 +38,6 @@ import type {
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import {
SnapLine,
snapLinearElementPoint,
} from "@excalidraw/excalidraw/snapping";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import {
@ -388,6 +388,7 @@ export class LinearElementEditor {
app, app,
event, event,
elementsMap, elementsMap,
{ includeSelfPoints: true }, // Include element's own points for snapping when editing
); );
_snapLines = snapLines; _snapLines = snapLines;
@ -1045,10 +1046,12 @@ export class LinearElementEditor {
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
lastUncommittedPoint: null, lastUncommittedPoint: null,
snapLines: [],
}; };
} }
let newPoint: LocalPoint; let newPoint: LocalPoint;
let snapLines: SnapLine[] = [];
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2]; const lastCommittedPoint = points[points.length - 2];
@ -1066,11 +1069,32 @@ export class LinearElementEditor {
height + lastCommittedPoint[1], height + lastCommittedPoint[1],
); );
} else { } 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( newPoint = LinearElementEditor.createPointAt(
element, element,
elementsMap, elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x, snappedPointerX,
scenePointerY - appState.editingLinearElement.pointerOffset.y, snappedPointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: app.getEffectiveGridSize(), : app.getEffectiveGridSize(),
@ -1096,6 +1120,7 @@ export class LinearElementEditor {
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1], lastUncommittedPoint: element.points[element.points.length - 1],
snapLines,
}; };
} }

View File

@ -1,4 +1,5 @@
import { import {
isCloseTo,
pointFrom, pointFrom,
pointRotateRads, pointRotateRads,
rangeInclusive, rangeInclusive,
@ -13,7 +14,11 @@ import {
getDraggedElementsBounds, getDraggedElementsBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { isBoundToContainer, isFrameLikeElement, isElbowArrow } from "@excalidraw/element"; import {
isBoundToContainer,
isFrameLikeElement,
isElbowArrow,
} from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element";
@ -37,7 +42,7 @@ import type {
AppClassProperties, AppClassProperties,
AppState, AppState,
KeyboardModifiersObject, KeyboardModifiersObject,
} from "./types"; } from "@excalidraw/excalidraw/types";
const SNAP_DISTANCE = 8; const SNAP_DISTANCE = 8;
@ -200,12 +205,8 @@ export const getLinearElementPoints = (
): GlobalPoint[] => { ): GlobalPoint[] => {
const { dragOffset, excludePointIndex } = options; const { dragOffset, excludePointIndex } = options;
// Only process linear elements and freedraw // Only process linear elements
if ( if (element.type !== "line" && element.type !== "arrow") {
element.type !== "line" &&
element.type !== "arrow" &&
element.type !== "freedraw"
) {
return []; return [];
} }
@ -292,11 +293,13 @@ export const getElementsCorners = (
const halfHeight = (y2 - y1) / 2; const halfHeight = (y2 - y1) / 2;
if ( if (
(element.type === "line" || element.type === "arrow" || element.type === "freedraw") && (element.type === "line" || element.type === "arrow") &&
!boundingBoxCorners !boundingBoxCorners
) { ) {
// For linear elements, use actual points instead of bounding box // For linear elements, use actual points instead of bounding box
const linearPoints = getLinearElementPoints(element, elementsMap, { dragOffset }); const linearPoints = getLinearElementPoints(element, elementsMap, {
dragOffset,
});
result = linearPoints; result = linearPoints;
} else if ( } else if (
(element.type === "diamond" || element.type === "ellipse") && (element.type === "diamond" || element.type === "ellipse") &&
@ -710,7 +713,12 @@ export const getReferenceSnapPointsForLinearElementPoint = (
editingPointIndex: number, editingPointIndex: number,
appState: AppState, appState: AppState,
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: {
includeSelfPoints?: boolean;
} = {},
) => { ) => {
const { includeSelfPoints = false } = options;
// Get all reference elements (excluding the one being edited) // Get all reference elements (excluding the one being edited)
const referenceElements = getReferenceElements( const referenceElements = getReferenceElements(
elements, elements,
@ -719,11 +727,13 @@ export const getReferenceSnapPointsForLinearElementPoint = (
elementsMap, elementsMap,
); );
let allSnapPoints: GlobalPoint[] = []; const allSnapPoints: GlobalPoint[] = [];
// Add snap points from all reference elements // Add snap points from all reference elements
const referenceGroups = getMaximumGroups(referenceElements, elementsMap) const referenceGroups = getMaximumGroups(
.filter( referenceElements,
elementsMap,
).filter(
(elementsGroup) => (elementsGroup) =>
!(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
); );
@ -732,8 +742,13 @@ export const getReferenceSnapPointsForLinearElementPoint = (
allSnapPoints.push(...getElementsCorners(elementGroup, elementsMap)); allSnapPoints.push(...getElementsCorners(elementGroup, elementsMap));
} }
// Note: We do not include other points from the same linear element // Include other points from the same linear element when creating new points or in editing mode
// as reference points when dragging a point, per user feedback if (includeSelfPoints) {
const elementPoints = getLinearElementPoints(editingElement, elementsMap, {
excludePointIndex: editingPointIndex >= 0 ? editingPointIndex : undefined,
});
allSnapPoints.push(...elementPoints);
}
return allSnapPoints; return allSnapPoints;
}; };
@ -746,9 +761,14 @@ export const snapLinearElementPoint = (
app: AppClassProperties, app: AppClassProperties,
event: KeyboardModifiersObject, event: KeyboardModifiersObject,
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: {
includeSelfPoints?: boolean;
} = {},
) => { ) => {
if (!isSnappingEnabled({ app, event, selectedElements: [editingElement] }) || if (
isElbowArrow(editingElement)) { !isSnappingEnabled({ app, event, selectedElements: [editingElement] }) ||
isElbowArrow(editingElement)
) {
return { return {
snapOffset: { x: 0, y: 0 }, snapOffset: { x: 0, y: 0 },
snapLines: [], snapLines: [],
@ -771,10 +791,14 @@ export const snapLinearElementPoint = (
editingPointIndex, editingPointIndex,
app.state, app.state,
elementsMap, elementsMap,
options,
); );
// Create a snap point for the current point position // Create a snap point for the current point position
const currentPointGlobal = pointFrom<GlobalPoint>(pointPosition.x, pointPosition.y); const currentPointGlobal = pointFrom<GlobalPoint>(
pointPosition.x,
pointPosition.y,
);
// Find nearest snaps // Find nearest snaps
for (const referencePoint of referenceSnapPoints) { for (const referencePoint of referenceSnapPoints) {
@ -822,7 +846,7 @@ export const snapLinearElementPoint = (
// Recalculate snap lines with the snapped position // Recalculate snap lines with the snapped position
const snappedPosition = pointFrom<GlobalPoint>( const snappedPosition = pointFrom<GlobalPoint>(
pointPosition.x + snapOffset.x, pointPosition.x + snapOffset.x,
pointPosition.y + snapOffset.y pointPosition.y + snapOffset.y,
); );
const snappedSnapsX: Snaps = []; const snappedSnapsX: Snaps = [];
@ -834,7 +858,7 @@ export const snapLinearElementPoint = (
const offsetY = referencePoint[1] - snappedPosition[1]; const offsetY = referencePoint[1] - snappedPosition[1];
// Only include points that we're actually snapping to // 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({ snappedSnapsX.push({
type: "point", type: "point",
points: [snappedPosition, referencePoint], points: [snappedPosition, referencePoint],
@ -842,7 +866,7 @@ export const snapLinearElementPoint = (
}); });
} }
if (Math.abs(offsetY) < 0.01) { // essentially zero after snapping if (isCloseTo(offsetY, 0, 0.01)) {
snappedSnapsY.push({ snappedSnapsY.push({
type: "point", type: "point",
points: [snappedPosition, referencePoint], points: [snappedPosition, referencePoint],

View File

@ -292,6 +292,20 @@ import { Scene } from "@excalidraw/element";
import { Store, CaptureUpdateAction } 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 { ElementUpdate } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
@ -424,18 +438,6 @@ import {
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { editorJotaiStore, type WritableAtom } from "../editor-jotai"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors"; import { ImageSceneDataError } from "../errors";
import {
getSnapLinesAtPointer,
snapDraggedElements,
isActiveToolNonLinearSnappable,
snapNewElement,
snapResizingElements,
isSnappingEnabled,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
} from "../snapping";
import { convertToExcalidrawElements } from "../data/transform"; import { convertToExcalidrawElements } from "../data/transform";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { import {
@ -5874,9 +5876,13 @@ class App extends React.Component<AppProps, AppState> {
const scenePointer = viewportCoordsToSceneCoords(event, this.state); const scenePointer = viewportCoordsToSceneCoords(event, this.state);
const { x: scenePointerX, y: scenePointerY } = scenePointer; const { x: scenePointerX, y: scenePointerY } = scenePointer;
// snap origin of the new element that's to be created
if ( if (
!this.state.newElement && !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( const { originOffset, snapLines } = getSnapLinesAtPointer(
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
@ -6047,12 +6053,32 @@ class App extends React.Component<AppProps, AppState> {
gridX, gridX,
gridY, 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)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
// update last uncommitted point // update last uncommitted point
this.scene.mutateElement( this.scene.mutateElement(
multiElement, multiElement,
@ -8778,6 +8804,26 @@ class App extends React.Component<AppProps, AppState> {
let dx = gridX - newElement.x; let dx = gridX - newElement.x;
let dy = gridY - newElement.y; 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) { if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize( ({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x, newElement.x,

View File

@ -10,11 +10,10 @@ import {
import { getShortcutKey } from "@excalidraw/common"; import { getShortcutKey } from "@excalidraw/common";
import { isNodeInFlowchart } from "@excalidraw/element"; import { isNodeInFlowchart, isGridModeEnabled } from "@excalidraw/element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isEraserActive } from "../appState"; import { isEraserActive } from "../appState";
import { isGridModeEnabled } from "../snapping";
import "./HintViewer.scss"; import "./HintViewer.scss";

View File

@ -12,10 +12,11 @@ import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
import { elementsAreInSameGroup } from "@excalidraw/element"; import { elementsAreInSameGroup } from "@excalidraw/element";
import { isGridModeEnabled } from "@excalidraw/element";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { isGridModeEnabled } from "../../snapping";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App"; import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { Island } from "../Island"; import { Island } from "../Island";
import { CloseIcon } from "../icons"; import { CloseIcon } from "../icons";

View File

@ -2,7 +2,8 @@ import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
import { THEME } from "@excalidraw/common"; import { THEME } from "@excalidraw/common";
import type { PointSnapLine, PointerSnapLine } from "../snapping"; import type { PointSnapLine, PointerSnapLine } from "@excalidraw/element";
import type { InteractiveCanvasAppState } from "../types"; import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b"; const SNAP_COLOR_LIGHT = "#ff6b6b";

View File

@ -11,6 +11,8 @@ import type { LinearElementEditor } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { SnapLine } from "@excalidraw/element";
import type { import type {
PointerType, PointerType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -56,7 +58,6 @@ import type App from "./components/App";
import type Library from "./data/library"; import type Library from "./data/library";
import type { FileSystemHandle } from "./data/filesystem"; import type { FileSystemHandle } from "./data/filesystem";
import type { ContextMenuItems } from "./components/ContextMenu"; import type { ContextMenuItems } from "./components/ContextMenu";
import type { SnapLine } from "./snapping";
import type { ImportedDataState } from "./data/types"; import type { ImportedDataState } from "./data/types";
import type { Language } from "./i18n"; import type { Language } from "./i18n";