zcbot/web/static/js/preview.js

592 lines
22 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)。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 { HTML_PREVIEW_CSP, htmlPreviewDocument } 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 _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;
// Markdown / HTML 共用“预览 / 源文件”切换。HTML 放入无权限 sandbox iframe
// 同时注入 CSP阻止脚本和外部资源请求保留内联样式与 data/blob 图片以便静态报告正常展示。
const _textPreviewState = { fp: null, mp: null };
function _resetTextModes(prefix) {
_textPreviewState[prefix] = null;
const modes = $(`${prefix}-modes`);
if (modes) modes.hidden = true;
}
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";
frame.title = "HTML 文件预览";
frame.setAttribute("sandbox", "");
frame.setAttribute("csp", HTML_PREVIEW_CSP);
frame.referrerPolicy = "no-referrer";
frame.srcdoc = htmlPreviewDocument(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 });
}
export async function openFilePreview(rel) {
_fpCurrentRel = rel;
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); 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();
$("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") _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);
}
function _showPdf(blob) {
const url = _trackBlobUrl(blob, "application/pdf");
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
}
// pptx/ppt → 后端转 PDF → iframe。main / mini 共用:传各自 body / meta / fallback / 追踪 blob 的 fn。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl) {
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("/v1/files/preview_pdf?path=" + encodeURIComponent(rel), {
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 预览";
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${trackFn(blob, "application/pdf")}"></iframe>`;
}
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); };
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"));
_resetTextModes("fp");
$("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");
_resetTextModes("mp");
_clearZoom(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, _trackMiniBlobUrl);
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") {
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
} 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"));
_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); };
$("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();
});