feat(grid): v3b/c — resizable splitters + saved layout presets
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>
This commit is contained in:
214
public/grid-presets.ts
Normal file
214
public/grid-presets.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user