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";
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
// distance. If their visual distances have a large break, prefer the nearest
// cluster before comparing offsets.
@@ -67,34 +71,34 @@ type Vector2D = {
type PointPair = [GlobalPoint, GlobalPoint];
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.
// For example, "selection->rectA" may collapse to its outermost snaplines,
// while "selection->rectB" still keeps its own independent snaplines.
type SnapPoint = {
point: GlobalPoint;
type: SnapPointType;
sourceId: SnapPointSourceId;
snapSourceId: SnapSourceId;
};
const outerSnapPoint = (
point: GlobalPoint,
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint => ({
point,
type: "outer",
sourceId,
snapSourceId,
});
export type PointSnap = {
type: "point";
points: PointPair;
pointTypes: [SnapPointType, SnapPointType];
sourceIds: [SnapPointSourceId, SnapPointSourceId];
snapSourceIds: [SnapSourceId, SnapSourceId];
// Distance along the rendered snapline, used to group visually nearby
// references before choosing which point snaps should win.
visualDistance: number;
@@ -234,6 +238,17 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
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 = (
elements: ExcalidrawElement[],
elementsMap: ElementsMap,
@@ -355,7 +370,7 @@ const getElementsSnapPoints = (
elements: ExcalidrawElement[],
elementsMap: ElementsMap,
options: Parameters<typeof getElementsCorners>[2] = {},
sourceId = SELECTION_SNAP_POINT_SOURCE_ID,
snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint[] => {
const points = getElementsCorners(elements, elementsMap, options);
// getElementsCorners() appends the center point last unless omitCenter is set.
@@ -364,17 +379,25 @@ const getElementsSnapPoints = (
return points.map((point, index) => ({
point,
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
.map((element) => element.id)
.sort()
.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 = (
selectedElements: readonly ExcalidrawElement[],
) => {
@@ -389,6 +412,18 @@ const getSelectedFrameIdsForSnapping = (
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 = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
@@ -403,7 +438,7 @@ const getReferenceElements = (
appState,
elementsMap,
).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 gapIsLargerThanSelection = gap.length > maxX - minX;
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
if (
gapIsLargerThanSelection &&
isWithinSnapOffset(centerOffset, minOffset.x)
) {
if (Math.abs(centerOffset) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -586,7 +624,7 @@ const getGapSnaps = (
const distanceToEndElementX = minX - endMaxX;
const sideOffsetRight = round(gap.length - distanceToEndElementX);
if (Math.abs(sideOffsetRight) <= minOffset.x) {
if (isWithinSnapOffset(sideOffsetRight, minOffset.x)) {
if (Math.abs(sideOffsetRight) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -607,7 +645,7 @@ const getGapSnaps = (
const distanceToStartElementX = startMinX - maxX;
const sideOffsetLeft = round(distanceToStartElementX - gap.length);
if (Math.abs(sideOffsetLeft) <= minOffset.x) {
if (isWithinSnapOffset(sideOffsetLeft, minOffset.x)) {
if (Math.abs(sideOffsetLeft) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -633,7 +671,10 @@ const getGapSnaps = (
const centerOffset = round(gapMidY - centerY);
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) {
nearestSnapsY.length = 0;
}
@@ -655,7 +696,7 @@ const getGapSnaps = (
const distanceToStartElementY = startMinY - maxY;
const sideOffsetTop = round(distanceToStartElementY - gap.length);
if (Math.abs(sideOffsetTop) <= minOffset.y) {
if (isWithinSnapOffset(sideOffsetTop, minOffset.y)) {
if (Math.abs(sideOffsetTop) < minOffset.y) {
nearestSnapsY.length = 0;
}
@@ -676,7 +717,7 @@ const getGapSnaps = (
const distanceToEndElementY = round(minY - endMaxY);
const sideOffsetBottom = gap.length - distanceToEndElementY;
if (Math.abs(sideOffsetBottom) <= minOffset.y) {
if (isWithinSnapOffset(sideOffsetBottom, minOffset.y)) {
if (Math.abs(sideOffsetBottom) < minOffset.y) {
nearestSnapsY.length = 0;
}
@@ -757,14 +798,22 @@ const filterPointSnapsToNearestCluster = (
const minOffset = Math.min(
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
);
const selectedOffsets = new Set(
nearestOffsetGroups
.filter(({ offset }) => Math.abs(offset) === minOffset)
.map(({ offset }) => offset),
);
const selectedOffsets = nearestOffsetGroups
.filter(({ offset }) =>
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE),
)
.map(({ offset }) => offset);
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;
}
@@ -830,7 +879,7 @@ export const getReferenceSnapPoints = (
elementGroup,
elementsMap,
{},
getSnapPointSourceId(elementGroup),
getSnapSourceId(elementGroup),
),
);
};
@@ -864,12 +913,15 @@ const getPointSnaps = (
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
if (Math.abs(offsetX) <= minOffset.x) {
if (isWithinSnapOffset(offsetX, minOffset.x)) {
nearestSnapsX.push({
type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs(
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({
type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
sourceIds: [thisSnapPoint.sourceId, otherSnapPoint.sourceId],
snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs(
otherSnapPoint.point[0] - thisSnapPoint.point[0],
),
@@ -1049,130 +1104,183 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
return Array.from(map.values());
};
type PointSnapLineMatch = {
sourceKey: string;
// Point snaplines are collected from every winning point snap first. Multiple
// 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;
};
type PointSnapLineCandidate = PointSnapLine & {
matches: PointSnapLineMatch[];
sourcePairs: PointSnapLineSourcePair[];
};
type PointSnapLineBucket = {
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.
*/
const getPointSnapLineMatch = (snap: PointSnap): PointSnapLineMatch => {
const getPointSnapLineSourcePair = (
snap: PointSnap,
): PointSnapLineSourcePair => {
return {
sourceKey: snap.sourceIds.join("->"),
pointType: snap.pointTypes.every((pointType) => pointType === "center")
? "center"
: "outer",
snapSourcePairKey: snap.snapSourceIds.join("->"),
pointType: isCenterToCenterSnap(snap) ? "center" : "outer",
};
};
/**
* Builds the outer snapline coordinates by source pair. Center snaplines use
* this map to decide whether the surrounding outer lines already explain them.
* Builds each snap source pair's outer coordinate range. Center snaplines use
* 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[],
getCoordinate: (snapLine: PointSnapLine) => number,
) => {
const outerCoordinates = new Map<string, number[]>();
const ranges = new Map<string, OuterSnapLineCoordinateRange>();
for (const snapLine of snapLines) {
const coordinate = getCoordinate(snapLine);
for (const match of snapLine.matches) {
if (match.pointType === "center") {
for (const sourcePair of snapLine.sourcePairs) {
if (sourcePair.pointType === "center") {
continue;
}
const sourceCoordinates = outerCoordinates.get(match.sourceKey) ?? [];
sourceCoordinates.push(coordinate);
outerCoordinates.set(match.sourceKey, sourceCoordinates);
const range = ranges.get(sourcePair.snapSourcePairKey);
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 = (
coordinate: number,
sourceOuterCoordinates: number[],
outerCoordinateRange?: OuterSnapLineCoordinateRange,
) => {
const hasOuterBefore = sourceOuterCoordinates.some(
(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) {
if (!outerCoordinateRange) {
return false;
}
const minCoordinate = Math.min(...sourceOuterCoordinates);
const maxCoordinate = Math.max(...sourceOuterCoordinates);
return (
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
* 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 = (
snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number,
): PointSnapLine[] => {
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
// be produced by multiple source pairs, so a line is removed only when every
// pair represented by that line considers it redundant.
const outerCoordinatesBySourceKey = getOuterCoordinatesBySourceKey(
snapLines,
getCoordinate,
);
// Track outer-line ranges per snap source pair. The same visual snapline can
// be produced by multiple snap source pairs, so a line is removed only when
// every pair represented by that line considers it redundant.
const outerCoordinateRangesBySnapSourcePairKey =
getOuterCoordinateRangesBySnapSourcePairKey(snapLines, getCoordinate);
return snapLines
.filter((snapLine) => {
const coordinate = getCoordinate(snapLine);
return snapLine.matches.some((match) => {
const sourceOuterCoordinates =
outerCoordinatesBySourceKey.get(match.sourceKey) ?? [];
if (match.pointType === "center") {
return !isRedundantCenterSnapLine(coordinate, sourceOuterCoordinates);
}
return !isRedundantOuterSnapLine(coordinate, sourceOuterCoordinates);
});
return snapLine.sourcePairs.some((sourcePair) =>
isPointSnapLineNeededBySourcePair(
sourcePair,
coordinate,
outerCoordinateRangesBySnapSourcePairKey,
),
);
})
.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 = (
nearestSnapsX: Snaps,
nearestSnapsY: Snaps,
@@ -1186,14 +1294,14 @@ const createPointSnapLines = (
// key = thisPoint.x
const key = round(snap.points[0][0]);
if (!snapsX[key]) {
snapsX[key] = { points: [], matches: [] };
snapsX[key] = { points: [], sourcePairs: [] };
}
snapsX[key].points.push(
...snap.points.map((p) =>
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
const key = round(snap.points[0][1]);
if (!snapsY[key]) {
snapsY[key] = { points: [], matches: [] };
snapsY[key] = { points: [], sourcePairs: [] };
}
snapsY[key].points.push(
...snap.points.map((p) =>
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]),
),
matches: snap.matches,
sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate;
});
@@ -1240,7 +1348,7 @@ const createPointSnapLines = (
})
.sort((a, b) => a[0] - b[0]),
),
matches: snap.matches,
sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate;
});
@@ -1277,10 +1385,13 @@ const createGapSnapLines = (
dragOffset: Vector2D,
gapSnaps: GapSnap[],
): 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(
selectedElements,
dragOffset,
);
).map((bound) => round(bound));
const gapSnapLines: GapSnapLine[] = [];
+271 -12
View File
@@ -1,11 +1,17 @@
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 { getDefaultAppState } from "../appState";
import {
getElementsCorners,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
snapDraggedElements,
@@ -56,6 +62,22 @@ const getHorizontalPointSnapLineCoordinates = (
.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 = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
@@ -74,6 +96,23 @@ const getHorizontalPointSnapLineMaxX = (
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>) => {
return points.map((point) => point.join(","));
};
@@ -111,7 +150,7 @@ const primeReferenceSnapPoints = (
return corners.map((point, index) => ({
point,
type: index === corners.length - 1 ? "center" : "outer",
sourceId: element.id,
snapSourceId: element.id,
}));
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
);
@@ -254,6 +293,57 @@ describe("snapping", () => {
).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", () => {
const angle = 0.68 as Radians;
const reference = API.createElement({
@@ -319,7 +409,7 @@ describe("snapping", () => {
...outerSnapPoints.map((point) => ({
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
type: "outer" as const,
sourceId: "referenceA",
snapSourceId: "referenceA",
})),
{
point: pointFrom<GlobalPoint>(
@@ -327,7 +417,7 @@ describe("snapping", () => {
centerSnapPoint[1],
),
type: "center" as const,
sourceId: "referenceA",
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(
@@ -335,7 +425,7 @@ describe("snapping", () => {
innerOuterSnapPoint[1],
),
type: "outer" as const,
sourceId: "referenceB",
snapSourceId: "referenceB",
},
];
@@ -387,6 +477,90 @@ describe("snapping", () => {
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", () => {
const selected = API.createElement({
type: "rectangle",
@@ -405,12 +579,12 @@ describe("snapping", () => {
{
point: pointFrom<GlobalPoint>(220, 50),
type: "center",
sourceId: "near",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
sourceId: "far",
snapSourceId: "far",
},
]);
@@ -443,12 +617,12 @@ describe("snapping", () => {
{
point: pointFrom<GlobalPoint>(220, 54),
type: "center",
sourceId: "near",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
sourceId: "far",
snapSourceId: "far",
},
]);
@@ -482,17 +656,17 @@ describe("snapping", () => {
{
point: pointFrom<GlobalPoint>(200, 50),
type: "center",
sourceId: "referenceA",
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(350, 50),
type: "center",
sourceId: "referenceB",
snapSourceId: "referenceB",
},
{
point: pointFrom<GlobalPoint>(500, 50),
type: "center",
sourceId: "referenceC",
snapSourceId: "referenceC",
},
]);
@@ -506,4 +680,89 @@ describe("snapping", () => {
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);
});
});