ZDDC/archive/js/table.js
ZDDC 3494053421 fix(preview): render HTML files instead of showing literal source
HTML files in the file previewer (archive, transmittal, classifier
popups) were dispatched to the text renderer because 'html'/'htm'
are in shared/preview-lib.js's TEXT_EXTENSIONS (which is shared with
the syntax-highlighting code path). Result: opening an .html file in
preview showed its source as a <pre> block, not the rendered page.

Fix in each tool's popup builder + dispatcher:

  - Add 'html' / 'htm' to the iframe branch (alongside pdf), so the
    popup ships an <iframe src=blob:...> instead of an empty
    #previewContent div. The blob's MIME type from getMimeType()
    is already 'text/html', so the browser renders natively.
  - Skip the text-render dispatch for html/htm (the iframe is enough).
  - Add  to the HTML iframe so an arbitrary archived
    HTML file cannot run scripts, navigate top, submit forms, or
    open popups in the popup-window's origin. PDFs don't need this
    since the browser's PDF viewer is sandboxed natively.

classifier/js/preview.js uses a getPreviewType() switch instead of
chained ifs; adds 'html' as its own preview type (checked BEFORE
'text' since html is in TEXT_EXTENSIONS).

mdedit already handled HTML specially (file-tree.js has an isHtml
check); no change there.

TIFF was already rendered via the shared zddc.preview.renderTiff
canvas viewer in all four tools — no change needed for that path.
If TIFF preview appears broken on the live prod server, that's the
v0.0.9-alpha-baked-in image; the fresh stable redeploy fixes it.
2026-05-03 16:48:19 -05:00

1058 lines
39 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 {
const 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=""') + '></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
};
})();