480 lines
17 KiB
JavaScript
480 lines
17 KiB
JavaScript
// 文件预览:主弹框(图/视频/PDF/文本/markdown/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 { 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"]),
|
|
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"]),
|
|
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;
|
|
|
|
// ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ─────
|
|
// body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel),
|
|
// 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。
|
|
const _zoomState = new WeakMap(); // bodyEl -> { img, scale, badge, timer }
|
|
|
|
function _applyZoom(z) {
|
|
// 用 CSS zoom(非 transform):zoom 改变布局盒尺寸,放大后 body 才会出滚动条能看溢出部分
|
|
z.img.style.zoom = z.scale === 1 ? "" : z.scale;
|
|
z.img.style.cursor = z.scale === 1 ? "zoom-in" : "zoom-out";
|
|
z.badge.textContent = Math.round(z.scale * 100) + "%";
|
|
z.badge.classList.add("show");
|
|
if (z.timer) clearTimeout(z.timer);
|
|
z.timer = setTimeout(() => z.badge.classList.remove("show"), 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 };
|
|
_zoomState.set(bodyEl, z);
|
|
img.style.cursor = "zoom-in";
|
|
img.addEventListener("dblclick", () => { z.scale = 1; _applyZoom(z); });
|
|
}
|
|
|
|
function _clearZoom(bodyEl) {
|
|
const z = _zoomState.get(bodyEl);
|
|
if (!z) return;
|
|
if (z.timer) clearTimeout(z.timer);
|
|
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");
|
|
_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");
|
|
|
|
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") {
|
|
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);
|
|
_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) {
|
|
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 = `<div class="md-render">${renderMd(text)}</div>`;
|
|
highlightIn(body);
|
|
}
|
|
|
|
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");
|
|
$("file-preview-modal").style.removeProperty("--preview-bottom-inset");
|
|
_clearZoom($("fp-body"));
|
|
$("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");
|
|
_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") {
|
|
if (blob.size > PREVIEW_TEXT_MAX) {
|
|
_showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
|
return;
|
|
}
|
|
const text = await blob.text();
|
|
body.className = "body";
|
|
if (cat === "md") {
|
|
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
|
|
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);
|
|
_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"));
|
|
$("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); };
|
|
$("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();
|
|
});
|