feat(html): TIFF and ZIP listing previews + favicon in app headers

Adds shared/preview-lib.js with two cross-tool renderers:
  - renderTiff (UTIF.js, lazy-loaded from CDN; PDF-style toolbar with
    page nav, zoom, fit-width/fit-page; multi-page TIFFs decode lazily)
  - renderZipListing (JSZip; sortable name/size/modified table, sticky
    header, host-grouped paths)

Wired into the four tools that have a preview surface (archive, classifier,
mdedit, transmittal). Cross-document compatible so the same renderer works
for popup-window tools (archive/classifier/transmittal) and inline tools
(mdedit). Archive previously had no image branch at all — now previews
JPG/PNG/GIF/WebP/BMP/SVG natively, plus TIFF via UTIF, plus the ZIP listing.

Adds the dark-blue rounded-square favicon to each app's header (left of
the title) and to the website navigation. Single inline SVG, sized via
.app-header__logo (in shared/base.css) for tools and .brand-logo (in
website/css/style.css) for the website. Self-contained — the SVG carries
its own background, no wrapper styling needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-01 15:23:26 -05:00
parent fedc3650b5
commit f01a177b73
15 changed files with 955 additions and 42 deletions

View file

@ -32,6 +32,7 @@ concat_files \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/init.js" \
"js/parser.js" \
"js/source.js" \

View file

