mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user