// preview.js — file-preview rendering for the browse tool's right pane. // // Default flow: showFilePreview(node) renders into the inline preview // pane (#previewBody). Popup flow: showFilePreview(node, {popup:true}) // opens a separate window — kept for users who want previews on a // second monitor. // // Rendering uses shared/preview-lib.js for content types it handles // (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes; // text into a
; markdown into the dedicated markdown plugin
// (preview-markdown.js); unknown extensions show a download button.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
console.error('[browse] zddc.preview not loaded — preview disabled.');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var MIME = {
'pdf': 'application/pdf',
'html': 'text/html', 'htm': 'text/html',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
'tif': 'image/tiff', 'tiff': 'image/tiff',
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel'
};
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
var fmtSize = util.fmtSize;
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so
// it falls through the same getFile() path as any local file.
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.arrayBuffer();
}
if (node.handle) {
var f = await node.handle.getFile();
return await f.arrayBuffer();
}
throw new Error('no source for file');
}
// Like getArrayBuffer, but also returns the server version token
// ({etag, lastModified}) captured from the content GET. The editors use
// it to send an If-Match precondition on save so a concurrent edit is
// rejected (412) instead of silently clobbered. FS-Access mode has no
// server version — etag/lastModified are null and the precondition is a
// clean no-op (a single locally-picked file has no concurrency).
async function getContentWithVersion(node) {
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var buf = await resp.arrayBuffer();
return {
buf: buf,
etag: resp.headers.get('ETag') || null,
lastModified: resp.headers.get('Last-Modified') || null
};
}
if (node.handle) {
var f = await node.handle.getFile();
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
}
throw new Error('no source for file');
}
async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/"
// URLs) load straight from the server — preserves Content-Type
// and lets relative links inside HTML resolve back to the server.
if (state.source === 'server' && node.url) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
var blob = new Blob([buf], { type: getMime(node.ext) });
return { url: URL.createObjectURL(blob), fromServer: false };
}
// ── Editor lifecycle helpers ─────────────────────────────────────────────
// The markdown and YAML plugins each mount a long-lived editor into the
// preview pane. Switching files (or clearing the pane) must dispose the
// live editor first — otherwise the Toast UI instance, its DOM, and its
// document-level resizer listeners leak when we overwrite the container.
function editorModules() {
var m = window.app.modules;
return [m.markdown, m.yamledit].filter(Boolean);
}
function disposeEditors() {
editorModules().forEach(function (mod) {
if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } }
});
}
// The editor module (if any) holding unsaved edits, else null.
function dirtyEditor() {
var mods = editorModules();
for (var i = 0; i < mods.length; i++) {
if (mods[i].isDirty && mods[i].isDirty()) return mods[i];
}
return null;
}
function samePreviewNode(a, b) {
if (!a || !b) return false;
if (a === b) return true;
if (a.url && b.url) return a.url === b.url;
return a.name === b.name && a.parentId === b.parentId;
}
// Tear down any live editor and blank the pane. Used by callers that
// reset the preview directly (rescope, popstate) so they don't leak the
// editor or strand its dirty state.
function clearPreview() {
disposeEditors();
var container = document.getElementById('previewBody');
if (container) container.innerHTML = '';
}
// Warn before a full page unload (reload / close / external nav) drops
// unsaved editor changes. SPA-internal switches are guarded in
// renderInline; this catches the browser-level exit.
window.addEventListener('beforeunload', function (e) {
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
});
// ── Inline rendering ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race
// (a newer selection started while its bytes were in flight) bails
// before writing stale content into the shared pane.
var renderSeq = 0;
function renderEmpty(container, msg) {
container.innerHTML = '' + escapeHtml(msg) + '';
}
function renderError(container, msg) {
container.innerHTML = ''
+ escapeHtml(msg) + '';
}
async function renderInline(node, opts) {
opts = opts || {};
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
// Guard unsaved editor edits before we tear the editor down.
var dm = dirtyEditor();
if (dm) {
var cur = dm.currentNode ? dm.currentNode() : null;
if (samePreviewNode(cur, node)) {
// Re-selecting the file we're already editing — don't reload
// and clobber the in-progress edits.
return;
}
if (opts.auto) {
// Keyboard/auto preview (cursor walking the tree): leave the
// dirty editor in place rather than prompting on every key.
return;
}
var label = cur ? cur.name : 'this file';
if (!window.confirm('Discard unsaved changes to ' + label + '?')) return;
}
// Safe to replace the pane now: dispose any live editor so its
// instance + document-level listeners don't leak.
disposeEditors();
var seq = ++renderSeq;
if (titleEl) titleEl.textContent = node.name;
if (metaEl) {
var meta = [];
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
if (node.ext) meta.push(node.ext.toUpperCase());
metaEl.textContent = meta.join(' · ');
}
if (popoutBtn) popoutBtn.classList.remove('hidden');
var ext = (node.ext || '').toLowerCase();
// Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
return;
}
// CodeMirror editor: the general editor for editable text files that
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
// Guided dialogs (Manage access, …) are the front door for the common
// .zddc tasks; this is the full/raw edit surface.
var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) {
try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Editor failed: ' + (e.message || e));
}
return;
}
// PDF → iframe (HTML now routes to the editor above).
if (ext === 'pdf') {
try {
var info = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Plain images (jpg/png/gif/webp/svg) →
. TIFF goes through preview-lib.
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '
';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
}
return;
}
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
}
return;
}
// Office docs (.docx via docx-preview, .xlsx/.xls via SheetJS) →
// shared/preview-lib renderers. .doc/.ppt etc. fall through to the
// download fallback below.
if (preview && preview.isOffice(ext)) {
try {
var officeBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
if (ext === 'docx') {
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
} else {
await preview.renderXlsx(document, container, officeBuf, { fileName: node.name });
}
} catch (e) {
renderError(container, 'Failed to render ' + ext.toUpperCase() + ': ' + (e.message || e));
}
return;
}
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars)';
}
container.innerHTML = '';
var pre = document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML =
''
+ 'No inline preview for .' + escapeHtml(ext) + '. '
+ '
'
+ 'Download ' + escapeHtml(node.name) + ''
+ '';
} catch (e) {
renderError(container, 'No source for ' + node.name);
}
}
// ── Popup window (kept for "Pop out" button) ────────────────────────────
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
var contentHtml;
if (ext === 'pdf') {
contentHtml = '';
} else if (ext === 'html' || ext === 'htm') {
contentHtml = '';
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
contentHtml = '
';
} else {
contentHtml = 'Loading preview…';
}
return ''
+ '' + safeName + ' — preview '
+ ''
+ contentHtml
+ '