@ -9,7 +9,17 @@
const processedLinks = new WeakSet();
let fileLinkHandlersAttached = false;
let filePreviewWindow = null;
const PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
// All extensions previewable in the popup. Image / tiff / zip / text routed
// through #previewContent below; pdf gets a direct iframe; docx/xlsx use
// dedicated lazy-loaded renderers.
const PREVIEW_EXTENSIONS = [
'pdf',
'docx', 'xlsx', 'xls',
...zddc.preview.IMAGE_EXTENSIONS,
...zddc.preview.TIFF_EXTENSIONS,
...zddc.preview.TEXT_EXTENSIONS,
'zip'
];
const loadedLibraries = new Map();
let resizing = null;
@ -445,6 +455,24 @@
}
/* docx-preview container */
.docx-wrapper { padding: 1rem; }
/* Image preview */
img.preview-image {
max-width: 100%;
max-height: 100%;
display: block;
margin: auto;
object-fit: contain;
}
/* Text preview */
pre.preview-text {
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--text);
background: var(--bg);
}
/* xlsx table styling */
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
.xlsx-table th, .xlsx-table td {
@ -529,8 +557,16 @@
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (zddc.preview.isTiff(ext)) {
await renderTiffInWindow(file);
} else if (zddc.preview.isZip(ext)) {
await renderZipInWindow(file);
} else if (zddc.preview.isImage(ext)) {
renderImageInWindow(file, url);
} else if (zddc.preview.isText(ext)) {
await renderTextInWindow(file);
}
} catch (err) {
console.error('Error loading file preview:', err);
alert(`Error loading preview: ${err.message}`);
@ -619,6 +655,91 @@
if (table) table.className = 'xlsx-table';
}
async function _getFileArrayBuffer(file) {
if (file.handle) {
const f = await file.handle.getFile();
return f.arrayBuffer();
}
if (file.url) {
const r = await fetch(file.url);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
}
throw new Error('No file source available');
}
/**
* Render an image (non-tiff) directly using the popup's <img> element.
* The browser handles decoding for jpg/jpeg/png/gif/webp/bmp/svg/ico natively.
*/
function renderImageInWindow(file, url) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
container.innerHTML = '';
const img = filePreviewWindow.document.createElement('img');
img.className = 'preview-image';
img.src = url;
img.alt = file.name || '';
container.appendChild(img);
}
/**
* Render a TIFF using the shared zddc.preview.renderTiff helper (UTIF.js).
*/
async function renderTiffInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const arrayBuffer = await _getFileArrayBuffer(file);
await zddc.preview.renderTiff(filePreviewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing using the shared zddc.preview.renderZipListing helper.
*/
async function renderZipInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const arrayBuffer = await _getFileArrayBuffer(file);
await zddc.preview.renderZipListing(filePreviewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Render a text file as preformatted text. Truncates very large files to
* keep the popup responsive users can Download to see the full file.
*/
async function renderTextInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const fileObj = file.handle ? await file.handle.getFile() : await fetch(file.url).then(r => r.blob());
let text = await fileObj.text();
const MAX = 200000;
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated, ' + (text.length - MAX) + ' more chars — Download for full file)';
container.innerHTML = '';
const pre = filePreviewWindow.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (err) {
console.error('Error rendering text file:', err);
container.innerHTML = `<div class="loading">Error reading file: ${err.message}</div>`;
}
}
/**
* Setup event delegation for file links
* Left-click: Download file (or preview if PDF and preview mode enabled)

View file

@ -20,6 +20,14 @@
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>

View file

@ -30,6 +30,7 @@ concat_files \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \
"../shared/zddc-filter.js" \

View file

@ -10,31 +10,15 @@
let currentRowIndex = null;
let previewWindow = null;
// File type mappings (extensions stored without leading dot, matching shared/zddc.js)
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bat', 'yaml', 'yml', 'ini', 'cfg', 'conf'];
// Use shared extension lists from window.zddc.preview where possible
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
const PDF_EXTENSIONS = ['pdf'];
const ZIP_EXTENSIONS = ['zip'];
// Cache for lazily loaded CDN libraries
const loadedLibraries = new Map();
/**
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
* Caches the promise so subsequent calls return immediately.
*/
function loadLibrary(url) {
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
document.head.appendChild(script);
});
loadedLibraries.set(url, promise);
return promise;
}
// Lazily load a script from CDN — delegates to shared cache.
const loadLibrary = zddc.preview.loadLibrary;
/**
* Initialize preview module
@ -265,12 +249,16 @@
previewWindow.focus();
}
// For office types, render content after window is ready
// For types that need decoding, render content after window is ready
const ext = (file.extension || '').toLowerCase();
if (ext === 'docx') {
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (TIFF_EXTENSIONS.includes(ext)) {
await renderTiffInWindow(file);
} else if (ZIP_EXTENSIONS.includes(ext)) {
await renderZipInWindow(file);
}
} catch (err) {
console.error('Error opening preview:', err);
@ -299,6 +287,8 @@
return `<pre>${escapeHtml(displayText)}</pre>`;
case 'docx':
case 'xlsx':
case 'tiff':
case 'zip':
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
default:
return `
@ -310,12 +300,13 @@
`;
}
}
/**
* Get preview type from extension
*/
function getPreviewType(ext) {
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
if (ext === 'docx') return 'docx';
@ -452,6 +443,42 @@
if (table) table.className = 'xlsx-table';
}
/**
* Render a TIFF file in the preview window using shared zddc.preview.renderTiff
*/
async function renderTiffInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing
*/
async function renderZipInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Escape HTML for safe display
*/

View file

@ -17,6 +17,14 @@
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>

View file

@ -40,6 +40,7 @@ concat_files \
concat_files \
"../shared/zddc.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \
"js/front-matter.js" \

View file

@ -150,6 +150,9 @@ async function openDirectory() {
directoryHandle = await window.showDirectoryPicker();
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
// Local picker wins over any active server-source mode.
serverSourceMode = false;
updateDirectoryStatus(directoryHandle.name);
await readDirectory(directoryHandle);
@ -667,12 +670,26 @@ async function readServerDirectory(dirUrl, parentNode, depth) {
*/
async function loadServerDirectory() {
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
serverSourceMode = true;
let href = window.location.href.split('?')[0].split('#')[0];
const lastSlash = href.lastIndexOf('/');
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
// Only enter server-source mode if the host actually serves JSON directory
// listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Select Directory" visible so the user can still load
// local files.
try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
if (!resp.ok) return;
const items = await resp.json();
if (!Array.isArray(items)) return;
} catch (_) {
return;
}
serverSourceMode = true;
const rootName = (() => {
const path = baseUrl.replace(/\/$/, '');
const seg = path.substring(path.lastIndexOf('/') + 1);
@ -686,15 +703,12 @@ async function loadServerDirectory() {
entries: {},
};
// Surface refresh, hide write-only controls
// Surface refresh, hide write-only controls. "Select Directory" stays
// visible so the user can switch to a local folder at any time.
const refreshBtn = document.getElementById('refresh-directory');
if (refreshBtn) refreshBtn.classList.remove('hidden');
const newFileRootBtn = document.getElementById('new-file-root');
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const selectDirBtn = document.getElementById('select-directory');
if (selectDirBtn) {
selectDirBtn.classList.add('hidden');
}
const stats = await readServerDirectory(baseUrl, fileTree, 0);
renderFileTree();

View file

@ -294,16 +294,25 @@ async function displayFileContent(fileHandle, filePath) {
document.getElementById('welcome-screen').classList.add('hidden');
document.getElementById('content-container').classList.remove('hidden');
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const isImage = imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
const lower = fileName.toLowerCase();
const lastDot = lower.lastIndexOf('.');
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
const isHtml = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm');
const isDocx = fileName.toLowerCase().endsWith('.docx');
const isXlsx = fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls');
const isPdf = fileName.toLowerCase().endsWith('.pdf');
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const isImage = imageExtensions.some(e => lower.endsWith(e));
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
const isZip = lower.endsWith('.zip');
const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
const isDocx = lower.endsWith('.docx');
const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls');
const isPdf = lower.endsWith('.pdf');
if (isImage) {
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isTiff) {
displayTiffPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isZip) {
displayZipPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isHtml) {
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
} else if (isDocx) {
@ -387,6 +396,94 @@ async function displayImagePreview(file, filePath, fileName, fileHandle, lastMod
editorInstances.set(filePath, instanceData);
}
/**
* Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas).
*/
async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const tiffContainer = document.createElement('div');
tiffContainer.className = 'flex-1 min-h-0';
tiffContainer.style.display = 'flex';
tiffContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(tiffContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering TIFF:', err);
tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display ZIP listing using shared zddc.preview.renderZipListing.
*/
async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) {
const contentContainer = document.getElementById('content-container');
if (!contentContainer) return;
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
if (editorInstances.has(filePath)) {
const existing = editorInstances.get(filePath);
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
return;
}
const fileViewContainer = document.createElement('div');
fileViewContainer.className = 'file-view-container flex flex-col h-full';
const fileHeader = document.createElement('div');
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
const fileTitle = document.createElement('span');
fileTitle.textContent = fileName || 'No file selected';
fileHeader.appendChild(fileTitle);
fileViewContainer.appendChild(fileHeader);
const zipContainer = document.createElement('div');
zipContainer.className = 'flex-1 min-h-0';
zipContainer.style.display = 'flex';
zipContainer.style.flexDirection = 'column';
fileViewContainer.appendChild(zipContainer);
contentContainer.appendChild(fileViewContainer);
try {
const arrayBuffer = await file.arrayBuffer();
await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName });
} catch (err) {
console.error('Error rendering ZIP listing:', err);
zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err);
}
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
}
/**
* Display HTML preview in sandboxed iframe
*/

View file

@ -17,6 +17,14 @@
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>

View file

@ -266,6 +266,16 @@ a:hover {
white-space: nowrap;
}
/* Brand logo sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;

544
shared/preview-lib.js Normal file
View file

@ -0,0 +1,544 @@
/**
* 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<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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 = '<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);
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 = '<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);

View file

@ -41,6 +41,7 @@ concat_files \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/reactive.js" \
"js/dom.js" \

View file

@ -11,11 +11,15 @@
// Current preview popup window reference
var previewWindow = null;
// Extensions that support rich in-browser preview
// Extensions that support rich in-browser preview (in addition to images,
// tiff, zip, and text — wired up in isPreviewable below).
var PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
// Extensions that preview as images
var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'];
// Use shared image / tiff / text lists from zddc.preview so the four tools
// stay in sync on what is previewable.
var IMAGE_EXTENSIONS = window.zddc.preview.IMAGE_EXTENSIONS;
var TIFF_EXTENSIONS = window.zddc.preview.TIFF_EXTENSIONS;
var TEXT_EXTENSIONS = window.zddc.preview.TEXT_EXTENSIONS;
// Cache for lazily loaded CDN libraries
var loadedLibraries = new Map();
@ -72,7 +76,11 @@
function isPreviewable(ext) {
var lower = (ext || '').toLowerCase();
return PREVIEW_EXTENSIONS.indexOf(lower) !== -1 || IMAGE_EXTENSIONS.indexOf(lower) !== -1;
return PREVIEW_EXTENSIONS.indexOf(lower) !== -1
|| IMAGE_EXTENSIONS.indexOf(lower) !== -1
|| TIFF_EXTENSIONS.indexOf(lower) !== -1
|| TEXT_EXTENSIONS.indexOf(lower) !== -1
|| lower === 'zip';
}
function hasFileSource(file) {
@ -147,6 +155,7 @@
'.sheet-tab:hover { background: #e8e8e8; }\n' +
'.sheet-tab.active { background: white; border-color: #ddd; border-bottom-color: white; margin-bottom: -1px; font-weight: 500; }\n' +
'img.preview-image { max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; display: block; }\n' +
'pre.preview-text { padding: 1rem; font-family: Consolas, Monaco, monospace; font-size: .85rem; white-space: pre-wrap; word-wrap: break-word; }\n' +
'</style>\n' +
'</head>\n' +
'<body>\n' +
@ -253,6 +262,55 @@
container.appendChild(img);
}
async function renderTiffInWindow(file) {
var container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
var arrayBuffer = await getFileArrayBuffer(file);
await window.zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('[transmittal] Error rendering TIFF:', err);
container.innerHTML = '<div class="loading">Error rendering TIFF: ' + util.escapeHtml(err.message || '') + '<br>Click Download to view in another application.</div>';
}
}
async function renderZipInWindow(file) {
var container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
var arrayBuffer = await getFileArrayBuffer(file);
await window.zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('[transmittal] Error rendering ZIP listing:', err);
container.innerHTML = '<div class="loading">Error reading ZIP: ' + util.escapeHtml(err.message || '') + '</div>';
}
}
async function renderTextInWindow(file) {
var container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
var arrayBuffer = await getFileArrayBuffer(file);
var text = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, ' + (text.length - MAX) + ' more chars — Download for full file)';
}
container.innerHTML = '';
var pre = previewWindow.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (err) {
console.error('[transmittal] Error reading text file:', err);
container.innerHTML = '<div class="loading">Error reading file: ' + util.escapeHtml(err.message || '') + '</div>';
}
}
async function showFilePreview(file) {
var ext = (file.extension || '').toLowerCase();
try {
@ -284,8 +342,14 @@
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (TIFF_EXTENSIONS.indexOf(ext) !== -1) {
await renderTiffInWindow(file);
} else if (ext === 'zip') {
await renderZipInWindow(file);
} else if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) {
await renderImageInWindow(file, url);
} else if (TEXT_EXTENSIONS.indexOf(ext) !== -1) {
await renderTextInWindow(file);
}
} catch (err) {
console.error('[transmittal] Error loading file preview:', err);

View file

@ -34,6 +34,14 @@ conventions at https://codeberg.org/VARASYS/ZDDC#file-naming-convention.
<div class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>