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
+5 -34
View File
@@ -817,32 +817,7 @@ const filterPointSnapsToNearestCluster = (
continue; continue;
} }
if (offsetSnaps.length < 2) {
offsetSnaps.forEach((snap) => keptPointSnaps.add(snap)); 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]);
}
} }
let writeIndex = 0; let writeIndex = 0;
@@ -1127,24 +1102,20 @@ type OuterSnapLineCoordinateRange = {
max: number; 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 * 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.
* Only center-to-center snaps count as center snaplines; a center-to-outer snap
* still explains an outer edge or midpoint alignment.
*/ */
const getPointSnapLineSourcePair = ( const getPointSnapLineSourcePair = (
snap: PointSnap, snap: PointSnap,
): PointSnapLineSourcePair => { ): PointSnapLineSourcePair => {
return { return {
snapSourcePairKey: snap.snapSourceIds.join("->"), 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 { API } from "./helpers/api";
import type { AppClassProperties, AppState } from "../types"; import type {
AppClassProperties,
AppState,
NormalizedZoomValue,
} from "../types";
type ReferenceSnapPoints = NonNullable< type ReferenceSnapPoints = NonNullable<
ReturnType<typeof SnapCache.getReferenceSnapPoints> ReturnType<typeof SnapCache.getReferenceSnapPoints>
@@ -96,6 +100,27 @@ const getHorizontalPointSnapLineMaxX = (
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0]; 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 = ( const getHorizontalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"], 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({ const selected = API.createElement({
type: "rectangle", type: "rectangle",
id: "selected", id: "selected",
@@ -596,7 +621,7 @@ describe("snapping", () => {
arrayToMap(elements), arrayToMap(elements),
); );
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(220); expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(900);
}); });
it("prefers a nearby point snap over a slightly better far offset", () => { it("prefers a nearby point snap over a slightly better far offset", () => {
@@ -681,6 +706,46 @@ describe("snapping", () => {
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500); 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", () => { it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
const selected = API.createElement({ const selected = API.createElement({
type: "rectangle", type: "rectangle",