// 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, m.zddcform].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; } // .zddc form view: a schema-driven form (option fields editable, // structure read-only) is the PRIMARY editor for .zddc files. It hands // off to the raw YAML editor on demand. Other YAML files skip it. var zddcForm = window.app.modules.zddcform; if (zddcForm && zddcForm.handles(node)) { try { await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { renderError(container, '.zddc form render failed: ' + (e.message || e)); } return; } // YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a // CodeMirror 5 editor with js-yaml linting; .zddc files also // get a schema-aware lint pass. 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, 'YAML render failed: ' + (e.message || e)); } return; } // PDF / HTML → iframe. if (ext === 'pdf' || ext === 'html' || ext === 'htm') { try { var info = await getBlobUrl(node); if (seq !== renderSeq) return; var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"'; 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 = '' + escapeHtml(node.name)
                    + ''; } 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 = '' + safeName + ''; } else { contentHtml = '
Loading preview…
'; } return '' + '' + safeName + ' — preview' + '

' + safeName + '

' + '
' + contentHtml + '