Changeset 0.18.1 (#80)

This commit is contained in:
2026-02-28 19:41:59 +00:00
parent e4a943b03e
commit a591b810a9
13 changed files with 1415 additions and 197 deletions

View File

@@ -634,7 +634,19 @@ a:hover { text-decoration: underline; }
.ext-rendered {
margin: 12px 0;
position: relative;
}
.ext-popout-btn {
position: absolute; top: 4px; right: 4px;
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); cursor: pointer; font-size: 14px;
width: 26px; height: 26px; border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s, background 0.15s;
z-index: 5;
}
.ext-rendered:hover .ext-popout-btn { opacity: 1; }
.ext-popout-btn:hover { background: var(--bg-hover); color: var(--text); }
/* HTML preview */
/* ── Side Panel (Preview + Notes) ──────── */
@@ -674,20 +686,9 @@ a:hover { text-decoration: underline; }
transition: all var(--transition); display: flex; align-items: center;
}
.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-tabs {
display: flex; gap: 2px; background: var(--bg-surface);
border-radius: var(--radius); padding: 2px;
}
.side-panel-tab {
padding: 4px 14px; border-radius: calc(var(--radius) - 2px);
font-size: 12px; font-family: var(--font); font-weight: 500;
background: none; border: none; color: var(--text-3);
cursor: pointer; transition: all var(--transition);
}
.side-panel-tab:hover { color: var(--text); }
.side-panel-tab.active {
background: var(--bg-raised); color: var(--text);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
.side-panel-label {
font-size: 12px; font-family: var(--font); font-weight: 600;
color: var(--text-2); white-space: nowrap;
}
.side-panel-close {
background: none; border: none; color: var(--text-3);
@@ -728,12 +729,58 @@ a:hover { text-decoration: underline; }
flex: 1; overflow-y: auto; min-height: 0;
}
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
.side-panel-overlay {
display: none; position: fixed; inset: 0; z-index: 199;
background: var(--overlay);
}
/* Mobile: full-width overlay */
@media (max-width: 768px) {
.side-panel.open {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100vw; min-width: 100vw; z-index: 200;
}
/* Disable dual-view on mobile — not enough space */
.side-panel-body.dual {
display: flex; flex-direction: column;
grid-template-columns: unset !important;
}
.side-panel-divider { display: none !important; }
.side-panel-dual-btn { display: none; }
/* Larger touch targets on mobile */
.side-panel-close { font-size: 20px; padding: 4px 10px; }
.side-panel-action-btn { padding: 6px 8px; }
.side-panel-header { padding: 10px 12px; }
}
/* ── Dual-View Mode ───────────────────────── */
.side-panel.dual-open {
width: 680px; min-width: 680px;
}
.side-panel-body.dual {
display: grid;
grid-template-columns: 1fr 6px 1fr;
overflow: hidden;
}
.side-panel-body.dual > .side-panel-page {
overflow-y: auto; min-width: 0; min-height: 0;
}
.side-panel-divider {
grid-column: 2;
width: 6px; cursor: col-resize;
background: var(--border);
transition: background 0.15s;
flex-shrink: 0;
}
.side-panel-divider:hover,
.side-panel-divider:active {
background: var(--accent); opacity: 0.5;
}
.side-panel-dual-btn.active {
background: var(--accent); color: var(--bg);
}
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }

View File

