fix(snaps): stabilize gap snapline rendering

This commit is contained in:
Ryan Di
2026-05-11 17:13:36 +10:00
parent 5ceb97e6b5
commit f42dda6769
2 changed files with 480 additions and 110 deletions
+209 -98
View File
@@ -39,6 +39,10 @@ import type {
} from "./types"; } from "./types";
const SNAP_DISTANCE = 8; const SNAP_DISTANCE = 8;
// Keep snap candidates with effectively identical offsets together. This needs
// to be wider than the 6-decimal rounding quantum to absorb JS floating-point
// noise around values like 4 and 4.000001.
const SNAP_OFFSET_TOLERANCE = 0.00001;
// Point snaps can come from both nearby and distant references within snap // Point snaps can come from both nearby and distant references within snap
// distance. If their visual distances have a large break, prefer the nearest // distance. If their visual distances have a large break, prefer the nearest
// cluster before comparing offsets. // cluster before comparing offsets.
@@ -67,34 +71,34 @@ type Vector2D = {
type PointPair = [GlobalPoint, GlobalPoint]; type PointPair = [GlobalPoint, GlobalPoint];
type SnapPointType = "outer" | "center"; type SnapPointType = "outer" | "center";
type SnapPointSourceId = string; type SnapSourceId = string;
const SELECTION_SNAP_POINT_SOURCE_ID = "selection"; const SELECTION_SNAP_SOURCE_ID = "selection";
// Source ids let us filter redundant snaplines only within one // Snap source ids let us filter redundant snaplines only within one
// selection-to-reference relationship, without mixing separate reference groups. // selection-to-reference relationship, without mixing separate reference groups.
// For example, "selection->rectA" may collapse to its outermost snaplines, // For example, "selection->rectA" may collapse to its outermost snaplines,
// while "selection->rectB" still keeps its own independent snaplines. // while "selection->rectB" still keeps its own independent snaplines.
type SnapPoint = { type SnapPoint = {
point: GlobalPoint; point: GlobalPoint;
type: SnapPointType; type: SnapPointType;
sourceId: SnapPointSourceId; snapSourceId: SnapSourceId;
}; };
const outerSnapPoint = ( const outerSnapPoint = (
point: GlobalPoint, point: GlobalPoint,
sourceId = SELECTION_SNAP_POINT_SOURCE_ID, snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint => ({ ): SnapPoint => ({
point, point,
type: "outer", type: "outer",
sourceId, snapSourceId,
}); });
export type PointSnap = { export type PointSnap = {
type: "point"; type: "point";
points: PointPair; points: PointPair;
pointTypes: [SnapPointType, SnapPointType]; pointTypes: [SnapPointType, SnapPointType];
sourceIds: [SnapPointSourceId, SnapPointSourceId]; snapSourceIds: [SnapSourceId, SnapSourceId];
// Distance along the rendered snapline, used to group visually nearby // Distance along the rendered snapline, used to group visually nearby
// references before choosing which point snaps should win. // references before choosing which point snaps should win.
visualDistance: number; visualDistance: number;
@@ -234,6 +238,17 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
return Math.abs(a - b) <= precision; return Math.abs(a - b) <= precision;
}; };
/**
* Keeps nearly identical snap offsets in the same winner set. This prevents
* snapline flicker when rounded geometry differs by a tiny floating-point tail.
*/
const isWithinSnapOffset = (offset: number, minOffset: number) => {
return (
Math.abs(offset) <= minOffset ||
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE)
);
};
export const getElementsCorners = ( export const getElementsCorners = (
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -355,7 +370,7 @@ const getElementsSnapPoints = (
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
options: Parameters<typeof getElementsCorners>[2] = {}, options: Parameters<typeof getElementsCorners>[2] = {},
sourceId = SELECTION_SNAP_POINT_SOURCE_ID, snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint[] => { ): SnapPoint[] => {
const points = getElementsCorners(elements, elementsMap, options); const points = getElementsCorners(elements, elementsMap, options);
// getElementsCorners() appends the center point last unless omitCenter is set. // getElementsCorners() appends the center point last unless omitCenter is set.
@@ -364,17 +379,25 @@ const getElementsSnapPoints = (
return points.map((point, index) => ({ return points.map((point, index) => ({
point, point,
type: hasCenterPoint && index === points.length - 1 ? "center" : "outer", type: hasCenterPoint && index === points.length - 1 ? "center" : "outer",
sourceId, snapSourceId,
})); }));
}; };
const getSnapPointSourceId = (elements: ExcalidrawElement[]) => { /**
* Builds a stable identity for one snap reference group. Sorting keeps grouped
* elements represented by the same snap source id regardless of element order.
*/
const getSnapSourceId = (elements: ExcalidrawElement[]) => {
return elements return elements
.map((element) => element.id) .map((element) => element.id)
.sort() .sort()
.join(","); .join(",");
}; };
/**
* Finds frames that the current selection already belongs to. Their children
* are still valid snap references; children of unrelated frames are not.
*/
const getSelectedFrameIdsForSnapping = ( const getSelectedFrameIdsForSnapping = (
selectedElements: readonly ExcalidrawElement[], selectedElements: readonly ExcalidrawElement[],
) => { ) => {
@@ -389,6 +412,18 @@ const getSelectedFrameIdsForSnapping = (
return selectedFrameIds; return selectedFrameIds;
}; };
/**
* Frame children are only snap references when the dragged selection is inside
* the same frame. This avoids snapping external elements to internals of a
* framed diagram.
*/
const canUseElementAsSnapReference = (
element: NonDeletedExcalidrawElement,
selectedFrameIds: Set<ExcalidrawElement["id"]>,
) => {
return !element.frameId || selectedFrameIds.has(element.frameId);
};
const getReferenceElements = ( const getReferenceElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
@@ -403,7 +438,7 @@ const getReferenceElements = (
appState, appState,
elementsMap, elementsMap,
).filter((element) => { ).filter((element) => {
return !element.frameId || selectedFrameIds.has(element.frameId); return canUseElementAsSnapReference(element, selectedFrameIds);
}); });
}; };
@@ -564,7 +599,10 @@ const getGapSnaps = (
const centerOffset = round(gapMidX - centerX); const centerOffset = round(gapMidX - centerX);
const gapIsLargerThanSelection = gap.length > maxX - minX; const gapIsLargerThanSelection = gap.length > maxX - minX;
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) { if (
gapIsLargerThanSelection &&
isWithinSnapOffset(centerOffset, minOffset.x)
) {
if (Math.abs(centerOffset) < minOffset.x) { if (Math.abs(centerOffset) < minOffset.x) {
nearestSnapsX.length = 0; nearestSnapsX.length = 0;
} }
@@ -586,7 +624,7 @@ const getGapSnaps = (
const distanceToEndElementX = minX - endMaxX; const distanceToEndElementX = minX - endMaxX;
const sideOffsetRight = round(gap.length - distanceToEndElementX); const sideOffsetRight = round(gap.length - distanceToEndElementX);
if (Math.abs(sideOffsetRight) <= minOffset.x) { if (isWithinSnapOffset(sideOffsetRight, minOffset.x)) {
if (Math.abs(sideOffsetRight) < minOffset.x) { if (Math.abs(sideOffsetRight) < minOffset.x) {
nearestSnapsX.length = 0; nearestSnapsX.length = 0;
} }
@@ -607,7 +645,7 @@ const getGapSnaps = (
const distanceToStartElementX = startMinX - maxX; const distanceToStartElementX = startMinX - maxX;
const sideOffsetLeft = round(distanceToStartElementX - gap.length); const sideOffsetLeft = round(distanceToStartElementX - gap.length);
if (Math.abs(sideOffsetLeft) <= minOffset.x) { if (isWithinSnapOffset(sideOffsetLeft, minOffset.x)) {
if (Math.abs(sideOffsetLeft) < minOffset.x) { if (Math.abs(sideOffsetLeft) < minOffset.x) {
nearestSnapsX.length = 0; nearestSnapsX.length = 0;
} }
@@ -633,7 +671,10 @@ const getGapSnaps = (
const centerOffset = round(gapMidY - centerY); const centerOffset = round(gapMidY - centerY);
const gapIsLargerThanSelection = gap.length > maxY - minY; const gapIsLargerThanSelection = gap.length > maxY - minY;
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) { if (
gapIsLargerThanSelection &&
isWithinSnapOffset(centerOffset, minOffset.y)
) {
if (Math.abs(centerOffset) < minOffset.y) { if (Math.abs(centerOffset) < minOffset.y) {
nearestSnapsY.length = 0; nearestSnapsY.length = 0;
} }
@@ -655,7 +696,7 @@ const getGapSnaps = (
const distanceToStartElementY = startMinY - maxY; const distanceToStartElementY = startMinY - maxY;
const sideOffsetTop = round(distanceToStartElementY - gap.length); const sideOffsetTop = round(distanceToStartElementY - gap.length);
if (Math.abs(sideOffsetTop) <= minOffset.y) { if (isWithinSnapOffset(sideOffsetTop, minOffset.y)) {
if (Math.abs(sideOffsetTop) < minOffset.y) { if (Math.abs(sideOffsetTop) < minOffset.y) {
nearestSnapsY.length = 0; nearestSnapsY.length = 0;
} }
@@ -676,7 +717,7 @@ const getGapSnaps = (
const distanceToEndElementY = round(minY - endMaxY); const distanceToEndElementY = round(minY - endMaxY);
const sideOffsetBottom = gap.length - distanceToEndElementY; const sideOffsetBottom = gap.length - distanceToEndElementY;
if (Math.abs(sideOffsetBottom) <= minOffset.y) { if (isWithinSnapOffset(sideOffsetBottom, minOffset.y)) {
if (Math.abs(sideOffsetBottom) < minOffset.y) { if (Math.abs(sideOffsetBottom) < minOffset.y) {
nearestSnapsY.length = 0; nearestSnapsY.length = 0;
} }
@@ -757,14 +798,22 @@ const filterPointSnapsToNearestCluster = (
const minOffset = Math.min( const minOffset = Math.min(
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)), ...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
); );
const selectedOffsets = new Set( const selectedOffsets = nearestOffsetGroups
nearestOffsetGroups .filter(({ offset }) =>
.filter(({ offset }) => Math.abs(offset) === minOffset) areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE),
.map(({ offset }) => offset), )
); .map(({ offset }) => offset);
for (const offsetSnaps of snapsByOffset.values()) { for (const offsetSnaps of snapsByOffset.values()) {
if (!selectedOffsets.has(round(offsetSnaps[0].offset))) { if (
!selectedOffsets.some((offset) =>
areRoughlyEqual(
round(offsetSnaps[0].offset),
offset,
SNAP_OFFSET_TOLERANCE,
),
)
) {
continue; continue;
} }
@@ -830,7 +879,7 @@ export const getReferenceSnapPoints = (
elementGroup, elementGroup,
elementsMap, elementsMap,
{}, {},
getSnapPointSourceId(elementGroup), getSnapSourceId(elementGroup),
), ),
); );
}; };
@@ -864,12 +913,15 @@ const getPointSnaps = (
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0]; const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1]; const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
if (Math.abs(offsetX) <= minOffset.x) { if (isWithinSnapOffset(offsetX, minOffset.x)) {
nearestSnapsX.push({ nearestSnapsX.push({
type: "point", type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point], points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type], pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId], snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs( visualDistance: Math.abs(
otherSnapPoint.point[1] - thisSnapPoint.point[1], otherSnapPoint.point[1] - thisSnapPoint.point[1],
), ),
@@ -877,12 +929,15 @@ const getPointSnaps = (
}); });
} }
if (Math.abs(offsetY) <= minOffset.y) { if (isWithinSnapOffset(offsetY, minOffset.y)) {
nearestSnapsY.push({ nearestSnapsY.push({
type: "point", type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point], points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type], pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId], snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs( visualDistance: Math.abs(
otherSnapPoint.point[0] - thisSnapPoint.point[0], otherSnapPoint.point[0] - thisSnapPoint.point[0],
), ),
@@ -1049,130 +1104,183 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
return Array.from(map.values()); return Array.from(map.values());
}; };
type PointSnapLineMatch = { // Point snaplines are collected from every winning point snap first. Multiple
sourceKey: string; // snap source pairs can collapse to the same rendered line, so candidates keep
// source-pair metadata until redundant center and inner outer lines are removed.
type PointSnapLineSourcePair = {
snapSourcePairKey: string;
pointType: SnapPointType; pointType: SnapPointType;
}; };
type PointSnapLineCandidate = PointSnapLine & { type PointSnapLineCandidate = PointSnapLine & {
matches: PointSnapLineMatch[]; sourcePairs: PointSnapLineSourcePair[];
}; };
type PointSnapLineBucket = { type PointSnapLineBucket = {
points: GlobalPoint[]; points: GlobalPoint[];
matches: PointSnapLineMatch[]; sourcePairs: PointSnapLineSourcePair[];
};
type OuterSnapLineCoordinateRange = {
count: number;
min: number;
max: number;
}; };
/** /**
* Describes which source pair produced a rendered point snapline, so later * Only center-to-center snaps count as center snaplines for redundancy. A
* center-to-outer snap still explains an outer edge or midpoint alignment.
*/
const isCenterToCenterSnap = (snap: PointSnap) => {
return snap.pointTypes.every((pointType) => pointType === "center");
};
/**
* Describes which snap source pair produced a rendered point snapline, so later
* filtering can reason about redundancy without mixing unrelated references. * filtering can reason about redundancy without mixing unrelated references.
*/ */
const getPointSnapLineMatch = (snap: PointSnap): PointSnapLineMatch => { const getPointSnapLineSourcePair = (
snap: PointSnap,
): PointSnapLineSourcePair => {
return { return {
sourceKey: snap.sourceIds.join("->"), snapSourcePairKey: snap.snapSourceIds.join("->"),
pointType: snap.pointTypes.every((pointType) => pointType === "center") pointType: isCenterToCenterSnap(snap) ? "center" : "outer",
? "center"
: "outer",
}; };
}; };
/** /**
* Builds the outer snapline coordinates by source pair. Center snaplines use * Builds each snap source pair's outer coordinate range. Center snaplines use
* this map to decide whether the surrounding outer lines already explain them. * this range to decide whether the surrounding outer lines already explain
* them, and outer snaplines use it to keep only the two extremes.
*/ */
const getOuterCoordinatesBySourceKey = ( const getOuterCoordinateRangesBySnapSourcePairKey = (
snapLines: PointSnapLineCandidate[], snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number, getCoordinate: (snapLine: PointSnapLine) => number,
) => { ) => {
const outerCoordinates = new Map<string, number[]>(); const ranges = new Map<string, OuterSnapLineCoordinateRange>();
for (const snapLine of snapLines) { for (const snapLine of snapLines) {
const coordinate = getCoordinate(snapLine); const coordinate = getCoordinate(snapLine);
for (const match of snapLine.matches) { for (const sourcePair of snapLine.sourcePairs) {
if (match.pointType === "center") { if (sourcePair.pointType === "center") {
continue; continue;
} }
const sourceCoordinates = outerCoordinates.get(match.sourceKey) ?? []; const range = ranges.get(sourcePair.snapSourcePairKey);
sourceCoordinates.push(coordinate);
outerCoordinates.set(match.sourceKey, sourceCoordinates); ranges.set(sourcePair.snapSourcePairKey, {
count: (range?.count ?? 0) + 1,
min: range ? Math.min(range.min, coordinate) : coordinate,
max: range ? Math.max(range.max, coordinate) : coordinate,
});
} }
} }
return outerCoordinates; return ranges;
}; };
// A center snapline is redundant when outer snaplines from the same source pair /**
// exist on both sides, because the outer lines already imply center alignment. * A center snapline is redundant when outer snaplines from the same snap source
* pair exist on both sides, because the outer lines already imply center
* alignment.
*/
const isRedundantCenterSnapLine = ( const isRedundantCenterSnapLine = (
coordinate: number, coordinate: number,
sourceOuterCoordinates: number[], outerCoordinateRange?: OuterSnapLineCoordinateRange,
) => { ) => {
const hasOuterBefore = sourceOuterCoordinates.some( if (!outerCoordinateRange) {
(outerCoordinate) => outerCoordinate < coordinate,
);
const hasOuterAfter = sourceOuterCoordinates.some(
(outerCoordinate) => outerCoordinate > coordinate,
);
return hasOuterBefore && hasOuterAfter;
};
// When more than two outer snaplines come from the same source pair, the inner
// ones are redundant; the two extremes communicate the same shape alignment.
const isRedundantOuterSnapLine = (
coordinate: number,
sourceOuterCoordinates: number[],
) => {
if (sourceOuterCoordinates.length <= 2) {
return false; return false;
} }
const minCoordinate = Math.min(...sourceOuterCoordinates); return (
const maxCoordinate = Math.max(...sourceOuterCoordinates); outerCoordinateRange.min < coordinate &&
outerCoordinateRange.max > coordinate
);
};
return coordinate !== minCoordinate && coordinate !== maxCoordinate; /**
* When more than two outer snaplines come from the same snap source pair, the
* inner ones are redundant; the two extremes communicate the same shape
* alignment.
*/
const isRedundantOuterSnapLine = (
coordinate: number,
outerCoordinateRange?: OuterSnapLineCoordinateRange,
) => {
if (!outerCoordinateRange || outerCoordinateRange.count <= 2) {
return false;
}
return (
coordinate !== outerCoordinateRange.min &&
coordinate !== outerCoordinateRange.max
);
};
/**
* Keeps a visual snapline if at least one snap source pair represented by the
* line still needs it. This matters when multiple snap source pairs collapse to
* the same rendered coordinate.
*/
const isPointSnapLineNeededBySourcePair = (
sourcePair: PointSnapLineSourcePair,
coordinate: number,
outerCoordinateRangesBySnapSourcePairKey: Map<
string,
OuterSnapLineCoordinateRange
>,
) => {
const outerCoordinateRange = outerCoordinateRangesBySnapSourcePairKey.get(
sourcePair.snapSourcePairKey,
);
if (sourcePair.pointType === "center") {
return !isRedundantCenterSnapLine(coordinate, outerCoordinateRange);
}
return !isRedundantOuterSnapLine(coordinate, outerCoordinateRange);
}; };
/** /**
* Removes point snaplines that are visually redundant, while preserving any line * Removes point snaplines that are visually redundant, while preserving any line
* that is still needed by at least one source pair represented on that line. * that is still needed by at least one snap source pair represented on that
* line.
*/ */
const filterRedundantPointSnapLines = ( const filterRedundantPointSnapLines = (
snapLines: PointSnapLineCandidate[], snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number, getCoordinate: (snapLine: PointSnapLine) => number,
): PointSnapLine[] => { ): PointSnapLine[] => {
if (snapLines.length < 3) { if (snapLines.length < 3) {
return snapLines.map(({ matches, ...snapLine }) => snapLine); return snapLines.map(({ sourcePairs, ...snapLine }) => snapLine);
} }
// Track outer-line coordinates per source pair. The same visual snapline can // Track outer-line ranges per snap source pair. The same visual snapline can
// be produced by multiple source pairs, so a line is removed only when every // be produced by multiple snap source pairs, so a line is removed only when
// pair represented by that line considers it redundant. // every pair represented by that line considers it redundant.
const outerCoordinatesBySourceKey = getOuterCoordinatesBySourceKey( const outerCoordinateRangesBySnapSourcePairKey =
snapLines, getOuterCoordinateRangesBySnapSourcePairKey(snapLines, getCoordinate);
getCoordinate,
);
return snapLines return snapLines
.filter((snapLine) => { .filter((snapLine) => {
const coordinate = getCoordinate(snapLine); const coordinate = getCoordinate(snapLine);
return snapLine.matches.some((match) => { return snapLine.sourcePairs.some((sourcePair) =>
const sourceOuterCoordinates = isPointSnapLineNeededBySourcePair(
outerCoordinatesBySourceKey.get(match.sourceKey) ?? []; sourcePair,
coordinate,
if (match.pointType === "center") { outerCoordinateRangesBySnapSourcePairKey,
return !isRedundantCenterSnapLine(coordinate, sourceOuterCoordinates); ),
} );
return !isRedundantOuterSnapLine(coordinate, sourceOuterCoordinates);
});
}) })
.map(({ matches, ...snapLine }) => snapLine); .map(({ sourcePairs, ...snapLine }) => snapLine);
}; };
/**
* Merges point snaps that render on the same x/y coordinate, attaches their
* snap source pair metadata, then removes redundant center and inner outer
* lines.
*/
const createPointSnapLines = ( const createPointSnapLines = (
nearestSnapsX: Snaps, nearestSnapsX: Snaps,
nearestSnapsY: Snaps, nearestSnapsY: Snaps,
@@ -1186,14 +1294,14 @@ const createPointSnapLines = (
// key = thisPoint.x // key = thisPoint.x
const key = round(snap.points[0][0]); const key = round(snap.points[0][0]);
if (!snapsX[key]) { if (!snapsX[key]) {
snapsX[key] = { points: [], matches: [] }; snapsX[key] = { points: [], sourcePairs: [] };
} }
snapsX[key].points.push( snapsX[key].points.push(
...snap.points.map((p) => ...snap.points.map((p) =>
pointFrom<GlobalPoint>(round(p[0]), round(p[1])), pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
), ),
); );
snapsX[key].matches.push(getPointSnapLineMatch(snap)); snapsX[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
} }
} }
} }
@@ -1204,14 +1312,14 @@ const createPointSnapLines = (
// key = thisPoint.y // key = thisPoint.y
const key = round(snap.points[0][1]); const key = round(snap.points[0][1]);
if (!snapsY[key]) { if (!snapsY[key]) {
snapsY[key] = { points: [], matches: [] }; snapsY[key] = { points: [], sourcePairs: [] };
} }
snapsY[key].points.push( snapsY[key].points.push(
...snap.points.map((p) => ...snap.points.map((p) =>
pointFrom<GlobalPoint>(round(p[0]), round(p[1])), pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
), ),
); );
snapsY[key].matches.push(getPointSnapLineMatch(snap)); snapsY[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
} }
} }
} }
@@ -1226,7 +1334,7 @@ const createPointSnapLines = (
}) })
.sort((a, b) => a[1] - b[1]), .sort((a, b) => a[1] - b[1]),
), ),
matches: snap.matches, sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate; } as PointSnapLineCandidate;
}); });
@@ -1240,7 +1348,7 @@ const createPointSnapLines = (
}) })
.sort((a, b) => a[0] - b[0]), .sort((a, b) => a[0] - b[0]),
), ),
matches: snap.matches, sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate; } as PointSnapLineCandidate;
}); });
@@ -1277,10 +1385,13 @@ const createGapSnapLines = (
dragOffset: Vector2D, dragOffset: Vector2D,
gapSnaps: GapSnap[], gapSnaps: GapSnap[],
): GapSnapLine[] => { ): GapSnapLine[] => {
// Use the same rounded bounds as getGapSnaps(). Otherwise a gap snap can be
// accepted during snapping but skipped here because the raw bounds miss the
// reference gap overlap by a tiny floating-point delta.
const [minX, minY, maxX, maxY] = getDraggedElementsBounds( const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
selectedElements, selectedElements,
dragOffset, dragOffset,
); ).map((bound) => round(bound));
const gapSnapLines: GapSnapLine[] = []; const gapSnapLines: GapSnapLine[] = [];
+271 -12
View File
@@ -1,11 +1,17 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { pointFrom, type GlobalPoint, type Radians } from "@excalidraw/math"; import {
pointFrom,
rangeInclusive,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { import {
getElementsCorners, getElementsCorners,
getVisibleGaps,
getReferenceSnapPoints, getReferenceSnapPoints,
SnapCache, SnapCache,
snapDraggedElements, snapDraggedElements,
@@ -56,6 +62,22 @@ const getHorizontalPointSnapLineCoordinates = (
.sort((a, b) => a - b); .sort((a, b) => a - b);
}; };
const getVerticalPointSnapLineCoordinates = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines
.filter((snapLine) => snapLine.type === "points")
.filter((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[0] === lastPoint[0];
})
.map((snapLine) => {
return snapLine.points[0][0];
})
.sort((a, b) => a - b);
};
const getHorizontalPointSnapLineMaxX = ( const getHorizontalPointSnapLineMaxX = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"], snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => { ) => {
@@ -74,6 +96,23 @@ const getHorizontalPointSnapLineMaxX = (
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0]; return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
}; };
const getHorizontalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) =>
snapLine.type === "gap" && snapLine.direction === "horizontal",
);
};
const getVerticalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) => snapLine.type === "gap" && snapLine.direction === "vertical",
);
};
const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => { const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => {
return points.map((point) => point.join(",")); return points.map((point) => point.join(","));
}; };
@@ -111,7 +150,7 @@ const primeReferenceSnapPoints = (
return corners.map((point, index) => ({ return corners.map((point, index) => ({
point, point,
type: index === corners.length - 1 ? "center" : "outer", type: index === corners.length - 1 ? "center" : "outer",
sourceId: element.id, snapSourceId: element.id,
})); }));
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0], }) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
); );
@@ -254,6 +293,57 @@ describe("snapping", () => {
).toBe(false); ).toBe(false);
}); });
it("does not use frame children as visible gap references when snapping outside elements", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 500,
height: 300,
});
const frameChildA = API.createElement({
type: "rectangle",
id: "frameChildA",
x: 50,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const frameChildB = API.createElement({
type: "rectangle",
id: "frameChildB",
x: 250,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 700,
y: 50,
width: 100,
height: 100,
});
const elements = [frame, frameChildA, frameChildB, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const visibleGaps = getVisibleGaps(
elements,
[selected],
app.state,
arrayToMap(elements),
);
expect(visibleGaps.horizontalGaps).toHaveLength(0);
expect(visibleGaps.verticalGaps).toHaveLength(0);
});
it("filters center and inner outer point snaplines for the same reference", () => { it("filters center and inner outer point snaplines for the same reference", () => {
const angle = 0.68 as Radians; const angle = 0.68 as Radians;
const reference = API.createElement({ const reference = API.createElement({
@@ -319,7 +409,7 @@ describe("snapping", () => {
...outerSnapPoints.map((point) => ({ ...outerSnapPoints.map((point) => ({
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]), point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
type: "outer" as const, type: "outer" as const,
sourceId: "referenceA", snapSourceId: "referenceA",
})), })),
{ {
point: pointFrom<GlobalPoint>( point: pointFrom<GlobalPoint>(
@@ -327,7 +417,7 @@ describe("snapping", () => {
centerSnapPoint[1], centerSnapPoint[1],
), ),
type: "center" as const, type: "center" as const,
sourceId: "referenceA", snapSourceId: "referenceA",
}, },
{ {
point: pointFrom<GlobalPoint>( point: pointFrom<GlobalPoint>(
@@ -335,7 +425,7 @@ describe("snapping", () => {
innerOuterSnapPoint[1], innerOuterSnapPoint[1],
), ),
type: "outer" as const, type: "outer" as const,
sourceId: "referenceB", snapSourceId: "referenceB",
}, },
]; ];
@@ -387,6 +477,90 @@ describe("snapping", () => {
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]); expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]);
}); });
it("filters center snaplines when matching outer offsets differ by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563984471,
y: -1553.9657067952232,
width: 140.1015625,
height: 140.1015625,
});
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 2532.2275640966914,
y: -1299.4323092037737,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalPointSnapLineCoordinates(snapLines)).toEqual([
2532.227564, 2672.329126,
]);
});
it("keeps outer snaplines stable while dragging a snapped element through rounding-equivalent offsets", () => {
const referenceMiddle = API.createElement({
type: "rectangle",
id: "referenceMiddle",
x: 2532.22756398447,
y: -1553.9657067952237,
width: 140.1015625,
height: 140.1015625,
});
const referenceAbove = API.createElement({
type: "rectangle",
id: "referenceAbove",
x: 2532.2275637826165,
y: -1779.7363232531268,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563096691,
y: -1328.1950902037736,
width: 140.1015625,
height: 140.1015625,
});
const elements = [referenceAbove, referenceMiddle, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
for (const dragOffsetX of [-4, -1, -0.1, 0, 0.1, 1, 4]) {
const { snapLines } = snapDraggedElements(
elements,
{ x: dragOffsetX, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const coordinates = getVerticalPointSnapLineCoordinates(snapLines);
expect(coordinates).toHaveLength(2);
expect(coordinates[1] - coordinates[0]).toBeCloseTo(selected.width, 5);
}
});
it("prefers the nearest visual cluster for same-offset point snaps", () => { it("prefers the nearest visual cluster for same-offset point snaps", () => {
const selected = API.createElement({ const selected = API.createElement({
type: "rectangle", type: "rectangle",
@@ -405,12 +579,12 @@ describe("snapping", () => {
{ {
point: pointFrom<GlobalPoint>(220, 50), point: pointFrom<GlobalPoint>(220, 50),
type: "center", type: "center",
sourceId: "near", snapSourceId: "near",
}, },
{ {
point: pointFrom<GlobalPoint>(900, 50), point: pointFrom<GlobalPoint>(900, 50),
type: "center", type: "center",
sourceId: "far", snapSourceId: "far",
}, },
]); ]);
@@ -443,12 +617,12 @@ describe("snapping", () => {
{ {
point: pointFrom<GlobalPoint>(220, 54), point: pointFrom<GlobalPoint>(220, 54),
type: "center", type: "center",
sourceId: "near", snapSourceId: "near",
}, },
{ {
point: pointFrom<GlobalPoint>(900, 50), point: pointFrom<GlobalPoint>(900, 50),
type: "center", type: "center",
sourceId: "far", snapSourceId: "far",
}, },
]); ]);
@@ -482,17 +656,17 @@ describe("snapping", () => {
{ {
point: pointFrom<GlobalPoint>(200, 50), point: pointFrom<GlobalPoint>(200, 50),
type: "center", type: "center",
sourceId: "referenceA", snapSourceId: "referenceA",
}, },
{ {
point: pointFrom<GlobalPoint>(350, 50), point: pointFrom<GlobalPoint>(350, 50),
type: "center", type: "center",
sourceId: "referenceB", snapSourceId: "referenceB",
}, },
{ {
point: pointFrom<GlobalPoint>(500, 50), point: pointFrom<GlobalPoint>(500, 50),
type: "center", type: "center",
sourceId: "referenceC", snapSourceId: "referenceC",
}, },
]); ]);
@@ -506,4 +680,89 @@ describe("snapping", () => {
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500); expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
}); });
it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0.0000004,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setVisibleGaps({
horizontalGaps: [
{
startBounds: [200, -100, 300, 0],
endBounds: [400, -100, 500, 0],
startSide: [pointFrom(300, -100), pointFrom(300, 0)],
endSide: [pointFrom(400, -100), pointFrom(400, 0)],
overlap: rangeInclusive(-100, 0),
length: 100,
},
],
verticalGaps: [],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalGapSnapLines(snapLines)).toHaveLength(2);
});
it("renders gap snaplines when the winning gap offset only differs by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 399.999999,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(0, 399.999999),
type: "outer",
snapSourceId: "reference",
},
]);
SnapCache.setVisibleGaps({
horizontalGaps: [],
verticalGaps: [
{
startBounds: [0, 100, 100, 200],
endBounds: [0, 250, 100, 350],
startSide: [pointFrom(0, 200), pointFrom(100, 200)],
endSide: [pointFrom(0, 250), pointFrom(100, 250)],
overlap: rangeInclusive(0, 100),
length: 50,
},
],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalGapSnapLines(snapLines)).toHaveLength(2);
});
}); });