ZDDC/classifier/js/preview.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

528 lines
19 KiB
JavaScript

/**
* Preview Module
* Opens file preview in a separate popup window
*/
(function() {
'use strict';
let currentBlobUrl = null;
let currentFile = null;
let currentRowIndex = null;
let previewWindow = null;
// Use shared extension lists from window.zddc.preview where possible
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
const PDF_EXTENSIONS = ['pdf'];
const ZIP_EXTENSIONS = ['zip'];
// Lazily load a script from CDN — delegates to shared cache.
const loadLibrary = zddc.preview.loadLibrary;
/**
* Initialize preview module
*/
function init() {
// Listen for row focused events from selection module
document.addEventListener('rowfocused', handleRowFocused);
// Set up toggle button to open/close preview window
const toggleBtn = document.getElementById('togglePreviewBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (previewWindow && !previewWindow.closed) {
// Close preview window
previewWindow.close();
previewWindow = null;
toggleBtn.classList.remove('preview-active');
} else if (currentFile) {
openPreviewWindow(currentFile);
toggleBtn.classList.add('preview-active');
}
});
}
}
/**
* Handle row focused event
*/
function handleRowFocused(e) {
const { rowIndex, file } = e.detail;
currentRowIndex = rowIndex;
if (file && file !== currentFile) {
currentFile = file;
// Update preview window if open
if (previewWindow && !previewWindow.closed) {
openPreviewWindow(file);
}
}
}
/**
* Open preview in a separate popup window
*/
async function openPreviewWindow(file) {
if (!file) return;
currentFile = file;
try {
const blob = await getFileBlob(file);
// Clean up previous blob URL
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(blob);
const fileName = zddc.joinExtension(file.originalFilename, file.extension);
// Build preview HTML with toolbar
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(fileName)} - Preview</title>
<style>
* { 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;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.toolbar h1 {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.btn:hover { background: #e8e8e8; }
iframe, img {
flex: 1;
width: 100%;
border: none;
}
img {
object-fit: contain;
background: #f0f0f0;
}
pre {
flex: 1;
padding: 1rem;
overflow: auto;
background: #fafafa;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.unsupported {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
.unsupported .icon { font-size: 3rem; margin-bottom: 1rem; }
#previewContent {
flex: 1;
overflow: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.1rem;
}
.docx-wrapper { padding: 1rem; }
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
.xlsx-table th, .xlsx-table td {
border: 1px solid #ddd;
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }
.xlsx-table tr:nth-child(even) { background: #fafafa; }
.xlsx-table tr:hover { background: #f0f7ff; }
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }
.sheet-tab {
padding: 0.4rem 1rem;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
font-size: 0.85rem;
background: transparent;
}
.sheet-tab:hover { background: #e8e8e8; }
.sheet-tab.active {
background: white;
border-color: #ddd;
border-bottom-color: white;
margin-bottom: -1px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="toolbar">
<h1>${escapeHtml(fileName)}</h1>
<button class="btn" onclick="downloadFile()">Download</button>
</div>
${await getPreviewContent(file, currentBlobUrl)}
<script>
var blobUrl = "${currentBlobUrl}";
var fileName = "${escapeHtml(fileName).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>`;
// Reuse existing window if open, otherwise create new one
if (previewWindow && !previewWindow.closed) {
previewWindow.document.open();
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
} else {
// Calculate window size
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);
previewWindow = window.open('', 'classifierPreview',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
if (!previewWindow) {
// Popup blocked - fall back to new tab
window.open(currentBlobUrl, '_blank');
return;
}
// Poll for window close — beforeunload is unreliable for popup close buttons
const closePoll = setInterval(() => {
if (previewWindow && previewWindow.closed) {
clearInterval(closePoll);
previewWindow = null;
const btn = document.getElementById('togglePreviewBtn');
if (btn) btn.classList.remove('preview-active');
}
}, 500);
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
}
// For types that need decoding, render content after window is ready
const ext = (file.extension || '').toLowerCase();
if (ext === 'docx') {
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (TIFF_EXTENSIONS.includes(ext)) {
await renderTiffInWindow(file);
} else if (ZIP_EXTENSIONS.includes(ext)) {
await renderZipInWindow(file);
}
} catch (err) {
console.error('Error opening preview:', err);
alert(`Error opening preview: ${err.message}`);
}
}
/**
* Get preview content HTML based on file type
*/
async function getPreviewContent(file, blobUrl) {
const ext = file.extension.toLowerCase();
const previewType = getPreviewType(ext);
switch (previewType) {
case 'pdf':
return `<iframe src="${blobUrl}#view=FitV"></iframe>`;
case 'html':
// Render the HTML natively (not as literal text). sandbox=""
// disables scripts / forms / top-level nav / plugins so an
// archived HTML file can't run code in the popup's origin.
return `<iframe src="${blobUrl}" sandbox=""></iframe>`;
case 'image':
return `<img src="${blobUrl}" alt="${escapeHtml(file.originalFilename)}" />`;
case 'text':
const text = await getFileText(file);
const maxLength = 100000;
const displayText = text.length > maxLength
? text.substring(0, maxLength) + '\n\n... (truncated)'
: text;
return `<pre>${escapeHtml(displayText)}</pre>`;
case 'docx':
case 'xlsx':
case 'tiff':
case 'zip':
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
default:
return `
<div class="unsupported">
<div class="icon">📄</div>
<p>Preview not available for ${ext} files</p>
<p style="margin-top: 0.5rem;">Click Download to view in external application</p>
</div>
`;
}
}
/**
* Get preview type from extension
*/
function getPreviewType(ext) {
// HTML is technically in TEXT_EXTENSIONS (used for editor
// syntax-highlighting elsewhere) but for previews we want to
// RENDER it, not show source. Check before the text branch.
if (ext === 'html' || ext === 'htm') return 'html';
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
if (ext === 'docx') return 'docx';
if (ext === 'xlsx' || ext === 'xls') return 'xlsx';
if (ZIP_EXTENSIONS.includes(ext)) return 'zip';
return 'none';
}
function getMimeType(ext) {
return window.app.modules.utils.getMimeType(ext);
}
/**
* Get file content as blob (handles both real and virtual files)
*/
async function getFileBlob(file) {
if (file.isVirtual) {
// Get from ZIP cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
// Get as arraybuffer and create blob with correct MIME type
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
return new Blob([arrayBuffer], { type: mimeType });
} else {
// Get from file handle
if (!file.handle) {
throw new Error('File handle not available');
}
return await file.handle.getFile();
}
}
/**
* Get file content as text (handles both real and virtual files)
*/
async function getFileText(file) {
if (file.isVirtual) {
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
return await zipEntry.async('string');
} else {
if (!file.handle) {
throw new Error('File handle not available');
}
const fileObj = await file.handle.getFile();
return await fileObj.text();
}
}
/**
* Render a DOCX file in the preview window using docx-preview library
*/
async function renderDocxInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
await loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
const blob = await getFileBlob(file);
const arrayBuffer = await blob.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 = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
container.innerHTML = '';
if (workbook.SheetNames.length > 1) {
const tabs = previewWindow.document.createElement('div');
tabs.className = 'sheet-tabs';
workbook.SheetNames.forEach((name, i) => {
const tab = previewWindow.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');
renderSheetInWindow(workbook, name, tableContainer);
};
tabs.appendChild(tab);
});
container.appendChild(tabs);
}
const tableContainer = previewWindow.document.createElement('div');
tableContainer.style.flex = '1';
tableContainer.style.overflow = 'auto';
container.appendChild(tableContainer);
renderSheetInWindow(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 in the preview window
*/
function renderSheetInWindow(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
const table = container.querySelector('table');
if (table) table.className = 'xlsx-table';
}
/**
* Render a TIFF file in the preview window using shared zddc.preview.renderTiff
*/
async function renderTiffInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing
*/
async function renderZipInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Download current file
*/
async function downloadFile() {
if (!currentFile) return;
try {
const blob = await getFileBlob(currentFile);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zddc.joinExtension(currentFile.originalFilename, currentFile.extension);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
console.error('Error downloading file:', err);
alert('Error downloading file: ' + err.message);
}
}
// Export module
window.app.modules.preview = {
init
};
})();