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).
239 lines
10 KiB
JavaScript
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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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 };
|
|
})();
|