Changeset 0.25.0 (#160)

This commit is contained in:
2026-03-08 16:54:17 +00:00
parent 937be26578
commit 2b01d540d6
63 changed files with 6942 additions and 2773 deletions

554
src/js/pane-container.js Normal file
View File

@@ -0,0 +1,554 @@
// ==========================================
// Chat Switchboard — PaneContainer
// ==========================================
// v0.25.0: Composable multi-pane layout system.
// Replaces the "one surface fills viewport" model with resizable
// side-by-side panes. Supports leaf panes (single component) and
// tabbed panes (N components, tab bar, one active at a time).
//
// Layout presets define the pane structure. The surface manifest
// declares which preset to use via __MANIFEST__.layout.
//
// Usage:
// const layout = PaneContainer.mount(
// document.getElementById('editorBody'),
// 'editor',
// { workspaceId: 'ws-123' }
// );
// const fileTree = layout.getPane('files');
// layout.resize('files', 250);
// layout.destroy();
const PaneContainer = {
_presets: {},
_active: null,
// ── Layout Preset Registration ──────────
/**
* Register a named layout preset.
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
* @param {object} definition - Layout tree definition
*
* Definition format:
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
*
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
*/
registerPreset(name, definition) {
this._presets[name] = definition;
},
// ── Mount ───────────────────────────────
/**
* Mount a layout preset into a root element.
* @param {HTMLElement} rootEl - Container element
* @param {string} presetName - Registered preset name
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
* @returns {object} Layout instance
*/
mount(rootEl, presetName, opts) {
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
const preset = this._presets[presetName];
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
opts = opts || {};
const surfaceId = window.__SURFACE__ || 'unknown';
// Build the DOM tree from the preset definition
const panes = new Map(); // id → { el, component, type }
const handles = [];
const buildNode = (def, parentEl) => {
if (def.type === 'leaf') {
return _buildLeafPane(def, parentEl, panes, opts);
}
if (def.type === 'tabbed') {
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
}
if (def.type === 'split') {
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
}
console.error('[PaneContainer] Unknown node type:', def.type);
return null;
};
rootEl.classList.add('pane-container');
const rootNode = buildNode(preset, rootEl);
// Restore persisted sizes
_restoreSizes(surfaceId, presetName, handles);
const instance = {
rootEl,
presetName,
_panes: panes,
_handles: handles,
_rootNode: rootNode,
/** Get the component instance for a pane by ID. */
getPane(id) {
const p = panes.get(id);
return p ? p.component : null;
},
/** Get the pane element by ID. */
getPaneEl(id) {
const p = panes.get(id);
return p ? p.el : null;
},
/** Programmatic resize of a leaf pane. */
resize(id, sizePx) {
const p = panes.get(id);
if (p?.el) {
p.el.style.flexBasis = sizePx + 'px';
p.el.style.flexGrow = '0';
p.el.style.flexShrink = '0';
}
},
/** Minimize a pane (collapse to 0). */
minimize(id) {
const p = panes.get(id);
if (!p?.el) return;
p._prevBasis = p.el.style.flexBasis;
p._prevGrow = p.el.style.flexGrow;
p.el.style.flexBasis = '0px';
p.el.style.flexGrow = '0';
p.el.style.overflow = 'hidden';
p.el.classList.add('pane-minimized');
},
/** Restore a minimized pane. */
restore(id) {
const p = panes.get(id);
if (!p?.el) return;
p.el.style.flexBasis = p._prevBasis || '';
p.el.style.flexGrow = p._prevGrow || '';
p.el.style.overflow = '';
p.el.classList.remove('pane-minimized');
},
/** Tear down the entire layout. */
destroy() {
// Destroy all component instances
for (const [, p] of panes) {
if (p.component?.destroy) p.component.destroy();
// Tabbed: destroy all tab components
if (p.tabs) {
for (const tab of p.tabs) {
if (tab.component?.destroy) tab.component.destroy();
}
}
}
panes.clear();
handles.length = 0;
rootEl.innerHTML = '';
rootEl.classList.remove('pane-container');
PaneContainer._active = null;
},
};
PaneContainer._active = instance;
return instance;
},
/** Get the currently active layout instance. */
active() { return this._active; },
};
// ── Leaf Pane Builder ───────────────────────
function _buildLeafPane(def, parentEl, panes, opts) {
const el = document.createElement('div');
el.className = 'pane pane-leaf';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
parentEl.appendChild(el);
// Component instantiation is deferred — the surface boot script
// creates components and mounts them into pane elements.
// We just register the pane slot here.
panes.set(def.id, {
el,
component: null, // set by surface boot script
type: 'leaf',
def,
});
return el;
}
// ── Tabbed Pane Builder ─────────────────────
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane pane-tabbed';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
// Tab bar
const tabBar = document.createElement('div');
tabBar.className = 'pane-tab-bar';
el.appendChild(tabBar);
// Content area
const contentArea = document.createElement('div');
contentArea.className = 'pane-tab-content';
el.appendChild(contentArea);
// Restore persisted active tab
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
let savedTab = 0;
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
if (savedTab >= (def.tabs || []).length) savedTab = 0;
const tabs = (def.tabs || []).map((tabDef, i) => {
// Tab button
const btn = document.createElement('button');
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
btn.textContent = tabDef.label || tabDef.id;
btn.dataset.tabIndex = i;
tabBar.appendChild(btn);
// Tab panel (mount point for component)
const panel = document.createElement('div');
panel.className = 'pane-tab-panel';
panel.dataset.tabId = tabDef.id;
panel.style.display = i === savedTab ? '' : 'none';
contentArea.appendChild(panel);
return {
id: tabDef.id,
label: tabDef.label,
component: tabDef.component,
opts: tabDef.opts || {},
btn,
panel,
instance: null, // lazy-created on first activation
_activated: i === savedTab,
};
});
// Tab click handler
const activateTab = (index) => {
tabs.forEach((tab, i) => {
const isActive = i === index;
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
tab.panel.style.display = isActive ? '' : 'none';
if (isActive) tab._activated = true;
});
// Persist
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
};
tabs.forEach((tab, i) => {
tab.btn.addEventListener('click', () => activateTab(i));
});
parentEl.appendChild(el);
panes.set(def.id, {
el,
component: null, // tabbed panes don't have a single component
type: 'tabbed',
def,
tabs,
activateTab,
getActiveTab() {
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
},
getTabPanel(tabId) {
const t = tabs.find(t => t.id === tabId);
return t ? t.panel : null;
},
});
return el;
}
// ── Split Builder ───────────────────────────
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
parentEl.appendChild(el);
const children = def.children || [];
const sizes = def.sizes || [];
children.forEach((childDef, i) => {
// Apply initial size from preset
if (sizes[i] != null && childDef.size === undefined) {
childDef.size = sizes[i];
}
// Build child
if (childDef.type === 'leaf') {
_buildLeafPane(childDef, el, panes, opts);
} else if (childDef.type === 'tabbed') {
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
} else if (childDef.type === 'split') {
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
}
// Add drag handle between children (not after last)
if (i < children.length - 1) {
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
handles.push(handle);
}
});
return el;
}
// ── Drag Handle ─────────────────────────────
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
const handle = document.createElement('div');
handle.className = 'pane-handle pane-handle--' + direction;
handle.dataset.handleIndex = index;
// Insert after the Nth child (child, handle, child, handle, child)
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
// But since we add sequentially, handle goes after the (index)th pane
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
const afterEl = childEls[index];
if (afterEl?.nextSibling) {
splitEl.insertBefore(handle, afterEl.nextSibling);
} else {
splitEl.appendChild(handle);
}
// Drag logic
let startX, startY, leftEl, rightEl, startLeftBasis, startRightBasis;
const onMouseDown = (e) => {
e.preventDefault();
const allPanes = Array.from(splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split'));
// Find the two panes adjacent to this handle
const handleIdx = Array.from(splitEl.children).indexOf(handle);
leftEl = splitEl.children[handleIdx - 1];
rightEl = splitEl.children[handleIdx + 1];
if (!leftEl || !rightEl) return;
startX = e.clientX;
startY = e.clientY;
// Use current rendered size (matches what flex layout computed)
startLeftBasis = leftEl.getBoundingClientRect().width;
startRightBasis = rightEl.getBoundingClientRect().width;
// Immediately lock the non-flex panes to their current sizes
// so the first move delta doesn't cause a visual snap
if (leftEl.dataset.paneFlex !== '1') {
leftEl.style.flexBasis = startLeftBasis + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
}
if (rightEl.dataset.paneFlex !== '1') {
rightEl.style.flexBasis = startRightBasis + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
handle.classList.add('pane-handle--active');
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e) => {
const delta = direction === 'horizontal'
? e.clientX - startX
: e.clientY - startY;
const leftIsFlex = leftEl.dataset.paneFlex === '1';
const rightIsFlex = rightEl.dataset.paneFlex === '1';
// Cap: no single fixed pane should exceed 60% of container
const maxSize = splitEl.getBoundingClientRect().width * 0.6;
if (leftIsFlex) {
// Only adjust the right (fixed) pane; left flex absorbs remainder
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
} else if (rightIsFlex) {
// Only adjust the left (fixed) pane; right flex absorbs remainder
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
} else {
// Both fixed — adjust both (original behavior)
const newLeft = Math.max(50, startLeftBasis + delta);
const newRight = Math.max(50, startRightBasis - delta);
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
};
const onMouseUp = () => {
handle.classList.remove('pane-handle--active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Persist sizes
_persistSizes(surfaceId, splitEl);
};
handle.addEventListener('mousedown', onMouseDown);
// Double-click resets to default
handle.addEventListener('dblclick', () => {
if (leftEl) {
if (leftEl.dataset.paneFlex === '1') {
leftEl.style.flex = '1'; leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
} else {
leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
}
}
if (rightEl) {
if (rightEl.dataset.paneFlex === '1') {
rightEl.style.flex = '1'; rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
} else {
rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
}
}
_persistSizes(surfaceId, splitEl);
});
return { handle, splitEl, index, direction };
}
// ── Persistence ─────────────────────────────
function _persistSizes(surfaceId, splitEl) {
const key = 'sb_layout_' + surfaceId;
try {
// Collect sizes for fixed panes only (skip flex panes)
const sizes = {};
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
const id = child.dataset?.paneId;
if (id && child.dataset.paneFlex !== '1') {
sizes[id] = child.getBoundingClientRect().width;
}
});
// Merge with existing persisted data
let existing = {};
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
Object.assign(existing, sizes);
localStorage.setItem(key, JSON.stringify(existing));
} catch (_) {}
}
function _restoreSizes(surfaceId, presetName, handles) {
const key = 'sb_layout_' + surfaceId;
try {
const saved = JSON.parse(localStorage.getItem(key) || '{}');
if (Object.keys(saved).length === 0) return;
// Find the split container to measure available width
const splitEl = document.querySelector('.pane-split--horizontal');
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
let totalFixed = 0;
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
totalFixed += saved[id];
}
});
// If saved sizes overflow, clear corrupt data and use defaults
if (totalFixed > containerWidth - 150) {
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
localStorage.removeItem(key);
return;
}
// Apply saved sizes to pane elements (skip flex panes)
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
el.style.flexBasis = saved[id] + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
}
});
} catch (_) {}
}
// ── Built-in Presets ────────────────────────
// single: One leaf pane (default chat UX)
PaneContainer.registerPreset('single', {
type: 'leaf',
id: 'main',
component: 'chat-pane',
});
// editor: files | code-editor | <chat, notes>
PaneContainer.registerPreset('editor', {
type: 'split',
id: 'root',
direction: 'horizontal',
sizes: [220, null, 380],
children: [
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
{ type: 'leaf', id: 'editor', component: 'code-editor' },
{
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
tabs: [
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
{ id: 'notes', label: 'Notes', component: 'note-editor' },
],
},
],
});
// split: generic two-pane layout
PaneContainer.registerPreset('split', {
type: 'split',
id: 'root',
direction: 'horizontal',
children: [
{ type: 'leaf', id: 'primary', component: null },
{ type: 'leaf', id: 'secondary', component: null },
],
});