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

View File

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

View File

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