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:
133
public/tabs.ts
133
public/tabs.ts
@@ -37,10 +37,18 @@ import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
|
||||
import {
|
||||
type GridLayout,
|
||||
type GridToggle,
|
||||
type GridSplits,
|
||||
type TrackSplit,
|
||||
isVisibleIndex,
|
||||
layoutCapacity,
|
||||
layoutTracks,
|
||||
loadGridLayout,
|
||||
saveGridLayout,
|
||||
adjustSplit,
|
||||
tracksToTemplate,
|
||||
loadGridSplits,
|
||||
saveGridSplits,
|
||||
splitForLayout,
|
||||
} from './grid-layout.js'
|
||||
import { mountCellMonitor, type CellMonitor } from './cell-monitor.js'
|
||||
|
||||
@@ -118,6 +126,8 @@ export class TabApp {
|
||||
// v2: when true, the focused quadrant is expanded to fill the grid (others stay
|
||||
// live behind it). Follows the focused pane; reset on layout change / tab close.
|
||||
private maximized = false
|
||||
// v3: persisted per-layout column/row track fractions (draggable splitters).
|
||||
private gridSplits: GridSplits = {}
|
||||
// When true, the home chooser is overlaid on top of the open tabs (so you can
|
||||
// jump back to Sessions/Projects without closing anything). Reset whenever a
|
||||
// tab is activated. Irrelevant while no tab is open (home shows regardless).
|
||||
@@ -168,8 +178,9 @@ export class TabApp {
|
||||
this.paneHost.appendChild(this.segControl)
|
||||
|
||||
// Split-grid: restore the persisted layout (forced to 'single' on screens
|
||||
// too narrow for a multi-pane board — see loadGridLayout).
|
||||
// too narrow for a multi-pane board — see loadGridLayout) + custom splits.
|
||||
this.gridLayout = loadGridLayout()
|
||||
this.gridSplits = loadGridSplits()
|
||||
|
||||
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
|
||||
this.setupPushToggle() // A1 🔔
|
||||
@@ -965,6 +976,23 @@ export class TabApp {
|
||||
this.gridToggle = toggle
|
||||
}
|
||||
|
||||
/** v3: the current layout + its split, for saving as a preset. */
|
||||
gridArrangement(): { layout: GridLayout; split: TrackSplit } {
|
||||
return { layout: this.gridLayout, split: splitForLayout(this.gridSplits, this.gridLayout) }
|
||||
}
|
||||
|
||||
/** v3: apply a saved preset — its layout plus its custom track fractions. */
|
||||
applyGridPreset(preset: { layout: GridLayout; split: TrackSplit }): void {
|
||||
this.gridSplits = { ...this.gridSplits, [preset.layout]: preset.split }
|
||||
saveGridSplits(this.gridSplits)
|
||||
if (preset.layout === this.gridLayout) {
|
||||
this.updateHomeView() // same layout, new split → just re-apply the template
|
||||
this.gridToggle?.refresh()
|
||||
} else {
|
||||
this.setGridLayout(preset.layout)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device
|
||||
* regains focus — in a grid all visible quadrants reclaim size, not just the
|
||||
* focused one. */
|
||||
@@ -1057,6 +1085,17 @@ export class TabApp {
|
||||
// inline approve, …) independent of how many layouts exist.
|
||||
this.paneHost.classList.toggle('grid', layout !== 'single')
|
||||
|
||||
// v3: apply custom track fractions (draggable splitters) as inline templates;
|
||||
// clear them in single mode so #term is not a grid.
|
||||
if (layout !== 'single') {
|
||||
const sp = splitForLayout(this.gridSplits, layout)
|
||||
this.paneHost.style.gridTemplateColumns = tracksToTemplate(sp.cols)
|
||||
this.paneHost.style.gridTemplateRows = tracksToTemplate(sp.rows)
|
||||
} else {
|
||||
this.paneHost.style.gridTemplateColumns = ''
|
||||
this.paneHost.style.gridTemplateRows = ''
|
||||
}
|
||||
|
||||
const cap = layoutCapacity(this.gridLayout)
|
||||
const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap)
|
||||
|
||||
@@ -1088,6 +1127,98 @@ export class TabApp {
|
||||
const placeholders =
|
||||
showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount)
|
||||
this.renderPlaceholders(placeholders, visibleCount)
|
||||
this.renderGutters(layout)
|
||||
}
|
||||
|
||||
/* ── v3: draggable splitters ──────────────────────────────────────── */
|
||||
|
||||
/** Replace the gutter handles for the current layout (none in single mode). */
|
||||
private renderGutters(layout: GridLayout): void {
|
||||
this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove())
|
||||
if (layout === 'single') return
|
||||
const { cols, rows } = layoutTracks(layout)
|
||||
const sp = splitForLayout(this.gridSplits, layout)
|
||||
this.placeAxisGutters('col', cols, sp.cols, true)
|
||||
this.placeAxisGutters('row', rows, sp.rows, true)
|
||||
}
|
||||
|
||||
/** Create (create=true) or reposition the gutter handles for one axis. */
|
||||
private placeAxisGutters(
|
||||
axis: 'col' | 'row',
|
||||
count: number,
|
||||
fractions: number[],
|
||||
create: boolean,
|
||||
): void {
|
||||
const total = fractions.reduce((a, b) => a + b, 0) || 1
|
||||
const existing = create
|
||||
? null
|
||||
: this.paneHost.querySelectorAll<HTMLElement>(`.grid-gutter-${axis}`)
|
||||
let acc = 0
|
||||
for (let g = 0; g < count - 1; g++) {
|
||||
acc += fractions[g] ?? 1
|
||||
const pct = (acc / total) * 100
|
||||
const handle = create ? this.makeGutter(axis, g) : existing?.[g]
|
||||
if (!handle) continue
|
||||
if (axis === 'col') handle.style.left = `${pct}%`
|
||||
else handle.style.top = `${pct}%`
|
||||
if (create) this.paneHost.appendChild(handle)
|
||||
}
|
||||
}
|
||||
|
||||
private makeGutter(axis: 'col' | 'row', gutterIndex: number): HTMLElement {
|
||||
const g = document.createElement('div')
|
||||
g.className = `grid-gutter grid-gutter-${axis}`
|
||||
g.setAttribute('role', 'separator')
|
||||
g.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal')
|
||||
g.addEventListener('pointerdown', (e) => this.beginGutterDrag(e, axis, gutterIndex))
|
||||
return g
|
||||
}
|
||||
|
||||
/** Drag a gutter: translate pixel motion into an fr delta and re-template live. */
|
||||
private beginGutterDrag(e: PointerEvent, axis: 'col' | 'row', gutterIndex: number): void {
|
||||
const layout = this.gridLayout
|
||||
if (layout === 'single') return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const handle = e.currentTarget as HTMLElement
|
||||
const rect = this.paneHost.getBoundingClientRect()
|
||||
const area = axis === 'col' ? rect.width : rect.height
|
||||
const start = axis === 'col' ? e.clientX : e.clientY
|
||||
const base = splitForLayout(this.gridSplits, layout)
|
||||
const startFractions = (axis === 'col' ? base.cols : base.rows).slice()
|
||||
const total = startFractions.reduce((a, b) => a + b, 0)
|
||||
try {
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// setPointerCapture may be unavailable (e.g. jsdom) — drag still works
|
||||
}
|
||||
const move = (ev: PointerEvent): void => {
|
||||
if (area <= 0) return
|
||||
const pos = axis === 'col' ? ev.clientX : ev.clientY
|
||||
const delta = ((pos - start) / area) * total
|
||||
this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta))
|
||||
}
|
||||
const up = (): void => {
|
||||
handle.removeEventListener('pointermove', move)
|
||||
handle.removeEventListener('pointerup', up)
|
||||
handle.removeEventListener('pointercancel', up)
|
||||
saveGridSplits(this.gridSplits) // persist only on release
|
||||
}
|
||||
handle.addEventListener('pointermove', move)
|
||||
handle.addEventListener('pointerup', up)
|
||||
handle.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
/** Apply a new fraction array live (template + gutter positions), no persist. */
|
||||
private setSplit(layout: GridLayout, axis: 'col' | 'row', fractions: number[]): void {
|
||||
const cur = splitForLayout(this.gridSplits, layout)
|
||||
const next: TrackSplit =
|
||||
axis === 'col' ? { cols: fractions, rows: cur.rows } : { cols: cur.cols, rows: fractions }
|
||||
this.gridSplits = { ...this.gridSplits, [layout]: next }
|
||||
if (axis === 'col') this.paneHost.style.gridTemplateColumns = tracksToTemplate(fractions)
|
||||
else this.paneHost.style.gridTemplateRows = tracksToTemplate(fractions)
|
||||
const { cols, rows } = layoutTracks(layout)
|
||||
this.placeAxisGutters(axis, axis === 'col' ? cols : rows, fractions, false)
|
||||
}
|
||||
|
||||
/** Replace the placeholder cells with `count` new ones ordered after the panes. */
|
||||
|
||||
Reference in New Issue
Block a user