mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.
Removed:
• mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
• zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
• tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
• mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
switch case in EmbeddedBytes)
• "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
error-message app list
• "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
• mdedit case in tests (handler_test.go, validate_test.go,
zddchandler_test.go) — test fixtures now use browse/classifier
• mdedit from build (per-tool build.sh loop, tool-list literals,
composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
• mdedit from freshen-channel's tool list and usage banner
• mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
Markdown Editor section in ARCHITECTURE.md rewritten to point at
browse/js/preview-markdown.js
• mdedit from CLAUDE.md, README.md, zddc/README.md tool lists
Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
547 lines
24 KiB
JavaScript
547 lines
24 KiB
JavaScript
/**
|
||
* 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 (browse).
|
||
*
|
||
* Public API on window.zddc.preview:
|
||
* loadLibrary(url) → Promise<void>
|
||
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
|
||
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
|
||
* 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, '>')
|
||
.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);
|
||
|
||
// UTIF is bundled (shared/vendor/utif.min.js) — window.UTIF is
|
||
// available synchronously. Promise.resolve() keeps the existing
|
||
// .then() chain shape so callers don't need to change.
|
||
return Promise.resolve().then(function () {
|
||
var ifds;
|
||
try {
|
||
ifds = window.UTIF.decode(arrayBuffer);
|
||
} catch (e) {
|
||
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
|
||
+ escapeHtml(e.message || e) + '</div>';
|
||
return;
|
||
}
|
||
if (!ifds || !ifds.length) {
|
||
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
|
||
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 = '<div class="tiff-error">Failed to decode page '
|
||
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
|
||
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);
|
||
|
||
// JSZip is bundled in every tool that uses preview-lib (each
|
||
// tool's build.sh concatenates shared/vendor/jszip.min.js).
|
||
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 = '<div class="zip-empty">Failed to read ZIP: '
|
||
+ escapeHtml(err.message || err) + '</div>';
|
||
});
|
||
}
|
||
|
||
// ── 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);
|