From d874643af5cf0d51c7b6c1536149244999df31d3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sun, 3 May 2026 20:40:02 -0500 Subject: [PATCH] release: v0.0.14 lockstep --- mdedit/dist/mdedit.html | 2 +- zddc/internal/apps/embedded/archive.html | 2 +- zddc/internal/apps/embedded/browse.html | 1347 +++++++++++++++++- zddc/internal/apps/embedded/classifier.html | 2 +- zddc/internal/apps/embedded/index.html | 2 +- zddc/internal/apps/embedded/mdedit.html | 2 +- zddc/internal/apps/embedded/transmittal.html | 2 +- zddc/internal/apps/embedded/versions.txt | 14 +- zddc/internal/handler/form.html | 2 +- 9 files changed, 1297 insertions(+), 78 deletions(-) diff --git a/mdedit/dist/mdedit.html b/mdedit/dist/mdedit.html index f7cfb8f..94a088f 100644 --- a/mdedit/dist/mdedit.html +++ b/mdedit/dist/mdedit.html @@ -1774,7 +1774,7 @@ body.help-open .app-header {
ZDDC Markdown - v0.0.13 + v0.0.14
diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 61f89b2..3281614 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.13 + v0.0.14
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 294397c..3c720a2 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -605,15 +605,55 @@ body { flex-wrap: wrap; } -.toolbar__path { +/* Breadcrumb path. The root node is a 🏠 link to "/" (online) or + the FS handle name (offline). Each segment is a clickable link in + server mode that re-navigates the browser; in FS-API mode they + render as plain spans because we don't keep ancestor handles. */ +.breadcrumbs { + flex: 1; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; font-family: Consolas, Monaco, monospace; font-size: 0.9rem; color: var(--text-muted); - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + padding: 0.1rem 0; + /* Hide the scrollbar but keep horizontal scroll for very deep paths */ + scrollbar-width: thin; +} + +.breadcrumbs .bc-link { + color: var(--primary); + text-decoration: none; + padding: 0.1rem 0.25rem; + border-radius: 3px; +} + +.breadcrumbs .bc-link:hover { + background: var(--bg-hover, rgba(0,0,0,0.05)); + text-decoration: underline; +} + +.breadcrumbs .bc-link--current { + color: var(--text); + font-weight: 500; + cursor: default; +} + +.breadcrumbs .bc-link--current:hover { + background: transparent; + text-decoration: none; +} + +.breadcrumbs .bc-sep { + color: var(--text-muted); + margin: 0 0.05rem; +} + +.breadcrumbs .bc-root { + font-size: 1rem; /* the 🏠 emoji renders a hair bigger */ + line-height: 1; } .toolbar__filter { @@ -627,12 +667,45 @@ body { font-size: 0.9rem; } +.toolbar__ext { + /* Multi-select extension filter. Native +
@@ -1311,6 +1387,551 @@ body { } }()); +/** + * ZDDC β€” shared preview helpers + * + * Cross-tool helpers for previewing file types that need a decoder: + * - TIFF (UTIF.js) β€” multi-page, browser-PDF-viewer-style toolbar + * - ZIP listing (JSZip) β€” sortable file-list view + * + * Renderers operate on any document (parent window or popup window), so the + * same code works for tools whose preview opens in a popup (classifier, + * archive, transmittal) and tools that render inline (mdedit). + * + * Public API on window.zddc.preview: + * loadLibrary(url) β†’ Promise + * renderTiff(doc, container, arrayBuffer, opts) β†’ Promise + * renderZipListing(doc, container, arrayBuffer, opts) β†’ Promise + * 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 = '' + safeName + '';
+        } else {
+            contentHtml = '
Loading preview…
'; + } + return '' + + '' + safeName + ' β€” preview' + + '

' + safeName + '

' + + '
' + + contentHtml + + '