diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index a46603b913..0887bc9e0f 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -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", }; }; diff --git a/packages/excalidraw/tests/snapping.test.ts b/packages/excalidraw/tests/snapping.test.ts index 166e9efae5..8000aabc5a 100644 --- a/packages/excalidraw/tests/snapping.test.ts +++ b/packages/excalidraw/tests/snapping.test.ts @@ -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 @@ -96,6 +100,27 @@ const getHorizontalPointSnapLineMaxX = ( return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0]; }; +const getHorizontalPointSnapLineXRange = ( + snapLines: ReturnType["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["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",