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.
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 {
|
|
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
|
|
};
|
|
})();
|