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

@@ -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 ─────────────────────────────────