fix(snaps): keep winning point snaplines complete

This commit is contained in:
Ryan Di
2026-05-11 19:27:05 +10:00
parent f42dda6769
commit 120b11119b
2 changed files with 74 additions and 38 deletions
+6 -35
View File
@@ -817,32 +817,7 @@ const filterPointSnapsToNearestCluster = (
continue;
}
if (offsetSnaps.length < 2) {
offsetSnaps.forEach((snap) => keptPointSnaps.add(snap));
continue;
}
const sortedSnaps = offsetSnaps
.slice()
.sort((a, b) => a.visualDistance - b.visualDistance);
let keepUntil = sortedSnaps.length;
// This runs after the winning snap offset is known. A big visual-distance
// gap means references after it belong to a separate, farther cluster.
for (let i = 1; i < sortedSnaps.length; i++) {
const distanceGap =
sortedSnaps[i].visualDistance - sortedSnaps[i - 1].visualDistance;
if (distanceGap > clusterBreakDistance) {
keepUntil = i;
break;
}
}
for (let i = 0; i < keepUntil; i++) {
keptPointSnaps.add(sortedSnaps[i]);
}
offsetSnaps.forEach((snap) => keptPointSnaps.add(snap));
}
let writeIndex = 0;
@@ -1127,24 +1102,20 @@ type OuterSnapLineCoordinateRange = {
max: number;
};
/**
* 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.
* Only center-to-center snaps count as center snaplines; a center-to-outer snap
* still explains an outer edge or midpoint alignment.
*/
const getPointSnapLineSourcePair = (
snap: PointSnap,
): PointSnapLineSourcePair => {
return {
snapSourcePairKey: snap.snapSourceIds.join("->"),
pointType: isCenterToCenterSnap(snap) ? "center" : "outer",
pointType: snap.pointTypes.every((pointType) => pointType === "center")
? "center"
: "outer",
};
};
+68 -3
View File
@@ -19,7 +19,11 @@ import {
import { API } from "./helpers/api";
import type { AppClassProperties, AppState } from "../types";
import type {
AppClassProperties,
AppState,
NormalizedZoomValue,
} from "../types";
type ReferenceSnapPoints = NonNullable<
ReturnType<typeof SnapCache.getReferenceSnapPoints>
@@ -96,6 +100,27 @@ const getHorizontalPointSnapLineMaxX = (
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
};
const getHorizontalPointSnapLineXRange = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
const horizontalSnapLine = snapLines
.filter((snapLine) => snapLine.type === "points")
.find((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
});
if (!horizontalSnapLine) {
return null;
}
return [
horizontalSnapLine.points[0][0],
horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0],
] as const;
};
const getHorizontalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
@@ -561,7 +586,7 @@ describe("snapping", () => {
}
});
it("prefers the nearest visual cluster for same-offset point snaps", () => {
it("keeps same-offset point snaps even across distant references", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
@@ -596,7 +621,7 @@ describe("snapping", () => {
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(220);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(900);
});
it("prefers a nearby point snap over a slightly better far offset", () => {
@@ -681,6 +706,46 @@ describe("snapping", () => {
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
});
it("keeps same-source same-offset point snaps across zoom-scaled cluster breaks", () => {
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 338.608871112217,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
zoom: { value: 1.5 as NormalizedZoomValue },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const range = getHorizontalPointSnapLineXRange(snapLines);
expect(range).not.toBe(null);
expect(range![0]).toBeCloseTo(reference.x, 6);
expect(range![1]).toBeCloseTo(selected.x + selected.width, 6);
});
it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
const selected = API.createElement({
type: "rectangle",