import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { LayoutEntry, LayoutPersistence, LayoutSpec, TileSpan, TileSpec, } from './types.js'; import { localStoragePersistence } from './persistence.js'; export interface UseWorkspaceLayoutOptions { /** Persistence key (route-scoped). Should be product + view unique. */ storageKey: string; /** Override the persistence layer — default is `localStorage`. */ persistence?: LayoutPersistence; } export interface UseWorkspaceLayoutHelpers { layout: LayoutSpec; /** Move a tile from `fromIndex` → `toIndex` (clamped). */ move: (fromIndex: number, toIndex: number) => void; /** Set a tile's span. */ resize: (id: string, span: TileSpan) => void; /** Reset to the default layout (one entry per tile, defaultSpan or 2). */ reset: () => void; /** True once the persistence load has resolved. */ hydrated: boolean; } /** * `useWorkspaceLayout` — derives the live layout from a tile registry + * persistence layer. Order + spans are persisted on every change. * * The hook is `tiles`-driven: pass the canonical tile registry and the * hook returns the current ordered+sized layout. Tiles absent from * persisted layout are appended in registry order; persisted entries * referencing removed tiles are dropped (defensive against schema * drift between releases). */ export function useWorkspaceLayout( tiles: TileSpec[], options: UseWorkspaceLayoutOptions, ): UseWorkspaceLayoutHelpers { const persistence = options.persistence ?? localStoragePersistence; const persistenceRef = useRef(persistence); persistenceRef.current = persistence; const defaults: LayoutSpec = useMemo( () => ({ entries: tiles.map((t) => ({ id: t.id, span: t.defaultSpan ?? 2 })), }), [tiles], ); const [layout, setLayout] = useState(defaults); const [hydrated, setHydrated] = useState(false); // One-shot load on mount. useEffect(() => { let cancelled = false; (async () => { try { const loaded = await persistenceRef.current.load(options.storageKey); if (cancelled) return; if (loaded) { setLayout(reconcile(loaded, tiles)); } } finally { if (!cancelled) setHydrated(true); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [options.storageKey]); // Re-reconcile whenever the tile registry changes (add/remove/reorder). // Short-circuit identical layouts to avoid setLayout → save effect → // re-render loop when callers forget to memoise `tiles`. useEffect(() => { if (!hydrated) return; setLayout((cur) => { const next = reconcile(cur, tiles); return sameLayout(cur, next) ? cur : next; }); }, [tiles, hydrated]); // Persist on every change (after hydration so initial load doesn't write back). useEffect(() => { if (!hydrated) return; void persistenceRef.current.save(options.storageKey, layout); }, [layout, options.storageKey, hydrated]); const move = useCallback((from: number, to: number) => { setLayout((cur) => { const len = cur.entries.length; if (from === to || from < 0 || from >= len) return cur; const clampedTo = Math.max(0, Math.min(len - 1, to)); const next = cur.entries.slice(); const [moved] = next.splice(from, 1); if (!moved) return cur; next.splice(clampedTo, 0, moved); return { entries: next }; }); }, []); const resize = useCallback((id: string, span: TileSpan) => { setLayout((cur) => ({ entries: cur.entries.map((e) => (e.id === id ? { ...e, span } : e)), })); }, []); const reset = useCallback(() => { setLayout(defaults); }, [defaults]); return { layout, move, resize, reset, hydrated }; } /** * Drop entries that no longer exist; append new tiles in registry order * with their default span. Pure function (used by tests). * * Also sanitises spans — a corrupt persisted entry with span=NaN / * 0 / negative / 100 used to render "grid-column: span NaN" which * silently broke the layout; we now clamp to `[1, 4]`. */ export function reconcile( layout: LayoutSpec, tiles: TileSpec[], ): LayoutSpec { const knownIds = new Set(tiles.map((t) => t.id)); const surviving: LayoutEntry[] = layout.entries .filter((e) => knownIds.has(e.id)) .map((e) => ({ id: e.id, span: sanitiseSpan(e.span) })); const seen = new Set(surviving.map((e) => e.id)); for (const t of tiles) { if (!seen.has(t.id)) { surviving.push({ id: t.id, span: sanitiseSpan(t.defaultSpan ?? 2) }); } } return { entries: surviving }; } function sanitiseSpan(span: unknown): TileSpan { const n = typeof span === 'number' && Number.isFinite(span) ? span : 2; return Math.max(1, Math.min(4, Math.round(n))) as TileSpan; } /** Cheap structural equality between two layouts. */ function sameLayout(a: LayoutSpec, b: LayoutSpec): boolean { if (a.entries.length !== b.entries.length) return false; for (let i = 0; i < a.entries.length; i++) { const ae = a.entries[i]!; const be = b.entries[i]!; if (ae.id !== be.id || ae.span !== be.span) return false; } return true; }