feat(studio): add stateless snap engine with alignment computation (#1227)

Pure-function snap computation module with zero React/DOM dependencies:

- resolveSnapAdjustment: edge/center alignment for drag gestures
- resolveResizeSnapAdjustment: snap only active resize edges
- resolveEquidistanceGuides: Figma-style spacing indicators
- extractSnapTargets, buildCompositionSnapTarget, buildGridSnapEdges
- studioUiPreferences: snap/grid settings persistence
- 38 unit tests covering threshold, grid priority, stress scenarios
This commit is contained in:
Miguel Ángel
2026-06-05 17:54:19 -04:00
committed by GitHub
parent 1324de54a8
commit 1863831a4d
3 changed files with 1249 additions and 0 deletions
@@ -0,0 +1,657 @@
// fallow-ignore-file code-duplication
import { describe, test, expect } from "vitest";
import {
extractSnapTargets,
buildCompositionSnapTarget,
buildGridSnapEdges,
resolveSnapAdjustment,
resolveResizeSnapAdjustment,
resolveEquidistanceGuides,
SNAP_THRESHOLD_PX,
type SnapTarget,
} from "./snapEngine";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rect(left: number, top: number, width: number, height: number) {
return { left, top, width, height };
}
function target(id: string, left: number, top: number, width: number, height: number): SnapTarget {
return {
left,
top,
right: left + width,
bottom: top + height,
centerX: left + width / 2,
centerY: top + height / 2,
id,
};
}
// ---------------------------------------------------------------------------
// extractSnapTargets
// ---------------------------------------------------------------------------
describe("extractSnapTargets", () => {
test("computes right, bottom, centerX, centerY", () => {
const [t] = extractSnapTargets([{ rect: rect(10, 20, 100, 50), id: "a" }]);
expect(t.left).toBe(10);
expect(t.top).toBe(20);
expect(t.right).toBe(110);
expect(t.bottom).toBe(70);
expect(t.centerX).toBe(60);
expect(t.centerY).toBe(45);
expect(t.id).toBe("a");
});
test("handles multiple rects", () => {
const targets = extractSnapTargets([
{ rect: rect(0, 0, 10, 10), id: "x" },
{ rect: rect(50, 50, 20, 30), id: "y" },
]);
expect(targets).toHaveLength(2);
expect(targets[1].right).toBe(70);
expect(targets[1].bottom).toBe(80);
});
});
// ---------------------------------------------------------------------------
// buildCompositionSnapTarget
// ---------------------------------------------------------------------------
describe("buildCompositionSnapTarget", () => {
test("has id 'composition' and correct edges", () => {
const t = buildCompositionSnapTarget(rect(0, 0, 1920, 1080));
expect(t.id).toBe("composition");
expect(t.left).toBe(0);
expect(t.right).toBe(1920);
expect(t.centerX).toBe(960);
expect(t.centerY).toBe(540);
});
});
// ---------------------------------------------------------------------------
// buildGridSnapEdges
// ---------------------------------------------------------------------------
describe("buildGridSnapEdges", () => {
test("generates correct grid lines", () => {
const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 100, 1);
// At scale=1, step=100: x lines at 100, 200 (not 0 or 300)
expect(x.map((e) => e.position)).toEqual([100, 200]);
expect(y.map((e) => e.position)).toEqual([100]);
expect(x[0].source).toBe("grid");
});
test("applies scale to grid spacing", () => {
const { x } = buildGridSnapEdges(rect(0, 0, 600, 100), 100, 2);
// step = 200, lines at 200, 400
expect(x.map((e) => e.position)).toEqual([200, 400]);
});
test("handles offset composition rect", () => {
const { x } = buildGridSnapEdges(rect(50, 0, 300, 100), 100, 1);
// Lines at 150, 250 (offset + step, offset + 2*step)
expect(x.map((e) => e.position)).toEqual([150, 250]);
});
test("returns empty for zero gridSpacing", () => {
const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 0, 1);
expect(x).toHaveLength(0);
expect(y).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// resolveSnapAdjustment — edge alignment
// ---------------------------------------------------------------------------
describe("resolveSnapAdjustment", () => {
const compositionTarget = target("composition", 0, 0, 1000, 800);
test("left-to-right alignment: moving left snaps to target right", () => {
// Target at x=200, width=100 => right edge at 300
// Moving rect at x=0, width=50. Propose dx=297 => proposed left=297
// Should snap left=300 (delta +3)
const t = target("a", 200, 100, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 100, 50, 50),
proposedDx: 297,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(300);
expect(result.guides.length).toBeGreaterThanOrEqual(1);
expect(result.guides[0].axis).toBe("x");
expect(result.guides[0].position).toBe(300);
});
test("right-to-left alignment: moving right snaps to target left", () => {
// Target at x=200. Moving rect width=50, at x=0.
// Proposed dx=146 => proposed right = 196. Target left=200. diff=4 within threshold.
const t = target("a", 200, 100, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 100, 50, 50),
proposedDx: 146,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// Proposed right = 196, target left = 200, adjustment = +4
expect(result.dx).toBe(150);
});
test("center-to-center alignment on X axis", () => {
// Target center at x=250. Moving rect width=100 at x=0 => center at 50.
// Propose dx=198 => proposed center=248, target center=250, diff=2
const t = target("a", 200, 100, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 100, 100, 50),
proposedDx: 198,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(200);
});
test("top-to-bottom alignment", () => {
// Target bottom at 200. Moving top proposed at 197. Should snap to 200.
const t = target("a", 100, 100, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(100, 0, 50, 50),
proposedDx: 0,
proposedDy: 197,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dy).toBe(200);
});
test("center-to-center alignment on Y axis", () => {
// Target centerY = 150. Moving height=100 at y=0 => center=50.
// Propose dy=98 => proposed center=148, target center=150, diff=2
const t = target("a", 100, 100, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(100, 0, 100, 100),
proposedDx: 0,
proposedDy: 98,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dy).toBe(100);
});
test("composition center snap", () => {
// Composition center at 500, 400. Moving rect 100x100 at 0,0 => center 50,50.
// Propose dx=447, dy=347 => proposed center 497,397. Should snap to 500,400.
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 100, 100),
proposedDx: 447,
proposedDy: 347,
targets: [compositionTarget],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(450);
expect(result.dy).toBe(350);
});
test("no snap when outside threshold", () => {
const t = target("a", 200, 200, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 10,
proposedDy: 10,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// Moving rect edges: left=10, center=35, right=60
// Target edges: left=200, center=250, right=300
// All distances > 6
expect(result.dx).toBe(10);
expect(result.dy).toBe(10);
expect(result.guides).toHaveLength(0);
});
test("multiple matching guides at same distance", () => {
// Two targets with left edges at 100 — both should produce guides
const t1 = target("a", 100, 0, 50, 50);
const t2 = target("b", 100, 200, 50, 50);
const result = resolveSnapAdjustment({
movingRect: rect(0, 100, 50, 50),
proposedDx: 97,
proposedDy: 0,
targets: [t1, t2],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(100);
// Should have a guide at x=100
const xGuides = result.guides.filter((g) => g.axis === "x");
expect(xGuides.length).toBeGreaterThanOrEqual(1);
expect(xGuides[0].position).toBe(100);
// The guide extent should cover both targets and the moving rect
expect(xGuides[0].from).toBe(0); // t1 top
expect(xGuides[0].to).toBe(250); // t2 bottom
});
test("disabled=true returns passthrough", () => {
const t = target("a", 100, 100, 50, 50);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 98,
proposedDy: 98,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: true,
});
expect(result.dx).toBe(98);
expect(result.dy).toBe(98);
expect(result.guides).toHaveLength(0);
});
test("threshold=0 means no snap", () => {
const t = target("a", 100, 100, 50, 50);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 99,
proposedDy: 99,
targets: [t],
threshold: 0,
disabled: false,
});
expect(result.dx).toBe(99);
expect(result.dy).toBe(99);
expect(result.guides).toHaveLength(0);
});
test("element snap takes priority over grid snap", () => {
// Element left edge at 100. Grid line at 97.
// Moving rect proposed left at 98 => dist to element=2, dist to grid=1.
// Grid is closer but element should win (priority).
// Actually the spec says element takes priority when both match within threshold.
// Let's set up: element at 103, grid at 97. Moving proposed left=100.
// Dist to element=3, dist to grid=3. Element should win.
const t = target("a", 103, 100, 50, 50);
const gridEdges = {
x: [{ position: 97, source: "grid" as const, id: "grid-x-0" }],
y: [],
};
const result = resolveSnapAdjustment({
movingRect: rect(0, 100, 50, 50),
proposedDx: 100,
proposedDy: 0,
targets: [t],
gridEdges,
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// Element at 103 wins over grid at 97 (both within threshold, same distance)
expect(result.dx).toBe(103);
});
test("grid snap used when no element matches", () => {
const gridEdges = {
x: [{ position: 100, source: "grid" as const, id: "grid-x-0" }],
y: [],
};
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 97,
proposedDy: 10,
targets: [],
gridEdges,
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(100);
});
test("snaps X and Y independently", () => {
const t = target("a", 200, 300, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 100, 100),
proposedDx: 198,
proposedDy: 500,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// X should snap (left-to-left, diff=2), Y should not snap (too far)
expect(result.dx).toBe(200);
expect(result.dy).toBe(500);
});
test("works correctly with many targets (80)", () => {
const targets: SnapTarget[] = [];
for (let i = 0; i < 80; i++) {
targets.push(target(`el-${i}`, i * 50, i * 30, 40, 20));
}
// Moving rect near target el-40: left=2000, top=1200
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 40, 20),
proposedDx: 1998,
proposedDy: 1198,
targets,
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(2000);
expect(result.dy).toBe(1200);
expect(result.guides.length).toBeGreaterThanOrEqual(1);
});
test("opposite-direction tie produces no snap (ambiguous midpoint)", () => {
const tA = target("a", 100, 100, 10, 10);
const tB = target("b", 120, 100, 10, 10);
// Moving rect at x=110, width=10 → left=110, right=120
// tA.right=110, distance=0; tB.left=120, distance=0 — both exact, opposite pull
const result = resolveSnapAdjustment({
movingRect: rect(110, 100, 10, 10),
proposedDx: 0,
proposedDy: 0,
targets: [tA, tB],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(0);
expect(result.dy).toBe(0);
});
test("handles subpixel positions from non-100% zoom", () => {
const t = target("a", 200.5, 100.3, 100, 100);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 197.8,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// left edge at 197.8, target left at 200.5, diff=2.7 within threshold
expect(result.dx).toBe(200.5);
});
});
// ---------------------------------------------------------------------------
// resolveResizeSnapAdjustment
// ---------------------------------------------------------------------------
describe("resolveResizeSnapAdjustment", () => {
test("only right edge snaps on X", () => {
// Moving rect at (100, 100) size 50x50, right=150.
// Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
const t = target("a", 200, 100, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(50); // right edge snaps to 200
});
test("only bottom edge snaps on Y", () => {
// Moving rect at (100, 100) size 50x50, bottom=150.
// Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3.
const t = target("a", 100, 200, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 0,
proposedDy: 47,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dy).toBe(50); // bottom edge snaps to 200
});
test("left edge does NOT snap during resize", () => {
// Target right at 150. Moving rect left=100. If drag were active,
// left would snap. But during resize, only right edge snaps.
// Moving rect at (100, 100) size 200x200, right=300.
// Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
// This SHOULD snap right to 150 (dx = -150). But left stays at 100.
const t = target("a", 50, 100, 100, 100); // right=150
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 200, 200),
proposedDx: -153,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
// Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
expect(result.dx).toBe(-150);
});
test("disabled=true returns passthrough for resize", () => {
const t = target("a", 200, 200, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 47,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: true,
});
expect(result.dx).toBe(47);
expect(result.dy).toBe(47);
expect(result.guides).toHaveLength(0);
});
test("resize produces guide lines", () => {
const t = target("a", 200, 100, 100, 100);
const result = resolveResizeSnapAdjustment({
movingRect: rect(100, 100, 50, 50),
proposedDx: 47,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.guides.length).toBeGreaterThanOrEqual(1);
const xGuide = result.guides.find((g) => g.axis === "x");
expect(xGuide).toBeDefined();
expect(xGuide!.position).toBe(200);
});
});
// ---------------------------------------------------------------------------
// resolveEquidistanceGuides
// ---------------------------------------------------------------------------
describe("resolveEquidistanceGuides", () => {
test("detects equal horizontal spacing", () => {
// Three elements in a row: A(0..40), moving(70..110), B(140..180)
// Gap A-moving = 70 - 40 = 30, gap moving-B = 140 - 110 = 30 => equal
const targets = [target("a", 0, 0, 40, 40), target("b", 140, 0, 40, 40)];
const guides = resolveEquidistanceGuides({
movingRect: rect(70, 0, 40, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
const xGuides = guides.filter((g) => g.axis === "x");
expect(xGuides.length).toBe(2);
expect(xGuides[0].size).toBe(30);
expect(xGuides[1].size).toBe(30);
});
test("detects equal vertical spacing", () => {
// A(y=0..40), moving(y=60..100), B(y=120..160)
// Gap = 20 each
const targets = [target("a", 0, 0, 40, 40), target("b", 0, 120, 40, 40)];
const guides = resolveEquidistanceGuides({
movingRect: rect(0, 60, 40, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
const yGuides = guides.filter((g) => g.axis === "y");
expect(yGuides.length).toBe(2);
expect(yGuides[0].size).toBe(20);
});
test("no equidistance when gaps differ", () => {
// A(0..40), moving(80..120), B(200..240)
// Gap A-moving = 40, gap moving-B = 80 => not equal
const targets = [target("a", 0, 0, 40, 40), target("b", 200, 0, 40, 40)];
const guides = resolveEquidistanceGuides({
movingRect: rect(80, 0, 40, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
const xGuides = guides.filter((g) => g.axis === "x");
expect(xGuides.length).toBe(0);
});
test("handles tolerance of 1px", () => {
// A(0..40), moving(70..110), B(139..179)
// Gap A-moving = 30, gap moving-B = 29 => difference = 1 => within tolerance
const targets = [target("a", 0, 0, 40, 40), target("b", 139, 0, 40, 40)];
const guides = resolveEquidistanceGuides({
movingRect: rect(70, 0, 40, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
const xGuides = guides.filter((g) => g.axis === "x");
expect(xGuides.length).toBe(2);
});
test("ignores overlapping elements", () => {
// A(0..100), moving(50..150), B(200..300) — A and moving overlap
const targets = [target("a", 0, 0, 100, 40), target("b", 200, 0, 100, 40)];
const guides = resolveEquidistanceGuides({
movingRect: rect(50, 0, 100, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
const xGuides = guides.filter((g) => g.axis === "x");
// Gap A-moving = 50 - 100 = -50 (overlap), should be skipped
expect(xGuides.length).toBe(0);
});
test("only reports triplets involving the moving rect", () => {
// A(0..40), B(60..100), C(120..160) — all gaps = 20 but none involves moving
// Moving rect is far away at (500..540)
const targets = [
target("a", 0, 0, 40, 40),
target("b", 60, 0, 40, 40),
target("c", 120, 0, 40, 40),
];
const guides = resolveEquidistanceGuides({
movingRect: rect(500, 0, 40, 40),
targets,
threshold: SNAP_THRESHOLD_PX,
});
// The A-B-C triplet doesn't involve moving, so no guides from it
// Any triplet involving moving would have huge gaps that don't match
const xGuides = guides.filter((g) => g.axis === "x");
expect(xGuides.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("edge cases", () => {
test("empty targets returns passthrough", () => {
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 10,
proposedDy: 20,
targets: [],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(10);
expect(result.dy).toBe(20);
expect(result.guides).toHaveLength(0);
});
test("exact match (zero distance) produces snap", () => {
const t = target("a", 100, 100, 50, 50);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 100,
proposedDy: 100,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(100);
expect(result.dy).toBe(100);
expect(result.guides.length).toBeGreaterThanOrEqual(1);
});
test("negative proposed delta works", () => {
const t = target("a", 50, 50, 100, 100);
// Moving rect at (200, 200), propose dx=-148 => proposed left=52, target left=50, diff=2
const result = resolveSnapAdjustment({
movingRect: rect(200, 200, 50, 50),
proposedDx: -148,
proposedDy: -148,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(-150);
expect(result.dy).toBe(-150);
});
test("left-to-left alignment", () => {
const t = target("a", 100, 0, 200, 200);
const result = resolveSnapAdjustment({
movingRect: rect(0, 300, 80, 80),
proposedDx: 97,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(100);
});
test("right-to-right alignment", () => {
// Target right = 300. Moving rect width=80 at x=0, right=80.
// Propose dx=217 => proposed right=297, target right=300, diff=3.
const t = target("a", 100, 0, 200, 200);
const result = resolveSnapAdjustment({
movingRect: rect(0, 300, 80, 80),
proposedDx: 217,
proposedDy: 0,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dx).toBe(220); // proposed left=220, proposed right=300
});
test("bottom-to-bottom alignment", () => {
// Target bottom = 200. Moving rect height=50 at y=0, bottom=50.
// Propose dy=147 => proposed bottom=197, target bottom=200, diff=3.
const t = target("a", 0, 0, 200, 200);
const result = resolveSnapAdjustment({
movingRect: rect(0, 0, 50, 50),
proposedDx: 0,
proposedDy: 147,
targets: [t],
threshold: SNAP_THRESHOLD_PX,
disabled: false,
});
expect(result.dy).toBe(150);
});
});
@@ -0,0 +1,575 @@
// fallow-ignore-file code-duplication
// Snap computation engine — pure functions, zero React/DOM dependencies.
// All position values are in overlay-space (screen) pixels.
export const SNAP_THRESHOLD_PX = 6;
const EQUIDISTANCE_TOLERANCE_PX = 1;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SnapEdge {
position: number;
source: "grid";
id: string;
}
export interface SnapTarget {
left: number;
top: number;
right: number;
bottom: number;
centerX: number;
centerY: number;
id: string;
}
export interface SnapGuide {
axis: "x" | "y";
position: number;
/** Extent of the guide line (min of involved elements). */
from: number;
/** Extent of the guide line (max of involved elements). */
to: number;
}
export interface SpacingGuide {
axis: "x" | "y";
/** Position of the gap (start of gap). */
position: number;
/** Size of the gap in pixels. */
size: number;
/** Extent for rendering the indicator. */
from: number;
/** Extent for rendering the indicator. */
to: number;
}
export interface SnapResult {
dx: number;
dy: number;
guides: SnapGuide[];
spacingGuides: SpacingGuide[];
}
// ---------------------------------------------------------------------------
// Rect shorthand used across the public API
// ---------------------------------------------------------------------------
export interface Rect {
left: number;
top: number;
width: number;
height: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rectRight(r: Rect): number {
return r.left + r.width;
}
function rectBottom(r: Rect): number {
return r.top + r.height;
}
function rectCenterX(r: Rect): number {
return r.left + r.width / 2;
}
function rectCenterY(r: Rect): number {
return r.top + r.height / 2;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Convert overlay rects to snap targets with precomputed edges & centers.
*/
export function extractSnapTargets(entries: Array<{ rect: Rect; id: string }>): SnapTarget[] {
return entries.map(({ rect: r, id }) => ({
left: r.left,
top: r.top,
right: rectRight(r),
bottom: rectBottom(r),
centerX: rectCenterX(r),
centerY: rectCenterY(r),
id,
}));
}
/**
* Create a snap target from the composition/overlay boundary.
*/
export function buildCompositionSnapTarget(rect: Rect): SnapTarget {
return {
left: rect.left,
top: rect.top,
right: rectRight(rect),
bottom: rectBottom(rect),
centerX: rectCenterX(rect),
centerY: rectCenterY(rect),
id: "composition",
};
}
/**
* Generate grid-line snap edges.
* `gridSpacing` is in composition pixels; `scale` converts to overlay pixels.
* X edges are vertical grid lines; Y edges are horizontal grid lines.
*/
export function buildGridSnapEdges(
compositionRect: Rect,
gridSpacing: number,
scale: number,
): { x: SnapEdge[]; y: SnapEdge[] } {
const xEdges: SnapEdge[] = [];
const yEdges: SnapEdge[] = [];
if (gridSpacing <= 0 || scale <= 0) return { x: xEdges, y: yEdges };
const step = gridSpacing * scale;
// Vertical grid lines (x-axis edges)
let x = compositionRect.left + step;
const xMax = compositionRect.left + compositionRect.width;
let idx = 0;
while (x < xMax) {
xEdges.push({ position: x, source: "grid", id: `grid-x-${idx}` });
x += step;
idx++;
}
// Horizontal grid lines (y-axis edges)
let y = compositionRect.top + step;
const yMax = compositionRect.top + compositionRect.height;
idx = 0;
while (y < yMax) {
yEdges.push({ position: y, source: "grid", id: `grid-y-${idx}` });
y += step;
idx++;
}
return { x: xEdges, y: yEdges };
}
// ---------------------------------------------------------------------------
// Internal snap resolution helpers
// ---------------------------------------------------------------------------
interface EdgeCandidate {
/** Distance the moving rect must adjust to align with this edge. */
adjustment: number;
/** Absolute distance (for comparison). */
distance: number;
/** Position of the guide line. */
guidePosition: number;
/** Source of the match. */
source: "element" | "composition" | "grid";
/** Id of the target or grid line. */
targetId: string;
}
/**
* Collect edge candidates on a single axis for a moving rect.
* `movingEdges` are the edges of the moving rect (e.g. left, centerX, right).
* `targetEdges` are the corresponding edges on each target.
*/
// fallow-ignore-next-line complexity
function collectCandidates(
movingEdges: number[],
targets: SnapTarget[],
targetEdgeExtractor: (t: SnapTarget) => number[],
gridEdges: SnapEdge[] | undefined,
threshold: number,
): EdgeCandidate[] {
const candidates: EdgeCandidate[] = [];
for (const target of targets) {
const tEdges = targetEdgeExtractor(target);
for (const mEdge of movingEdges) {
for (const tEdge of tEdges) {
const adjustment = tEdge - mEdge;
const distance = Math.abs(adjustment);
if (distance <= threshold) {
candidates.push({
adjustment,
distance,
guidePosition: tEdge,
source: target.id === "composition" ? "composition" : "element",
targetId: target.id,
});
}
}
}
}
if (gridEdges) {
for (const edge of gridEdges) {
for (const mEdge of movingEdges) {
const adjustment = edge.position - mEdge;
const distance = Math.abs(adjustment);
if (distance <= threshold) {
candidates.push({
adjustment,
distance,
guidePosition: edge.position,
source: "grid",
targetId: edge.id,
});
}
}
}
}
return candidates;
}
/**
* From a list of candidates, pick the best adjustment:
* - Element/composition matches take priority over grid matches.
* - Among equal-priority matches, pick the smallest distance.
* - Return all guides that share the winning adjustment.
*/
function pickBest(candidates: EdgeCandidate[]): {
adjustment: number;
matches: EdgeCandidate[];
} | null {
if (candidates.length === 0) return null;
// Partition into element/composition vs grid
const elementCandidates = candidates.filter(
(c) => c.source === "element" || c.source === "composition",
);
const gridCandidates = candidates.filter((c) => c.source === "grid");
// Pick the pool with the best (smallest distance) match, preferring element
let pool: EdgeCandidate[];
const bestElem = elementCandidates.length
? Math.min(...elementCandidates.map((c) => c.distance))
: Infinity;
const bestGrid = gridCandidates.length
? Math.min(...gridCandidates.map((c) => c.distance))
: Infinity;
if (bestElem <= bestGrid) {
pool = elementCandidates;
} else {
pool = gridCandidates;
}
const minDist = Math.min(...pool.map((c) => c.distance));
const winners = pool.filter((c) => c.distance === minDist);
// When candidates at the same distance pull in opposite directions (e.g.
// element centered between two equidistant targets), suppress the snap
// entirely — the element holds where the user dragged it.
const hasPositive = winners.some((c) => c.adjustment > 0);
const hasNegative = winners.some((c) => c.adjustment < 0);
if (hasPositive && hasNegative) return null;
const adjustment = winners[0].adjustment;
const matches = pool.filter((c) => c.adjustment === adjustment);
return { adjustment, matches };
}
// ---------------------------------------------------------------------------
// Guide extent computation
// ---------------------------------------------------------------------------
function computeGuideExtent(
axis: "x" | "y",
movingRect: Rect,
matchedTargetIds: string[],
targetMap: Map<string, SnapTarget>,
): { from: number; to: number } {
const extents: number[] = [];
if (axis === "x") {
extents.push(movingRect.top, rectBottom(movingRect));
for (const tid of matchedTargetIds) {
const t = targetMap.get(tid);
if (t) extents.push(t.top, t.bottom);
}
} else {
extents.push(movingRect.left, rectRight(movingRect));
for (const tid of matchedTargetIds) {
const t = targetMap.get(tid);
if (t) extents.push(t.left, t.right);
}
}
return { from: Math.min(...extents), to: Math.max(...extents) };
}
// ---------------------------------------------------------------------------
// Shared guide-building logic
// ---------------------------------------------------------------------------
function buildGuidesFromMatches(
bestX: { adjustment: number; matches: EdgeCandidate[] } | null,
bestY: { adjustment: number; matches: EdgeCandidate[] } | null,
adjustedRect: Rect,
targetMap: Map<string, SnapTarget>,
): SnapGuide[] {
const guides: SnapGuide[] = [];
for (const [axis, best] of [
["x", bestX],
["y", bestY],
] as const) {
if (!best) continue;
const seenPositions = new Set<number>();
for (const m of best.matches) {
if (seenPositions.has(m.guidePosition)) continue;
seenPositions.add(m.guidePosition);
const targetIds = best.matches
.filter((mm) => mm.guidePosition === m.guidePosition)
.map((mm) => mm.targetId);
const extent = computeGuideExtent(axis, adjustedRect, targetIds, targetMap);
guides.push({ axis, position: m.guidePosition, from: extent.from, to: extent.to });
}
}
return guides;
}
const DISABLED_RESULT = (dx: number, dy: number): SnapResult => ({
dx,
dy,
guides: [],
spacingGuides: [],
});
// ---------------------------------------------------------------------------
// resolveSnapAdjustment — main drag snap entry point
// ---------------------------------------------------------------------------
// fallow-ignore-next-line complexity
export function resolveSnapAdjustment(input: {
movingRect: Rect;
proposedDx: number;
proposedDy: number;
targets: SnapTarget[];
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
threshold: number;
disabled: boolean;
}): SnapResult {
if (input.disabled || input.threshold <= 0) {
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
}
const mr = input.movingRect;
const proposed: Rect = {
left: mr.left + input.proposedDx,
top: mr.top + input.proposedDy,
width: mr.width,
height: mr.height,
};
const xCandidates = collectCandidates(
[proposed.left, rectCenterX(proposed), rectRight(proposed)],
input.targets,
(t) => [t.left, t.centerX, t.right],
input.gridEdges?.x,
input.threshold,
);
const yCandidates = collectCandidates(
[proposed.top, rectCenterY(proposed), rectBottom(proposed)],
input.targets,
(t) => [t.top, t.centerY, t.bottom],
input.gridEdges?.y,
input.threshold,
);
const bestX = pickBest(xCandidates);
const bestY = pickBest(yCandidates);
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
const adjustedRect: Rect = {
left: mr.left + adjustedDx,
top: mr.top + adjustedDy,
width: mr.width,
height: mr.height,
};
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
return {
dx: adjustedDx,
dy: adjustedDy,
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
spacingGuides: [], // computed separately via resolveEquidistanceGuides
};
}
// ---------------------------------------------------------------------------
// resolveResizeSnapAdjustment — resize variant (only right/bottom snap)
// ---------------------------------------------------------------------------
// fallow-ignore-next-line complexity
export function resolveResizeSnapAdjustment(input: {
movingRect: Rect;
proposedDx: number;
proposedDy: number;
targets: SnapTarget[];
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
threshold: number;
disabled: boolean;
}): SnapResult {
if (input.disabled || input.threshold <= 0) {
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
}
const mr = input.movingRect;
const proposedRight = rectRight(mr) + input.proposedDx;
const proposedBottom = rectBottom(mr) + input.proposedDy;
const xCandidates = collectCandidates(
[proposedRight],
input.targets,
(t) => [t.left, t.centerX, t.right],
input.gridEdges?.x,
input.threshold,
);
const yCandidates = collectCandidates(
[proposedBottom],
input.targets,
(t) => [t.top, t.centerY, t.bottom],
input.gridEdges?.y,
input.threshold,
);
const bestX = pickBest(xCandidates);
const bestY = pickBest(yCandidates);
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
const adjustedRect: Rect = {
left: mr.left,
top: mr.top,
width: mr.width + adjustedDx,
height: mr.height + adjustedDy,
};
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
return {
dx: adjustedDx,
dy: adjustedDy,
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
spacingGuides: [], // computed separately via resolveEquidistanceGuides
};
}
// ---------------------------------------------------------------------------
// resolveEquidistanceGuides
// ---------------------------------------------------------------------------
// fallow-ignore-next-line complexity
export function resolveEquidistanceGuides(input: {
movingRect: Rect;
targets: SnapTarget[];
threshold: number;
}): SpacingGuide[] {
const guides: SpacingGuide[] = [];
const mr = input.movingRect;
const movingTarget: SnapTarget = {
left: mr.left,
top: mr.top,
right: rectRight(mr),
bottom: rectBottom(mr),
centerX: rectCenterX(mr),
centerY: rectCenterY(mr),
id: "\0__snap_moving__",
};
const allTargets = [...input.targets, movingTarget];
// X axis: sort by centerX, scan for equal gaps between adjacent triplets
const sortedX = [...allTargets].sort((a, b) => a.centerX - b.centerX);
for (let i = 0; i < sortedX.length - 2; i++) {
const a = sortedX[i];
const b = sortedX[i + 1];
const c = sortedX[i + 2];
// Gap between A and B = B.left - A.right
const gapAB = b.left - a.right;
const gapBC = c.left - b.right;
if (gapAB < 0 || gapBC < 0) continue; // overlapping elements
// Check if the moving rect is one of A, B, or C
const involvesMoving =
a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
if (!involvesMoving) continue;
if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
const crossMin = Math.min(a.top, b.top, c.top);
const crossMax = Math.max(a.bottom, b.bottom, c.bottom);
// Gap A-B
guides.push({
axis: "x",
position: a.right,
size: gapAB,
from: crossMin,
to: crossMax,
});
// Gap B-C
guides.push({
axis: "x",
position: b.right,
size: gapBC,
from: crossMin,
to: crossMax,
});
}
}
// Y axis: sort by centerY, scan for equal gaps between adjacent triplets
const sortedY = [...allTargets].sort((a, b) => a.centerY - b.centerY);
for (let i = 0; i < sortedY.length - 2; i++) {
const a = sortedY[i];
const b = sortedY[i + 1];
const c = sortedY[i + 2];
const gapAB = b.top - a.bottom;
const gapBC = c.top - b.bottom;
if (gapAB < 0 || gapBC < 0) continue;
const involvesMoving =
a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
if (!involvesMoving) continue;
if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
const crossMin = Math.min(a.left, b.left, c.left);
const crossMax = Math.max(a.right, b.right, c.right);
guides.push({
axis: "y",
position: a.bottom,
size: gapAB,
from: crossMin,
to: crossMax,
});
guides.push({
axis: "y",
position: b.bottom,
size: gapBC,
from: crossMin,
to: crossMax,
});
}
}
return guides;
}
@@ -11,6 +11,10 @@ export interface StudioUiPreferences {
audioMuted?: boolean;
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
snapEnabled?: boolean;
gridVisible?: boolean;
gridSpacing?: number;
snapToGrid?: boolean;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -28,6 +32,7 @@ function getBrowserStorage(): Storage | null {
}
}
// fallow-ignore-next-line complexity
function readStorage(storage: Storage | null): StudioUiPreferences {
if (!storage) return {};
try {
@@ -67,6 +72,18 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
(v: unknown): v is string => typeof v === "string",
);
}
if (typeof parsed.snapEnabled === "boolean") {
preferences.snapEnabled = parsed.snapEnabled;
}
if (typeof parsed.gridVisible === "boolean") {
preferences.gridVisible = parsed.gridVisible;
}
if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
preferences.gridSpacing = parsed.gridSpacing;
}
if (typeof parsed.snapToGrid === "boolean") {
preferences.snapToGrid = parsed.snapToGrid;
}
return preferences;
} catch {
return {};