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:
Yaojia Wang
2026-07-11 20:09:49 +02:00
parent cd97114f87
commit 007e598802
9 changed files with 902 additions and 1 deletions

214
public/grid-presets.ts Normal file
View 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
}