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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user