/** * public/grid-presets.ts — saved split-grid layout presets (v3). * * A preset bundles a layout + its custom track fractions under a name, so you can * save a board arrangement you like (e.g. "2×2, left column wide") and re-apply it * in one click. Persistence + the tiny toolbar dropdown live here; TabApp exposes * the current arrangement and an apply() via hooks, keeping tabs.ts lean. */ import { type GridLayout, type TrackSplit, gridAllowed, GRID_MIN_WIDTH } from './grid-layout.js' export interface GridPreset { name: string layout: GridLayout split: TrackSplit } const PRESETS_KEY = 'web-terminal:grid-presets' const VALID_LAYOUTS: readonly string[] = ['single', 'split-2', 'row-3', 'grid-4', 'grid-6'] function isPreset(v: unknown): v is GridPreset { if (v === null || typeof v !== 'object') return false const p = v as Record return ( typeof p['name'] === 'string' && typeof p['layout'] === 'string' && VALID_LAYOUTS.includes(p['layout']) && typeof p['split'] === 'object' && p['split'] !== null ) } /** Load saved presets (best-effort; drops malformed entries). */ export function loadPresets(): GridPreset[] { try { const raw = localStorage.getItem(PRESETS_KEY) if (raw === null) return [] const parsed: unknown = JSON.parse(raw) if (Array.isArray(parsed)) return parsed.filter(isPreset) } catch { // localStorage / JSON unavailable } return [] } /** Persist the presets list. */ export function savePresets(list: readonly GridPreset[]): void { try { localStorage.setItem(PRESETS_KEY, JSON.stringify(list)) } catch { // localStorage unavailable — run without persistence } } export interface PresetHooks { /** The current layout + its split, to snapshot into a new preset. */ current: () => { layout: GridLayout; split: TrackSplit } /** Apply a chosen preset (set layout + splits + re-render). */ apply: (preset: GridPreset) => void } export interface PresetMenu { /** Re-sync visibility (desktop gate) — call on layout/resize changes. */ refresh: () => void dispose: () => void } /** Short human label for a layout in a preset row. */ const LAYOUT_LABEL: Readonly> = { single: 'single', 'split-2': '1×2', 'row-3': '1×3', 'grid-4': '2×2', 'grid-6': '2×3', } /** Mount the presets dropdown into the toolbar. Desktop-only (hidden below the * grid width gate, like the layout toggle). */ export function mountGridPresets(toolbar: HTMLElement, hooks: PresetHooks): PresetMenu { let presets = loadPresets() const root = document.createElement('div') root.className = 'grid-presets' const btn = document.createElement('button') btn.type = 'button' btn.className = 'grid-presets-btn' btn.textContent = '☰' btn.title = 'Layout presets' btn.setAttribute('aria-label', 'Layout presets') const menu = document.createElement('div') menu.className = 'grid-presets-menu' menu.hidden = true const list = document.createElement('div') list.className = 'gp-list' const saveRow = document.createElement('div') saveRow.className = 'gp-save' const nameInput = document.createElement('input') nameInput.className = 'gp-name' nameInput.type = 'text' nameInput.placeholder = 'Save current as…' nameInput.maxLength = 40 const saveBtn = document.createElement('button') saveBtn.type = 'button' saveBtn.className = 'gp-save-btn' saveBtn.textContent = 'Save' saveRow.append(nameInput, saveBtn) menu.append(list, saveRow) root.append(btn, menu) toolbar.appendChild(root) const renderList = (): void => { list.replaceChildren() if (presets.length === 0) { list.appendChild(el('div', 'gp-empty', 'No saved presets')) return } presets.forEach((preset, i) => { const row = el('div', 'gp-row') const apply = el('button', 'gp-apply') apply.append( el('span', 'gp-apply-name', preset.name), el('span', 'gp-apply-layout', LAYOUT_LABEL[preset.layout]), ) apply.addEventListener('click', () => { hooks.apply(preset) close() }) const del = el('button', 'gp-del', '×') del.title = 'Delete preset' del.setAttribute('aria-label', `Delete preset ${preset.name}`) del.addEventListener('click', (e) => { e.stopPropagation() presets = presets.filter((_, j) => j !== i) savePresets(presets) renderList() }) row.append(apply, del) list.appendChild(row) }) } const save = (): void => { const name = nameInput.value.trim() if (name === '') return const { layout, split } = hooks.current() // Replace a same-named preset rather than duplicating. presets = [...presets.filter((p) => p.name !== name), { name, layout, split }] savePresets(presets) nameInput.value = '' renderList() } saveBtn.addEventListener('click', save) nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') save() e.stopPropagation() }) const open = (): void => { renderList() menu.hidden = false document.addEventListener('pointerdown', onOutside, true) document.addEventListener('keydown', onKey, true) } const close = (): void => { menu.hidden = true document.removeEventListener('pointerdown', onOutside, true) document.removeEventListener('keydown', onKey, true) } const onOutside = (e: Event): void => { if (!root.contains(e.target as Node)) close() } const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() } btn.addEventListener('click', () => (menu.hidden ? open() : close())) const refresh = (): void => { root.style.display = gridAllowed() ? 'inline-flex' : 'none' if (!gridAllowed()) close() } const mql = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`) : null mql?.addEventListener('change', refresh) refresh() return { refresh, dispose: (): void => { mql?.removeEventListener('change', refresh) close() root.remove() }, } } /** Local element helper (kept tiny to avoid a cross-module import cycle). */ function el( tag: K, cls: string, text?: string, ): HTMLElementTagNameMap[K] { const node = document.createElement(tag) node.className = cls if (text !== undefined) node.textContent = text return node }