feat: line snapping

This commit is contained in:
Ryan Di
2025-06-13 17:50:06 +10:00
parent 56c05b3099
commit 5403fa8a0d
3 changed files with 264 additions and 5 deletions

View File

@ -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<ExcalidrawLinearElement>,
@ -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

View File

@ -8334,6 +8334,7 @@ class App extends React.Component<AppProps, AppState> {
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
snapLines: newLinearElementEditor.snapLines,
});
return;

View File

@ -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<GlobalPoint>(
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<GlobalPoint>(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<GlobalPoint>(
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[],