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:
parent
fedc3650b5
commit
f01a177b73
15 changed files with 955 additions and 42 deletions
|
|
@ -32,6 +32,7 @@ concat_files \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
|
"../shared/preview-lib.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
"js/parser.js" \
|
"js/parser.js" \
|
||||||
"js/source.js" \
|
"js/source.js" \
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,17 @@
|
||||||
const processedLinks = new WeakSet();
|
const processedLinks = new WeakSet();
|
||||||
let fileLinkHandlersAttached = false;
|
let fileLinkHandlersAttached = false;
|
||||||
let filePreviewWindow = null;
|
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();
|
const loadedLibraries = new Map();
|
||||||
let resizing = null;
|
let resizing = null;
|
||||||
|
|
||||||
|
|
@ -445,6 +455,24 @@
|
||||||
}
|
}
|
||||||
/* docx-preview container */
|
/* docx-preview container */
|
||||||
.docx-wrapper { padding: 1rem; }
|
.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 styling */
|
||||||
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||||
.xlsx-table th, .xlsx-table td {
|
.xlsx-table th, .xlsx-table td {
|
||||||
|
|
@ -529,8 +557,16 @@
|
||||||
await renderDocxInWindow(file);
|
await renderDocxInWindow(file);
|
||||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||||
await renderXlsxInWindow(file);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading file preview:', err);
|
console.error('Error loading file preview:', err);
|
||||||
alert(`Error loading preview: ${err.message}`);
|
alert(`Error loading preview: ${err.message}`);
|
||||||
|
|
@ -619,6 +655,91 @@
|
||||||
if (table) table.className = 'xlsx-table';
|
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
|
* Setup event delegation for file links
|
||||||
* Left-click: Download file (or preview if PDF and preview mode enabled)
|
* Left-click: Download file (or preview if PDF and preview mode enabled)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<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">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ concat_files \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/utils.js" \
|
"js/utils.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
|
|
|
||||||
|
|
@ -10,31 +10,15 @@
|
||||||
let currentRowIndex = null;
|
let currentRowIndex = null;
|
||||||
let previewWindow = null;
|
let previewWindow = null;
|
||||||
|
|
||||||
// File type mappings (extensions stored without leading dot, matching shared/zddc.js)
|
// Use shared extension lists from window.zddc.preview where possible
|
||||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
|
||||||
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'xml', 'csv', 'log', 'html', 'css', 'js', 'ts', 'py', 'sh', 'bat', 'yaml', 'yml', 'ini', 'cfg', 'conf'];
|
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
|
||||||
|
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
|
||||||
const PDF_EXTENSIONS = ['pdf'];
|
const PDF_EXTENSIONS = ['pdf'];
|
||||||
const ZIP_EXTENSIONS = ['zip'];
|
const ZIP_EXTENSIONS = ['zip'];
|
||||||
|
|
||||||
// Cache for lazily loaded CDN libraries
|
// Lazily load a script from CDN — delegates to shared cache.
|
||||||
const loadedLibraries = new Map();
|
const loadLibrary = zddc.preview.loadLibrary;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize preview module
|
* Initialize preview module
|
||||||
|
|
@ -265,12 +249,16 @@
|
||||||
previewWindow.focus();
|
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();
|
const ext = (file.extension || '').toLowerCase();
|
||||||
if (ext === 'docx') {
|
if (ext === 'docx') {
|
||||||
await renderDocxInWindow(file);
|
await renderDocxInWindow(file);
|
||||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||||
await renderXlsxInWindow(file);
|
await renderXlsxInWindow(file);
|
||||||
|
} else if (TIFF_EXTENSIONS.includes(ext)) {
|
||||||
|
await renderTiffInWindow(file);
|
||||||
|
} else if (ZIP_EXTENSIONS.includes(ext)) {
|
||||||
|
await renderZipInWindow(file);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error opening preview:', err);
|
console.error('Error opening preview:', err);
|
||||||
|
|
@ -299,6 +287,8 @@
|
||||||
return `<pre>${escapeHtml(displayText)}</pre>`;
|
return `<pre>${escapeHtml(displayText)}</pre>`;
|
||||||
case 'docx':
|
case 'docx':
|
||||||
case 'xlsx':
|
case 'xlsx':
|
||||||
|
case 'tiff':
|
||||||
|
case 'zip':
|
||||||
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
|
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
|
||||||
default:
|
default:
|
||||||
return `
|
return `
|
||||||
|
|
@ -310,12 +300,13 @@
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get preview type from extension
|
* Get preview type from extension
|
||||||
*/
|
*/
|
||||||
function getPreviewType(ext) {
|
function getPreviewType(ext) {
|
||||||
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
|
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
|
||||||
|
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
|
||||||
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
|
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
|
||||||
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
|
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
|
||||||
if (ext === 'docx') return 'docx';
|
if (ext === 'docx') return 'docx';
|
||||||
|
|
@ -452,6 +443,42 @@
|
||||||
if (table) table.className = 'xlsx-table';
|
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
|
* Escape HTML for safe display
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<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">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ concat_files \
|
||||||
concat_files \
|
concat_files \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/utils.js" \
|
"js/utils.js" \
|
||||||
"js/front-matter.js" \
|
"js/front-matter.js" \
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,9 @@ async function openDirectory() {
|
||||||
directoryHandle = await window.showDirectoryPicker();
|
directoryHandle = await window.showDirectoryPicker();
|
||||||
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
||||||
|
|
||||||
|
// Local picker wins over any active server-source mode.
|
||||||
|
serverSourceMode = false;
|
||||||
|
|
||||||
updateDirectoryStatus(directoryHandle.name);
|
updateDirectoryStatus(directoryHandle.name);
|
||||||
await readDirectory(directoryHandle);
|
await readDirectory(directoryHandle);
|
||||||
|
|
||||||
|
|
@ -667,12 +670,26 @@ async function readServerDirectory(dirUrl, parentNode, depth) {
|
||||||
*/
|
*/
|
||||||
async function loadServerDirectory() {
|
async function loadServerDirectory() {
|
||||||
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
||||||
serverSourceMode = true;
|
|
||||||
|
|
||||||
let href = window.location.href.split('?')[0].split('#')[0];
|
let href = window.location.href.split('?')[0].split('#')[0];
|
||||||
const lastSlash = href.lastIndexOf('/');
|
const lastSlash = href.lastIndexOf('/');
|
||||||
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
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 rootName = (() => {
|
||||||
const path = baseUrl.replace(/\/$/, '');
|
const path = baseUrl.replace(/\/$/, '');
|
||||||
const seg = path.substring(path.lastIndexOf('/') + 1);
|
const seg = path.substring(path.lastIndexOf('/') + 1);
|
||||||
|
|
@ -686,15 +703,12 @@ async function loadServerDirectory() {
|
||||||
entries: {},
|
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');
|
const refreshBtn = document.getElementById('refresh-directory');
|
||||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||||
const newFileRootBtn = document.getElementById('new-file-root');
|
const newFileRootBtn = document.getElementById('new-file-root');
|
||||||
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
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);
|
const stats = await readServerDirectory(baseUrl, fileTree, 0);
|
||||||
renderFileTree();
|
renderFileTree();
|
||||||
|
|
|
||||||
|
|
@ -294,16 +294,25 @@ async function displayFileContent(fileHandle, filePath) {
|
||||||
document.getElementById('welcome-screen').classList.add('hidden');
|
document.getElementById('welcome-screen').classList.add('hidden');
|
||||||
document.getElementById('content-container').classList.remove('hidden');
|
document.getElementById('content-container').classList.remove('hidden');
|
||||||
|
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
const lower = fileName.toLowerCase();
|
||||||
const isImage = imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
|
const lastDot = lower.lastIndexOf('.');
|
||||||
|
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
|
||||||
|
|
||||||
const isHtml = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm');
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
||||||
const isDocx = fileName.toLowerCase().endsWith('.docx');
|
const isImage = imageExtensions.some(e => lower.endsWith(e));
|
||||||
const isXlsx = fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls');
|
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
|
||||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
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) {
|
if (isImage) {
|
||||||
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
|
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) {
|
} else if (isHtml) {
|
||||||
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
|
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||||
} else if (isDocx) {
|
} else if (isDocx) {
|
||||||
|
|
@ -387,6 +396,94 @@ async function displayImagePreview(file, filePath, fileName, fileHandle, lastMod
|
||||||
editorInstances.set(filePath, instanceData);
|
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
|
* Display HTML preview in sandboxed iframe
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@
|
||||||
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
|
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<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">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Markdown</span>
|
<span class="app-header__title">ZDDC Markdown</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,16 @@ a:hover {
|
||||||
white-space: nowrap;
|
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 ──────────────────────────────────────────────────────── */
|
||||||
.build-timestamp {
|
.build-timestamp {
|
||||||
font-size: 0.55rem;
|
font-size: 0.55rem;
|
||||||
|
|
|
||||||
544
shared/preview-lib.js
Normal file
544
shared/preview-lib.js
Normal 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, '&')
|
||||||
|
.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 = '<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);
|
||||||
|
|
@ -41,6 +41,7 @@ concat_files \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/hash.js" \
|
"../shared/hash.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
|
"../shared/preview-lib.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/reactive.js" \
|
"js/reactive.js" \
|
||||||
"js/dom.js" \
|
"js/dom.js" \
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@
|
||||||
// Current preview popup window reference
|
// Current preview popup window reference
|
||||||
var previewWindow = null;
|
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'];
|
var PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
|
||||||
|
|
||||||
// Extensions that preview as images
|
// Use shared image / tiff / text lists from zddc.preview so the four tools
|
||||||
var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
// 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
|
// Cache for lazily loaded CDN libraries
|
||||||
var loadedLibraries = new Map();
|
var loadedLibraries = new Map();
|
||||||
|
|
@ -72,7 +76,11 @@
|
||||||
|
|
||||||
function isPreviewable(ext) {
|
function isPreviewable(ext) {
|
||||||
var lower = (ext || '').toLowerCase();
|
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) {
|
function hasFileSource(file) {
|
||||||
|
|
@ -147,6 +155,7 @@
|
||||||
'.sheet-tab:hover { background: #e8e8e8; }\n' +
|
'.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' +
|
'.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' +
|
'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' +
|
'</style>\n' +
|
||||||
'</head>\n' +
|
'</head>\n' +
|
||||||
'<body>\n' +
|
'<body>\n' +
|
||||||
|
|
@ -253,6 +262,55 @@
|
||||||
container.appendChild(img);
|
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) {
|
async function showFilePreview(file) {
|
||||||
var ext = (file.extension || '').toLowerCase();
|
var ext = (file.extension || '').toLowerCase();
|
||||||
try {
|
try {
|
||||||
|
|
@ -284,8 +342,14 @@
|
||||||
await renderDocxInWindow(file);
|
await renderDocxInWindow(file);
|
||||||
} else if (ext === 'xlsx' || ext === 'xls') {
|
} else if (ext === 'xlsx' || ext === 'xls') {
|
||||||
await renderXlsxInWindow(file);
|
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) {
|
} else if (IMAGE_EXTENSIONS.indexOf(ext) !== -1) {
|
||||||
await renderImageInWindow(file, url);
|
await renderImageInWindow(file, url);
|
||||||
|
} else if (TEXT_EXTENSIONS.indexOf(ext) !== -1) {
|
||||||
|
await renderTextInWindow(file);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[transmittal] Error loading file preview:', err);
|
console.error('[transmittal] Error loading file preview:', err);
|
||||||
|
|
|
||||||
|
|
@ -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 class="dropdown-menu hidden" role="menu" id="bottom-dropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<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">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue