Files
web-terminal/public/grid-presets.ts
Yaojia Wang 007e598802 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>
2026-07-11 20:09:49 +02:00

215 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}