Compare commits

..

12 Commits

Author SHA1 Message Date
Ryan Di
60a459b135 refactor: remove points function from snapping and move to linear editor 2025-08-04 18:04:59 +10:00
Ryan Di
7332e76d56 refactor: simplify code 2025-08-04 13:46:33 +10:00
Ryan Di
dceaa53b0c fix: do not snap to pointer when creating 2025-08-04 12:33:49 +10:00
Ryan Di
6e968324fb fix snapshots 2025-08-04 12:09:06 +10:00
dwelle
09b18cacec Merge branch 'master' into ryan-di/line-snapping
# Conflicts:
#	packages/element/src/linearElementEditor.ts
#	packages/element/src/snapping.ts
#	packages/excalidraw/components/App.tsx
2025-07-31 22:42:52 +02:00
Ryan Di
0e197ef5c4 fix: do not snap to each other when moving multiple points together 2025-06-26 17:22:42 +10:00
Ryan Di
a0f7edadec test: update snapshots 2025-06-24 21:02:48 +10:00
Ryan Di
58c9bb4712 merge: with master 2025-06-24 21:00:06 +10:00
Ryan Di
d1c6304d42 test: update snapshots 2025-06-24 20:41:27 +10:00
Ryan Di
c1a54455bb feat: add snapping on top of angle locking when both enabled 2025-06-24 18:37:07 +10:00
Ryan Di
07640dd756 feat: extend line snapping to creation 2025-06-16 20:55:27 +10:00
Ryan Di
5403fa8a0d feat: line snapping 2025-06-13 17:50:06 +10:00
11 changed files with 754 additions and 140 deletions

View File

@@ -105,6 +105,7 @@ export * from "./selection";
export * from "./shape"; export * from "./shape";
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

