+ * TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
+ * isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
+ *
+ * Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
+ */
+
+(function (root) {
+ 'use strict';
+
+ var TIFF_EXTENSIONS = ['tif', 'tiff'];
+ var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
+ var TEXT_EXTENSIONS = [
+ 'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
+ 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
+ 'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
+ 'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
+ 'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
+ 'sql', 'env'
+ ];
+ var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
+
+ function lowerExt(ext) { return (ext || '').toLowerCase(); }
+ function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
+ function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
+ function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
+ function isZip(ext) { return lowerExt(ext) === 'zip'; }
+ function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
+
+ // ββ CDN library loader (parent window cache) βββββββββββββββββββββββββββββ
+
+ var _libCache = new Map();
+
+ function loadLibrary(url) {
+ if (_libCache.has(url)) return _libCache.get(url);
+ var p = new Promise(function (resolve, reject) {
+ var s = document.createElement('script');
+ s.src = url;
+ s.onload = function () { resolve(); };
+ s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
+ document.head.appendChild(s);
+ });
+ _libCache.set(url, p);
+ return p;
+ }
+
+ // ββ Style injection (idempotent per-document) ββββββββββββββββββββββββββββ
+
+ function injectStyles(doc, id, css) {
+ if (doc.getElementById(id)) return;
+ var style = doc.createElement('style');
+ style.id = id;
+ style.textContent = css;
+ doc.head.appendChild(style);
+ }
+
+ // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ function formatSize(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';
+ }
+
+ function formatDate(d) {
+ if (!d) return '';
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ // ββ TIFF renderer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ var TIFF_CSS =
+ '.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
+ '.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
+ 'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
+ '.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
+ '.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
+ '.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
+ '.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
+ 'border-radius:3px;text-align:center;font-size:.85rem;}' +
+ '.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
+ 'background:#fff;font-size:.85rem;}' +
+ '.tiff-toolbar .tiff-spacer{flex:1;}' +
+ '.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
+ 'justify-content:center;padding:1rem;}' +
+ '.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
+ 'image-rendering:auto;}' +
+ '.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
+ 'padding:2rem;text-align:center;}';
+
+ function renderTiff(doc, container, arrayBuffer, opts) {
+ opts = opts || {};
+ injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
+
+ return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
+ var ifds;
+ try {
+ ifds = window.UTIF.decode(arrayBuffer);
+ } catch (e) {
+ container.innerHTML = 'Failed to parse TIFF: '
+ + escapeHtml(e.message || e) + '
';
+ return;
+ }
+ if (!ifds || !ifds.length) {
+ container.innerHTML = 'No images found in TIFF.
';
+ return;
+ }
+
+ // Reset container to a flex column
+ container.innerHTML = '';
+ container.style.display = 'flex';
+ container.style.flexDirection = 'column';
+ container.style.minHeight = '0';
+ container.style.height = '100%';
+ container.style.overflow = 'hidden';
+
+ // Toolbar
+ var toolbar = doc.createElement('div');
+ toolbar.className = 'tiff-toolbar';
+
+ var btnPrev = doc.createElement('button');
+ btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
+ btnPrev.title = 'Previous page'; btnPrev.textContent = 'β';
+
+ var pageInfo = doc.createElement('span');
+ pageInfo.className = 'tiff-page-info';
+ var pageInput = doc.createElement('input');
+ pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
+ pageInput.className = 'tiff-page-input';
+ var pageOf = doc.createElement('span');
+ pageOf.textContent = ' of ' + ifds.length;
+ pageInfo.appendChild(doc.createTextNode('Page '));
+ pageInfo.appendChild(pageInput);
+ pageInfo.appendChild(pageOf);
+
+ var btnNext = doc.createElement('button');
+ btnNext.className = 'tiff-btn'; btnNext.type = 'button';
+ btnNext.title = 'Next page'; btnNext.textContent = 'βΆ';
+
+ var spacer = doc.createElement('span');
+ spacer.className = 'tiff-spacer';
+
+ var btnZoomOut = doc.createElement('button');
+ btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
+ btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = 'β';
+
+ var zoomSelect = doc.createElement('select');
+ zoomSelect.className = 'tiff-zoom-select';
+ var zoomOptions = [
+ ['fit-width', 'Fit width'],
+ ['fit-page', 'Fit page'],
+ ['0.5', '50%'],
+ ['0.75', '75%'],
+ ['1', '100%'],
+ ['1.25', '125%'],
+ ['1.5', '150%'],
+ ['2', '200%'],
+ ['3', '300%'],
+ ['4', '400%']
+ ];
+ zoomOptions.forEach(function (z) {
+ var o = doc.createElement('option');
+ o.value = z[0]; o.textContent = z[1];
+ zoomSelect.appendChild(o);
+ });
+ zoomSelect.value = 'fit-width';
+
+ var btnZoomIn = doc.createElement('button');
+ btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
+ btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
+
+ toolbar.appendChild(btnPrev);
+ toolbar.appendChild(pageInfo);
+ toolbar.appendChild(btnNext);
+ toolbar.appendChild(spacer);
+ toolbar.appendChild(btnZoomOut);
+ toolbar.appendChild(zoomSelect);
+ toolbar.appendChild(btnZoomIn);
+
+ // Viewport with canvas
+ var viewport = doc.createElement('div');
+ viewport.className = 'tiff-viewport';
+ var canvas = doc.createElement('canvas');
+ canvas.className = 'tiff-canvas';
+ viewport.appendChild(canvas);
+
+ container.appendChild(toolbar);
+ container.appendChild(viewport);
+
+ // Render state
+ var currentPage = 0;
+ var zoom = 1;
+ var fitMode = 'width'; // 'width' | 'page' | null
+ var decoded = new Array(ifds.length);
+
+ function decodePage(i) {
+ if (decoded[i]) return decoded[i];
+ var ifd = ifds[i];
+ window.UTIF.decodeImage(arrayBuffer, ifd);
+ var rgba = window.UTIF.toRGBA8(ifd);
+ decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
+ return decoded[i];
+ }
+
+ function applyZoom() {
+ var page = decoded[currentPage];
+ if (!page) return;
+ var availW = viewport.clientWidth - 32; // padding
+ var availH = viewport.clientHeight - 32;
+ var scale;
+ if (fitMode === 'width') {
+ scale = availW / page.w;
+ } else if (fitMode === 'page') {
+ scale = Math.min(availW / page.w, availH / page.h);
+ } else {
+ scale = zoom;
+ }
+ if (!isFinite(scale) || scale <= 0) scale = 1;
+ canvas.style.width = (page.w * scale) + 'px';
+ canvas.style.height = (page.h * scale) + 'px';
+ }
+
+ function renderPage() {
+ var page;
+ try {
+ page = decodePage(currentPage);
+ } catch (e) {
+ container.innerHTML = 'Failed to decode page '
+ + (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '
';
+ return;
+ }
+ canvas.width = page.w;
+ canvas.height = page.h;
+ var ctx = canvas.getContext('2d');
+ var imgData = ctx.createImageData(page.w, page.h);
+ imgData.data.set(page.rgba);
+ ctx.putImageData(imgData, 0, 0);
+ applyZoom();
+ pageInput.value = String(currentPage + 1);
+ btnPrev.disabled = currentPage <= 0;
+ btnNext.disabled = currentPage >= ifds.length - 1;
+ }
+
+ function setZoomFromSelect() {
+ var v = zoomSelect.value;
+ if (v === 'fit-width') { fitMode = 'width'; }
+ else if (v === 'fit-page') { fitMode = 'page'; }
+ else { fitMode = null; zoom = parseFloat(v) || 1; }
+ applyZoom();
+ }
+
+ function nudgeZoom(factor) {
+ if (fitMode) {
+ // capture current effective scale before leaving fit mode
+ var page = decoded[currentPage];
+ if (page) {
+ var availW = viewport.clientWidth - 32;
+ var availH = viewport.clientHeight - 32;
+ zoom = fitMode === 'width'
+ ? availW / page.w
+ : Math.min(availW / page.w, availH / page.h);
+ } else {
+ zoom = 1;
+ }
+ fitMode = null;
+ }
+ zoom = Math.max(0.1, Math.min(8, zoom * factor));
+ // Match select option if any are close, else show as percent
+ var matched = false;
+ for (var i = 0; i < zoomSelect.options.length; i++) {
+ var ov = zoomSelect.options[i].value;
+ if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
+ zoomSelect.value = ov; matched = true; break;
+ }
+ }
+ if (!matched) {
+ // Nearest standard step
+ var best = '1', bestDiff = Infinity;
+ for (var j = 0; j < zoomSelect.options.length; j++) {
+ var v2 = zoomSelect.options[j].value;
+ if (v2 === 'fit-width' || v2 === 'fit-page') continue;
+ var diff = Math.abs(parseFloat(v2) - zoom);
+ if (diff < bestDiff) { bestDiff = diff; best = v2; }
+ }
+ zoom = parseFloat(best);
+ zoomSelect.value = best;
+ }
+ applyZoom();
+ }
+
+ btnPrev.addEventListener('click', function () {
+ if (currentPage > 0) { currentPage--; renderPage(); }
+ });
+ btnNext.addEventListener('click', function () {
+ if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
+ });
+ pageInput.addEventListener('change', function () {
+ var n = parseInt(pageInput.value, 10);
+ if (!isNaN(n) && n >= 1 && n <= ifds.length) {
+ currentPage = n - 1;
+ renderPage();
+ } else {
+ pageInput.value = String(currentPage + 1);
+ }
+ });
+ zoomSelect.addEventListener('change', setZoomFromSelect);
+ btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
+ btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
+
+ // Keyboard nav (only when toolbar/viewport in focus path)
+ container.tabIndex = 0;
+ container.addEventListener('keydown', function (e) {
+ if (e.target === pageInput) return;
+ if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
+ if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
+ } else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
+ if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
+ }
+ });
+
+ // Re-fit on viewport resize
+ if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
+ var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
+ ro.observe(viewport);
+ } else if (doc.defaultView) {
+ doc.defaultView.addEventListener('resize', function () { applyZoom(); });
+ }
+
+ renderPage();
+ });
+ }
+
+ // ββ ZIP listing renderer βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ var ZIP_CSS =
+ '.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
+ 'font-size:.85rem;color:#444;}' +
+ '.zip-table-wrap{flex:1;overflow:auto;}' +
+ '.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
+ '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
+ '.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
+ 'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
+ 'font-weight:600;}' +
+ '.zip-table thead th:hover{background:#e6e6e6;}' +
+ '.zip-table thead th.zip-sort-asc::after{content:" β²";font-size:.7rem;color:#888;}' +
+ '.zip-table thead th.zip-sort-desc::after{content:" βΌ";font-size:.7rem;color:#888;}' +
+ '.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
+ '.zip-table tbody tr:hover{background:#f6faff;}' +
+ '.zip-table .zip-folder{color:#888;}' +
+ '.zip-table .zip-name{color:#222;}' +
+ '.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
+ 'white-space:nowrap;color:#555;}' +
+ '.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
+ '.zip-empty{padding:2rem;text-align:center;color:#888;}';
+
+ function renderZipListing(doc, container, arrayBuffer, opts) {
+ opts = opts || {};
+ injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
+
+ return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
+ return window.JSZip.loadAsync(arrayBuffer);
+ }).then(function (zip) {
+ var entries = [];
+ zip.forEach(function (relativePath, zipEntry) {
+ if (zipEntry.dir) return;
+ var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
+ entries.push({
+ path: relativePath,
+ name: relativePath.split('/').pop(),
+ size: size,
+ modified: zipEntry.date instanceof Date ? zipEntry.date : null
+ });
+ });
+
+ container.innerHTML = '';
+ container.style.display = 'flex';
+ container.style.flexDirection = 'column';
+ container.style.minHeight = '0';
+ container.style.height = '100%';
+ container.style.overflow = 'hidden';
+
+ var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
+
+ var header = doc.createElement('div');
+ header.className = 'zip-header';
+ header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
+ + (totalSize ? ' Β· ' + formatSize(totalSize) + ' uncompressed' : '');
+ container.appendChild(header);
+
+ if (!entries.length) {
+ var empty = doc.createElement('div');
+ empty.className = 'zip-empty';
+ empty.textContent = '(empty archive)';
+ container.appendChild(empty);
+ return;
+ }
+
+ var wrap = doc.createElement('div');
+ wrap.className = 'zip-table-wrap';
+
+ var table = doc.createElement('table');
+ table.className = 'zip-table';
+ var thead = doc.createElement('thead');
+ var trh = doc.createElement('tr');
+ var cols = [
+ { key: 'path', label: 'Name', cls: 'zip-col-name' },
+ { key: 'size', label: 'Size', cls: 'zip-col-size' },
+ { key: 'modified', label: 'Modified', cls: 'zip-col-date' }
+ ];
+ cols.forEach(function (c) {
+ var th = doc.createElement('th');
+ th.className = c.cls;
+ th.dataset.key = c.key;
+ th.textContent = c.label;
+ trh.appendChild(th);
+ });
+ thead.appendChild(trh);
+ table.appendChild(thead);
+
+ var tbody = doc.createElement('tbody');
+ table.appendChild(tbody);
+
+ wrap.appendChild(table);
+ container.appendChild(wrap);
+
+ var sortKey = 'path';
+ var sortDir = 1;
+
+ function render() {
+ var sorted = entries.slice().sort(function (a, b) {
+ var av, bv;
+ if (sortKey === 'size') { av = a.size; bv = b.size; }
+ else if (sortKey === 'modified') {
+ av = a.modified ? a.modified.getTime() : 0;
+ bv = b.modified ? b.modified.getTime() : 0;
+ } else {
+ av = a.path.toLowerCase(); bv = b.path.toLowerCase();
+ }
+ if (av < bv) return -1 * sortDir;
+ if (av > bv) return 1 * sortDir;
+ return 0;
+ });
+
+ tbody.innerHTML = '';
+ sorted.forEach(function (e) {
+ var tr = doc.createElement('tr');
+ var td1 = doc.createElement('td');
+ var slash = e.path.lastIndexOf('/');
+ if (slash >= 0) {
+ var folder = doc.createElement('span');
+ folder.className = 'zip-folder';
+ folder.textContent = e.path.substring(0, slash + 1);
+ td1.appendChild(folder);
+ }
+ var name = doc.createElement('span');
+ name.className = 'zip-name';
+ name.textContent = e.name;
+ td1.appendChild(name);
+
+ var td2 = doc.createElement('td');
+ td2.className = 'zip-size';
+ td2.textContent = formatSize(e.size);
+
+ var td3 = doc.createElement('td');
+ td3.className = 'zip-date';
+ td3.textContent = formatDate(e.modified);
+
+ tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
+ tbody.appendChild(tr);
+ });
+
+ // Update sort arrows
+ var ths = thead.querySelectorAll('th');
+ for (var i = 0; i < ths.length; i++) {
+ ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
+ if (ths[i].dataset.key === sortKey) {
+ ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
+ }
+ }
+ }
+
+ thead.querySelectorAll('th').forEach(function (th) {
+ th.addEventListener('click', function () {
+ var k = th.dataset.key;
+ if (sortKey === k) sortDir = -sortDir;
+ else { sortKey = k; sortDir = 1; }
+ render();
+ });
+ });
+
+ render();
+ }).catch(function (err) {
+ container.innerHTML = 'Failed to read ZIP: '
+ + escapeHtml(err.message || err) + '
';
+ });
+ }
+
+ // ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ if (!root.zddc) root.zddc = {};
+ root.zddc.preview = {
+ TIFF_EXTENSIONS: TIFF_EXTENSIONS,
+ IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
+ TEXT_EXTENSIONS: TEXT_EXTENSIONS,
+ OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
+ isTiff: isTiff,
+ isImage: isImage,
+ isText: isText,
+ isZip: isZip,
+ isOffice: isOffice,
+ loadLibrary: loadLibrary,
+ renderTiff: renderTiff,
+ renderZipListing: renderZipListing,
+ formatSize: formatSize,
+ formatDate: formatDate
+ };
+})(typeof window !== 'undefined' ? window : this);
+
// Bootstrap window.app for the browse tool. Mirrors the convention
// used by every other ZDDC tool β ./build's CSS/JS concat order means
// this file runs FIRST inside the IIFE-of-IIFEs.
@@ -1341,14 +1962,29 @@ body {
// Current filter substring (lowercase).
filterText: '',
+ // Selected extensions (Set of lowercase strings, no leading
+ // dot). Empty set = no extension filtering.
+ extFilter: new Set(),
+
// The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, depth,
- // parentId, expanded, loaded, childIds }
+ // parentId, expanded, loaded, childIds, isZip, zipFile,
+ // zipPath }
+ // - isZip: set when the node IS a .zip file we know how to
+ // expand inline (server file or FS handle).
+ // - zipFile: cached JSZip instance for this archive (set
+ // after first expand).
+ // - zipPath: relative path WITHIN a zip (set on virtual
+ // children of an expanded zip; null otherwise).
// Stored flat in a Map keyed by id; render order derived
// from a depth-first walk.
nodes: new Map(),
rootIds: [],
- nextId: 1
+ nextId: 1,
+
+ // Single shared popup window for file preview (across
+ // multiple file clicks). Same pattern as archive's preview.
+ previewWindow: null
};
})();
@@ -1474,12 +2110,36 @@ body {
}
}
+ // CDN library loader. Idempotent β multiple callers share the
+ // same in-flight Promise. Used by ZIP expansion + the file
+ // preview popup.
+ var libCache = new Map();
+ function loadScript(url) {
+ if (libCache.has(url)) return libCache.get(url);
+ var p = new Promise(function (resolve, reject) {
+ var s = document.createElement('script');
+ s.src = url;
+ s.onload = function () { resolve(); };
+ s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
+ document.head.appendChild(s);
+ });
+ libCache.set(url, p);
+ return p;
+ }
+
+ function ensureJSZip() {
+ if (window.JSZip) return Promise.resolve();
+ return loadScript('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
+ }
+
// Public API
window.app.modules.loader = {
fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode,
- splitExt: splitExt
+ splitExt: splitExt,
+ ensureJSZip: ensureJSZip,
+ loadScript: loadScript
};
})();
@@ -1499,6 +2159,10 @@ body {
function newNode(raw, parentId, depth) {
var id = state.nextId++;
+ // ZIP files are treated as folders for tree purposes β the
+ // chevron lets the user expand them inline. The actual
+ // contents are loaded on first expand via JSZip.
+ var isZip = !raw.isDir && raw.ext === 'zip';
var node = {
id: id,
name: raw.name,
@@ -1512,7 +2176,11 @@ body {
parentId: parentId,
expanded: false,
loaded: false,
- childIds: []
+ childIds: [],
+ isZip: isZip,
+ zipFile: null, // cached JSZip instance
+ zipPath: raw.zipPath || null, // path within zip (for virtual children)
+ zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries)
};
state.nodes.set(id, node);
return node;
@@ -1586,14 +2254,14 @@ body {
for (var i = 0; i < ids.length; i++) {
out.push(ids[i]);
var n = state.nodes.get(ids[i]);
- if (n.isDir && n.expanded) walk(n.childIds);
+ if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
}
}
// Re-sort everything at all levels so a sort change reorders
// already-loaded children consistently.
sortNodes(state.rootIds);
state.nodes.forEach(function (n) {
- if (n.isDir && n.loaded) sortNodes(n.childIds);
+ if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
});
walk(state.rootIds);
return out;
@@ -1623,17 +2291,20 @@ body {
function rowHtml(node) {
var indent = node.depth * 1.2;
- var iconChar = node.isDir ? 'π' : 'π';
- var labelClass = node.isDir ? 'is-folder' : 'is-file';
- var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
+ var expandable = node.isDir || node.isZip;
+ var iconChar = node.isDir ? 'π' : (node.isZip ? 'ποΈ' : 'π');
+ var chevronClass = 'tree-name__chevron'
+ + (expandable ? '' : ' tree-name__chevron--leaf');
var nameInner;
if (node.isDir) {
nameInner = ''
+ escapeHtml(node.name) + '';
} else {
- // File: clickable link. In server mode, href is a real URL
- // that opens the file. In FS mode, click handler reads the
- // file via the handle and triggers a download (Phase 2).
+ // File / zip: clickable. Plain click β preview popup.
+ // Modifier-click (ctrl/cmd) and middle-click β open in
+ // new tab (browser default for the href). Server mode
+ // gets the real URL (so right-click β save-link-as also
+ // works); FS mode and zip-virtual children get '#'.
var href = node.url || '#';
nameInner = ''
+ + '" data-id="' + node.id
+ + '" data-isdir="' + node.isDir
+ + '" data-iszip="' + node.isZip + '">'
+ ''
+ ''
+ ''
@@ -1668,19 +2341,24 @@ body {
applyFilter();
updateCount();
updateSortHeaders();
+ renderBreadcrumbs();
+ renderExtFilter();
}
- // Filter is purely DOM-level: hide rows whose name doesn't match.
+ // Filter is purely DOM-level: hide rows whose name doesn't match
+ // and (if any extensions are selected) whose ext isn't in the set.
// Cheap, immediate, no model rebuild.
function applyFilter() {
var f = state.filterText;
+ var ef = state.extFilter;
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var n = state.nodes.get(parseInt(row.dataset.id, 10));
if (!n) continue;
- var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
- row.classList.toggle('tree-row--filtered', !match);
+ var nameMatch = !f || n.name.toLowerCase().indexOf(f) !== -1;
+ var extMatch = !ef.size || n.isDir || ef.has(n.ext);
+ row.classList.toggle('tree-row--filtered', !(nameMatch && extMatch));
}
}
@@ -1689,11 +2367,81 @@ body {
if (!el) return;
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
- el.textContent = state.filterText
+ var anyFilter = state.filterText || state.extFilter.size;
+ el.textContent = anyFilter
? rows.length + ' of ' + total + ' shown'
: total + ' item' + (total === 1 ? '' : 's');
}
+ // ββ Breadcrumbs ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ function renderBreadcrumbs() {
+ var el = document.getElementById('breadcrumbs');
+ if (!el) return;
+ var html = '';
+ if (state.source === 'server') {
+ // Server mode: every segment links to its directory URL.
+ // The browser navigates β server returns embedded browse β
+ // the new instance auto-loads that directory's listing.
+ var path = state.currentPath || '/';
+ var parts = path.split('/').filter(Boolean);
+ html += 'π ';
+ var sofar = '';
+ for (var i = 0; i < parts.length; i++) {
+ sofar += '/' + parts[i];
+ var isLast = i === parts.length - 1;
+ html += '/';
+ if (isLast) {
+ html += ''
+ + escapeHtml(parts[i]) + '';
+ } else {
+ html += ''
+ + escapeHtml(parts[i]) + '';
+ }
+ }
+ html += '/';
+ } else if (state.source === 'fs') {
+ // FS-API mode: ancestor handles weren't retained when the
+ // user picked the root, so we can't navigate up. Show the
+ // root as π + handle name without links.
+ var name = state.rootHandle ? state.rootHandle.name : '';
+ html += 'π ';
+ if (name) {
+ html += '/';
+ html += '' + escapeHtml(name) + '';
+ }
+ html += '/';
+ }
+ el.innerHTML = html;
+ }
+
+ // ββ Extension filter βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ function renderExtFilter() {
+ var sel = document.getElementById('extFilter');
+ if (!sel) return;
+ // Collect unique extensions from currently-loaded nodes (any
+ // depth). Folders excluded. Empty-string ext omitted (no-ext
+ // files would be filtered out by selecting any other ext).
+ var exts = new Set();
+ state.nodes.forEach(function (n) {
+ if (!n.isDir && n.ext) exts.add(n.ext);
+ });
+ var sorted = Array.from(exts).sort();
+ // Preserve current selection when re-rendering after expand.
+ var selected = state.extFilter;
+ var html = '';
+ for (var i = 0; i < sorted.length; i++) {
+ var e = sorted[i];
+ var isSel = selected.has(e) ? ' selected' : '';
+ html += '';
+ }
+ sel.innerHTML = html;
+ // Size to fit content β multi-selects can be cramped otherwise.
+ sel.size = Math.min(Math.max(sorted.length, 2), 6);
+ }
+
function updateSortHeaders() {
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
@@ -1704,29 +2452,163 @@ body {
}
}
- // Load a folder's children (lazy; idempotent re-loads).
+ // Load a folder's children (lazy; idempotent re-loads). Dispatches
+ // by node kind:
+ // - regular folder β server JSON listing OR FS-API enumeration
+ // - zip file β fetch+JSZip; entries become virtual children
+ // - zip child dir β already-listed entries from the parent zip
+ // (zips are enumerated whole, so child dirs
+ // are pre-populated when the zip expands)
async function loadChildren(node) {
if (node.loaded) return;
try {
- var raw;
- if (state.source === 'server') {
- raw = await loader.fetchServerChildren(pathFor(node) + '/');
- } else if (state.source === 'fs') {
- raw = await loader.fetchFsChildren(node.handle);
- } else {
- return;
+ if (node.isZip) {
+ await loadZipChildren(node);
+ } else if (node._zipSyntheticDir) {
+ // Synthetic dir node materialized when a zip's entry
+ // list referenced "a/b/file" but had no "a/" entry.
+ // Re-walk the owning zip's flat entry list with the
+ // dir's full prefix.
+ var owner = state.nodes.get(node.zipParentId);
+ if (!owner || !owner.zipEntries) {
+ throw new Error('zip parent not loaded');
+ }
+ setZipDirChildren(node, owner, node.zipPath + '/');
+ } else if (node.isDir) {
+ var raw;
+ if (state.source === 'server') {
+ raw = await loader.fetchServerChildren(pathFor(node) + '/');
+ } else if (state.source === 'fs') {
+ raw = await loader.fetchFsChildren(node.handle);
+ } else {
+ return;
+ }
+ setChildren(node.id, raw);
}
- setChildren(node.id, raw);
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
}
}
+ // Fetch a zip's bytes, parse with JSZip, and materialize its
+ // entries as a tree of virtual nodes. JSZip's entry list is flat
+ // (full paths); we reconstruct the directory hierarchy on top.
+ async function loadZipChildren(zipNode) {
+ await loader.ensureJSZip();
+ var arrayBuffer;
+ if (state.source === 'server' && zipNode.url) {
+ var resp = await fetch(zipNode.url);
+ if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
+ arrayBuffer = await resp.arrayBuffer();
+ } else if (zipNode.handle) {
+ // FS-API: top-level zip in a local folder.
+ var f = await zipNode.handle.getFile();
+ arrayBuffer = await f.arrayBuffer();
+ } else if (zipNode.zipParentId != null) {
+ // Nested zip inside another zip β read from parent JSZip.
+ var parent = state.nodes.get(zipNode.zipParentId);
+ if (!parent || !parent.zipFile) {
+ throw new Error('parent zip not loaded');
+ }
+ arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
+ } else {
+ throw new Error('cannot fetch zip bytes (no source)');
+ }
+ var zip = await window.JSZip.loadAsync(arrayBuffer);
+ zipNode.zipFile = zip;
+
+ // Build a path β raw-entry map. Entry paths are
+ // "dir/sub/file.ext" or "dir/" for directories. We slice
+ // to immediate children of zipNode (i.e. zero slashes after
+ // a leading prefix). For nested directories, we synthesize
+ // folder nodes that lazy-expand to the next level via the
+ // same raw-entry list β keep it on the zipNode for replay.
+ zipNode.zipEntries = []; // for re-walk on expand of subdirs
+ zip.forEach(function (relPath, entry) {
+ zipNode.zipEntries.push({
+ path: relPath.replace(/\/$/, ''),
+ isDir: entry.dir,
+ size: (entry._data && entry._data.uncompressedSize) || 0,
+ modTime: entry.date instanceof Date ? entry.date : null,
+ rawPath: relPath
+ });
+ });
+
+ // Now seed top-level children of the zip itself.
+ setZipDirChildren(zipNode, zipNode, '');
+ }
+
+ // Populate node's childIds with the entries directly under
+ // pathPrefix (relative to the owning zip). Directory entries
+ // become folder nodes whose own children are seeded on first
+ // expand by this same function (recursively descending zipPath).
+ function setZipDirChildren(node, zipOwner, pathPrefix) {
+ var seen = new Map(); // immediate child name β raw entry
+ zipOwner.zipEntries.forEach(function (e) {
+ if (!e.path.startsWith(pathPrefix)) return;
+ var rest = e.path.substring(pathPrefix.length);
+ if (rest === '') return;
+ // Take the FIRST segment of the remaining path
+ var slash = rest.indexOf('/');
+ var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
+ var isImmediateFile = !e.isDir && slash === -1;
+ var isImmediateDir = e.isDir && slash === -1;
+ // For deeply-nested entries (rest contains a slash), we
+ // surface only the first segment as a synthetic folder
+ // entry. For immediate entries, we emit the entry as-is.
+ if (isImmediateFile || isImmediateDir) {
+ // Immediate entry β use the real metadata.
+ seen.set(firstSeg, {
+ name: firstSeg,
+ isDir: e.isDir,
+ size: e.size,
+ modTime: e.modTime,
+ ext: e.isDir ? '' : loader.splitExt(firstSeg),
+ url: null,
+ handle: null,
+ zipPath: e.path,
+ zipParentId: zipOwner.id
+ });
+ } else if (slash !== -1 && !seen.has(firstSeg)) {
+ // Deeper entry, no explicit dir entry yet β synthesize.
+ seen.set(firstSeg, {
+ name: firstSeg,
+ isDir: true,
+ size: 0,
+ modTime: null,
+ ext: '',
+ url: null,
+ handle: null,
+ zipPath: pathPrefix + firstSeg,
+ zipParentId: zipOwner.id
+ });
+ }
+ });
+ // Drop existing children (re-load case)
+ node.childIds.forEach(function (id) { state.nodes.delete(id); });
+ node.childIds = [];
+ seen.forEach(function (raw) {
+ var n = newNode(raw, node.id, node.depth + 1);
+ // Synthetic dir nodes inside zip don't have a dedicated
+ // load path β they re-walk zipEntries on expand. Mark
+ // them so the dispatcher knows.
+ if (raw.isDir && !n.isZip) {
+ n._zipSyntheticDir = true;
+ }
+ node.childIds.push(n.id);
+ });
+ sortNodes(node.childIds);
+ node.loaded = true;
+ }
+
// Toggle a folder's expanded state. Loads children on first expand.
+ // Treats "expandable" as either a real directory OR a zip file
+ // (zip files act like folders for tree purposes β the chevron
+ // expands them and the contents come from JSZip).
async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId);
- if (!n || !n.isDir) return;
+ if (!n || !(n.isDir || n.isZip)) return;
if (!n.expanded && !n.loaded) {
await loadChildren(n);
if (!n.loaded) return; // load failed
@@ -1738,16 +2620,12 @@ body {
// Recursive expand: load + expand all descendants of nodeId. Used
// for Shift-click on a folder. Walks breadth-first, fanning out
// through children, grand-children, etc. until every reachable
- // folder is loaded and marked expanded. Status bar shows progress
- // because deeply-nested trees can take a while.
- //
- // Parallelism: kept conservative (per-level fan-out) to avoid
- // hammering zddc-server with hundreds of concurrent listing
- // fetches. Browsers also throttle per-origin concurrency, but
- // queuing politely is friendlier than fighting that.
+ // expandable node (folder OR zip) is loaded and marked expanded.
+ // Skips zip-EXPANSION recursion to avoid auto-loading every
+ // archive in the tree (those can be huge); plain folders only.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
- if (!root || !root.isDir) return;
+ if (!root || !(root.isDir || root.isZip)) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtreeβ¦');
var processed = 0;
@@ -1755,7 +2633,6 @@ body {
while (queue.length) {
var batch = queue;
queue = [];
- // Load this level's children in parallel (Promise.all).
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
@@ -1763,28 +2640,26 @@ body {
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
- if (c && c.isDir) queue.push(c);
+ // Recurse into plain folders only β don't auto-
+ // expand zip archives during a subtree expand
+ // (they can be very large).
+ if (c && c.isDir && !c.isZip) queue.push(c);
}
}
- // Re-render after each level so the user sees progress
- // rather than a long pause then a sudden full-tree dump.
render();
status('Expanding subtree⦠(' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
- // Recursive collapse: mark this node and every descendant as
- // collapsed. Doesn't unload β if the user re-expands later, the
- // children are still in memory and re-render is instant.
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
- if (!root || !root.isDir) return;
+ if (!root || !(root.isDir || root.isZip)) return;
function walk(n) {
n.expanded = false;
for (var i = 0; i < n.childIds.length; i++) {
var c = state.nodes.get(n.childIds[i]);
- if (c && c.isDir) walk(c);
+ if (c && (c.isDir || c.isZip)) walk(c);
}
}
walk(root);
@@ -1829,10 +2704,257 @@ body {
applyFilter();
updateCount();
},
+ setExtFilter: function (extArr) {
+ state.extFilter = new Set((extArr || []).map(function (e) {
+ return String(e).toLowerCase().replace(/^\./, '');
+ }));
+ applyFilter();
+ updateCount();
+ },
pathFor: pathFor
};
})();
+// preview.js β file preview popup. Reuses shared/preview-lib.js for
+// TIFF, ZIP listing, and image-rendering helpers; native iframe for
+// PDF and HTML; 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, '"');
+ }
+
+ 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 = '';
+ } else if (ext === 'html' || ext === 'htm') {
+ contentHtml = '';
+ } else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
+ contentHtml = ' ';
+ } else {
+ contentHtml = '';
+ }
+ return ''
+ + '' + safeName + ' β preview'
+ + '' + safeName + ''
+ + ''
+ + contentHtml
+ + ' |