From 004a8f19c1edc392ca95e9d31050bf1bc5a41d9e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 00:35:50 +0200 Subject: [PATCH] feat(canvas): alignment guides + snap while dragging nodes Draw.io / Figma style: while dragging a node, show dashed cyan guide lines when its edges (left / center / right / top / middle / bottom) align with another node's, and snap the position to the matched line within a configurable threshold. - utils/alignment.ts: pure snap math, returns delta + guide segments. Same-size boxes show all aligned guides simultaneously. - canvas/AlignmentGuides.tsx: SVG overlay locked to the React Flow viewport (panned/zoomed correctly). - hooks/useAlignmentGuides.ts: wires onNodeDrag/onNodeDragStop, applies snap via setNodes, listens for Alt to temporarily disable. - utils/alignmentSettings.ts: localStorage-backed prefs (enabled, threshold 2-16px) with a tiny CustomEvent pub-sub so the SettingsPanel and the drag hook stay in sync without a global store. - Sidebar settings panel: toggle + threshold slider. Multi-selection drag uses the union bounding box. Children with parentId are skipped for v1 to avoid mixing absolute and parent-relative coordinates. Hold Alt to escape snap. --- .../src/components/canvas/AlignmentGuides.tsx | 70 +++++++ .../src/components/canvas/CanvasContainer.tsx | 7 + .../canvas/__tests__/AlignmentGuides.test.tsx | 47 +++++ frontend/src/components/panels/Sidebar.tsx | 50 +++++ frontend/src/hooks/useAlignmentGuides.ts | 85 +++++++++ .../src/utils/__tests__/alignment.test.ts | 110 +++++++++++ .../utils/__tests__/alignmentSettings.test.ts | 42 +++++ frontend/src/utils/alignment.ts | 173 ++++++++++++++++++ frontend/src/utils/alignmentSettings.ts | 43 +++++ 9 files changed, 627 insertions(+) create mode 100644 frontend/src/components/canvas/AlignmentGuides.tsx create mode 100644 frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx create mode 100644 frontend/src/hooks/useAlignmentGuides.ts create mode 100644 frontend/src/utils/__tests__/alignment.test.ts create mode 100644 frontend/src/utils/__tests__/alignmentSettings.test.ts create mode 100644 frontend/src/utils/alignment.ts create mode 100644 frontend/src/utils/alignmentSettings.ts diff --git a/frontend/src/components/canvas/AlignmentGuides.tsx b/frontend/src/components/canvas/AlignmentGuides.tsx new file mode 100644 index 0000000..ecad7ea --- /dev/null +++ b/frontend/src/components/canvas/AlignmentGuides.tsx @@ -0,0 +1,70 @@ +import { useViewport } from '@xyflow/react' +import type { Guide } from '@/utils/alignment' + +interface AlignmentGuidesProps { + guides: Guide[] + color?: string +} + +/** + * SVG overlay that draws alignment guide lines on top of the React Flow canvas. + * Coordinates are in canvas (flow) space; we read the viewport transform to + * project them into screen space so lines stay locked to nodes when the user + * pans or zooms. + */ +export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) { + const { x: vx, y: vy, zoom } = useViewport() + + if (guides.length === 0) return null + + return ( + + {guides.map((g, i) => { + if (g.axis === 'x') { + const x = g.position * zoom + vx + const y1 = g.start * zoom + vy + const y2 = g.end * zoom + vy + return ( + + ) + } + const y = g.position * zoom + vy + const x1 = g.start * zoom + vx + const x2 = g.end * zoom + vx + return ( + + ) + })} + + ) +} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 9ec2726..7433492 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes' import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' +import { AlignmentGuides } from './AlignmentGuides' +import { useAlignmentGuides } from '@/hooks/useAlignmentGuides' import type { NodeData, EdgeData } from '@/types' interface CanvasContainerProps { @@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o [] ) + const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() + return (
+ setLassoMode((m) => !m)} diff --git a/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx new file mode 100644 index 0000000..d178fdf --- /dev/null +++ b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { AlignmentGuides } from '../AlignmentGuides' +import type { Guide } from '@/utils/alignment' + +vi.mock('@xyflow/react', () => ({ + useViewport: () => ({ x: 50, y: 100, zoom: 2 }), +})) + +describe('AlignmentGuides', () => { + it('renders nothing when no guides', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeNull() + }) + + it('projects an x-axis guide through the viewport transform', () => { + const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }] + const { container } = render() + const line = container.querySelector('line')! + // x = position * zoom + vx → 100*2 + 50 = 250 + expect(line.getAttribute('x1')).toBe('250') + expect(line.getAttribute('x2')).toBe('250') + // y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500 + expect(line.getAttribute('y1')).toBe('100') + expect(line.getAttribute('y2')).toBe('500') + }) + + it('projects a y-axis guide horizontally', () => { + const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }] + const { container } = render() + const line = container.querySelector('line')! + // y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170 + expect(line.getAttribute('y1')).toBe('200') + expect(line.getAttribute('y2')).toBe('200') + expect(line.getAttribute('x1')).toBe('70') + expect(line.getAttribute('x2')).toBe('170') + }) + + it('renders one line per guide', () => { + const guides: Guide[] = [ + { axis: 'x', position: 100, start: 0, end: 200 }, + { axis: 'y', position: 50, start: 10, end: 60 }, + ] + const { container } = render() + expect(container.querySelectorAll('line')).toHaveLength(2) + }) +}) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 7cf6da4..e7554d8 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -7,6 +7,12 @@ import { useAuthStore } from '@/stores/authStore' import { scanApi, settingsApi } from '@/api/client' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' +import { + type AlignmentSettings, + readAlignmentSettings, + writeAlignmentSettings, + subscribeAlignmentSettings, +} from '@/utils/alignmentSettings' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' @@ -321,6 +327,7 @@ function ScanHistoryPanel() { function SettingsPanel() { const [interval, setIntervalValue] = useState(60) const [saving, setSaving] = useState(false) + const [alignment, setAlignment] = useState(readAlignmentSettings) useEffect(() => { settingsApi.get() @@ -328,6 +335,14 @@ function SettingsPanel() { .catch(() => {/* use default */}) }, []) + useEffect(() => subscribeAlignmentSettings(setAlignment), []) + + const updateAlignment = (patch: Partial) => { + const next = { ...alignment, ...patch } + setAlignment(next) + writeAlignmentSettings(next) + } + const handleSave = async () => { setSaving(true) try { @@ -369,6 +384,41 @@ function SettingsPanel() { > {saving ? 'Saving…' : 'Save'} + +
+ Canvas + + + +
+ +
+ updateAlignment({ threshold: Number(e.target.value) })} + className="flex-1 cursor-pointer accent-[#00d4ff]" + aria-label="Alignment snap threshold" + /> + {alignment.threshold}px +
+

+ Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable. +

+
+
) } diff --git a/frontend/src/hooks/useAlignmentGuides.ts b/frontend/src/hooks/useAlignmentGuides.ts new file mode 100644 index 0000000..e189429 --- /dev/null +++ b/frontend/src/hooks/useAlignmentGuides.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useReactFlow, type Node, type NodeDragHandler } from '@xyflow/react' +import { computeSnap, unionBox, type Box, type Guide } from '@/utils/alignment' +import { + type AlignmentSettings, + readAlignmentSettings, + writeAlignmentSettings, + subscribeAlignmentSettings, +} from '@/utils/alignmentSettings' +import type { NodeData } from '@/types' + +function nodeBox(n: Node): Box | null { + // Skip nodes with a parent — alignment between absolute & parent-relative + // coordinates is misleading. Top-level nodes only for v1. + if (n.parentId) return null + const width = n.measured?.width ?? n.width ?? null + const height = n.measured?.height ?? n.height ?? null + if (width == null || height == null) return null + return { id: n.id, x: n.position.x, y: n.position.y, width, height } +} + +export function useAlignmentGuides() { + const [settings, setSettings] = useState(readAlignmentSettings) + const [guides, setGuides] = useState([]) + const altDownRef = useRef(false) + const { setNodes, getNodes } = useReactFlow>() + + useEffect(() => subscribeAlignmentSettings(setSettings), []) + + useEffect(() => { + const down = (e: KeyboardEvent) => { if (e.altKey) altDownRef.current = true } + const up = (e: KeyboardEvent) => { if (!e.altKey) altDownRef.current = false } + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + } + }, []) + + const update = useCallback((patch: Partial) => { + setSettings((s) => { + const next = { ...s, ...patch } + writeAlignmentSettings(next) + return next + }) + }, []) + + const onNodeDrag: NodeDragHandler = useCallback((_event, dragNode, dragNodes) => { + if (!settings.enabled || altDownRef.current) { + if (guides.length > 0) setGuides([]) + return + } + const all = getNodes() + const draggedSet = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id)) + const draggedBoxes = all.filter((n) => draggedSet.has(n.id)).map(nodeBox).filter((b): b is Box => b !== null) + if (draggedBoxes.length === 0) { + if (guides.length > 0) setGuides([]) + return + } + const dragged = draggedBoxes.length === 1 ? draggedBoxes[0] : unionBox(draggedBoxes) + if (!dragged) return + const candidates = all + .filter((n) => !draggedSet.has(n.id)) + .map(nodeBox) + .filter((b): b is Box => b !== null) + const result = computeSnap(dragged, candidates, settings.threshold) + setGuides(result.guides) + if (result.deltaX !== 0 || result.deltaY !== 0) { + setNodes((ns) => + ns.map((n) => + draggedSet.has(n.id) + ? { ...n, position: { x: n.position.x + result.deltaX, y: n.position.y + result.deltaY } } + : n, + ), + ) + } + }, [settings.enabled, settings.threshold, getNodes, setNodes, guides.length]) + + const onNodeDragStop: NodeDragHandler = useCallback(() => { + if (guides.length > 0) setGuides([]) + }, [guides.length]) + + return { guides, settings, update, onNodeDrag, onNodeDragStop } +} diff --git a/frontend/src/utils/__tests__/alignment.test.ts b/frontend/src/utils/__tests__/alignment.test.ts new file mode 100644 index 0000000..db2afa8 --- /dev/null +++ b/frontend/src/utils/__tests__/alignment.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { computeSnap, unionBox, type Box } from '../alignment' + +const box = (id: string, x: number, y: number, width = 100, height = 50): Box => ({ id, x, y, width, height }) + +describe('computeSnap', () => { + it('returns zero deltas with no candidates', () => { + const r = computeSnap(box('a', 0, 0), [], 6) + expect(r).toEqual({ deltaX: 0, deltaY: 0, guides: [] }) + }) + + it('snaps left edges when within threshold (same-size boxes show all aligned guides)', () => { + const dragged = box('a', 102, 200) // left=102 + const other = box('b', 100, 0) // left=100 → delta -2 → all three edges tie + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(-2) + expect(r.deltaY).toBe(0) + // Same width: left/center/right all tie at delta -2. Draw all three guides. + expect(r.guides.map((g) => g.position).sort((a, b) => a - b)).toEqual([100, 150, 200]) + expect(r.guides.every((g) => g.axis === 'x')).toBe(true) + }) + + it('shows a single guide when only one edge aligns', () => { + const dragged = box('a', 102, 200, 80, 50) // left=102, center=142, right=182 + const other = box('b', 100, 0, 100, 50) // left=100, center=150, right=200 + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(-2) // left↔left wins + expect(r.guides).toHaveLength(1) + expect(r.guides[0].position).toBe(100) + }) + + it('does not snap when outside threshold', () => { + const dragged = box('a', 110, 200) + const other = box('b', 100, 0) + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(0) + expect(r.guides).toHaveLength(0) + }) + + it('snaps centerX to centerX', () => { + const dragged = box('a', 51, 300, 100, 50) // center=101 + const other = box('b', 75, 0, 50, 50) // center=100 → delta -1 + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(-1) + expect(r.guides).toHaveLength(1) + expect(r.guides[0].position).toBe(100) + }) + + it('snaps right edge of dragged to left edge of candidate', () => { + const dragged = box('a', 0, 200) // right=100 + const other = box('b', 102, 0) // left=102 → delta +2 + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(2) + }) + + it('snaps both axes simultaneously', () => { + const dragged = box('a', 102, 53, 80, 30) + const other = box('b', 100, 50, 100, 50) + const r = computeSnap(dragged, [other], 6) + expect(r.deltaX).toBe(-2) + expect(r.deltaY).toBe(-3) + // Distinct sizes → exactly one X guide and one Y guide. + expect(r.guides.filter((g) => g.axis === 'x')).toHaveLength(1) + expect(r.guides.filter((g) => g.axis === 'y')).toHaveLength(1) + }) + + it('picks the closest match when multiple candidates compete', () => { + const dragged = box('a', 103, 0) + const closer = box('b', 100, 0) // delta -3 + const farther = box('c', 109, 0) // delta +6 — also within threshold but worse + const r = computeSnap(dragged, [closer, farther], 6) + expect(r.deltaX).toBe(-3) + }) + + it('ignores the dragged box itself when present in candidates', () => { + const dragged = box('a', 102, 0) + const r = computeSnap(dragged, [dragged, box('b', 100, 0)], 6) + expect(r.deltaX).toBe(-2) + }) + + it('returns no snap with non-positive threshold', () => { + const r = computeSnap(box('a', 100, 0), [box('b', 100, 0)], 0) + expect(r.guides).toEqual([]) + }) + + it('guide span covers both involved boxes', () => { + const dragged = box('a', 102, 200, 100, 50) // y range 200..250 + const other = box('b', 100, 0, 100, 50) // y range 0..50 + const r = computeSnap(dragged, [other], 6) + const g = r.guides[0] + expect(g.start).toBeLessThanOrEqual(0) + expect(g.end).toBeGreaterThanOrEqual(250) + }) +}) + +describe('unionBox', () => { + it('returns null for empty input', () => { + expect(unionBox([])).toBeNull() + }) + + it('returns the box itself for one input', () => { + const b = box('a', 10, 20, 30, 40) + expect(unionBox([b])).toMatchObject({ x: 10, y: 20, width: 30, height: 40 }) + }) + + it('computes union of multiple boxes', () => { + const u = unionBox([box('a', 0, 0, 50, 50), box('b', 100, 100, 50, 50)]) + expect(u).toMatchObject({ x: 0, y: 0, width: 150, height: 150 }) + }) +}) diff --git a/frontend/src/utils/__tests__/alignmentSettings.test.ts b/frontend/src/utils/__tests__/alignmentSettings.test.ts new file mode 100644 index 0000000..5c8c260 --- /dev/null +++ b/frontend/src/utils/__tests__/alignmentSettings.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + DEFAULT_ALIGNMENT_SETTINGS, + readAlignmentSettings, + writeAlignmentSettings, + subscribeAlignmentSettings, +} from '../alignmentSettings' + +describe('alignmentSettings', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('returns defaults when nothing stored', () => { + expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS) + }) + + it('roundtrips through localStorage', () => { + writeAlignmentSettings({ enabled: false, threshold: 10 }) + expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: 10 }) + }) + + it('falls back to defaults when stored value is corrupted', () => { + localStorage.setItem('homelable.alignmentGuides', '{not json') + expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS) + }) + + it('fills missing fields from defaults', () => { + localStorage.setItem('homelable.alignmentGuides', JSON.stringify({ enabled: false })) + expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: DEFAULT_ALIGNMENT_SETTINGS.threshold }) + }) + + it('notifies subscribers on write', () => { + const listener = vi.fn() + const unsubscribe = subscribeAlignmentSettings(listener) + writeAlignmentSettings({ enabled: false, threshold: 8 }) + expect(listener).toHaveBeenCalledWith({ enabled: false, threshold: 8 }) + unsubscribe() + writeAlignmentSettings({ enabled: true, threshold: 4 }) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/utils/alignment.ts b/frontend/src/utils/alignment.ts new file mode 100644 index 0000000..f125617 --- /dev/null +++ b/frontend/src/utils/alignment.ts @@ -0,0 +1,173 @@ +// Alignment-snap math for drag-time guides (draw.io / Figma style). +// +// Given a dragged box and a set of candidate boxes, return the snap delta +// (clamped by threshold, expressed in canvas px) plus the guide segments that +// should be drawn while the snap is engaged. +// +// All coordinates are absolute canvas coordinates — callers must resolve +// child-of-parent positions before calling. + +export interface Box { + id: string + x: number + y: number + width: number + height: number +} + +export type GuideAxis = 'x' | 'y' + +export interface Guide { + axis: GuideAxis + // Position along the perpendicular axis (x for vertical guide, y for horizontal). + position: number + // Span along the parallel axis: min/max of involved boxes' opposite-axis extents. + start: number + end: number +} + +export interface SnapResult { + deltaX: number + deltaY: number + guides: Guide[] +} + +type EdgeKind = 'min' | 'center' | 'max' + +interface AxisLine { + kind: EdgeKind + pos: number + // Span of the source box along the perpendicular axis (used to extend guide segments). + spanStart: number + spanEnd: number +} + +function xLines(box: Box): AxisLine[] { + const top = box.y + const bottom = box.y + box.height + return [ + { kind: 'min', pos: box.x, spanStart: top, spanEnd: bottom }, + { kind: 'center', pos: box.x + box.width / 2, spanStart: top, spanEnd: bottom }, + { kind: 'max', pos: box.x + box.width, spanStart: top, spanEnd: bottom }, + ] +} + +function yLines(box: Box): AxisLine[] { + const left = box.x + const right = box.x + box.width + return [ + { kind: 'min', pos: box.y, spanStart: left, spanEnd: right }, + { kind: 'center', pos: box.y + box.height / 2, spanStart: left, spanEnd: right }, + { kind: 'max', pos: box.y + box.height, spanStart: left, spanEnd: right }, + ] +} + +interface Match { + delta: number + guidePos: number + candidate: AxisLine + dragLine: AxisLine +} + +// Find all (dragLine, candidateLine) pairs at the smallest |delta| within threshold. +// When boxes share dimensions, all three edges align simultaneously — return all. +function bestMatches(dragLines: AxisLine[], candidateLines: AxisLine[], threshold: number): Match[] { + let bestAbs = Infinity + const matches: Match[] = [] + for (const d of dragLines) { + for (const c of candidateLines) { + const delta = c.pos - d.pos + const abs = Math.abs(delta) + if (abs > threshold) continue + if (abs < bestAbs - 1e-9) { + bestAbs = abs + matches.length = 0 + } + if (abs <= bestAbs + 1e-9) { + matches.push({ delta, guidePos: c.pos, candidate: c, dragLine: d }) + } + } + } + return matches +} + +/** + * Compute snap delta + visible guides for a drag operation. + * + * @param dragged Bounding box of the dragged node (or selection bbox). + * @param candidates Other nodes' boxes to consider. + * @param threshold Max distance (canvas px) at which a snap engages. + */ +export function computeSnap(dragged: Box, candidates: Box[], threshold: number): SnapResult { + if (candidates.length === 0 || threshold <= 0) { + return { deltaX: 0, deltaY: 0, guides: [] } + } + + const dragX = xLines(dragged) + const dragY = yLines(dragged) + + let bestXMatches: Match[] = [] + let bestYMatches: Match[] = [] + let xBestAbs = Infinity + let yBestAbs = Infinity + + for (const cand of candidates) { + if (cand.id === dragged.id) continue + const mx = bestMatches(dragX, xLines(cand), threshold) + if (mx.length > 0) { + const abs = Math.abs(mx[0].delta) + if (abs < xBestAbs - 1e-9) { + xBestAbs = abs + bestXMatches = mx + } else if (abs <= xBestAbs + 1e-9) { + bestXMatches = bestXMatches.concat(mx) + } + } + const my = bestMatches(dragY, yLines(cand), threshold) + if (my.length > 0) { + const abs = Math.abs(my[0].delta) + if (abs < yBestAbs - 1e-9) { + yBestAbs = abs + bestYMatches = my + } else if (abs <= yBestAbs + 1e-9) { + bestYMatches = bestYMatches.concat(my) + } + } + } + + const deltaX = bestXMatches[0]?.delta ?? 0 + const deltaY = bestYMatches[0]?.delta ?? 0 + + const guides: Guide[] = [] + const seenX = new Set() + for (const m of bestXMatches) { + if (seenX.has(m.guidePos)) continue + seenX.add(m.guidePos) + const start = Math.min(m.dragLine.spanStart + deltaY, m.candidate.spanStart) + const end = Math.max(m.dragLine.spanEnd + deltaY, m.candidate.spanEnd) + guides.push({ axis: 'x', position: m.guidePos, start, end }) + } + const seenY = new Set() + for (const m of bestYMatches) { + if (seenY.has(m.guidePos)) continue + seenY.add(m.guidePos) + const start = Math.min(m.dragLine.spanStart + deltaX, m.candidate.spanStart) + const end = Math.max(m.dragLine.spanEnd + deltaX, m.candidate.spanEnd) + guides.push({ axis: 'y', position: m.guidePos, start, end }) + } + + return { deltaX, deltaY, guides } +} + +/** Bounding box of multiple boxes — used for multi-selection drag. */ +export function unionBox(boxes: Box[]): Box | null { + if (boxes.length === 0) return null + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const b of boxes) { + if (b.x < minX) minX = b.x + if (b.y < minY) minY = b.y + if (b.x + b.width > maxX) maxX = b.x + b.width + if (b.y + b.height > maxY) maxY = b.y + b.height + } + return { id: '__union__', x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} diff --git a/frontend/src/utils/alignmentSettings.ts b/frontend/src/utils/alignmentSettings.ts new file mode 100644 index 0000000..caeab6f --- /dev/null +++ b/frontend/src/utils/alignmentSettings.ts @@ -0,0 +1,43 @@ +// Persisted client-side settings for alignment guides. +// Kept in localStorage (per-user UI preference, not canvas data). +// Same-tab updates propagate via a CustomEvent so the drag hook and the +// settings panel can stay in sync without a global store. + +export interface AlignmentSettings { + enabled: boolean + threshold: number +} + +export const DEFAULT_ALIGNMENT_SETTINGS: AlignmentSettings = { enabled: true, threshold: 6 } + +const KEY = 'homelable.alignmentGuides' +const EVENT = 'homelable:alignment-settings-changed' + +export function readAlignmentSettings(): AlignmentSettings { + try { + const raw = localStorage.getItem(KEY) + if (!raw) return DEFAULT_ALIGNMENT_SETTINGS + const parsed = JSON.parse(raw) as Partial + return { + enabled: parsed.enabled ?? DEFAULT_ALIGNMENT_SETTINGS.enabled, + threshold: typeof parsed.threshold === 'number' ? parsed.threshold : DEFAULT_ALIGNMENT_SETTINGS.threshold, + } + } catch { + return DEFAULT_ALIGNMENT_SETTINGS + } +} + +export function writeAlignmentSettings(s: AlignmentSettings): void { + try { + localStorage.setItem(KEY, JSON.stringify(s)) + window.dispatchEvent(new CustomEvent(EVENT, { detail: s })) + } catch { + /* quota / SSR */ + } +} + +export function subscribeAlignmentSettings(listener: (s: AlignmentSettings) => void): () => void { + const handler = (e: Event) => listener((e as CustomEvent).detail) + window.addEventListener(EVENT, handler) + return () => window.removeEventListener(EVENT, handler) +}