584 lines
21 KiB
JavaScript
584 lines
21 KiB
JavaScript
// ==========================================
|
|
// 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);
|
|
}
|
|
|
|
// Track adjacent panes for dblclick reset
|
|
let lastLeftEl = null, lastRightEl = null;
|
|
|
|
initDragResize(handle, {
|
|
direction,
|
|
onStart(_clientPos) {
|
|
// Find the two panes adjacent to this handle
|
|
const handleIdx = Array.from(splitEl.children).indexOf(handle);
|
|
const leftEl = splitEl.children[handleIdx - 1];
|
|
const rightEl = splitEl.children[handleIdx + 1];
|
|
if (!leftEl || !rightEl) return false;
|
|
|
|
lastLeftEl = leftEl;
|
|
lastRightEl = rightEl;
|
|
|
|
const isHoriz = direction === 'horizontal';
|
|
const dim = isHoriz ? 'width' : 'height';
|
|
const startLeftBasis = leftEl.getBoundingClientRect()[dim];
|
|
const startRightBasis = rightEl.getBoundingClientRect()[dim];
|
|
|
|
// Lock non-flex panes to current rendered size 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');
|
|
|
|
return { leftEl, rightEl, startLeftBasis, startRightBasis };
|
|
},
|
|
onMove(delta, ctx) {
|
|
const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx;
|
|
const leftIsFlex = leftEl.dataset.paneFlex === '1';
|
|
const rightIsFlex = rightEl.dataset.paneFlex === '1';
|
|
const isHoriz = direction === 'horizontal';
|
|
const dim = isHoriz ? 'width' : 'height';
|
|
const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6;
|
|
|
|
if (leftIsFlex) {
|
|
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) {
|
|
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
|
|
leftEl.style.flexBasis = newLeft + 'px';
|
|
leftEl.style.flexGrow = '0';
|
|
leftEl.style.flexShrink = '0';
|
|
} else {
|
|
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';
|
|
}
|
|
},
|
|
onEnd(_ctx) {
|
|
handle.classList.remove('pane-handle--active');
|
|
_persistSizes(surfaceId, splitEl);
|
|
},
|
|
});
|
|
|
|
// Double-click resets to default
|
|
handle.addEventListener('dblclick', () => {
|
|
_resetPaneSize(lastLeftEl);
|
|
_resetPaneSize(lastRightEl);
|
|
_persistSizes(surfaceId, splitEl);
|
|
});
|
|
|
|
return { handle, splitEl, index, direction };
|
|
}
|
|
|
|
/** Reset a pane element to its default flex sizing. */
|
|
function _resetPaneSize(el) {
|
|
if (!el) return;
|
|
if (el.dataset.paneFlex === '1') {
|
|
el.style.flex = '1';
|
|
}
|
|
el.style.flexBasis = '';
|
|
el.style.flexGrow = '';
|
|
el.style.flexShrink = '';
|
|
}
|
|
|
|
// ── Persistence ─────────────────────────────
|
|
|
|
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
|
|
var _paneSyncTimer = null;
|
|
function _syncPaneLayoutsToServer() {
|
|
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
|
|
_paneSyncTimer = setTimeout(function() {
|
|
try {
|
|
// Collect all sb_layout_* keys
|
|
var layouts = {};
|
|
for (var i = 0; i < localStorage.length; i++) {
|
|
var k = localStorage.key(i);
|
|
if (k && k.indexOf('sb_layout_') === 0) {
|
|
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
|
|
}
|
|
}
|
|
if (typeof API !== 'undefined' && API._put) {
|
|
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
|
|
}
|
|
} catch (_) {}
|
|
}, 2000); // 2s debounce — avoid hammering during resize drags
|
|
}
|
|
|
|
// v0.27.0: On page load, restore pane layouts from server if available.
|
|
// Called once from PaneContainer init (or surface boot).
|
|
function _loadPaneLayoutsFromServer() {
|
|
if (typeof API === 'undefined' || !API._get) return;
|
|
API._get('/api/v1/settings').then(function(settings) {
|
|
if (!settings || !settings.pane_layouts) return;
|
|
var layouts = settings.pane_layouts;
|
|
for (var key in layouts) {
|
|
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
|
|
// Only overwrite if localStorage is empty for this key
|
|
if (!localStorage.getItem(key)) {
|
|
localStorage.setItem(key, JSON.stringify(layouts[key]));
|
|
}
|
|
}
|
|
}
|
|
}).catch(function() {});
|
|
}
|
|
|
|
// Kick off server layout load on script execution
|
|
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
|
|
|
|
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));
|
|
|
|
// v0.27.0: Debounced sync to server for cross-device persistence
|
|
_syncPaneLayoutsToServer();
|
|
} 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 },
|
|
],
|
|
});
|