Same pattern as the browse fix. archive, transmittal, classifier previously CDN-loaded jszip + docx-preview on first preview of a .zip / .docx file via shared/preview-lib.js's loadLibrary helper. That meant each first-preview blocked on a CDN round-trip + parse, and broke entirely under restrictive networks or CSPs. Vendor both libs under shared/vendor/ and concat them at the top of each tool's build, ahead of init.js. window.JSZip + window.docx are now defined immediately on page load. Drop the redundant loadLibrary calls (and classifier's stray <script src="cdn..."> tag in the template, plus archive's bespoke loadJSZip helper in export.js). xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too large to inline, and only fires on .xlsx preview which is a rarer path. Bundle size impact (uncompressed): archive: 304 KB → 476 KB (+172 KB) transmittal: 449 KB → 621 KB (+172 KB) classifier: 252 KB → 424 KB (+172 KB) With the gzip middleware (~75% reduction on HTML) and ETag-cached revalidation now in place, the wire-size delta is ~40 KB per tool on the first load and 0 on every subsequent load until redeploy.
1077 lines
40 KiB
JavaScript
1077 lines
40 KiB
JavaScript
// Table management functionality
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
|
|
// FileBlobCache, processedLinks, preview state, and utilities
|
|
const fileBlobCache = new Map();
|
|
const processedLinks = new WeakSet();
|
|
let fileLinkHandlersAttached = false;
|
|
let filePreviewWindow = null;
|
|
// 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;
|
|
|
|
// Currently-previewing file (visual highlight in the file table). Survives
|
|
// re-renders via applyPreviewHighlight, which is called at the tail of
|
|
// updateFileTable. Cleared when the preview popup is closed.
|
|
let currentPreviewFileId = null;
|
|
let previewWindowWatcher = null;
|
|
|
|
function setCurrentPreviewFile(fileId) {
|
|
currentPreviewFileId = fileId;
|
|
applyPreviewHighlight();
|
|
}
|
|
|
|
function applyPreviewHighlight() {
|
|
const tbody = document.getElementById('filesTableBody');
|
|
if (!tbody) return;
|
|
// Clear any prior highlight first.
|
|
tbody.querySelectorAll('tr.is-previewing').forEach(el => el.classList.remove('is-previewing'));
|
|
tbody.querySelectorAll('.revision-file.is-previewing').forEach(el => el.classList.remove('is-previewing'));
|
|
if (!currentPreviewFileId) return;
|
|
const checkbox = tbody.querySelector(`input[type="checkbox"][data-file-id="${cssEscape(currentPreviewFileId)}"]`);
|
|
if (!checkbox) return;
|
|
const wrapper = checkbox.closest('.revision-file');
|
|
if (wrapper) wrapper.classList.add('is-previewing');
|
|
const row = checkbox.closest('tr');
|
|
if (row) row.classList.add('is-previewing');
|
|
}
|
|
|
|
function cssEscape(s) {
|
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(s);
|
|
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
|
|
}
|
|
|
|
// Watch the preview popup; clear the highlight when the user closes it so
|
|
// the table doesn't keep advertising a preview that's no longer on screen.
|
|
function watchPreviewWindow() {
|
|
if (previewWindowWatcher) {
|
|
clearInterval(previewWindowWatcher);
|
|
previewWindowWatcher = null;
|
|
}
|
|
if (!filePreviewWindow) return;
|
|
previewWindowWatcher = setInterval(() => {
|
|
if (!filePreviewWindow || filePreviewWindow.closed) {
|
|
clearInterval(previewWindowWatcher);
|
|
previewWindowWatcher = null;
|
|
if (currentPreviewFileId) setCurrentPreviewFile(null);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
|
|
/**
|
|
* Get or create a blob URL for a file.
|
|
* - Local files: reads via File System Access API, caches the blob URL.
|
|
* - HTTP files: fetches the remote URL, caches the blob URL.
|
|
* Returns a Promise<string> resolving to a blob: URL.
|
|
*/
|
|
async function getFileBlobUrl(file) {
|
|
if (fileBlobCache.has(file.id)) {
|
|
return fileBlobCache.get(file.id);
|
|
}
|
|
let blob;
|
|
if (file.handle) {
|
|
// Local file via File System Access API
|
|
const f = await file.handle.getFile();
|
|
blob = f;
|
|
} else if (file.url) {
|
|
// HTTP file — fetch and convert to blob
|
|
const resp = await fetch(file.url);
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + file.url);
|
|
blob = await resp.blob();
|
|
} else {
|
|
throw new Error('File has neither a handle nor a URL');
|
|
}
|
|
const url = URL.createObjectURL(blob);
|
|
fileBlobCache.set(file.id, url);
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Clean up blob URLs for files no longer displayed
|
|
*/
|
|
function cleanupUnusedBlobUrls() {
|
|
const displayedFileIds = new Set(window.app.filteredFiles.map(f => f.id));
|
|
for (const [fileId, url] of fileBlobCache.entries()) {
|
|
if (!displayedFileIds.has(fileId)) {
|
|
URL.revokeObjectURL(url);
|
|
fileBlobCache.delete(fileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Revoke all blob URLs and clear cache
|
|
*/
|
|
function cleanupAllBlobUrls() {
|
|
for (const url of fileBlobCache.values()) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
fileBlobCache.clear();
|
|
}
|
|
|
|
// Update file table
|
|
function updateFileTable() {
|
|
const tbody = document.getElementById('filesTableBody');
|
|
|
|
if (window.app.filteredFiles.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="3" class="empty-table">
|
|
No files found matching the current filters.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
cleanupUnusedBlobUrls(); // Clean up all blob URLs
|
|
return;
|
|
}
|
|
|
|
// Group and sort files
|
|
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
|
|
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
|
|
|
|
// Build table rows
|
|
const rows = [];
|
|
sorted.forEach(group => {
|
|
rows.push(createFileGroupRow(group));
|
|
});
|
|
|
|
tbody.innerHTML = rows.join('');
|
|
|
|
// Re-apply the preview highlight after every re-render so a file that
|
|
// was being previewed when filters changed still shows as previewing if
|
|
// it's still in the visible set.
|
|
applyPreviewHighlight();
|
|
|
|
// Clean up blob URLs for files no longer visible
|
|
cleanupUnusedBlobUrls();
|
|
}
|
|
|
|
// Create row for a file group
|
|
function createFileGroupRow(group) {
|
|
// Generate one <tr> per revision; last row gets class group-last for border
|
|
const lastIndex = group.sortedRevisions.length - 1;
|
|
return group.sortedRevisions.map((revision, i) => {
|
|
const titleClass = revision.hasModifier ? 'revision-title-modifier' : 'revision-title-base';
|
|
const titleHtml = `<div class="${titleClass}">${window.app.modules.app.escapeHtml(revision.title)}</div>`;
|
|
const revisionHtml = createRevisionHtml(group.trackingNumber, revision);
|
|
const lastClass = i === lastIndex ? ' group-last' : '';
|
|
|
|
// First row includes trackingNumber cell with rowspan
|
|
if (i === 0) {
|
|
return `
|
|
<tr class="group-row${lastClass}">
|
|
<td data-field="trackingNumber" rowspan="${group.sortedRevisions.length}">${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
|
|
<td data-field="title">${titleHtml}</td>
|
|
<td data-field="revisions">${revisionHtml}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
// Subsequent rows omit trackingNumber cell
|
|
return `
|
|
<tr class="group-row${lastClass}">
|
|
<td data-field="title">${titleHtml}</td>
|
|
<td data-field="revisions">${revisionHtml}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Create HTML for a revision
|
|
function createRevisionHtml(trackingNumber, revision) {
|
|
const filesHtml = revision.files.map(file =>
|
|
createFileHtml(file)
|
|
).join(' ');
|
|
|
|
return `
|
|
<div class="revision-group">
|
|
<div class="revision-item">
|
|
<span class="revision-info">
|
|
<span class="revision-id">${window.app.modules.app.escapeHtml(revision.revision)}</span>
|
|
<span class="revision-status">(${window.app.modules.app.escapeHtml(revision.status)})</span>
|
|
</span>
|
|
${filesHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Create HTML for a file
|
|
function createFileHtml(file) {
|
|
const checked = window.app.selectedFiles.has(file.id) ? 'checked' : '';
|
|
const fullPath = file.path || file.folderPath + '/' + file.name;
|
|
|
|
// Handle files with path errors (Windows 260-char limit)
|
|
if (file.hasPathError) {
|
|
const errorTitle = `⚠️ Cannot access: Microsoft Windows path length limit (260 chars)\n\nPath: ${fullPath}\n\nUse 'subst' to map archive to a drive letter, or shorten folder names.`;
|
|
return `
|
|
<span class="revision-file">
|
|
<input type="checkbox"
|
|
data-file-id="${file.id}"
|
|
${checked}
|
|
onchange="toggleFileSelection('${file.id}')">
|
|
<span class="path-error-indicator" title="${window.app.modules.app.escapeHtml(errorTitle)}">⚠️</span>
|
|
<span class="file-link-disabled"
|
|
title="${window.app.modules.app.escapeHtml(errorTitle)}">
|
|
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
|
|
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
|
|
</span>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<span class="revision-file">
|
|
<input type="checkbox"
|
|
data-file-id="${file.id}"
|
|
${checked}
|
|
onchange="toggleFileSelection('${file.id}')">
|
|
<a href="#"
|
|
class="file-link"
|
|
data-file-id="${file.id}"
|
|
data-file-name="${window.app.modules.app.escapeHtml(file.name)}"
|
|
title="${window.app.modules.app.escapeHtml(fullPath)}">
|
|
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
|
|
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
|
|
</a>
|
|
</span>
|
|
`;
|
|
}
|
|
|
|
// Toggle file selection
|
|
function toggleFileSelection(fileId) {
|
|
if (window.app.selectedFiles.has(fileId)) {
|
|
window.app.selectedFiles.delete(fileId);
|
|
} else {
|
|
window.app.selectedFiles.add(fileId);
|
|
}
|
|
window.app.modules.app.updateStatusBar();
|
|
updateSelectAllVisibleCheckbox();
|
|
}
|
|
|
|
// Toggle selection of all visible files based on checkbox state
|
|
function toggleSelectAllVisible(selectAll) {
|
|
window.app.filteredFiles.forEach(file => {
|
|
if (selectAll) {
|
|
window.app.selectedFiles.add(file.id);
|
|
} else {
|
|
window.app.selectedFiles.delete(file.id);
|
|
}
|
|
});
|
|
|
|
updateFileTable();
|
|
window.app.modules.app.updateStatusBar();
|
|
updateSelectAllVisibleCheckbox();
|
|
}
|
|
|
|
// Update the select all visible checkbox to reflect current state
|
|
function updateSelectAllVisibleCheckbox() {
|
|
const checkbox = document.getElementById('selectAllVisibleCheckbox');
|
|
if (!checkbox) return;
|
|
|
|
const visibleCount = window.app.filteredFiles.length;
|
|
if (visibleCount === 0) {
|
|
checkbox.checked = false;
|
|
checkbox.indeterminate = false;
|
|
return;
|
|
}
|
|
|
|
const selectedVisibleCount = window.app.filteredFiles.filter(f =>
|
|
window.app.selectedFiles.has(f.id)
|
|
).length;
|
|
|
|
if (selectedVisibleCount === 0) {
|
|
checkbox.checked = false;
|
|
checkbox.indeterminate = false;
|
|
} else if (selectedVisibleCount === visibleCount) {
|
|
checkbox.checked = true;
|
|
checkbox.indeterminate = false;
|
|
} else {
|
|
checkbox.checked = false;
|
|
checkbox.indeterminate = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Memory-efficient blob URL management
|
|
*
|
|
* fileBlobCache: Maps file IDs to blob URLs for reuse
|
|
* processedLinks: WeakSet tracks DOM elements that already have blob URLs
|
|
* - Automatically garbage collected when DOM elements are removed
|
|
* - Prevents redundant async operations on mouseover
|
|
*/
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Check if file preview mode is enabled
|
|
*/
|
|
function isFilePreviewEnabled() {
|
|
const toggle = document.getElementById('filePreviewToggle');
|
|
return toggle && toggle.checked;
|
|
}
|
|
|
|
/**
|
|
* Show file preview in a separate popup window
|
|
* Supports PDF (iframe), DOCX (docx-preview), XLSX/XLS (SheetJS)
|
|
*/
|
|
async function showFilePreview(file) {
|
|
const ext = file.extension.toLowerCase();
|
|
|
|
try {
|
|
// For HTML preview, prefer the file's real server URL over a
|
|
// blob URL when available (zddc-server-backed archives have
|
|
// file.url set; local FileSystemAccessAPI mode doesn't).
|
|
//
|
|
// Why it matters: HTML files in an archive often link to
|
|
// sibling/parent paths via relative URLs — e.g.
|
|
// ../.archive/<tracking>.html — which zddc-server intercepts
|
|
// and resolves. From a blob: URL the relative resolution
|
|
// produces blob:.../.archive/X.html, which never reaches the
|
|
// server. Loading the iframe from the actual https://zddc.../
|
|
// URL means relative links resolve back to the server and the
|
|
// .archive interception fires as designed.
|
|
//
|
|
// Other types (pdf, images rendered via canvas / iframe etc.)
|
|
// are content-only — they don't depend on relative URLs — so
|
|
// a blob URL is fine.
|
|
const isHtml = ext === 'html' || ext === 'htm';
|
|
const url = (isHtml && file.url)
|
|
? file.url
|
|
: await getFileBlobUrl(file);
|
|
|
|
// Mirror the parent window's theme in the popup
|
|
const parentTheme = document.documentElement.getAttribute('data-theme') || '';
|
|
const themeAttr = parentTheme ? ` data-theme="${parentTheme}"` : '';
|
|
|
|
// Base HTML shell for the preview window
|
|
const previewHtml = `
|
|
<!DOCTYPE html>
|
|
<html${themeAttr}>
|
|
<head>
|
|
<title>${window.app.modules.app.escapeHtml(file.name)} - Preview</title>
|
|
<style>
|
|
:root {
|
|
--bg: #ffffff;
|
|
--bg-secondary: #f5f5f5;
|
|
--bg-hover: #e8e8e8;
|
|
--text: #212529;
|
|
--text-muted: #666666;
|
|
--border: #dddddd;
|
|
--primary: #2a5a8a;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--border: #3e3e42;
|
|
--primary: #4a90c4;
|
|
}
|
|
}
|
|
[data-theme="dark"] {
|
|
--bg: #1e1e1e;
|
|
--bg-secondary: #252526;
|
|
--bg-hover: #2d2d30;
|
|
--text: #d4d4d4;
|
|
--text-muted: #9d9d9d;
|
|
--border: #3e3e42;
|
|
--primary: #4a90c4;
|
|
}
|
|
[data-theme="light"] {
|
|
--bg: #ffffff;
|
|
--bg-secondary: #f5f5f5;
|
|
--bg-hover: #e8e8e8;
|
|
--text: #212529;
|
|
--text-muted: #666666;
|
|
--border: #dddddd;
|
|
--primary: #2a5a8a;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
.toolbar h1 {
|
|
flex: 1;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--text);
|
|
}
|
|
.btn {
|
|
padding: 0.4rem 0.8rem;
|
|
font-size: 0.85rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
.btn:hover { background: var(--bg-hover); }
|
|
iframe {
|
|
flex: 1;
|
|
width: 100%;
|
|
border: none;
|
|
}
|
|
#previewContent {
|
|
flex: 1;
|
|
overflow: auto;
|
|
background: var(--bg);
|
|
}
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--text-muted);
|
|
font-size: 1.1rem;
|
|
}
|
|
/* 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 {
|
|
border: 1px solid var(--border);
|
|
padding: 0.35rem 0.5rem;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
color: var(--text);
|
|
}
|
|
.xlsx-table th { background: var(--bg-secondary); font-weight: 600; position: sticky; top: 0; }
|
|
.xlsx-table tr:nth-child(even) { background: var(--bg-secondary); }
|
|
.xlsx-table tr:hover { background: var(--bg-hover); }
|
|
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
|
|
.sheet-tab {
|
|
padding: 0.4rem 1rem;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
border-bottom: none;
|
|
font-size: 0.85rem;
|
|
background: transparent;
|
|
color: var(--text);
|
|
}
|
|
.sheet-tab:hover { background: var(--bg-hover); }
|
|
.sheet-tab.active {
|
|
background: var(--bg);
|
|
border-color: var(--border);
|
|
border-bottom-color: var(--bg);
|
|
margin-bottom: -1px;
|
|
font-weight: 500;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="toolbar">
|
|
<h1>${window.app.modules.app.escapeHtml(file.name)}</h1>
|
|
<button class="btn" onclick="downloadFile()">Download</button>
|
|
</div>
|
|
${(ext === 'pdf' || ext === 'html' || ext === 'htm')
|
|
? '<iframe src="' + url + '"' + (ext === 'pdf' ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"') + '></iframe>'
|
|
: '<div id="previewContent"><div class="loading">Loading preview...</div></div>'}
|
|
<script>
|
|
var blobUrl = "${url}";
|
|
var fileName = "${window.app.modules.app.escapeHtml(file.name).replace(/"/g, '\\"')}";
|
|
|
|
function downloadFile() {
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = fileName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
<\/script>
|
|
</body>
|
|
</html>`;
|
|
|
|
// Open or reuse the preview window
|
|
if (filePreviewWindow && !filePreviewWindow.closed) {
|
|
filePreviewWindow.document.open();
|
|
filePreviewWindow.document.write(previewHtml);
|
|
filePreviewWindow.document.close();
|
|
filePreviewWindow.focus();
|
|
} else {
|
|
const width = Math.round(screen.width * 0.6);
|
|
const height = Math.round(screen.height * 0.8);
|
|
const left = Math.round((screen.width - width) / 2);
|
|
const top = Math.round((screen.height - height) / 2);
|
|
|
|
filePreviewWindow = window.open('', 'filePreview',
|
|
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
|
|
|
|
if (!filePreviewWindow) {
|
|
window.open(url, '_blank');
|
|
return;
|
|
}
|
|
|
|
filePreviewWindow.document.write(previewHtml);
|
|
filePreviewWindow.document.close();
|
|
filePreviewWindow.focus();
|
|
}
|
|
|
|
// For non-PDF / non-HTML types, render content into the
|
|
// preview window. PDF and HTML are already wired up via the
|
|
// <iframe> in the popup's body (see buildPreviewHtml above);
|
|
// for HTML this means the page is RENDERED, not shown as
|
|
// literal source text. The previewBlobUrl carries the right
|
|
// MIME type ('text/html') so the iframe loads natively.
|
|
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
|
// iframe already wired in popup HTML; nothing to do
|
|
} else if (ext === 'docx') {
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a DOCX file in the preview window using docx-preview library
|
|
*/
|
|
async function renderDocxInWindow(file) {
|
|
const container = filePreviewWindow.document.getElementById('previewContent');
|
|
if (!container) return;
|
|
|
|
try {
|
|
// jszip + docx-preview are vendored (concatenated by build.sh
|
|
// ahead of every tool module), so window.JSZip and window.docx
|
|
// are already defined here.
|
|
const arrayBuffer = await (file.handle
|
|
? file.handle.getFile().then(f => f.arrayBuffer())
|
|
: fetch(file.url).then(r => r.arrayBuffer()));
|
|
|
|
container.innerHTML = '';
|
|
await window.docx.renderAsync(arrayBuffer, container);
|
|
} catch (err) {
|
|
console.error('Error rendering DOCX:', err);
|
|
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render an XLSX/XLS file in the preview window using SheetJS
|
|
*/
|
|
async function renderXlsxInWindow(file) {
|
|
const container = filePreviewWindow.document.getElementById('previewContent');
|
|
if (!container) return;
|
|
|
|
try {
|
|
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
|
|
|
|
const arrayBuffer = await (file.handle
|
|
? file.handle.getFile().then(f => f.arrayBuffer())
|
|
: fetch(file.url).then(r => r.arrayBuffer()));
|
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Build sheet tabs if multiple sheets
|
|
if (workbook.SheetNames.length > 1) {
|
|
const tabs = filePreviewWindow.document.createElement('div');
|
|
tabs.className = 'sheet-tabs';
|
|
workbook.SheetNames.forEach((name, i) => {
|
|
const tab = filePreviewWindow.document.createElement('button');
|
|
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
|
tab.textContent = name;
|
|
tab.onclick = () => {
|
|
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
renderSheet(workbook, name, tableContainer);
|
|
};
|
|
tabs.appendChild(tab);
|
|
});
|
|
container.appendChild(tabs);
|
|
}
|
|
|
|
const tableContainer = filePreviewWindow.document.createElement('div');
|
|
tableContainer.style.flex = '1';
|
|
tableContainer.style.overflow = 'auto';
|
|
container.appendChild(tableContainer);
|
|
|
|
renderSheet(workbook, workbook.SheetNames[0], tableContainer);
|
|
} catch (err) {
|
|
console.error('Error rendering XLSX:', err);
|
|
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render a single sheet as an HTML table
|
|
*/
|
|
function renderSheet(workbook, sheetName, container) {
|
|
const sheet = workbook.Sheets[sheetName];
|
|
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
|
container.innerHTML = html;
|
|
// Apply styling to the generated table
|
|
const table = container.querySelector('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
|
|
* Left-click: Download file (or preview if PDF and preview mode enabled)
|
|
* Right-click: Allow "Open in new tab" with blob URL
|
|
*/
|
|
function setupFileLinkHandlers() {
|
|
if (fileLinkHandlersAttached) return;
|
|
|
|
const table = document.getElementById('filesTable');
|
|
if (!table) {
|
|
console.warn('Files table not found');
|
|
return;
|
|
}
|
|
|
|
// Handle clicks - download file or show preview
|
|
table.addEventListener('click', async (e) => {
|
|
const link = e.target.closest('.file-link');
|
|
if (!link) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const fileId = link.getAttribute('data-file-id');
|
|
const fileName = link.getAttribute('data-file-name');
|
|
|
|
if (!fileId || !fileName) {
|
|
console.error('Invalid link data');
|
|
return;
|
|
}
|
|
|
|
const file = window.app.files.find(f => f.id === fileId);
|
|
|
|
if (!file) {
|
|
console.error(`File not found: ${fileId}`);
|
|
alert('File not found. Please refresh and try again.');
|
|
return;
|
|
}
|
|
|
|
// Check if file preview is enabled and file type is previewable
|
|
if (isFilePreviewEnabled() && PREVIEW_EXTENSIONS.includes(file.extension.toLowerCase())) {
|
|
await showFilePreview(file);
|
|
setCurrentPreviewFile(file.id);
|
|
watchPreviewWindow();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!file.handle && file.url) {
|
|
// HTTP mode: open the file URL directly in a new tab
|
|
window.open(file.url, '_blank');
|
|
} else {
|
|
// Local mode: create blob URL and trigger download
|
|
const url = await getFileBlobUrl(file);
|
|
const downloadLink = document.createElement('a');
|
|
downloadLink.href = url;
|
|
downloadLink.download = fileName;
|
|
document.body.appendChild(downloadLink);
|
|
downloadLink.click();
|
|
document.body.removeChild(downloadLink);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error opening file:', err);
|
|
alert(`Error opening file: ${err.message}`);
|
|
}
|
|
}, true); // Use capture phase
|
|
|
|
// Handle mouseover - pre-load URL for fast right-click / middle-click
|
|
table.addEventListener('mouseover', async (e) => {
|
|
const link = e.target.closest('.file-link');
|
|
if (!link) return;
|
|
|
|
// Skip if already processed (prevents redundant operations)
|
|
if (processedLinks.has(link)) return;
|
|
|
|
const fileId = link.getAttribute('data-file-id');
|
|
if (!fileId) return;
|
|
|
|
const file = window.app.files.find(f => f.id === fileId);
|
|
if (!file) {
|
|
console.warn(`File not found for pre-load: ${fileId}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!file.handle && file.url) {
|
|
// HTTP mode: set href directly — no async needed
|
|
link.href = file.url;
|
|
link.target = '_blank';
|
|
processedLinks.add(link);
|
|
} else {
|
|
// Local mode: pre-load blob URL asynchronously
|
|
const url = await getFileBlobUrl(file);
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
processedLinks.add(link);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error pre-loading file link:', err);
|
|
// Don't mark as processed so it can retry
|
|
}
|
|
}, true); // Use capture phase
|
|
|
|
// Handle context menu - ensure blob URL is set (fallback if mouseover didn't fire)
|
|
table.addEventListener('contextmenu', async (e) => {
|
|
const link = e.target.closest('.file-link');
|
|
if (!link) return;
|
|
|
|
// If already processed, blob URL is set - allow context menu to work
|
|
if (processedLinks.has(link)) return;
|
|
|
|
const fileId = link.getAttribute('data-file-id');
|
|
if (!fileId) return;
|
|
|
|
const file = window.app.files.find(f => f.id === fileId);
|
|
if (!file) {
|
|
console.warn(`File not found for context menu: ${fileId}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get blob URL and set it as href synchronously as possible
|
|
const url = await getFileBlobUrl(file);
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
|
|
// Mark as processed
|
|
processedLinks.add(link);
|
|
} catch (err) {
|
|
console.error('Error preparing file for context menu:', err);
|
|
// Don't mark as processed so it can retry
|
|
}
|
|
}, true); // Use capture phase
|
|
|
|
fileLinkHandlersAttached = true;
|
|
}
|
|
|
|
// Get MIME type from extension
|
|
function getMimeType(extension) {
|
|
const ext = extension.toLowerCase();
|
|
const mimeTypes = {
|
|
// Documents
|
|
'pdf': 'application/pdf',
|
|
'doc': 'application/msword',
|
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'xls': 'application/vnd.ms-excel',
|
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'ppt': 'application/vnd.ms-powerpoint',
|
|
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
|
|
// Text
|
|
'txt': 'text/plain',
|
|
'csv': 'text/csv',
|
|
'html': 'text/html',
|
|
'htm': 'text/html',
|
|
'xml': 'text/xml',
|
|
'json': 'application/json',
|
|
|
|
// Code
|
|
'js': 'text/javascript',
|
|
'css': 'text/css',
|
|
'py': 'text/plain',
|
|
'java': 'text/plain',
|
|
'cpp': 'text/plain',
|
|
'c': 'text/plain',
|
|
'h': 'text/plain',
|
|
|
|
// Images
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'png': 'image/png',
|
|
'gif': 'image/gif',
|
|
'bmp': 'image/bmp',
|
|
'svg': 'image/svg+xml',
|
|
'webp': 'image/webp',
|
|
'ico': 'image/x-icon',
|
|
|
|
// Archives
|
|
'zip': 'application/zip',
|
|
'rar': 'application/x-rar-compressed',
|
|
'7z': 'application/x-7z-compressed',
|
|
'tar': 'application/x-tar',
|
|
'gz': 'application/gzip',
|
|
|
|
// CAD
|
|
'dwg': 'application/acad',
|
|
'dxf': 'application/dxf',
|
|
'dwf': 'model/vnd.dwf',
|
|
'dgn': 'application/x-dgn',
|
|
|
|
// Other
|
|
'mp4': 'video/mp4',
|
|
'mp3': 'audio/mpeg',
|
|
'wav': 'audio/wav',
|
|
'avi': 'video/x-msvideo',
|
|
'mov': 'video/quicktime',
|
|
'md': 'text/markdown',
|
|
'log': 'text/plain',
|
|
'ini': 'text/plain',
|
|
'cfg': 'text/plain',
|
|
'conf': 'text/plain',
|
|
'yaml': 'text/yaml',
|
|
'yml': 'text/yaml'
|
|
};
|
|
|
|
return mimeTypes[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
// Sort table
|
|
function sortTable(field) {
|
|
if (window.app.sortField === field) {
|
|
// Toggle direction
|
|
window.app.sortDirection = window.app.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
// New field, default to ascending
|
|
window.app.sortField = field;
|
|
window.app.sortDirection = 'asc';
|
|
}
|
|
|
|
updateSortIndicators();
|
|
window.app.modules.filtering.applyFilters(); // Re-apply filters which will trigger table update
|
|
window.app.modules.urlState.push();
|
|
}
|
|
|
|
// Update sort indicators
|
|
function updateSortIndicators() {
|
|
// Remove all sort indicators
|
|
document.querySelectorAll('th[data-sort]').forEach(th => {
|
|
th.removeAttribute('data-sort');
|
|
});
|
|
|
|
// Add current sort indicator
|
|
const th = document.querySelector(`th[data-field="${window.app.sortField}"]`);
|
|
if (th) {
|
|
th.setAttribute('data-sort', window.app.sortDirection);
|
|
}
|
|
}
|
|
|
|
// Column resize functionality
|
|
function initializeColumnResize() {
|
|
const handles = document.querySelectorAll('.resize-handle');
|
|
handles.forEach(handle => {
|
|
handle.addEventListener('mousedown', startResize);
|
|
});
|
|
|
|
document.addEventListener('mousemove', doResize);
|
|
document.addEventListener('mouseup', stopResize);
|
|
}
|
|
|
|
function startResize(e) {
|
|
const th = e.target.parentElement;
|
|
resizing = {
|
|
th: th,
|
|
startX: e.clientX,
|
|
startWidth: th.offsetWidth
|
|
};
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
}
|
|
|
|
function doResize(e) {
|
|
if (! resizing) return;
|
|
|
|
const diff = e.clientX - resizing.startX;
|
|
const newWidth = Math.max(100, resizing.startWidth + diff);
|
|
resizing.th.style.width = newWidth + 'px';
|
|
|
|
// Update corresponding column
|
|
const field = resizing.th.getAttribute('data-field');
|
|
const cells = document.querySelectorAll(`td[data-field="${field}"]`);
|
|
cells.forEach(cell => {
|
|
cell.style.width = newWidth + 'px';
|
|
});
|
|
}
|
|
|
|
function stopResize() {
|
|
if (resizing) {
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
resizing = null;
|
|
}
|
|
}
|
|
|
|
// Toggle all files (Ctrl+A shortcut handler)
|
|
// wrapper around toggleSelectAllVisible for keyboard shortcuts
|
|
function toggleSelectAll() {
|
|
toggleSelectAllVisible(true);
|
|
}
|
|
|
|
/**
|
|
* Clean up resources when page unloads
|
|
*/
|
|
window.addEventListener('beforeunload', () => {
|
|
cleanupAllBlobUrls();
|
|
});
|
|
|
|
window.app.modules.table = {
|
|
updateFileTable,
|
|
toggleFileSelection,
|
|
toggleSelectAllVisible,
|
|
updateSelectAllVisibleCheckbox,
|
|
setupFileLinkHandlers,
|
|
updateSortIndicators,
|
|
sortTable,
|
|
initializeColumnResize
|
|
};
|
|
})();
|