// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+
// 同时再开一个的小窗预览(mini)。PDF 用 PDF.js canvas 渲染以兼容 App WebView,
// docx/xlsx 走 loadScript 懒加载 vendor。
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
import { state } from "./state.js";
import { $ } from "./dom.js";
import { humanSize, escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { configureHtmlPreviewFrame } from "./preview_content.js";
import { logout } from "./auth.js";
import { downloadFile } from "./media.js";
// ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js";
const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js";
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"]),
html: new Set(["html","htm"]),
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",
"xml","css","scss","sh","bash","zsh","sql","conf","env",
]),
docx: new Set(["docx"]),
xlsx: new Set(["xlsx","xls"]),
ppt: new Set(["pptx","ppt"]),
};
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;
let _fpCurrentTaskId = "";
let _fpCurrentLegacy = false;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe
// 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。
const _textPreviewState = { fp: null, mp: null };
function _resetTextModes(prefix) {
_textPreviewState[prefix] = null;
const modes = $(`${prefix}-modes`);
if (modes) modes.hidden = true;
}
async function _loadPdfJs() {
await loadScript(PDFJS_SRC);
if (!window.pdfjsLib || !window.pdfjsLib.getDocument) {
throw new Error("PDF.js 不可用");
}
window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_SRC;
return window.pdfjsLib;
}
function _showSource(body, text) {
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showHtml(body, text) {
body.className = "body html-preview";
body.innerHTML = "";
const frame = document.createElement("iframe");
frame.className = "preview-frame preview-html-frame";
configureHtmlPreviewFrame(frame, text);
body.appendChild(frame);
}
function _renderTextMode(prefix, mode) {
const state = _textPreviewState[prefix];
if (!state) return;
state.mode = mode;
const body = $(`${prefix}-body`);
const previewBtn = $(`${prefix}-mode-preview`);
const sourceBtn = $(`${prefix}-mode-source`);
const showingPreview = mode === "preview";
previewBtn.classList.toggle("active", showingPreview);
sourceBtn.classList.toggle("active", !showingPreview);
previewBtn.setAttribute("aria-pressed", String(showingPreview));
sourceBtn.setAttribute("aria-pressed", String(!showingPreview));
if (!showingPreview) {
_showSource(body, state.text);
} else if (state.cat === "md") {
body.className = "body";
body.innerHTML = `
${renderMd(state.text)}
`;
highlightIn(body);
} else {
_showHtml(body, state.text);
}
}
function _showRenderableText(prefix, cat, text) {
_textPreviewState[prefix] = { cat, text, mode: "preview" };
$(`${prefix}-modes`).hidden = false;
_renderTextMode(prefix, "preview");
}
// ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ─────
// body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel),
// 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。
const _zoomState = new WeakMap(); // bodyEl -> { img, scale, badge, timer }
function _captureBase(z) {
const img = z.img;
if (!z.baseW) {
z.baseW = img.clientWidth || img.naturalWidth || 0;
z.baseH = img.clientHeight || img.naturalHeight || 0;
}
return z.baseW > 0;
}
function _applyZoom(z, hint) {
const img = z.img;
if (z.scale === 1) {
// 复位:还原 CSS 里的 max-width/height:100% 自适应贴合
img.style.maxWidth = "";
img.style.maxHeight = "";
img.style.width = "";
img.style.height = "";
img.style.cursor = ""; // 100%:普通箭头(左键不缩放,放大镜会误导)
} else {
// 以 scale=1 时的"贴合显示"尺寸为基准,给显式像素尺寸。
// 不用 CSS zoom:图片是带 max-width/height:100% 的 flex item,zoom 放大后会被
// 百分比 max 约束重新夹回 → 视觉不变;显式 px + max:none 才真正撑大并让 body 出滚动条。
if (!_captureBase(z)) return; // 图片还没量到尺寸(未加载完),本次跳过,下次再缩
img.style.maxWidth = "none";
img.style.maxHeight = "none";
img.style.width = Math.round(z.baseW * z.scale) + "px";
img.style.height = Math.round(z.baseH * z.scale) + "px";
img.style.cursor = "grab"; // 放大后可左键拖动平移
}
z.badge.textContent = hint || (Math.round(z.scale * 100) + "%");
z.badge.classList.add("show");
if (z.timer) clearTimeout(z.timer);
z.timer = setTimeout(() => z.badge.classList.remove("show"), hint ? 1400 : 1000);
}
// 给某 body 内的图片接上 Ctrl+滚轮缩放(badge + 双击复位),记录到 _zoomState。
function _makeImageZoomable(bodyEl, img) {
_clearZoom(bodyEl);
const card = bodyEl.closest(".card") || bodyEl;
const badge = document.createElement("div");
badge.className = "zoom-badge"; // 挂到 card 而非 body,放大后 body 滚动时徽标不跟着滚走
card.appendChild(badge);
const z = { img, scale: 1, badge, timer: null, baseW: 0, baseH: 0 };
_zoomState.set(bodyEl, z);
img.draggable = false; // 关掉浏览器原生图片拖拽(否则拖出 ghost image)
// 图片一加载完就量好贴合尺寸做基准,避免首次缩放时还没渲染量到 0
if (img.complete) _captureBase(z);
else img.addEventListener("load", () => _captureBase(z), { once: true });
img.addEventListener("dblclick", () => {
if (z.scale === 1) return; // 本来就 100%,无需提示
z.scale = 1;
_applyZoom(z, "已复位 · 100%");
});
// 放大后左键拖动平移:本质是改 body 的滚动位置(图片此时已撑大、body 可滚)
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
img.addEventListener("mousedown", (e) => {
if (e.button !== 0 || z.scale === 1) return; // 仅左键、仅放大态
e.preventDefault();
dragging = true;
sx = e.clientX; sy = e.clientY;
sl = bodyEl.scrollLeft; st = bodyEl.scrollTop;
img.style.cursor = "grabbing";
});
z._onMove = (e) => {
if (!dragging) return;
bodyEl.scrollLeft = sl - (e.clientX - sx);
bodyEl.scrollTop = st - (e.clientY - sy);
};
z._onUp = () => {
if (!dragging) return;
dragging = false;
img.style.cursor = z.scale === 1 ? "" : "grab";
};
document.addEventListener("mousemove", z._onMove);
document.addEventListener("mouseup", z._onUp);
}
function _clearZoom(bodyEl) {
const z = _zoomState.get(bodyEl);
if (!z) return;
if (z.timer) clearTimeout(z.timer);
if (z._onMove) document.removeEventListener("mousemove", z._onMove);
if (z._onUp) document.removeEventListener("mouseup", z._onUp);
z.badge.remove();
_zoomState.delete(bodyEl);
}
// 只挂一次:Ctrl+滚轮缩当前图片;否则拦住滚动穿透到背景对话列表。
function _bindBodyWheel(bodyEl) {
bodyEl.addEventListener("wheel", (e) => {
const z = _zoomState.get(bodyEl);
if (e.ctrlKey) {
if (!z) return; // 无可缩放图片:不拦,交还浏览器
e.preventDefault(); // 有图片:吃掉浏览器页面缩放,改缩图片
z.scale = Math.min(8, Math.max(0.1, +(z.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1)).toFixed(3)));
_applyZoom(z);
return;
}
// 非 ctrl:容器不可滚 / 已到边界时阻断,避免冒泡到背景滚动对话列表
const canScroll = bodyEl.scrollHeight > bodyEl.clientHeight + 1;
if (!canScroll) { e.preventDefault(); return; }
const atTop = bodyEl.scrollTop <= 0 && e.deltaY < 0;
const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 1 && e.deltaY > 0;
if (atTop || atBottom) e.preventDefault();
}, { passive: false });
}
function _fileDownloadUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/download`
: "/v1/files/download";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
function _pptPreviewUrl(rel, taskId = "", legacy = false) {
const base = taskId
? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf`
: "/v1/files/preview_pdf";
return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "");
}
export async function openFilePreview(rel, taskId = "", legacy = false) {
_fpCurrentRel = rel;
_fpCurrentTaskId = taskId;
_fpCurrentLegacy = legacy;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
const body = $("fp-body");
_resetTextModes("fp");
_clearZoom(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");
// 遮罩铺满整屏后,把输入区抬到遮罩之上继续可用(CSS body.fp-open #chat-form)
document.body.classList.add("fp-open");
const cat = _categorize(rel);
// pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, taskId, legacy); return; }
try {
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
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" || cat === "html") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
if (cat === "md" || cat === "html") _showRenderableText("fp", cat, 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") await _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) {
// SVG 在
里必须 Content-Type=image/svg+xml 才渲染;下载接口靠服务端
// mimetypes 猜类型,部分部署环境(Linux)未注册 .svg → octet-stream → 不显示。
// 这里据扩展名强制纠正,与服务端响应头无关。
const mime = /\.svg$/i.test(_fpCurrentRel || "") ? "image/svg+xml" : "";
const url = _trackBlobUrl(blob, mime);
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);
_makeImageZoomable(body, 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);
}
const _pdfPreviewStates = new WeakMap();
function _clearPdfPreview(body) {
const state = _pdfPreviewStates.get(body);
if (!state) return;
state.disposed = true;
if (state.renderTask) state.renderTask.cancel();
if (state.loadingTask) state.loadingTask.destroy();
if (state.resizeObserver) state.resizeObserver.disconnect();
if (state.onResize) window.removeEventListener("resize", state.onResize);
if (state.pdf) state.pdf.destroy();
_pdfPreviewStates.delete(body);
}
async function _showPdfIn(body, blob, fallbackFn) {
_clearPdfPreview(body);
const state = {
disposed: false,
pdf: null,
loadingTask: null,
pageNumber: 1,
zoom: 1,
renderTask: null,
renderSeq: 0,
resizeObserver: null,
onResize: null,
};
_pdfPreviewStates.set(body, state);
body.className = "body pdf-preview";
body.innerHTML = `
适宽
`;
const viewportHost = body.querySelector(".pdf-viewport");
const pageInput = body.querySelector(".pdf-page");
const pagesLabel = body.querySelector(".pdf-pages");
const zoomLabel = body.querySelector(".pdf-zoom");
const prev = body.querySelector(".pdf-prev");
const next = body.querySelector(".pdf-next");
const zoomOut = body.querySelector(".pdf-zoom-out");
const zoomIn = body.querySelector(".pdf-zoom-in");
const renderPage = async () => {
if (state.disposed || !state.pdf) return;
const seq = ++state.renderSeq;
if (state.renderTask) state.renderTask.cancel();
const page = await state.pdf.getPage(state.pageNumber);
if (state.disposed || seq !== state.renderSeq) return;
const baseViewport = page.getViewport({ scale: 1 });
const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width);
const viewport = page.getViewport({ scale: fitScale * state.zoom });
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.className = "pdf-canvas";
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + "px";
canvas.style.height = Math.floor(viewport.height) + "px";
viewportHost.innerHTML = "";
viewportHost.appendChild(canvas);
state.renderTask = page.render({
canvasContext: context,
viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
});
try {
await state.renderTask.promise;
} catch (e) {
if (e && e.name === "RenderingCancelledException") return;
throw e;
} finally {
if (seq === state.renderSeq) state.renderTask = null;
}
if (state.disposed || seq !== state.renderSeq) return;
pageInput.value = String(state.pageNumber);
prev.disabled = state.pageNumber <= 1;
next.disabled = state.pageNumber >= state.pdf.numPages;
zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
viewportHost.scrollTo({ top: 0, left: 0 });
};
const goToPage = (pageNumber) => {
if (!state.pdf) return;
state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber));
renderPage().catch((e) => {
if (state.disposed) return;
_clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message);
});
};
prev.onclick = () => goToPage(state.pageNumber - 1);
next.onclick = () => goToPage(state.pageNumber + 1);
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
zoomOut.onclick = () => {
state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2));
renderPage().catch((e) => {
if (state.disposed) return;
_clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message);
});
};
zoomIn.onclick = () => {
state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2));
renderPage().catch((e) => {
if (state.disposed) return;
_clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message);
});
};
try {
const pdfjsLib = await _loadPdfJs();
const data = new Uint8Array(await blob.arrayBuffer());
state.loadingTask = pdfjsLib.getDocument({ data });
const pdf = await state.loadingTask.promise;
state.loadingTask = null;
if (state.disposed || _pdfPreviewStates.get(body) !== state) {
pdf.destroy();
return;
}
state.pdf = pdf;
pagesLabel.textContent = String(pdf.numPages);
pageInput.max = String(pdf.numPages);
pageInput.disabled = false;
zoomOut.disabled = false;
zoomIn.disabled = false;
await renderPage();
const rerender = () => {
if (state.disposed) return;
renderPage().catch(() => {});
};
if (window.ResizeObserver) {
state.resizeObserver = new ResizeObserver(rerender);
state.resizeObserver.observe(viewportHost);
} else {
state.onResize = rerender;
window.addEventListener("resize", rerender);
}
} catch (e) {
if (!state.disposed) {
_clearPdfPreview(body);
fallbackFn("PDF 预览失败:" + e.message);
}
}
}
async function _showPdf(blob) {
await _showPdfIn($("fp-body"), blob, _showFallback);
}
// pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false) {
body.className = "body center";
body.innerHTML = ``;
if (metaEl) metaEl.textContent = "";
let r;
try {
r = await fetch(_pptPreviewUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token },
});
} catch (e) {
fallbackFn("加载失败:" + e.message);
return;
}
if (r.status === 401) { logout(); return; }
if (!r.ok) {
let msg = "PPT 在线预览不可用,请下载查看";
if (r.status === 501) msg = "服务器未装 LibreOffice,无法在线预览 PPT,请下载查看";
else if (r.status === 500) msg = "PPT 转换失败,请下载原文件查看";
fallbackFn(msg);
return;
}
const blob = await r.blob();
if (metaEl) metaEl.textContent = humanSize(blob.size) + " · PDF 预览";
await _showPdfIn(body, blob, fallbackFn);
}
function _showText(text) { _showSource($("fp-body"), text); }
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, _fpCurrentTaskId, _fpCurrentLegacy); };
ph.appendChild(document.createElement("br"));
ph.appendChild(br);
ph.appendChild(dl);
body.appendChild(ph);
}
export function closeFilePreview() {
$("file-preview-modal").classList.remove("show");
document.body.classList.remove("fp-open");
$("file-preview-modal").style.removeProperty("--preview-bottom-inset");
_clearZoom($("fp-body"));
_clearPdfPreview($("fp-body"));
_resetTextModes("fp");
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
_fpCurrentTaskId = "";
_fpCurrentLegacy = false;
}
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");
_resetTextModes("mp");
_clearZoom(body);
_clearPdfPreview(body);
body.className = "body center";
body.innerHTML = `加载中…
`;
_flushMiniBlobUrls();
$("mini-preview-modal").classList.add("show");
const cat = _categorize(rel);
if (cat === "ppt") {
await _showPptAsPdf(rel, $("mp-body"), $("mp-meta"), _showMiniFallback);
return;
}
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" || cat === "html") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text);
else _showSource(body, text);
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";
const _mime = /\.svg$/i.test(_mpCurrentRel || "") ? "image/svg+xml" : "";
img.src = _trackMiniBlobUrl(blob, _mime);
body.appendChild(img);
_makeImageZoomable(body, 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") {
await _showPdfIn(body, blob, _showMiniFallback);
} 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");
_clearZoom($("mp-body"));
_clearPdfPreview($("mp-body"));
_resetTextModes("mp");
$("mp-body").innerHTML = "";
_flushMiniBlobUrls();
_mpCurrentRel = null;
}
// 删文件时:若该 rel 正在主/小预览中则关掉(供 main 的 deletePastedFile 等调用,
// 不对外暴露内部 current-rel 状态)。
export function closePreviewIfShowing(rel) {
if (_fpCurrentRel === rel) closeFilePreview();
if (_mpCurrentRel === rel) closeMiniPreview();
}
_bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body"));
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy); };
$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview");
$("fp-mode-source").onclick = () => _renderTextMode("fp", "source");
$("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); };
$("mp-mode-preview").onclick = () => _renderTextMode("mp", "preview");
$("mp-mode-source").onclick = () => _renderTextMode("mp", "source");
$("mini-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "mini-preview-modal") closeMiniPreview();
});