@@ -7,6 +7,7 @@ import {
type LocalPoint, type LocalPoint,
pointDistance, pointDistance,
vectorFromPoint, vectorFromPoint,
line,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
} from "@excalidraw/math"; } from "@excalidraw/math";
@@ -26,6 +27,9 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
isPathALoop, isPathALoop,
snapLinearElementPoint,
snapToDiscreteAngle,
type SnapLine,
type Store, type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -321,9 +325,10 @@ 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[] = [];
if (selectedPointsIndices && draggingPoint) { if (selectedPointsIndices && draggingPoint) {
if ( if (
shouldRotateWithDiscreteAngle(event) && shouldRotateWithDiscreteAngle(event) &&
@@ -340,13 +345,85 @@ 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;
if (!isElbowArrow(element)) {
const { snapOffset, snapLines } = snapLinearElementPoint(
app.scene.getNonDeletedElements(),
element,
lastClickedPoint,
pointFrom<GlobalPoint>(effectiveGridX, 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 result = snapToDiscreteAngle(
snapLines,
angleLine,
pointFrom(gridX, gridY),
referencePointCoords,
);
dxFromReference = result.dxFromReference;
dyFromReference = result.dyFromReference;
_snapLines = result.snapLines;
} 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,
);
const newDraggingPointPosition = pointFrom(
referencePoint[0] + rotatedX,
referencePoint[1] + rotatedY,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -356,21 +433,41 @@ 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
const originalPointerX =
scenePointerX - linearElementEditor.pointerOffset.x;
const originalPointerY =
scenePointerY - linearElementEditor.pointerOffset.y;
const { snapOffset, snapLines } = snapLinearElementPoint(
app.scene.getNonDeletedElements(),
element,
lastClickedPoint,
pointFrom(originalPointerX, originalPointerY),
app,
event,
elementsMap,
{ includeSelfPoints: true, selectedPointsIndices },
);
_snapLines = snapLines;
// Apply snap offset to get final coordinates
const snappedPointerX = originalPointerX + snapOffset.x;
const snappedPointerY = originalPointerY + snapOffset.y;
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
elementsMap, elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x, snappedPointerX,
scenePointerY - linearElementEditor.pointerOffset.y, snappedPointerY,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
@@ -384,15 +481,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,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
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,
@@ -488,6 +577,7 @@ export class LinearElementEditor {
...app.state, ...app.state,
selectedLinearElement: newLinearElementEditor, selectedLinearElement: newLinearElementEditor,
suggestedBindings, suggestedBindings,
snapLines: _snapLines,
}; };
} }
@@ -1025,7 +1115,10 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
app: AppClassProperties, app: AppClassProperties,
): LinearElementEditor | null { ): {
editingLinearElement: LinearElementEditor;
snapLines: readonly SnapLine[];
} | null {
const appState = app.state; const appState = app.state;
if (!appState.selectedLinearElement?.isEditing) { if (!appState.selectedLinearElement?.isEditing) {
return null; return null;
@@ -1034,7 +1127,10 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return appState.selectedLinearElement; return {
editingLinearElement: appState.selectedLinearElement,
snapLines: appState.snapLines,
};
} }
const { points } = element; const { points } = element;
@@ -1044,37 +1140,131 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]); LinearElementEditor.deletePoints(element, app, [points.length - 1]);
} }
return appState.selectedLinearElement?.lastUncommittedPoint return {
? { editingLinearElement: {
...appState.selectedLinearElement, ...appState.selectedLinearElement,
lastUncommittedPoint: null, lastUncommittedPoint: null,
} isDragging: false,
: appState.selectedLinearElement; pointerOffset: { x: 0, y: 0 },
},
snapLines: [],
};
} }
let newPoint: LocalPoint; let newPoint: LocalPoint;
let snapLines: SnapLine[] = [];
const [gridX, gridY] = getGridPoint(
scenePointerX,
scenePointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
);
const [lastCommittedX, lastCommittedY] = points[points.length - 2] ?? [
0, 0,
];
const lastCommittedPointCoords =
LinearElementEditor.getPointGlobalCoordinates(
element,
pointFrom(lastCommittedX, lastCommittedY),
elementsMap,
);
let dxFromLastCommitted = gridX - lastCommittedPointCoords[0];
let dyFromLastCommitted = gridY - lastCommittedPointCoords[1];
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2]; ({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
getLockedLinearCursorAlignSize(
lastCommittedPointCoords[0],
lastCommittedPointCoords[1],
gridX,
gridY,
));
const [width, height] = LinearElementEditor._getShiftLockedDelta( const effectiveGridX = lastCommittedPointCoords[0] + dxFromLastCommitted;
element, const effectiveGridY = lastCommittedPointCoords[1] + dyFromLastCommitted;
elementsMap,
lastCommittedPoint, if (!isElbowArrow(element)) {
pointFrom(scenePointerX, scenePointerY), const { snapOffset, snapLines: _snapLines } = snapLinearElementPoint(
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), app.scene.getNonDeletedElements(),
element,
points.length - 1,
pointFrom(effectiveGridX, 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 result = snapToDiscreteAngle(
_snapLines,
angleLine,
pointFrom(gridX, gridY),
lastCommittedPointCoords,
);
dxFromLastCommitted = result.dxFromReference;
dyFromLastCommitted = result.dyFromReference;
snapLines = result.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 =
scenePointerX - appState.selectedLinearElement.pointerOffset.x;
const originalPointerY =
scenePointerY - appState.selectedLinearElement.pointerOffset.y;
const { snapOffset, snapLines: snappingLines } = snapLinearElementPoint(
app.scene.getNonDeletedElements(),
element,
points.length - 1,
pointFrom(originalPointerX, 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.selectedLinearElement.pointerOffset.x, snappedPointerX,
scenePointerY - appState.selectedLinearElement.pointerOffset.y, snappedPointerY,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null ? null
: app.getEffectiveGridSize(), : app.getEffectiveGridSize(),
@@ -1087,7 +1277,7 @@ export class LinearElementEditor {
app.scene, app.scene,
new Map([ new Map([
[ [
element.points.length - 1, points.length - 1,
{ {
point: newPoint, point: newPoint,
}, },
@@ -1097,9 +1287,13 @@ export class LinearElementEditor {
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [newPoint]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.selectedLinearElement, editingLinearElement: {
lastUncommittedPoint: element.points[element.points.length - 1], ...appState.selectedLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
},
snapLines,
}; };
} }
@@ -1125,18 +1319,53 @@ export class LinearElementEditor {
static getPointsGlobalCoordinates( static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: {
dragOffset?: { x: number; y: number };
excludePointsIndices?: readonly number[];
} = {},
): GlobalPoint[] { ): GlobalPoint[] {
const { dragOffset, excludePointsIndices } = options;
if (!element.points || element.points.length === 0) {
return [];
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
return element.points.map((p) => {
const { x, y } = element; let elementX = element.x;
return pointRotateRads( let elementY = element.y;
pointFrom(x + p[0], y + p[1]),
if (dragOffset) {
elementX += dragOffset.x;
elementY += dragOffset.y;
}
const globalPoints: GlobalPoint[] = [];
for (let i = 0; i < element.points.length; i++) {
// Skip the point being edited if specified
if (
excludePointsIndices?.length &&
excludePointsIndices.find((index) => index === i) !== undefined
) {
continue;
}
const p = element.points[i];
const globalX = elementX + p[0];
const globalY = elementY + p[1];
const rotated = pointRotateRads<GlobalPoint>(
pointFrom(globalX, globalY),
pointFrom(cx, cy), pointFrom(cx, cy),
element.angle, element.angle,
); );
}); globalPoints.push(rotated);
}
return globalPoints;
} }
static getPointAtIndexGlobalCoordinates( static getPointAtIndexGlobalCoordinates(

View File

@@ -1,4 +1,8 @@
import { import {
isCloseTo,
line,
linesIntersectAt,
pointDistance,
pointFrom, pointFrom,
pointRotateRads, pointRotateRads,
rangeInclusive, rangeInclusive,
@@ -13,7 +17,7 @@ import {
getDraggedElementsBounds, getDraggedElementsBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element"; import { isBoundToContainer, isElbowArrow } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element"; import { getMaximumGroups } from "@excalidraw/element";
@@ -29,14 +33,18 @@ import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
AppClassProperties, AppClassProperties,
AppState, AppState,
KeyboardModifiersObject, KeyboardModifiersObject,
} from "./types"; } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "./linearElementEditor";
const SNAP_DISTANCE = 8; const SNAP_DISTANCE = 8;
@@ -229,6 +237,19 @@ export const getElementsCorners = (
const halfHeight = (y2 - y1) / 2; const halfHeight = (y2 - y1) / 2;
if ( if (
(element.type === "line" || element.type === "arrow") &&
!boundingBoxCorners
) {
// For linear elements, use actual points instead of bounding box
const linearPoints = LinearElementEditor.getPointsGlobalCoordinates(
element as NonDeleted<ExcalidrawLinearElement>,
elementsMap,
{
dragOffset,
},
);
result = linearPoints;
} else if (
(element.type === "diamond" || element.type === "ellipse") && (element.type === "diamond" || element.type === "ellipse") &&
!boundingBoxCorners !boundingBoxCorners
) { ) {
@@ -627,6 +648,183 @@ export const getReferenceSnapPoints = (
.flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap));
}; };
export const getReferenceSnapPointsForLinearElementPoint = (
elements: readonly NonDeletedExcalidrawElement[],
editingElement: ExcalidrawLinearElement,
editingPointIndex: number,
appState: AppState,
elementsMap: ElementsMap,
options: {
includeSelfPoints?: boolean;
selectedPointsIndices?: readonly number[];
} = {},
) => {
const { includeSelfPoints = false } = options;
// Get all reference elements (excluding the one being edited)
const referenceElements = getReferenceElements(
elements,
[editingElement],
appState,
elementsMap,
);
const 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));
}
// Include other points from the same linear element when creating new points or in editing mode
if (includeSelfPoints) {
const elementPoints = LinearElementEditor.getPointsGlobalCoordinates(
editingElement as NonDeleted<ExcalidrawLinearElement>,
elementsMap,
{
excludePointsIndices: options.selectedPointsIndices,
},
);
allSnapPoints.push(...elementPoints);
}
return allSnapPoints;
};
export const snapLinearElementPoint = (
elements: readonly NonDeletedExcalidrawElement[],
editingElement: ExcalidrawLinearElement,
editingPointIndex: number,
pointerPosition: GlobalPoint,
app: AppClassProperties,
event: KeyboardModifiersObject,
elementsMap: ElementsMap,
options: {
includeSelfPoints?: boolean;
selectedPointsIndices?: readonly number[];
} = {},
) => {
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,
options,
);
// Find nearest snaps
for (const referencePoint of referenceSnapPoints) {
const offsetX = referencePoint[0] - pointerPosition[0];
const offsetY = referencePoint[1] - pointerPosition[1];
if (Math.abs(offsetX) <= minOffset.x) {
if (Math.abs(offsetX) < minOffset.x) {
nearestSnapsX.length = 0;
}
nearestSnapsX.push({
type: "point",
points: [pointerPosition, 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: [pointerPosition, 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>(
pointerPosition[0] + snapOffset.x,
pointerPosition[1] + 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 (isCloseTo(offsetX, 0, 0.01)) {
snappedSnapsX.push({
type: "point",
points: [snappedPosition, referencePoint],
offset: 0,
});
}
if (isCloseTo(offsetY, 0, 0.01)) {
snappedSnapsY.push({
type: "point",
points: [snappedPosition, referencePoint],
offset: 0,
});
}
}
pointSnapLines = createPointSnapLines(snappedSnapsX, snappedSnapsY);
}
return {
snapOffset,
snapLines: pointSnapLines,
};
};
const getPointSnaps = ( const getPointSnaps = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
selectionSnapPoints: GlobalPoint[], selectionSnapPoints: GlobalPoint[],
@@ -1406,3 +1604,79 @@ export const isActiveToolNonLinearSnappable = (
activeToolType === TOOL_TYPE.text activeToolType === TOOL_TYPE.text
); );
}; };
/**
* Snaps to discrete angle rotation logic.
* This function handles the common pattern of finding intersections between
* angle lines and snap lines, and updating the snap lines accordingly.
*
* @param snapLines - The original snap lines from snapping
* @param angleLine - The line representing the discrete angle constraint
* @param gridPosition - The grid position (original pointer position)
* @param referencePosition - The reference position (usually the start point)
* @returns Object containing updated snap lines and position deltas
*/
export const snapToDiscreteAngle = (
snapLines: SnapLine[],
angleLine: [GlobalPoint, GlobalPoint],
gridPosition: GlobalPoint,
referencePosition: GlobalPoint,
): {
snapLines: SnapLine[];
dxFromReference: number;
dyFromReference: number;
} => {
if (snapLines.length === 0) {
return {
snapLines: [],
dxFromReference: gridPosition[0] - referencePosition[0],
dyFromReference: gridPosition[1] - referencePosition[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>(
line(angleLine[0], angleLine[1]),
snapLine,
);
if (intersection) {
const dxFromReference = intersection[0] - referencePosition[0];
const dyFromReference = intersection[1] - referencePosition[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]),
},
);
const updatedSnapLine: PointSnapLine = {
type: "points",
points: [furthestPoint.point, intersection],
};
return {
snapLines: [updatedSnapLine],
dxFromReference,
dyFromReference,
};
}
}
// If no intersection found, return original snap lines with grid position
return {
snapLines,
dxFromReference: gridPosition[0] - referencePosition[0],
dyFromReference: gridPosition[1] - referencePosition[1],
};
};

View File

@@ -377,7 +377,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -479,7 +479,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -547,7 +547,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -598,7 +598,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -639,7 +639,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -687,7 +687,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -745,7 +745,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -843,7 +843,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);

View File

@@ -16,6 +16,7 @@ import {
vectorSubtract, vectorSubtract,
vectorDot, vectorDot,
vectorNormalize, vectorNormalize,
line,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
@@ -233,9 +234,21 @@ import {
hitElementBoundingBox, hitElementBoundingBox,
isLineElement, isLineElement,
isSimpleArrow, isSimpleArrow,
isGridModeEnabled,
SnapCache,
isActiveToolNonLinearSnappable,
getSnapLinesAtPointer,
snapLinearElementPoint,
snapToDiscreteAngle,
isSnappingEnabled,
getReferenceSnapPoints,
getVisibleGaps,
snapDraggedElements,
snapNewElement,
snapResizingElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -361,18 +374,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 {
@@ -5809,9 +5810,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(),
@@ -5860,40 +5865,45 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.isEditing && this.state.selectedLinearElement?.isEditing &&
!this.state.selectedLinearElement.isDragging !this.state.selectedLinearElement.isDragging
) { ) {
const editingLinearElement = LinearElementEditor.handlePointerMove( const result = LinearElementEditor.handlePointerMove(
event, event,
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
this, this,
); );
const linearElement = editingLinearElement
? this.scene.getElement(editingLinearElement.elementId)
: null;
if ( if (result) {
editingLinearElement && const { editingLinearElement, snapLines } = result;
editingLinearElement !== this.state.selectedLinearElement
) { if (
// Since we are reading from previous state which is not possible with editingLinearElement &&
// automatic batching in React 18 hence using flush sync to synchronously editingLinearElement !== this.state.selectedLinearElement
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. ) {
flushSync(() => { // Since we are reading from previous state which is not possible with
this.setState({ // automatic batching in React 18 hence using flush sync to synchronously
selectedLinearElement: editingLinearElement, // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
flushSync(() => {
this.setState({
selectedLinearElement: editingLinearElement,
snapLines,
});
}); });
}); }
} const latestLinearElement = this.scene.getElement(
if ( editingLinearElement.elementId,
editingLinearElement?.lastUncommittedPoint != null &&
linearElement &&
isBindingElementType(linearElement.type)
) {
this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
); );
} else if (this.state.suggestedBindings.length) { if (
this.setState({ suggestedBindings: [] }); editingLinearElement.lastUncommittedPoint != null &&
latestLinearElement &&
isBindingElementType(latestLinearElement.type)
) {
this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
);
} else if (this.state.suggestedBindings.length) {
this.setState({ suggestedBindings: [] });
}
} }
} }
@@ -5980,7 +5990,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
@@ -5992,10 +6004,65 @@ class App extends React.Component<AppProps, AppState> {
)); ));
} }
const effectiveGridX = lastCommittedX + dxFromLastCommitted + rx;
const effectiveGridY = lastCommittedY + dyFromLastCommitted + ry;
if (!isElbowArrow(multiElement)) {
const { snapOffset, snapLines } = snapLinearElementPoint(
this.scene.getNonDeletedElements(),
multiElement,
points.length - 1,
pointFrom(effectiveGridX, effectiveGridY),
this,
event,
this.scene.getNonDeletedElementsMap(),
{
includeSelfPoints: true,
selectedPointsIndices: [points.length - 1],
},
);
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),
);
const result = snapToDiscreteAngle(
snapLines,
angleLine,
pointFrom(gridX, gridY),
pointFrom(lastCommittedX + rx, lastCommittedY + ry),
);
dxFromLastCommitted = result.dxFromReference;
dyFromLastCommitted = result.dyFromReference;
this.setState({
snapLines: result.snapLines,
});
} 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)) {
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,
@@ -8674,7 +8741,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;
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { const rotateWithDiscreteAngle =
shouldRotateWithDiscreteAngle(event) && points.length === 2;
if (rotateWithDiscreteAngle) {
({ width: dx, height: dy } = getLockedLinearCursorAlignSize( ({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
newElement.x, newElement.x,
newElement.y, newElement.y,
@@ -8683,6 +8753,60 @@ 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,
pointFrom(effectiveGridX, effectiveGridY),
this,
event,
this.scene.getNonDeletedElementsMap(),
{
includeSelfPoints: true,
selectedPointsIndices: [points.length - 1],
},
);
if (snapLines.length > 0) {
if (rotateWithDiscreteAngle) {
const angleLine = line<GlobalPoint>(
pointFrom(effectiveGridX, effectiveGridY),
pointFrom(newElement.x, newElement.y),
);
const result = snapToDiscreteAngle(
snapLines,
angleLine,
pointFrom(gridX, gridY),
pointFrom(newElement.x, newElement.y),
);
dx = result.dxFromReference;
dy = result.dyFromReference;
this.setState({
snapLines: result.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,

View File

@@ -11,11 +11,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

@@ -4,13 +4,7 @@ import {
supported as nativeFileSystemSupported, supported as nativeFileSystemSupported,
} from "browser-fs-access"; } from "browser-fs-access";
import { import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
EVENT,
MIME_TYPES,
debounce,
isIOS,
isAndroid,
} from "@excalidraw/common";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@@ -19,8 +13,6 @@ import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">; type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500; const INPUT_CHANGE_INTERVAL_MS = 500;
// increase timeout for mobile devices to give more time for file selection
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
export const fileOpen = <M extends boolean | undefined = false>(opts: { export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[]; extensions?: FILE_EXTENSION[];
@@ -49,22 +41,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes, mimeTypes,
multiple: opts.multiple ?? false, multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => { legacySetup: (resolve, reject, input) => {
const isMobile = isIOS || isAndroid; const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const intervalMs = isMobile
? MOBILE_INPUT_CHANGE_INTERVAL_MS
: INPUT_CHANGE_INTERVAL_MS;
const scheduleRejection = debounce(reject, intervalMs);
const focusHandler = () => { const focusHandler = () => {
checkForFile(); checkForFile();
// on mobile, be less aggressive with rejection document.addEventListener(EVENT.KEYUP, scheduleRejection);
if (!isMobile) { document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
document.addEventListener(EVENT.KEYUP, scheduleRejection); scheduleRejection();
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
}
}; };
const checkForFile = () => { const checkForFile = () => {
// this hack might not work when expecting multiple files // this hack might not work when expecting multiple files
if (input.files?.length) { if (input.files?.length) {
@@ -72,15 +55,12 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
resolve(ret as RetType); resolve(ret as RetType);
} }
}; };
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler); window.addEventListener(EVENT.FOCUS, focusHandler);
}); });
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
checkForFile(); checkForFile();
}, intervalMs); }, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => { return (rejectPromise) => {
clearInterval(interval); clearInterval(interval);
scheduleRejection.cancel(); scheduleRejection.cancel();
@@ -89,9 +69,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection); document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) { if (rejectPromise) {
// so that something is shown in console if we need to debug this // so that something is shown in console if we need to debug this
console.warn( console.warn("Opening the file was canceled (legacy-fs).");
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
);
rejectPromise(new AbortError()); rejectPromise(new AbortError());
} }
}; };

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

@@ -8634,7 +8634,10 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,
@@ -9280,7 +9283,10 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"openMenu": null, "openMenu": null,
"openPopup": null, "openPopup": null,
"openSidebar": null, "openSidebar": null,
"originSnapOffset": null, "originSnapOffset": {
"x": 0,
"y": 0,
},
"pasteDialog": { "pasteDialog": {
"data": null, "data": null,
"shown": false, "shown": false,

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,
@@ -55,7 +57,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";