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

View File

@@ -201,3 +201,112 @@ export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): G
},
}
}
/* ── Resizable splitters (v3) ────────────────────────────────────────── */
/** The (columns, rows) track counts a layout lays its panes out in. */
export function layoutTracks(layout: GridLayout): { cols: number; rows: number } {
switch (layout) {
case 'single':
return { cols: 1, rows: 1 }
case 'split-2':
return { cols: 2, rows: 1 }
case 'row-3':
return { cols: 3, rows: 1 }
case 'grid-4':
return { cols: 2, rows: 2 }
case 'grid-6':
return { cols: 3, rows: 2 }
}
}
/** Per-axis track fractions (relative fr units) for one layout. */
export interface TrackSplit {
cols: number[]
rows: number[]
}
/** Persisted custom track fractions, per layout (missing → equal tracks). */
export type GridSplits = Partial<Record<GridLayout, TrackSplit>>
/** Equal-fraction split for a layout (every track = 1fr). */
export function defaultSplit(layout: GridLayout): TrackSplit {
const { cols, rows } = layoutTracks(layout)
return { cols: Array<number>(cols).fill(1), rows: Array<number>(rows).fill(1) }
}
/** Smallest fraction a track may shrink to while its neighbor grows. */
export const MIN_TRACK_FRACTION = 0.3
/**
* Move the boundary between track `gutterIndex` and `gutterIndex+1` by `delta`
* (in fr units), trading the delta between the two adjacent tracks and clamping
* both to MIN_TRACK_FRACTION. Pure — returns a new array (the input untouched).
*/
export function adjustSplit(
fractions: readonly number[],
gutterIndex: number,
delta: number,
min = MIN_TRACK_FRACTION,
): number[] {
if (gutterIndex < 0 || gutterIndex >= fractions.length - 1) return fractions.slice()
let a = (fractions[gutterIndex] ?? 1) + delta
let b = (fractions[gutterIndex + 1] ?? 1) - delta
if (a < min) {
b -= min - a
a = min
}
if (b < min) {
a -= min - b
b = min
}
if (a < min || b < min) return fractions.slice() // can't satisfy both
const next = fractions.slice()
next[gutterIndex] = a
next[gutterIndex + 1] = b
return next
}
/** Render fractions as a grid-template value (e.g. "1fr 1.4fr"). */
export function tracksToTemplate(fractions: readonly number[]): string {
return fractions.map((f) => `${f}fr`).join(' ')
}
const GRID_SPLITS_KEY = 'web-terminal:grid-splits'
/** Load persisted splits (best-effort; shape validated per-layout at use). */
export function loadGridSplits(): GridSplits {
try {
const raw = localStorage.getItem(GRID_SPLITS_KEY)
if (raw === null) return {}
const parsed: unknown = JSON.parse(raw)
if (parsed !== null && typeof parsed === 'object') return parsed as GridSplits
} catch {
// localStorage / JSON unavailable — use equal tracks
}
return {}
}
/** Persist the custom splits map. */
export function saveGridSplits(splits: GridSplits): void {
try {
localStorage.setItem(GRID_SPLITS_KEY, JSON.stringify(splits))
} catch {
// localStorage unavailable — run without persistence
}
}
/** The valid split for a layout: the stored one if its shape matches the layout's
* track counts and all fractions are positive, else the equal default. */
export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSplit {
const { cols, rows } = layoutTracks(layout)
const stored = splits[layout]
const ok =
stored !== undefined &&
Array.isArray(stored.cols) &&
stored.cols.length === cols &&
Array.isArray(stored.rows) &&
stored.rows.length === rows &&
[...stored.cols, ...stored.rows].every((n) => typeof n === 'number' && n > 0)
return ok ? stored : defaultSplit(layout)
}