ZDDC/browse/js/preview.js
ZDDC 424bf8e769 feat(browse): Phase 2 — preview popup, ZIP expansion, ext filter, breadcrumbs
Bundles Phase 2 polish + the user-requested header/breadcrumb work:

- Breadcrumbs replacing the plain currentPath span. Server mode
  renders linkified ancestor segments (each <a> navigates to that
  directory; the browser fetches browse.html, the new instance
  auto-loads the listing). FS-API mode renders the rootHandle name
  as a non-link (no ancestor handles to navigate). Both prefix the
  path with a 🏠 root icon. Trailing slash + bold-current segment
  match common file-explorer conventions.

- Subdued 'Select Directory' button in server mode. Once browse is
  serving a real directory listing, the local-folder switcher is
  available but visually quiet (btn--subtle: transparent, muted
  color). FS-API mode keeps the primary styling (it's how the user
  got there). New btn--subtle CSS class added to browse's tree.css.
  A refresh button (⟳) appears next to it in both modes; clicking
  it re-fetches the current root listing.

- Header consistency: browse now matches archive's header layout
  (refresh + help buttons in addition to theme on the right). Help
  is a placeholder for future help dialog wiring.

- File preview popup. Click a file row → opens a popup window with
  the file rendered. Plain types (PDF, HTML, image) load in
  iframes; TIFF + ZIP listings via shared/preview-lib.js's
  renderTiff / renderZipListing helpers; text via <pre>; unknown
  types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
  shift) and middle-click still open the file in a new tab via the
  underlying <a target=_blank>. Single popup window is reused
  across multiple file clicks (matches archive's UX).

- ZIP inline expansion. .zip files have a chevron and act like
  folders in the tree. First expand fetches the zip bytes
  (server URL or FS handle or parent-zip read), parses with JSZip
  (auto-loaded from CDN), and synthesizes the entry tree. Nested
  directories within the zip lazy-expand on demand by re-walking
  the cached entry list at the right path prefix. Click on a
  zip-entry file opens the preview popup with bytes read from
  JSZip. Recursive expand-all skips zip archives by design — they
  can be very large, and explicit click-to-expand is safer.

- Extension multi-select filter. Toolbar now has a <select
  multiple> populated with extensions present in the current
  view. Filter is OR-of-selected; combined with the name filter
  it's AND-of-both. Folders pass through (so expanding a folder
  whose name doesn't match the ext filter still shows its file
  children that do match).
2026-05-03 20:39:49 -05:00

239 lines
10 KiB
JavaScript

// preview.js — file preview popup. Reuses shared/preview-lib.js for
// TIFF, ZIP listing, and image-rendering helpers; native iframe for
// PDF and HTML; <pre> for text; download button for everything else.
//
// Lifecycle: a single popup window is reused across multiple file
// clicks (state.previewWindow). Subsequent clicks rewrite its
// contents instead of spawning a new window — same UX as the archive
// tool.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
// shared/preview-lib.js wasn't concatenated in. Bail loudly so
// the bug shows up in console rather than mysteriously failing.
console.error('[browse] zddc.preview not loaded — preview popup disabled.');
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var MIME = {
'pdf': 'application/pdf',
'html': 'text/html', 'htm': 'text/html',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
'tif': 'image/tiff', 'tiff': 'image/tiff',
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css'
};
// Pull bytes for a file node. Three sources:
// - server URL (zddc-server-backed file, including downloads
// of archived files served at real paths)
// - FS-API handle (local folder)
// - JSZip entry (file inside an expanded zip; reads from
// parent's cached JSZip instance)
async function getArrayBuffer(node) {
if (node.zipParentId != null) {
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipFile) {
throw new Error('parent zip not loaded');
}
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
}
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.arrayBuffer();
}
if (node.handle) {
var f = await node.handle.getFile();
return await f.arrayBuffer();
}
throw new Error('no source for file');
}
function getMime(ext) {
return MIME[ext] || 'application/octet-stream';
}
// Build a blob URL for the file's bytes. For server-mode regular
// files (not in a zip), prefer the live URL — relative links and
// server-side interception (e.g. .archive resolution) work then.
async function getBlobUrl(node) {
if (state.source === 'server' && node.url && node.zipParentId == null) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
var blob = new Blob([buf], { type: getMime(node.ext) });
return { url: URL.createObjectURL(blob), fromServer: false };
}
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
// Inline PDF and HTML previews load in iframes. HTML uses
// sandbox="allow-same-origin allow-popups
// allow-popups-to-escape-sandbox" — same posture as archive's
// preview: links navigate, scripts blocked, popups allowed.
var contentHtml;
if (ext === 'pdf') {
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
} else if (ext === 'html' || ext === 'htm') {
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
} else {
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
}
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
+ '<title>' + safeName + ' — preview</title><style>'
+ '*{margin:0;padding:0;box-sizing:border-box;}'
+ 'body{display:flex;flex-direction:column;height:100vh;'
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
+ 'text-overflow:ellipsis;white-space:nowrap;}'
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
+ 'border-radius:4px;background:white;cursor:pointer;}'
+ '.btn:hover{background:#e8e8e8;}'
+ 'iframe{flex:1;width:100%;border:none;}'
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
+ 'color:#666;font-size:1.1rem;}'
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
+ 'margin:auto;display:block;}'
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
+ '</style></head><body>'
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
+ contentHtml
+ '<script>'
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
+ 'var fileName=' + JSON.stringify(node.name) + ';'
+ 'function downloadFile(){var a=document.createElement("a");'
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
+ 'a.click();document.body.removeChild(a);}'
+ '</' + 'script></body></html>';
}
async function renderTextInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
var buf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars — Download for full file)';
}
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderTiffInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error rendering TIFF: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function renderZipInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error reading ZIP: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function showFilePreview(node) {
if (node.isDir) return;
var ext = (node.ext || '').toLowerCase();
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Preview failed: ' + e.message);
return;
}
var html = popupShell(node, info.url);
var win = state.previewWindow;
if (win && !win.closed) {
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
} else {
var w = Math.round(screen.width * 0.6);
var h = Math.round(screen.height * 0.8);
var left = Math.round((screen.width - w) / 2);
var top = Math.round((screen.height - h) / 2);
win = window.open('', 'browseFilePreview',
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
+ ',resizable=yes,scrollbars=yes');
if (!win) {
// Popup blocked — fall back to opening the file directly.
window.open(info.url, '_blank', 'noopener');
return;
}
win.document.write(html);
win.document.close();
win.focus();
state.previewWindow = win;
}
// Async content rendering for the non-iframe types.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
return; // iframe wired in popupShell
}
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
return; // <img> wired in popupShell
}
if (preview && preview.isTiff(ext)) {
await renderTiffInWindow(node, win);
} else if (preview && preview.isZip(ext)) {
await renderZipInWindow(node, win);
} else if (preview && preview.isText(ext)) {
await renderTextInWindow(node, win);
} else {
// Unknown type — show a friendly "no preview, click
// download" placeholder.
var c = win.document.getElementById('previewContent');
if (c) {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
}
}
window.app.modules.preview = { showFilePreview: showFilePreview };
})();