ZDDC/browse/js/preview.js
ZDDC 2dc2d032a0 feat(archive,browse): treat .zip transmittal folders as folders + shared zip adapter
New shared/zip-source.js: a ZipDirectoryHandle / ZipFileHandle pair
that exposes a JSZip instance behind the File-System-Access surface
(values/entries/keys, getDirectoryHandle/getFileHandle, getFile) —
read-only, with a zip-slip guard. Mirrors shared/zddc-source.js's
HTTP polyfill. Wired into archive's and browse's build.sh (both
already bundle JSZip).

archive: a .zip whose name minus ".zip" parses as a transmittal-folder
name is now scanned as that transmittal folder. Offline, the zip is
opened in the browser (ZipDirectoryHandle) and its members enumerated
exactly like an uncompressed folder's files — table/export/hash paths
are unchanged (they go through file.handle.getFile()). Online, the
scanner recurses into the server's "<…>.zip/" virtual-directory
listing, so members come back as "<…>.zip/<member>" URLs the server
extracts on demand — no whole-zip download.

browse: the offline (file://) zip path is migrated onto the shared
adapter — expanding a .zip now opens it as a ZipDirectoryHandle and
its members become ordinary dir/file nodes handled by the normal
fetchFsChildren path (nested zips fall out by recursion). The bespoke
flat-entry walker (loadZipChildren / setZipDirChildren / zipEntries /
zipParentId / zipPath / _zipSyntheticDir) is gone — one zip
implementation repo-wide. Markdown members inside a zip are flagged
read-only (the ZipFileHandle refuses createWritable; server "<…>.zip/"
URLs 405 on PUT).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:29:14 -05:00

331 lines
15 KiB
JavaScript

// preview.js — file-preview rendering for the browse tool's right pane.
//
// Default flow: showFilePreview(node) renders into the inline preview
// pane (#previewBody). Popup flow: showFilePreview(node, {popup:true})
// opens a separate window — kept for users who want previews on a
// second monitor.
//
// Rendering uses shared/preview-lib.js for content types it handles
// (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes;
// text into a <pre>; markdown into the dedicated markdown plugin
// (preview-markdown.js); unknown extensions show a download button.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
console.error('[browse] zddc.preview not loaded — preview 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',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel'
};
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so
// it falls through the same getFile() path as any local file.
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');
}
async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type
// and lets relative links inside HTML resolve back to the server.
if (state.source === 'server' && node.url) {
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 };
}
// ── Inline rendering ────────────────────────────────────────────────────
function renderEmpty(container, msg) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
}
function renderError(container, msg) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ escapeHtml(msg) + '</div>';
}
async function renderInline(node) {
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
if (titleEl) titleEl.textContent = node.name;
if (metaEl) {
var meta = [];
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
if (node.ext) meta.push(node.ext.toUpperCase());
metaEl.textContent = meta.join(' · ');
}
if (popoutBtn) popoutBtn.classList.remove('hidden');
var ext = (node.ext || '').toLowerCase();
// Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
return;
}
// PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try {
var info = await getBlobUrl(node);
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Plain images (jpg/png/gif/webp/svg) → <img>. TIFF goes through preview-lib.
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
}
return;
}
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
}
return;
}
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars)';
}
container.innerHTML = '';
var pre = document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
container.innerHTML =
'<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
+ '<br><a class="btn btn-primary btn-sm" download="' + escapeHtml(node.name)
+ '" href="' + escapeHtml(fallbackInfo.url) + '" style="margin-top:1rem">'
+ 'Download ' + escapeHtml(node.name) + '</a>'
+ '</div>';
} catch (e) {
renderError(container, 'No source for ' + node.name);
}
}
// ── Popup window (kept for "Pop out" button) ────────────────────────────
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
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 renderInPopupWindow(node, win, info) {
var ext = (node.ext || '').toLowerCase();
if (ext === 'pdf' || ext === 'html' || ext === 'htm') return;
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) return;
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
if (preview && preview.isTiff(ext)) {
var tb = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, tb, { fileName: node.name });
} else if (preview && preview.isZip(ext)) {
var zb = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, zb, { fileName: node.name });
} else if (preview && preview.isText(ext)) {
var txb = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txb);
var MAX = 200000;
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} else {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderInPopup(node) {
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Pop-out 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) {
window.open(info.url, '_blank', 'noopener');
return;
}
win.document.write(html);
win.document.close();
win.focus();
state.previewWindow = win;
}
await renderInPopupWindow(node, win, info);
}
// ── Public entry ────────────────────────────────────────────────────────
async function showFilePreview(node, opts) {
if (node.isDir) return;
opts = opts || {};
if (opts.popup) return renderInPopup(node);
return renderInline(node);
}
window.app.modules.preview = {
showFilePreview: showFilePreview,
// Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer
};
})();