// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+ // 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。 // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // _categorize 也供媒体段判图/视频。反向依赖 downloadFile(main 媒体段)、logout(auth)。 import { state } from "./state.js"; import { $ } from "./dom.js"; import { humanSize, escapeHtml } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; import { logout } from "./auth.js"; import { downloadFile } from "./main.js"; // ───── file preview ───── const PREVIEW_TEXT_MAX = 2 * 1024 * 1024; const PREVIEW_BIN_MAX = 50 * 1024 * 1024; const _scriptCache = new Map(); function loadScript(src) { if (_scriptCache.has(src)) return _scriptCache.get(src); const p = new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = src; s.onload = () => resolve(); s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); }; document.head.appendChild(s); }); _scriptCache.set(src, p); return p; } const _previewBlobUrls = new Set(); function _trackBlobUrl(blob, mime) { const b = mime ? new Blob([blob], { type: mime }) : blob; const url = URL.createObjectURL(b); _previewBlobUrls.add(url); return url; } function _flushBlobUrls() { for (const u of _previewBlobUrls) URL.revokeObjectURL(u); _previewBlobUrls.clear(); } const _EXT_GROUPS = { image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]), video: new Set(["mp4","webm","mov","mkv","m4v"]), pdf: new Set(["pdf"]), md: new Set(["md","markdown"]), text: new Set([ "txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv", "py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp", "html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env", ]), docx: new Set(["docx"]), xlsx: new Set(["xlsx","xls"]), }; export function _categorize(rel) { const m = /\.([a-z0-9]+)$/i.exec(rel); const ext = m ? m[1].toLowerCase() : ""; for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat; return "fallback"; } let _fpCurrentRel = null; export async function openFilePreview(rel) { _fpCurrentRel = rel; const name = rel.split("/").pop() || rel; $("fp-name").textContent = name; $("fp-meta").textContent = ""; const body = $("fp-body"); body.className = "body center"; body.innerHTML = `
加载中…
`; // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0) const cf = $("chat-form"); const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0; $("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px"); $("file-preview-modal").classList.add("show"); const cat = _categorize(rel); try { const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }); if (!r.ok) throw new Error("HTTP " + r.status); const blob = await r.blob(); $("fp-meta").textContent = humanSize(blob.size); if (cat === "text" || cat === "md") { if (blob.size > PREVIEW_TEXT_MAX) { _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); if (cat === "md") _showMarkdown(text); else _showText(text); return; } if (blob.size > PREVIEW_BIN_MAX) { _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } if (cat === "image") _showImage(blob); else if (cat === "video") _showVideo(blob); else if (cat === "pdf") _showPdf(blob); else if (cat === "docx") await _showDocx(blob); else if (cat === "xlsx") await _showXlsx(blob); else _showFallback("暂不支持在线预览此格式,请下载查看"); } catch (e) { if (e.status === 401) { closeFilePreview(); logout(); return; } _showFallback("加载失败:" + e.message); } } function _showImage(blob) { const url = _trackBlobUrl(blob); const body = $("fp-body"); body.className = "body center"; body.innerHTML = ""; const img = document.createElement("img"); img.className = "preview-img"; img.src = url; body.appendChild(img); } function _showVideo(blob) { const url = _trackBlobUrl(blob); const body = $("fp-body"); body.className = "body center"; body.innerHTML = ""; const v = document.createElement("video"); v.className = "preview-video"; v.src = url; v.controls = true; v.autoplay = true; body.appendChild(v); } function _showPdf(blob) { const url = _trackBlobUrl(blob, "application/pdf"); const body = $("fp-body"); body.className = "body"; body.innerHTML = ``; } function _showText(text) { const body = $("fp-body"); body.className = "body"; body.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "preview-text"; pre.textContent = text; body.appendChild(pre); } function _showMarkdown(text) { const body = $("fp-body"); body.className = "body"; body.innerHTML = `
${renderMd(text)}
`; highlightIn(body); } async function _showDocx(blob) { const body = $("fp-body"); body.className = "body center"; body.innerHTML = `
解析 docx 中…
`; try { await loadScript("/static/vendor/jszip.min.js"); await loadScript("/static/vendor/docx-preview.min.js"); } catch (e) { _showFallback("docx 解析库加载失败:" + e.message); return; } if (!window.docx || !window.docx.renderAsync) { _showFallback("docx 解析库不可用"); return; } body.className = "body"; body.innerHTML = `
`; try { await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, { inWrapper: false, ignoreLastRenderedPageBreak: true, }); } catch (e) { _showFallback("docx 渲染失败:" + e.message); } } async function _showXlsx(blob) { const body = $("fp-body"); body.className = "body center"; body.innerHTML = `
解析表格中…
`; try { await loadScript("/static/vendor/xlsx.full.min.js"); } catch (e) { _showFallback("xlsx 解析库加载失败:" + e.message); return; } if (!window.XLSX || !window.XLSX.read) { _showFallback("xlsx 解析库不可用"); return; } let wb; try { const ab = await blob.arrayBuffer(); wb = window.XLSX.read(ab, { type: "array" }); } catch (e) { _showFallback("xlsx 解析失败:" + e.message); return; } const names = wb.SheetNames || []; if (!names.length) { _showFallback("xlsx 内无 sheet"); return; } body.className = "body"; const tabsHtml = names.map((n, i) => `` ).join(""); body.innerHTML = `
${tabsHtml}
`; const render = (i) => { const ws = wb.Sheets[names[i]]; $("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws); }; body.querySelectorAll(".xlsx-tab").forEach((btn) => { btn.onclick = () => { body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); render(parseInt(btn.dataset.i)); }; }); render(0); } function _showFallback(msg) { const body = $("fp-body"); body.className = "body center"; body.innerHTML = ""; const ph = document.createElement("div"); ph.className = "ph"; ph.textContent = msg; const br = document.createElement("br"); const dl = document.createElement("button"); dl.className = "primary"; dl.textContent = "下载原文件"; dl.style.marginTop = "12px"; dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; ph.appendChild(document.createElement("br")); ph.appendChild(br); ph.appendChild(dl); body.appendChild(ph); } export function closeFilePreview() { $("file-preview-modal").classList.remove("show"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); $("fp-body").innerHTML = ""; _flushBlobUrls(); _fpCurrentRel = null; } let _mpCurrentRel = null; const _miniPreviewBlobUrls = new Set(); function _trackMiniBlobUrl(blob, mime) { const b = mime ? new Blob([blob], { type: mime }) : blob; const url = URL.createObjectURL(b); _miniPreviewBlobUrls.add(url); return url; } function _flushMiniBlobUrls() { for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u); _miniPreviewBlobUrls.clear(); } export function openPasteFilePreview(rel) { if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel); else openFilePreview(rel); } async function openMiniFilePreview(rel) { _mpCurrentRel = rel; const name = rel.split("/").pop() || rel; $("mp-name").textContent = name; $("mp-meta").textContent = ""; const body = $("mp-body"); body.className = "body center"; body.innerHTML = `
加载中…
`; _flushMiniBlobUrls(); $("mini-preview-modal").classList.add("show"); const cat = _categorize(rel); try { const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }); if (!r.ok) throw new Error("HTTP " + r.status); const blob = await r.blob(); $("mp-meta").textContent = humanSize(blob.size); if (cat === "text" || cat === "md") { if (blob.size > PREVIEW_TEXT_MAX) { _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); body.className = "body"; if (cat === "md") { body.innerHTML = `
${renderMd(text)}
`; highlightIn(body); } else { body.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "preview-text"; pre.textContent = text; body.appendChild(pre); } return; } if (blob.size > PREVIEW_BIN_MAX) { _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } body.innerHTML = ""; if (cat === "image") { body.className = "body center"; const img = document.createElement("img"); img.className = "preview-img"; img.src = _trackMiniBlobUrl(blob); body.appendChild(img); } else if (cat === "video") { body.className = "body center"; const v = document.createElement("video"); v.className = "preview-video"; v.src = _trackMiniBlobUrl(blob); v.controls = true; body.appendChild(v); } else if (cat === "pdf") { body.className = "body"; body.innerHTML = ``; } else { _showMiniFallback("暂不支持小窗预览此格式,请下载查看"); } } catch (e) { if (e.status === 401) { closeMiniPreview(); logout(); return; } _showMiniFallback("加载失败:" + e.message); } } function _showMiniFallback(msg) { const body = $("mp-body"); body.className = "body center"; body.innerHTML = ""; const ph = document.createElement("div"); ph.className = "ph"; ph.textContent = msg; const dl = document.createElement("button"); dl.className = "primary"; dl.textContent = "下载原文件"; dl.style.marginTop = "12px"; dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; ph.appendChild(document.createElement("br")); ph.appendChild(dl); body.appendChild(ph); } export function closeMiniPreview() { $("mini-preview-modal").classList.remove("show"); $("mp-body").innerHTML = ""; _flushMiniBlobUrls(); _mpCurrentRel = null; } // 删文件时:若该 rel 正在主/小预览中则关掉(供 main 的 deletePastedFile 等调用, // 不对外暴露内部 current-rel 状态)。 export function closePreviewIfShowing(rel) { if (_fpCurrentRel === rel) closeFilePreview(); if (_mpCurrentRel === rel) closeMiniPreview(); } $("fp-close").onclick = closeFilePreview; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; $("file-preview-modal").addEventListener("click", (e) => { if (e.target.id === "file-preview-modal") closeFilePreview(); }); $("mp-close").onclick = closeMiniPreview; $("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; $("mini-preview-modal").addEventListener("click", (e) => { if (e.target.id === "mini-preview-modal") closeMiniPreview(); });