feat: add snapping on top of angle locking when both enabled

This commit is contained in:
Ryan Di 2025-06-24 18:37:07 +10:00
parent 07640dd756
commit c1a54455bb
3 changed files with 424 additions and 74 deletions

View File

@ -7,6 +7,8 @@ import {
type LocalPoint,
pointDistance,
vectorFromPoint,
line,
linesIntersectAt,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -25,7 +27,7 @@ import {
snapLinearElementPoint,
} from "@excalidraw/element/snapping";
import type { Store } from "@excalidraw/element";
import { ShapeCache, type Store } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
@ -60,8 +62,6 @@ import {
isFixedPointBinding,
} from "./typeChecks";
import { ShapeCache } from "./ShapeCache";
import {
isPathALoop,
getBezierCurveLength,
@ -327,7 +327,6 @@ export class LinearElementEditor {
: 0
: linearElementEditor.pointerDownState.lastClickedPoint;
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
let _snapLines: SnapLine[] = [];
@ -348,13 +347,119 @@ export class LinearElementEditor {
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
elementsMap,
referencePoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
const referencePointCoords =
LinearElementEditor.getPointGlobalCoordinates(
element,
referencePoint,
elementsMap,
);
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
);
let dxFromReference = gridX - referencePointCoords[0];
let dyFromReference = gridY - referencePointCoords[1];
if (shouldRotateWithDiscreteAngle(event)) {
({ width: dxFromReference, height: dyFromReference } =
getLockedLinearCursorAlignSize(
referencePointCoords[0],
referencePointCoords[1],
gridX,
gridY,
customLineAngle,
));
}
const effectiveGridX = referencePointCoords[0] + dxFromReference;
const effectiveGridY = referencePointCoords[1] + dyFromReference;
let newDraggingPointPosition = pointFrom(
effectiveGridX,
effectiveGridY,
);
if (!isElbowArrow(element)) {
const { snapOffset, snapLines } = snapLinearElementPoint(
scene.getNonDeletedElements(),
element,
lastClickedPoint,
{ x: effectiveGridX, y: effectiveGridY },
app,
event,
elementsMap,
{ includeSelfPoints: true },
);
_snapLines = snapLines;
if (snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) {
const angleLine = line<GlobalPoint>(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(referencePointCoords[0], referencePointCoords[1]),
);
const firstSnapLine = snapLines[0];
if (
firstSnapLine.type === "points" &&
firstSnapLine.points.length > 1
) {
const snapLine = line(
firstSnapLine.points[0],
firstSnapLine.points[1],
);
const intersection = linesIntersectAt<GlobalPoint>(
angleLine,
snapLine,
);
if (intersection) {
dxFromReference = intersection[0] - referencePointCoords[0];
dyFromReference = intersection[1] - referencePointCoords[1];
const furthestPoint = firstSnapLine.points.reduce(
(furthest, point) => {
const distance = pointDistance(intersection, point);
if (distance > furthest.distance) {
return { point, distance };
}
return furthest;
},
{
point: firstSnapLine.points[0],
distance: pointDistance(
intersection,
firstSnapLine.points[0],
),
},
);
firstSnapLine.points = [furthestPoint.point, intersection];
_snapLines = [firstSnapLine];
}
}
} else if (snapLines.length > 0) {
const snappedGridX = effectiveGridX + snapOffset.x;
const snappedGridY = effectiveGridY + snapOffset.y;
dxFromReference = snappedGridX - referencePointCoords[0];
dyFromReference = snappedGridY - referencePointCoords[1];
}
}
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(dxFromReference, dyFromReference),
pointFrom(0, 0),
-element.angle as Radians,
);
newDraggingPointPosition = pointFrom(
referencePoint[0] + rotatedX,
referencePoint[1] + rotatedY,
);
LinearElementEditor.movePoints(
@ -364,14 +469,11 @@ export class LinearElementEditor {
[
selectedIndex,
{
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
point: newDraggingPointPosition,
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
]) as PointsPositionUpdates,
);
} else {
// Apply object snapping for the point being dragged
@ -388,7 +490,7 @@ export class LinearElementEditor {
app,
event,
elementsMap,
{ includeSelfPoints: true }, // Include element's own points for snapping when editing
{ includeSelfPoints: true },
);
_snapLines = snapLines;
@ -415,15 +517,7 @@ export class LinearElementEditor {
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
snappedPointerX,
snappedPointerY,
event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize(),
)
? newDraggingPointPosition
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
@ -1053,20 +1147,122 @@ export class LinearElementEditor {
let newPoint: LocalPoint;
let snapLines: SnapLine[] = [];
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
const [lastCommittedX, lastCommittedY] = points[points.length - 2] ?? [
0, 0,
];
const lastCommittedPointCoords =
LinearElementEditor.getPointGlobalCoordinates(
element,
pointFrom(lastCommittedX, lastCommittedY),
elementsMap,
lastCommittedPoint,
pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
let dxFromLastCommitted = gridX - lastCommittedPointCoords[0];
let dyFromLastCommitted = gridY - lastCommittedPointCoords[1];
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
lastCommittedPointCoords[0],
lastCommittedPointCoords[1],
gridX,
gridY,
));
const effectiveGridX = lastCommittedPointCoords[0] + dxFromLastCommitted;
const effectiveGridY = lastCommittedPointCoords[1] + dyFromLastCommitted;
if (!isElbowArrow(element)) {
const { snapOffset, snapLines: _snapLines } = snapLinearElementPoint(
app.scene.getNonDeletedElements(),
element,
points.length - 1,
{ x: effectiveGridX, y: effectiveGridY },
app,
event,
elementsMap,
{ includeSelfPoints: true },
);
snapLines = _snapLines;
if (_snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) {
const angleLine = line<GlobalPoint>(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(lastCommittedPointCoords[0], lastCommittedPointCoords[1]),
);
const firstSnapLine = _snapLines[0];
if (
firstSnapLine.type === "points" &&
firstSnapLine.points.length > 1
) {
const snapLine = line(
firstSnapLine.points[0],
firstSnapLine.points[1],
);
const intersection = linesIntersectAt<GlobalPoint>(
angleLine,
snapLine,
);
if (intersection) {
dxFromLastCommitted =
intersection[0] - lastCommittedPointCoords[0];
dyFromLastCommitted =
intersection[1] - lastCommittedPointCoords[1];
const furthestPoint = firstSnapLine.points.reduce(
(furthest, point) => {
const distance = pointDistance(intersection, point);
if (distance > furthest.distance) {
return { point, distance };
}
return furthest;
},
{
point: firstSnapLine.points[0],
distance: pointDistance(
intersection,
firstSnapLine.points[0],
),
},
);
firstSnapLine.points = [furthestPoint.point, intersection];
snapLines = [firstSnapLine];
}
} else {
snapLines = [];
}
} else if (_snapLines.length > 0) {
const snappedGridX = effectiveGridX + snapOffset.x;
const snappedGridY = effectiveGridY + snapOffset.y;
dxFromLastCommitted = snappedGridX - lastCommittedPointCoords[0];
dyFromLastCommitted = snappedGridY - lastCommittedPointCoords[1];
} else {
snapLines = [];
}
}
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(dxFromLastCommitted, dyFromLastCommitted),
pointFrom(0, 0),
-element.angle as Radians,
);
newPoint = pointFrom(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
lastCommittedX + rotatedX,
lastCommittedY + rotatedY,
);
} else {
const originalPointerX =
@ -1107,7 +1303,7 @@ export class LinearElementEditor {
app.scene,
new Map([
[
element.points.length - 1,
points.length - 1,
{
point: newPoint,
},
@ -1117,6 +1313,7 @@ export class LinearElementEditor {
} else {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],

View File

@ -2,6 +2,7 @@ import {
isCloseTo,
pointFrom,
pointRotateRads,
pointsEqual,
rangeInclusive,
rangeIntersection,
rangesOverlap,
@ -196,7 +197,7 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
};
export const getLinearElementPoints = (
element: ExcalidrawElement,
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
options: {
dragOffset?: Vector2D;
@ -205,13 +206,11 @@ export const getLinearElementPoints = (
): GlobalPoint[] => {
const { dragOffset, excludePointIndex } = options;
// Only process linear elements
if (element.type !== "line" && element.type !== "arrow") {
if (isElbowArrow(element)) {
return [];
}
const linearElement = element as ExcalidrawLinearElement;
if (!linearElement.points || linearElement.points.length === 0) {
if (!element.points || element.points.length === 0) {
return [];
}
@ -225,13 +224,13 @@ export const getLinearElementPoints = (
const globalPoints: GlobalPoint[] = [];
for (let i = 0; i < linearElement.points.length; i++) {
for (let i = 0; i < element.points.length; i++) {
// Skip the point being edited if specified
if (excludePointIndex !== undefined && i === excludePointIndex) {
continue;
}
const localPoint = linearElement.points[i];
const localPoint = element.points[i];
const globalX = elementX + localPoint[0];
const globalY = elementY + localPoint[1];
@ -747,6 +746,21 @@ export const getReferenceSnapPointsForLinearElementPoint = (
const elementPoints = getLinearElementPoints(editingElement, elementsMap, {
excludePointIndex: editingPointIndex >= 0 ? editingPointIndex : undefined,
});
const shouldSkipFirstOrLast =
editingElement.points.length > 2 &&
pointsEqual(
editingElement.points[0],
editingElement.points[editingElement.points.length - 1],
);
if (shouldSkipFirstOrLast) {
if (editingPointIndex === 0) {
elementPoints.pop();
}
if (editingPointIndex === editingElement.points.length - 1) {
elementPoints.shift();
}
}
allSnapPoints.push(...elementPoints);
}

View File

@ -16,6 +16,8 @@ import {
vectorSubtract,
vectorDot,
vectorNormalize,
line,
linesIntersectAt,
} from "@excalidraw/math";
import { isPointInShape } from "@excalidraw/utils/collision";
import { getSelectionBoxShape } from "@excalidraw/utils/shape";
@ -306,9 +308,9 @@ import {
snapLinearElementPoint,
} from "@excalidraw/element";
import type { ElementUpdate } from "@excalidraw/element";
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawBindableElement,
@ -5948,6 +5950,7 @@ class App extends React.Component<AppProps, AppState> {
flushSync(() => {
this.setState({
editingLinearElement,
snapLines: editingLinearElement.snapLines,
});
});
}
@ -6043,7 +6046,9 @@ class App extends React.Component<AppProps, AppState> {
let dxFromLastCommitted = gridX - rx - lastCommittedX;
let dyFromLastCommitted = gridY - ry - lastCommittedY;
if (shouldRotateWithDiscreteAngle(event)) {
const rotateWithDiscreteAngle = shouldRotateWithDiscreteAngle(event);
if (rotateWithDiscreteAngle) {
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
// actual coordinate of the last committed point
@ -6053,27 +6058,94 @@ class App extends React.Component<AppProps, AppState> {
gridX,
gridY,
));
} else if (!isElbowArrow(multiElement)) {
}
const effectiveGridX = lastCommittedX + dxFromLastCommitted + rx;
const effectiveGridY = lastCommittedY + dyFromLastCommitted + ry;
if (!isElbowArrow(multiElement)) {
const { snapOffset, snapLines } = snapLinearElementPoint(
this.scene.getNonDeletedElements(),
multiElement,
points.length - 1,
{ x: gridX, y: gridY },
{ x: effectiveGridX, y: effectiveGridY },
this,
event,
this.scene.getNonDeletedElementsMap(),
{ includeSelfPoints: true },
);
const snappedGridX = gridX + snapOffset.x;
const snappedGridY = gridY + snapOffset.y;
if (snapLines.length > 0) {
if (rotateWithDiscreteAngle) {
// Create line from effective position to last committed point
const angleLine = line<GlobalPoint>(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(lastCommittedX + rx, lastCommittedY + ry),
);
dxFromLastCommitted = snappedGridX - rx - lastCommittedX;
dyFromLastCommitted = snappedGridY - ry - lastCommittedY;
// Find intersection with first snap line
const firstSnapLine = snapLines[0];
if (
firstSnapLine.type === "points" &&
firstSnapLine.points.length > 1
) {
const snapLine = line(
firstSnapLine.points[0],
firstSnapLine.points[1],
);
const intersection = linesIntersectAt<GlobalPoint>(
angleLine,
snapLine,
);
this.setState({
snapLines,
});
if (intersection) {
dxFromLastCommitted = intersection[0] - rx - lastCommittedX;
dyFromLastCommitted = intersection[1] - ry - lastCommittedY;
// Find the furthest point from the intersection
const furthestPoint = firstSnapLine.points.reduce(
(furthest, point) => {
const distance = pointDistance(intersection, point);
if (distance > furthest.distance) {
return { point, distance };
}
return furthest;
},
{
point: firstSnapLine.points[0],
distance: pointDistance(
intersection,
firstSnapLine.points[0],
),
},
);
firstSnapLine.points = [furthestPoint.point, intersection];
this.setState({
snapLines: [firstSnapLine],
});
this.setState({
snapLines: [firstSnapLine],
});
}
}
} else {
const snappedGridX = effectiveGridX + snapOffset.x;
const snappedGridY = effectiveGridY + snapOffset.y;
dxFromLastCommitted = snappedGridX - rx - lastCommittedX;
dyFromLastCommitted = snappedGridY - ry - lastCommittedY;
this.setState({
snapLines,
});
}
} else {
this.setState({
snapLines: [],
});
}
}
if (isPathALoop(points, this.state.zoom.value)) {
@ -8804,27 +8876,10 @@ class App extends React.Component<AppProps, AppState> {
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;
const rotateWithDiscreteAngle =
shouldRotateWithDiscreteAngle(event) && points.length === 2;
this.setState({ snapLines });
}
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
if (rotateWithDiscreteAngle) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x,
newElement.y,
@ -8833,6 +8888,90 @@ class App extends React.Component<AppProps, AppState> {
));
}
const effectiveGridX = newElement.x + dx;
const effectiveGridY = newElement.y + dy;
// Snap a two-point line/arrow as well
if (!isElbowArrow(newElement)) {
const { snapOffset, snapLines } = snapLinearElementPoint(
this.scene.getNonDeletedElements(),
newElement,
points.length - 1,
{ x: effectiveGridX, y: effectiveGridY },
this,
event,
this.scene.getNonDeletedElementsMap(),
{ includeSelfPoints: true },
);
if (snapLines.length > 0) {
if (rotateWithDiscreteAngle) {
const angleLine = line<GlobalPoint>(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(newElement.x, newElement.y),
);
const firstSnapLine = snapLines.find(
(snapLine) =>
snapLine.type === "points" && snapLine.points.length > 2,
);
if (firstSnapLine && firstSnapLine.points.length > 1) {
const snapLine = line(
firstSnapLine.points[0],
firstSnapLine.points[1],
);
const intersection = linesIntersectAt<GlobalPoint>(
angleLine,
snapLine,
);
if (intersection) {
dx = intersection[0] - newElement.x;
dy = intersection[1] - newElement.y;
// Find the furthest point from the intersection
const furthestPoint = firstSnapLine.points.reduce(
(furthest, point) => {
const distance = pointDistance(intersection, point);
if (distance > furthest.distance) {
return { point, distance };
}
return furthest;
},
{
point: firstSnapLine.points[0],
distance: pointDistance(
intersection,
firstSnapLine.points[0],
),
},
);
firstSnapLine.points = [furthestPoint.point, intersection];
this.setState({
snapLines: [firstSnapLine],
});
} else {
this.setState({
snapLines: [],
});
}
}
} else {
dx = gridX + snapOffset.x - newElement.x;
dy = gridY + snapOffset.y - newElement.y;
this.setState({
snapLines,
});
}
} else {
this.setState({
snapLines: [],
});
}
}
if (points.length === 1) {
this.scene.mutateElement(
newElement,