Changeset 0.25.0 (#160)
This commit is contained in:
554
src/js/pane-container.js
Normal file
554
src/js/pane-container.js
Normal 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 },
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user