feat: line snapping
This commit is contained in:
parent
56c05b3099
commit
5403fa8a0d
@ -33,6 +33,11 @@ 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 {
|
||||||
@ -150,6 +155,7 @@ export class LinearElementEditor {
|
|||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
public readonly elbowed: boolean;
|
||||||
public readonly customLineAngle: number | null;
|
public readonly customLineAngle: number | null;
|
||||||
|
public readonly snapLines: readonly SnapLine[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -188,6 +194,7 @@ export class LinearElementEditor {
|
|||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||||
this.customLineAngle = null;
|
this.customLineAngle = null;
|
||||||
|
this.snapLines = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -323,6 +330,8 @@ export class LinearElementEditor {
|
|||||||
// point that's being dragged (out of all selected points)
|
// 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) &&
|
||||||
@ -365,11 +374,33 @@ export class LinearElementEditor {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} 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(
|
||||||
|
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(
|
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(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -386,8 +417,8 @@ export class LinearElementEditor {
|
|||||||
? LinearElementEditor.createPointAt(
|
? LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
snappedPointerX,
|
||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
snappedPointerY,
|
||||||
event[KEYS.CTRL_OR_CMD]
|
event[KEYS.CTRL_OR_CMD]
|
||||||
? null
|
? null
|
||||||
: app.getEffectiveGridSize(),
|
: app.getEffectiveGridSize(),
|
||||||
@ -461,6 +492,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
snapLines: _snapLines,
|
||||||
hoverPointIndex:
|
hoverPointIndex:
|
||||||
lastClickedPoint === 0 ||
|
lastClickedPoint === 0 ||
|
||||||
lastClickedPoint === element.points.length - 1
|
lastClickedPoint === element.points.length - 1
|
||||||
|
|||||||
@ -8334,6 +8334,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
? newLinearElementEditor
|
? newLinearElementEditor
|
||||||
: null,
|
: null,
|
||||||
selectedLinearElement: newLinearElementEditor,
|
selectedLinearElement: newLinearElementEditor,
|
||||||
|
snapLines: newLinearElementEditor.snapLines,
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
getDraggedElementsBounds,
|
getDraggedElementsBounds,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
import { isBoundToContainer, isFrameLikeElement, isElbowArrow } from "@excalidraw/element";
|
||||||
|
|
||||||
import { getMaximumGroups } from "@excalidraw/element";
|
import { getMaximumGroups } from "@excalidraw/element";
|
||||||
|
|
||||||
@ -29,6 +29,7 @@ import type { MaybeTransformHandleType } from "@excalidraw/element";
|
|||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -189,6 +190,68 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
|
|||||||
return Math.abs(a - b) <= precision;
|
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 = (
|
export const getElementsCorners = (
|
||||||
elements: ExcalidrawElement[],
|
elements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@ -229,6 +292,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") &&
|
||||||
|
!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") &&
|
(element.type === "diamond" || element.type === "ellipse") &&
|
||||||
!boundingBoxCorners
|
!boundingBoxCorners
|
||||||
) {
|
) {
|
||||||
@ -634,6 +704,162 @@ 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,
|
||||||
|
) => {
|
||||||
|
// 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 = (
|
const getPointSnaps = (
|
||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
selectionSnapPoints: GlobalPoint[],
|
selectionSnapPoints: GlobalPoint[],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user