449 lines
14 KiB
JavaScript
449 lines
14 KiB
JavaScript
// ==========================================
|
||
// 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 => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${_escPanel(a.title || '')}">${a.icon || ''}</button>`)
|
||
.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, '>').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
|
||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||
}
|
||
}
|
||
|
||
// ── 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);
|
||
}
|