// ========================================== // Chat Switchboard – Panel Registry (v0.22.0) // ========================================== // Workspace-aware panel system. Panels are shown inside the // workspace secondary pane (.workspace-secondary). The workspace // owns the resize handle between primary and secondary. // // Each panel registers with name, DOM element, and lifecycle hooks. // The registry owns all open/close/focus logic — individual panels // never touch the secondary container directly. // // Layout model (v0.22.0): // .workspace // .workspace-primary ← main content (chat / notes / editor) // .workspace-handle ← drag-resize between panes // .workspace-secondary ← panel pages (preview / notes / project) // ── Panel Registry ────────────────────────── const PanelRegistry = { _panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } } _active: null, // currently visible panel name _order: [], // registration order (for tab rendering + cycle) /** * Register a panel. * @param {string} name — unique identifier (e.g. 'preview', 'notes') * @param {object} opts * @param {HTMLElement} opts.element — the .side-panel-page div * @param {string} opts.label — display name * @param {Function} [opts.onOpen] — called when panel becomes visible * @param {Function} [opts.onClose] — called when panel is hidden * @param {Function} [opts.saveState] — returns state snapshot before hiding * @param {Function} [opts.restoreState]— receives state snapshot on re-show * @param {Array} [opts.actions] — per-panel action button configs * Each action: { id, icon, title, onClick, visible? } */ register(name, opts) { if (this._panels[name]) { console.warn(`[panels] panel "${name}" already registered, replacing`); } this._panels[name] = { element: opts.element, label: opts.label || name, onOpen: opts.onOpen || null, onClose: opts.onClose || null, saveState: opts.saveState || null, restoreState: opts.restoreState || null, actions: opts.actions || [], state: {}, }; if (!this._order.includes(name)) { this._order.push(name); } this._renderLabel(); }, /** * Open a panel (and the secondary pane if collapsed). * Hides current active panel, shows the requested one. */ open(name) { const panel = this._panels[name]; if (!panel) { console.warn(`[panels] unknown panel: "${name}"`); return; } const container = this._container(); if (!container) return; // Already active — just refresh label/actions if (this._active === name) { this._renderLabel(); this._renderActions(); return; } // Hide current active (saves state + fires onClose) if (this._active && this._active !== name) { this._hide(this._active); } // Hide ALL panel pages in the body, including unregistered ones // (e.g. sidePanelPreview which is a static placeholder) const body = this._body(); if (body) { body.querySelectorAll('.side-panel-page').forEach(el => { if (el !== panel.element) el.style.display = 'none'; }); } // Show the secondary pane container.classList.add('open'); this._showHandle(true); this._show(name); this._active = name; this._showOverlay(); this._renderLabel(); this._renderActions(); }, /** * Close a specific panel. If it's active, close the secondary pane. */ close(name) { const panel = this._panels[name]; if (!panel) return; if (this._active === name) { this._hide(name); this._active = null; this._closeContainer(); } }, /** * Toggle: if this panel is visible, close it. Otherwise, open it. */ toggle(name) { if (this.isOpen(name)) { this.close(name); } else { this.open(name); } }, /** * Close all panels and the secondary pane. */ closeAll() { if (this._active) { this._hide(this._active); this._active = null; } this._closeContainer(); }, /** Is a specific panel currently visible? */ isOpen(name) { return this._active === name; }, /** Is the secondary pane open at all? */ isContainerOpen() { return this._container()?.classList.contains('open') || false; }, /** Get the active panel name (or null). */ active() { return this._active; }, /** * Cycle to the next registered panel. If container is closed, open * the first panel. Wraps around. */ cycle() { if (this._order.length === 0) return; if (!this._active) { this.open(this._order[0]); return; } const idx = this._order.indexOf(this._active); const next = this._order[(idx + 1) % this._order.length]; this.open(next); }, // ── Internal ──────────────────────────── /** The secondary pane element. */ _container() { return document.getElementById('workspaceSecondary'); }, _body() { return document.getElementById('sidePanelBody'); }, /** Show a panel: set display, restore state, fire onOpen. */ _show(name) { const panel = this._panels[name]; if (!panel) return; panel.element.style.display = ''; // Restore state if previously saved if (panel.restoreState && Object.keys(panel.state).length > 0) { panel.restoreState(panel.state); } if (panel.onOpen) panel.onOpen(); }, /** Hide a panel: save state, call onClose, set display:none. */ _hide(name) { const panel = this._panels[name]; if (!panel) return; if (panel.saveState) { panel.state = panel.saveState() || {}; } if (panel.onClose) panel.onClose(); panel.element.style.display = 'none'; }, /** Close the secondary pane. */ _closeContainer() { const container = this._container(); if (!container) return; container.classList.remove('open', 'fullscreen'); container.style.width = ''; container.style.minWidth = ''; this._showHandle(false); this._hideOverlay(); }, /** Show/hide the workspace resize handle. */ _showHandle(show) { const handle = document.getElementById('workspaceHandle'); if (handle) handle.classList.toggle('active', show); }, // ── Mobile overlay ────────────────────── _isMobile() { return window.innerWidth <= 768; }, /** Show overlay behind secondary on mobile (tap-to-close). */ _showOverlay() { const ov = document.getElementById('sidePanelOverlay'); if (ov && this._isMobile()) ov.style.display = 'block'; }, /** Hide the mobile overlay. */ _hideOverlay() { const ov = document.getElementById('sidePanelOverlay'); if (ov) ov.style.display = 'none'; }, // ── Label and action rendering ────────── /** Update the header label to show the active panel name. */ _renderLabel() { const el = document.getElementById('sidePanelLabel'); if (!el) return; if (this._active) { const p = this._panels[this._active]; el.textContent = p ? p.label : ''; } else { el.textContent = ''; } }, /** Render per-panel action buttons for the active panel. */ _renderActions() { const slot = document.getElementById('sidePanelPanelActions'); if (!slot) return; if (!this._active) { slot.innerHTML = ''; return; } const panel = this._panels[this._active]; slot.innerHTML = (panel.actions || []) .filter(a => !a.visible || a.visible()) .map(a => ``) .join(''); }, /** Refresh action button visibility (call after state changes). */ refreshActions() { this._renderActions(); }, }; /** Minimal HTML escaping for panel labels/titles. */ function _escPanel(s) { if (!s) return ''; return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Secondary Pane Fullscreen ────────────── function toggleSidePanelFullscreen() { const panel = document.getElementById('workspaceSecondary'); if (!panel) return; panel.classList.toggle('fullscreen'); const btn = document.getElementById('sidePanelFullscreenBtn'); if (btn) { const isFS = panel.classList.contains('fullscreen'); btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen'; btn.innerHTML = isFS ? '' : ''; } } // ── Workspace Resize Handle ──────────────── function _initWorkspaceResize() { let startX, startW; const secondary = document.getElementById('workspaceSecondary'); const handle = document.getElementById('workspaceHandle'); if (!handle || !secondary) return; const onDown = (e) => { if (!handle.classList.contains('active')) return; if (secondary.classList.contains('fullscreen')) return; e.preventDefault(); const clientX = e.clientX ?? e.touches?.[0]?.clientX; startX = clientX; startW = secondary.getBoundingClientRect().width; secondary.style.transition = 'none'; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; const onMove = (e) => { const cx = e.clientX ?? e.touches?.[0]?.clientX ?? startX; const delta = startX - cx; const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta)); secondary.style.width = newW + 'px'; secondary.style.minWidth = newW + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; secondary.style.transition = ''; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp); }; handle.addEventListener('mousedown', onDown); handle.addEventListener('touchstart', onDown, { passive: false }); } // ── Mobile Swipe Navigation ───────────────── function _initPanelSwipe() { const body = document.getElementById('sidePanelBody'); if (!body) return; let startX = 0; let startY = 0; let tracking = false; body.addEventListener('touchstart', (e) => { if (!PanelRegistry.isContainerOpen()) return; if (PanelRegistry._order.length < 2) return; if (e.touches.length !== 1) return; startX = e.touches[0].clientX; startY = e.touches[0].clientY; tracking = true; }, { passive: true }); body.addEventListener('touchend', (e) => { if (!tracking) return; tracking = false; const touch = e.changedTouches[0]; if (!touch) return; const dx = touch.clientX - startX; const dy = touch.clientY - startY; // Require horizontal movement > 80px and more horizontal than vertical if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return; if (dx > 0) { _cyclePanelDirection(-1); } else { _cyclePanelDirection(1); } }, { passive: true }); } /** * Cycle panels in a specific direction. * direction: +1 = next, -1 = previous. */ function _cyclePanelDirection(direction) { const order = PanelRegistry._order; if (order.length < 2 || !PanelRegistry._active) return; const idx = order.indexOf(PanelRegistry._active); const next = order[(idx + direction + order.length) % order.length]; PanelRegistry.open(next); } // ── Responsive Resize Handler ─────────────── function _initPanelResponsive() { let wasWide = window.innerWidth > 768; window.addEventListener('resize', () => { const isWide = window.innerWidth > 768; // Update overlay visibility: show on mobile if panel open, hide on desktop if (PanelRegistry.isContainerOpen()) { const ov = document.getElementById('sidePanelOverlay'); if (ov) { ov.style.display = isWide ? 'none' : 'block'; } } wasWide = isWide; }); } // ── Panel Overlay (mobile tap-to-close) ───── function _initPanelOverlay() { const ov = document.getElementById('sidePanelOverlay'); if (!ov) return; ov.addEventListener('click', () => { PanelRegistry.closeAll(); }); } // ── Legacy Compat (thin wrappers) ─────────── function openSidePanel(tab) { PanelRegistry.open(tab); } function closeSidePanel() { PanelRegistry.closeAll(); } function switchSidePanelTab(tab) { PanelRegistry.open(tab); }