Resizable splitters: draggable gutters between grid tracks adjust per-layout column/row fractions (fr units), applied as an inline grid-template and persisted (web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested helper in grid-layout.ts; the drag translates pixel motion into an fr delta. Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current layout + its split under a name, re-apply it in one click, or delete it (web-terminal:grid-presets). Desktop-only, like the layout toggle. - grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate, load/saveGridSplits, splitForLayout (validates stored shape). - tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline template; gridArrangement()/applyGridPreset() hooks. - main.ts: mount the presets dropdown. style.css: gutters + presets menu. - tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
/**
|
||
* 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 } 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<string, unknown>
|
||
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<Record<GridLayout, string>> = {
|
||
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: 1024px)')
|
||
: 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<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
cls: string,
|
||
text?: string,
|
||
): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag)
|
||
node.className = cls
|
||
if (text !== undefined) node.textContent = text
|
||
return node
|
||
}
|