This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/panels.js
2026-03-02 01:55:19 +00:00

440 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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
if (this._active && this._active !== name) {
this._hide(this._active);
}
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 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);
}