From af16b14a520e083bb8b62ef654587b6c200abdd2 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 8 Jun 2026 11:44:24 -0500 Subject: [PATCH] =?UTF-8?q?feat(browse):=20render=20previewable=20files=20?= =?UTF-8?q?(.html)=20safely,=20with=20a=20Source=E2=87=84Preview=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderable types (.html/.htm) now show RENDERED by default again — but in a sandboxed iframe (no scripts, no same-origin) so arbitrary HTML is safe — and a header toggle flips to the CodeMirror source view (and back). Non-renderable text files open straight in CodeMirror as before; PDF stays an iframe; markdown keeps its own rendered/source toggle. - RENDERABLE map + per-node view mode (rendered default; remembers only the last-toggled node, so switching files resets). Toggle button sits by Pop out. - The same-node render guard now allows a deliberate toggle through (prompting to discard if the source editor is dirty); clearPreview hides the toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/preview.js | 69 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/browse/js/preview.js b/browse/js/preview.js index 385a497..bc6eb07 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -132,6 +132,9 @@ disposeEditors(); var container = document.getElementById('previewBody'); if (container) container.innerHTML = ''; + toggleTargetNode = null; + var tb = document.getElementById('previewViewToggle'); + if (tb) tb.classList.add('hidden'); } // Warn before a full page unload (reload / close / external nav) drops @@ -141,6 +144,41 @@ if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; } }); + // ── Rendered ⇄ Source toggle ───────────────────────────────────────────── + // Some types we can RENDER, not just edit (.html). Those show rendered by + // default (sandboxed — no scripts, no same-origin) with a toggle to the + // CodeMirror source view. Markdown has its own rendered/source toggle, so + // it's not here. Extend RENDERABLE to add more (svg already previews as an + // image; csv could render as a table later). + var RENDERABLE = { html: 1, htm: 1 }; + function isRenderable(ext) { return !!RENDERABLE[(ext || '').toLowerCase()]; } + function nodeKey(node) { return (node && (node.url || node.name)) || ''; } + // Per-node mode; 'rendered' is the default. Only the node the user last + // toggled is remembered, so switching files resets to rendered. + var viewToggle = { key: null, mode: 'rendered' }; + var toggleTargetNode = null; + function effectiveMode(node) { + return (viewToggle.key && viewToggle.key === nodeKey(node)) ? viewToggle.mode : 'rendered'; + } + function ensureViewToggleBtn() { + var btn = document.getElementById('previewViewToggle'); + if (btn) return btn; + var popout = document.getElementById('previewPopout'); + if (!popout || !popout.parentNode) return null; + btn = document.createElement('button'); + btn.id = 'previewViewToggle'; + btn.type = 'button'; + btn.className = 'btn btn-sm btn-secondary hidden'; + popout.parentNode.insertBefore(btn, popout); + btn.addEventListener('click', function () { + if (!toggleTargetNode) return; + var next = effectiveMode(toggleTargetNode) === 'rendered' ? 'source' : 'rendered'; + viewToggle = { key: nodeKey(toggleTargetNode), mode: next }; + renderInline(toggleTargetNode, { toggle: true }); + }); + return btn; + } + // ── Inline rendering ──────────────────────────────────────────────────── // Bumped on every renderInline entry; a render that loses the race @@ -169,9 +207,10 @@ var dm = dirtyEditor(); if (dm) { var cur = dm.currentNode ? dm.currentNode() : null; - if (samePreviewNode(cur, node)) { + if (samePreviewNode(cur, node) && !opts.toggle) { // Re-selecting the file we're already editing — don't reload - // and clobber the in-progress edits. + // and clobber the in-progress edits. (A deliberate view toggle + // falls through to the discard prompt below.) return; } if (opts.auto) { @@ -199,6 +238,32 @@ var ext = (node.ext || '').toLowerCase(); + // Rendered ⇄ Source toggle button — shown only for renderable types. + var toggleBtn = ensureViewToggleBtn(); + if (toggleBtn) { + if (isRenderable(ext)) { + toggleTargetNode = node; + toggleBtn.classList.remove('hidden'); + toggleBtn.textContent = effectiveMode(node) === 'rendered' ? '⟨⟩ Source' : '◱ Preview'; + } else { + toggleBtn.classList.add('hidden'); + } + } + + // Renderable types (.html) — show rendered by default, sandboxed for + // safety (no scripts, no same-origin). The toggle flips to source. + if (isRenderable(ext) && effectiveMode(node) === 'rendered') { + try { + var rinfo = await getBlobUrl(node); + if (seq !== renderSeq) return; + container.innerHTML = ''; + } catch (e) { + renderError(container, e.message || String(e)); + } + return; + } + // Markdown plugin (if loaded) takes over for .md / .markdown. if ((ext === 'md' || ext === 'markdown') && window.app.modules.markdown &&