zcbot/web/static/js/preview.js

773 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 文件预览:主弹框(图/视频/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 = `<div class="md-render">${renderMd(state.text)}</div>`;
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 = `<div class="ph">加载中…</div>`;
// 让出聊天输入区高度,弹框不遮挡 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 在 <img> 里必须 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 = `
<div class="pdf-toolbar">
<button class="small pdf-prev" type="button" aria-label="上一页" disabled>上一页</button>
<label>第 <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages">…</span> 页</label>
<button class="small pdf-next" type="button" aria-label="下一页" disabled>下一页</button>
<span class="pdf-toolbar-spacer"></span>
<button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled></button>
<span class="pdf-zoom">适宽</span>
<button class="small pdf-zoom-in" type="button" aria-label="放大" disabled></button>
</div>
<div class="pdf-viewport"><div class="ph">解析 PDF 中…</div></div>`;
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 = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
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 = `<div class="ph">解析 docx 中…</div>`;
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 = `<div class="docx-host"></div>`;
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 = `<div class="ph">解析表格中…</div>`;
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) =>
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
).join("");
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
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 = `<div class="ph">加载中…</div>`;
_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();
});