@@ -191,33 +191,33 @@
</div>
</main>
<!-- Side Panel (preview + notes) -->
<!-- Side Panel (registry-driven, see panels.js) -->
<aside class="side-panel" id="sidePanel">
<div class="side-panel-resize" id="sidePanelResize"></div>
<div class="side-panel-header">
<div class="side-panel-tabs" id="sidePanelTabs">
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
</div>
<span class="side-panel-label" id="sidePanelLabel"></span>
<div class="side-panel-actions">
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
<span id="sidePanelPanelActions"></span>
<button class="side-panel-action-btn side-panel-dual-btn" id="sidePanelDualBtn" onclick="PanelRegistry.toggleDual()" title="Toggle split view" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
</button>
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
<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>
</button>
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel"></button>
<button class="side-panel-close" onclick="PanelRegistry.closeAll()" title="Close panel"></button>
</div>
</div>
<div class="side-panel-body">
<!-- Preview tab -->
<div class="side-panel-page" id="sidePanelPreview">
<div class="side-panel-body" id="sidePanelBody">
<!-- Dual-view divider (hidden by default, shown in dual mode) -->
<div class="side-panel-divider" id="sidePanelDivider" style="display:none"></div>
<!-- Preview panel -->
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
<div class="side-panel-empty" id="previewEmpty">
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
</div>
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
</div>
<!-- Notes tab -->
<!-- Notes panel -->
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
<div class="notes-toolbar">
<div class="notes-search-wrap">
@@ -313,6 +313,7 @@
</div>
</div>
</aside>
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
</div><!-- .app-body -->
@@ -1111,6 +1112,7 @@
<script src="js/events.js?v=%%APP_VERSION%%"></script>
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
<script src="js/api.js?v=%%APP_VERSION%%"></script>
<script src="js/panels.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>

View File

