ZDDC/archive/js/table.js
ZDDC 915ab8a87a fix(preview): make HTML iframe links navigate (zddc-server-backed archive)
User report: opening an .html file with a '../.archive/' hyperlink in
a new tab works (zddc-server intercepts and serves the right file),
but clicking the same link inside the file previewer does nothing.

Two combined causes:

  1. The previewer's iframe was loaded from a blob: URL (built from
     the file's bytes). Relative URLs in the iframe resolve relative
     to the blob URL — '../.archive/X.html' becomes 'blob:.../.archive/
     X.html', which is gibberish. The browser never sends a request to
     the server, so the .archive interception never fires.

  2. sandbox="" disables every iframe capability including popups,
     so even <a target=_blank> is silently swallowed.

Fix per tool:

  - archive (table.js): for HTML preview, use file.url (the real
    server URL) directly when available; fall back to blob only for
    File-System-Access-API mode where there's no server to intercept
    anyway. Now relative links in archived HTMLs resolve against the
    actual server origin and the .archive interception fires as
    designed. Sandbox loosens to allow-same-origin + allow-popups +
    allow-popups-to-escape-sandbox so resources within the iframe
    load and link clicks (default target / target=_blank / middle-
    click) work normally. allow-scripts is intentionally NOT set —
    archived HTML still cannot run JS in the popup's origin.

  - transmittal (files-preview.js) + classifier (preview.js): same
    sandbox loosening for consistency. These tools' files are
    typically local (FileSystemAccessAPI), so the file.url branch
    doesn't apply — relative URLs that depend on a server still
    won't resolve in local mode (intrinsic limitation, no server).

Tested behavior preserved:
  - PDFs: unchanged (no sandbox, browser's PDF viewer handles).
  - Images / docx / xlsx / tiff / zip / text: unchanged.
  - HTML in zddc-server-backed archive: relative '../.archive/' links
    now navigate the iframe to the correct target file.
2026-05-03 18:54:55 -05:00

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 {
await loadLibrary('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
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
};
})();