Changeset 0.18.1 (#80)
This commit is contained in:
@@ -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 ─────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user