@@ -422,8 +422,14 @@ function initListeners() {
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js
_initAttachmentListeners(); // from attachments.js
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
_initSidePanelResize(); // from ui-format.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag
_initPanelSwipe(); // from panels.js — mobile swipe navigation
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
}
function _initGlobalKeyboard() {
@@ -438,8 +444,8 @@ function _initGlobalKeyboard() {
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
if (document.getElementById('sidePanel')?.classList.contains('open')) {
closeSidePanel(); return;
if (PanelRegistry.isContainerOpen()) {
PanelRegistry.closeAll(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
@@ -453,6 +459,20 @@ function _initGlobalKeyboard() {
return;
}
// Ctrl/Cmd+\: cycle side panels
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === '\\') {
e.preventDefault();
PanelRegistry.cycle();
return;
}
// Ctrl/Cmd+Shift+\: toggle dual-view split
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '\\') {
e.preventDefault();
PanelRegistry.toggleDual();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();

View File

@@ -196,12 +196,59 @@ const Extensions = {
},
},
// UI injection points (v0.17.0 — stub for now)
// UI primitives — safe wrappers around primary UI components.
// Extensions use these instead of reaching into globals directly.
ui: {
inject: (region, el) => {
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
toast(msg, type = 'success') {
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
},
/** Open the side-panel preview with arbitrary HTML content. */
openPreview(html) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
},
/** Is the current theme dark? */
isDark() {
return document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
},
/** Is the viewport at mobile width (≤ 768px)? */
isMobile() {
return window.innerWidth <= 768;
},
/** Is the side panel container currently open? */
isPanelOpen() {
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
return false;
},
/** Show a confirm dialog. Returns Promise<boolean>. */
confirm(msg, opts) {
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
return Promise.resolve(window.confirm(msg));
},
/** Inject an element into a named UI region (stub for future use). */
inject(region, el) {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
},
createMenu: (anchor, opts) => {
/** Create a popup menu anchored to an element. */
createMenu(anchor, opts) {
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
return null;
},

View File

@@ -12,14 +12,12 @@ var _noteEditor = null; // CM6 noteEditor instance
// ── Notes ─────────────────────────────────────
async function openNotes() {
const panel = document.getElementById('sidePanel');
const notesTab = document.getElementById('sidePanelNotes');
// If notes panel is already open and showing, close it
if (panel?.classList.contains('open') && notesTab?.style.display !== 'none') {
closeSidePanel();
// Toggle: if notes is active, close it; otherwise open it
if (PanelRegistry.isOpen('notes')) {
PanelRegistry.close('notes');
return;
}
openSidePanel('notes');
PanelRegistry.open('notes');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
@@ -745,6 +743,38 @@ function _showSaveToNoteModal(opts) {
// ── Notes Listeners (extracted from initListeners) ──
function _registerNotesPanel() {
const el = document.getElementById('sidePanelNotes');
if (!el) return;
PanelRegistry.register('notes', {
element: el,
label: 'Notes',
onOpen() {
// Loading is handled by openNotes() which calls loadNotesList
},
saveState() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
const graphView = document.getElementById('notesGraphView');
let viewMode = 'list';
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
return {
scrollTop: listView?.scrollTop || 0,
editingNoteId: _editingNoteId,
viewMode,
};
},
restoreState(state) {
if (state.scrollTop) {
const listView = document.getElementById('notesListView');
if (listView) listView.scrollTop = state.scrollTop;
}
},
});
}
function _initNotesListeners() {
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));

646
src/js/panels.js Normal file
View File

@@ -0,0 +1,646 @@
// ==========================================
// Chat Switchboard Panel Registry
// ==========================================
// Independent panel system replacing the hardcoded two-tab side panel.
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the <aside> container directly.
//
// Supports single-panel and dual-view modes. In dual mode the panel
// body splits into primary (left) + divider + secondary (right) via
// CSS grid. The split ratio is drag-adjustable.
//
// See DESIGN-0.18.1.md for full spec.
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active: null, // primary (left) panel name
_secondary: null, // secondary (right) panel name — null when not in dual mode
_dualMode: false,
_splitRatio: 0.5, // 01, fraction of space allocated to primary
_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 for tab button
* @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 container if closed).
*
* Single mode: hides current active, shows requested panel.
* Dual mode:
* - Already visible (active or secondary): swap it to primary.
* - Not visible: replaces secondary panel.
*/
open(name) {
const panel = this._panels[name];
if (!panel) {
console.warn(`[panels] unknown panel: "${name}"`);
return;
}
const container = this._container();
if (!container) return;
if (this._dualMode) {
// Already primary — no-op
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// Currently secondary — promote to primary, swap
if (this._secondary === name) {
const oldActive = this._active;
this._active = name;
this._secondary = oldActive;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// New panel — replace secondary
if (this._secondary) this._hide(this._secondary);
this._show(name);
this._secondary = name;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// ── Single mode ──
if (this._active && this._active !== name) {
this._hide(this._active);
}
container.classList.add('open');
this._show(name);
this._active = name;
this._showOverlay();
this._renderLabel();
this._renderActions();
},
/**
* Close a specific panel.
*
* Dual mode: closing either panel exits dual, keeping the other.
* Single mode: closes container.
*/
close(name) {
const panel = this._panels[name];
if (!panel) return;
if (this._dualMode) {
if (name === this._secondary) {
this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
if (name === this._active) {
this._hide(this._active);
// Promote secondary to primary
this._active = this._secondary;
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
return; // not visible — nothing to close
}
// ── Single mode ──
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 container.
*/
closeAll() {
if (this._secondary) {
this._hide(this._secondary);
this._secondary = null;
}
if (this._active) {
this._hide(this._active);
this._active = null;
}
if (this._dualMode) this._exitDualLayout();
this._closeContainer();
},
/** Is a specific panel currently visible? */
isOpen(name) {
return this._active === name || this._secondary === name;
},
/** Is the panel container open at all? */
isContainerOpen() {
return this._container()?.classList.contains('open') || false;
},
/** Get the primary panel name (or null). */
active() {
return this._active;
},
/** Is dual-view mode active? */
isDual() {
return this._dualMode;
},
/**
* Toggle dual-view mode.
*
* Entering: picks the next registered panel as secondary. If
* container is closed, opens first two panels. Needs ≥ 2 panels.
* Exiting: hides secondary, keeps primary.
*/
toggleDual() {
if (this._dualMode) {
// Exit dual
if (this._secondary) this._hide(this._secondary);
this._secondary = null;
this._exitDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Enter dual — need at least 2 panels
if (this._order.length < 2) return;
const container = this._container();
if (!container) return;
// If container not open, open first panel as primary
if (!this._active) {
container.classList.add('open');
this._show(this._order[0]);
this._active = this._order[0];
}
// Pick secondary: first registered panel that isn't active
const secondary = this._order.find(n => n !== this._active);
if (!secondary) return;
this._show(secondary);
this._secondary = secondary;
this._dualMode = true;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
},
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*
* In dual mode, cycles the secondary panel through non-primary panels.
*/
cycle() {
if (this._order.length === 0) return;
if (!this._active) {
this.open(this._order[0]);
return;
}
if (this._dualMode && this._secondary) {
// Cycle secondary through panels that aren't the primary
const others = this._order.filter(n => n !== this._active);
if (others.length < 2) return; // only one option, already showing
const idx = others.indexOf(this._secondary);
const next = others[(idx + 1) % others.length];
if (next === this._secondary) return;
this._hide(this._secondary);
this._show(next);
this._secondary = next;
this._applyDualLayout();
this._renderLabel();
this._renderActions();
return;
}
// Single mode: cycle active
const idx = this._order.indexOf(this._active);
const next = this._order[(idx + 1) % this._order.length];
this.open(next);
},
// ── Internal ────────────────────────────
_container() {
return document.getElementById('sidePanel');
},
_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, clear grid placement. */
_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';
panel.element.style.gridColumn = '';
},
/** Close the <aside> container. */
_closeContainer() {
const container = this._container();
if (!container) return;
container.classList.remove('open', 'fullscreen', 'dual-open');
container.style.width = '';
container.style.minWidth = '';
this._hideOverlay();
},
// ── Dual layout ─────────────────────────
/** Apply CSS grid layout for dual-view. */
_applyDualLayout() {
this._dualMode = true;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (!body || !container) return;
body.classList.add('dual');
container.classList.add('dual-open');
if (divider) divider.style.display = '';
// Assign grid columns: primary=1, divider=2, secondary=3
const primary = this._panels[this._active];
const secondary = this._panels[this._secondary];
if (primary) primary.element.style.gridColumn = '1';
if (secondary) secondary.element.style.gridColumn = '3';
this._applySplitRatio();
},
/** Remove dual layout, return to single-panel flexbox. */
_exitDualLayout() {
this._dualMode = false;
const body = this._body();
const container = this._container();
const divider = document.getElementById('sidePanelDivider');
if (body) {
body.classList.remove('dual');
body.style.gridTemplateColumns = '';
}
if (container) container.classList.remove('dual-open');
if (divider) divider.style.display = 'none';
// Clear grid-column on all panels
for (const name of this._order) {
const p = this._panels[name];
if (p) p.element.style.gridColumn = '';
}
},
/** Set grid-template-columns based on current _splitRatio. */
_applySplitRatio() {
const body = this._body();
if (!body || !this._dualMode) return;
const r = this._splitRatio;
body.style.gridTemplateColumns = `${r}fr 6px ${1 - r}fr`;
},
// ── Mobile overlay ──────────────────────
_isMobile() {
return window.innerWidth <= 768;
},
/** Show overlay behind panel 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';
},
// ── Tab 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 = '';
}
// Update dual-view toggle button visibility
const dualBtn = document.getElementById('sidePanelDualBtn');
if (dualBtn) {
dualBtn.style.display = this._order.length >= 2 ? '' : 'none';
dualBtn.classList.toggle('active', this._dualMode);
}
},
/** Render per-panel action buttons for the primary (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;');
}
// ── Side Panel Container (global operations) ──
function toggleSidePanelFullscreen() {
const panel = document.getElementById('sidePanel');
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>';
}
}
// ── Side Panel Resize (outer edge) ──────────
function _initSidePanelResize() {
let startX, startW;
const panel = document.getElementById('sidePanel');
const handle = document.getElementById('sidePanelResize');
if (!handle || !panel) return;
handle.addEventListener('mousedown', (e) => {
if (panel.classList.contains('fullscreen')) return;
e.preventDefault();
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
panel.style.transition = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const delta = startX - e.clientX;
const minW = PanelRegistry.isDual() ? 480 : 280;
const newW = Math.max(minW, Math.min(window.innerWidth * 0.7, startW + delta));
panel.style.width = newW + 'px';
panel.style.minWidth = newW + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
panel.style.transition = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ── Dual-View Divider Resize ────────────────
function _initDualDivider() {
const divider = document.getElementById('sidePanelDivider');
if (!divider) return;
divider.addEventListener('mousedown', (e) => {
if (!PanelRegistry.isDual()) return;
e.preventDefault();
const body = document.getElementById('sidePanelBody');
if (!body) return;
const bodyRect = body.getBoundingClientRect();
body.style.cursor = 'col-resize';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (e) => {
const x = e.clientX - bodyRect.left;
const total = bodyRect.width - 6; // subtract divider width
const ratio = Math.max(0.2, Math.min(0.8, x / (total + 6)));
PanelRegistry._splitRatio = ratio;
PanelRegistry._applySplitRatio();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
body.style.cursor = '';
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ── 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;
// Only track single-finger swipes
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) {
// Swipe right → previous panel
_cyclePanelDirection(-1);
} else {
// Swipe left → next panel
_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 ───────────────
// Auto-exit dual mode when viewport shrinks below threshold.
function _initPanelResponsive() {
let wasWide = window.innerWidth > 768;
window.addEventListener('resize', () => {
const isWide = window.innerWidth > 768;
// Viewport shrank to mobile while dual was active → collapse
if (wasWide && !isWide && PanelRegistry.isDual()) {
PanelRegistry.toggleDual(); // exits dual, keeps primary
}
// 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, remove after full migration) ──
function openSidePanel(tab) {
PanelRegistry.open(tab);
}
function closeSidePanel() {
PanelRegistry.closeAll();
}
function switchSidePanelTab(tab) {
PanelRegistry.open(tab);
}

View File

@@ -578,6 +578,11 @@ const UI = {
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
this._scrollToBottom();
// Live-update preview panel if open (debounced)
if (typeof _livePreviewUpdate === 'function') {
_livePreviewUpdate(content);
}
}
currentEvent = '';
} catch (e) { /* partial JSON */ }

View File

@@ -2,7 +2,7 @@
// Chat Switchboard UI Formatting & Helpers
// ==========================================
// Pure utility layer: esc(), markdown rendering, code blocks,
// side panel, time formatting. Loaded before all other UI files.
// preview panel, time formatting. Loaded after panels.js.
// ── HTML Escaping ───────────────────────────
@@ -102,7 +102,8 @@ function _formatMarked(text) {
const decoded = _decodeHTML(code);
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
if (handled && fakeContainer.innerHTML) {
return `<div class="ext-rendered" data-ext-block="${codeId}">${fakeContainer.innerHTML}</div>`;
const popBtn = `<button class="ext-popout-btn" onclick="popOutExtBlock('${codeId}')" title="Open in side panel">⧉</button>`;
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
}
if (handled && !fakeContainer.innerHTML) {
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
@@ -252,20 +253,16 @@ function toggleHTMLPreview(codeId) {
frame.style.display = '';
if (empty) empty.style.display = 'none';
// Show clear button
const clearBtn = document.getElementById('sidePanelClearBtn');
if (clearBtn) clearBtn.style.display = '';
openSidePanel('preview');
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
function clearPreview() {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
const clearBtn = document.getElementById('sidePanelClearBtn');
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
if (empty) empty.style.display = '';
if (clearBtn) clearBtn.style.display = 'none';
PanelRegistry.refreshActions();
}
function downloadCode(codeId, lang) {
@@ -292,76 +289,142 @@ function downloadCode(codeId, lang) {
UI.toast('Downloaded ' + filename, 'success');
}
// ── Side Panel ──────────────────────────────
// ── Preview Panel Registration ──────────────
function openSidePanel(tab) {
const panel = document.getElementById('sidePanel');
panel.classList.add('open');
if (tab) switchSidePanelTab(tab);
}
function _registerPreviewPanel() {
const el = document.getElementById('sidePanelPreview');
if (!el) return;
function closeSidePanel() {
const panel = document.getElementById('sidePanel');
panel.classList.remove('open', 'fullscreen');
panel.style.width = ''; panel.style.minWidth = '';
}
function switchSidePanelTab(tab) {
// Update tab buttons
document.querySelectorAll('.side-panel-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
PanelRegistry.register('preview', {
element: el,
label: 'Preview',
actions: [
{
id: 'sidePanelClearBtn',
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
title: 'Clear preview',
onClickName: 'clearPreview()',
visible: _hasPreviewContent,
},
],
saveState() {
const frame = document.getElementById('previewFrame');
return {
srcdoc: frame?.srcdoc || '',
hasContent: frame?.style.display !== 'none',
};
},
restoreState(state) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame && state.srcdoc) {
frame.srcdoc = state.srcdoc;
frame.style.display = state.hasContent ? '' : 'none';
if (empty) empty.style.display = state.hasContent ? 'none' : '';
}
},
});
// Show/hide pages
document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none';
document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none';
}
function toggleSidePanelFullscreen() {
const panel = document.getElementById('sidePanel');
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>';
function _hasPreviewContent() {
const frame = document.getElementById('previewFrame');
return frame && frame.style.display !== 'none' && frame.srcdoc;
}
// ── Pop-out for Extension-Rendered Blocks ───
/**
* Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
* preview panel at full size.
*/
function popOutExtBlock(blockId) {
const block = document.querySelector(`[data-ext-block="${blockId}"]`);
if (!block) return;
// Clone rendered content (skip the pop-out button itself)
const clone = block.cloneNode(true);
const btn = clone.querySelector('.ext-popout-btn');
if (btn) btn.remove();
const lang = block.getAttribute('data-ext-lang') || '';
// Wrap in a minimal HTML document for the iframe
const html = `<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<style>
body { margin: 0; padding: 16px; display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh; background: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body > * { max-width: 100%; }
svg { max-width: 100%; height: auto; }
</style>
</head><body>${clone.innerHTML}</body></html>`;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
// ── Side Panel Resize ───────────────────────
// ── Live Preview During Streaming ───────────
function _initSidePanelResize() {
let startX, startW;
const panel = document.getElementById('sidePanel');
const handle = document.getElementById('sidePanelResize');
if (!handle || !panel) return;
let _livePreviewTimer = null;
handle.addEventListener('mousedown', (e) => {
if (panel.classList.contains('fullscreen')) return;
e.preventDefault();
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
panel.style.transition = 'none'; // disable animation during drag
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
/**
* Called on each streaming delta with the accumulated raw markdown
* content. If the preview panel is open, extracts the last completed
* HTML code block and updates the iframe. Debounced at 500ms.
*/
function _livePreviewUpdate(rawContent) {
// Only live-update if preview is the active panel
if (!PanelRegistry.isOpen('preview')) return;
const onMove = (e) => {
const delta = startX - e.clientX; // dragging left = wider
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
panel.style.width = newW + 'px';
panel.style.minWidth = newW + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
panel.style.transition = ''; // re-enable animation
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
clearTimeout(_livePreviewTimer);
_livePreviewTimer = setTimeout(() => {
const html = _extractLastHTMLBlock(rawContent);
if (!html) return;
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
PanelRegistry.refreshActions();
}, 500);
}
/**
* Extract the last completed fenced HTML code block from raw markdown.
* Returns the raw HTML content or null if none found.
*
* "Completed" means both opening ``` and closing ``` are present.
*/
function _extractLastHTMLBlock(text) {
if (!text) return null;
// Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
let lastMatch = null;
let m;
while ((m = pattern.exec(text)) !== null) {
const blockContent = m[1];
const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
lastMatch = blockContent;
}
}
return lastMatch;
}
// ── Helpers ─────────────────────────────────