feat(web): preview HTML and source files
This commit is contained in:
parent
f217672365
commit
328607671a
|
|
@ -0,0 +1,40 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { htmlPreviewDocument } from "../web/static/js/preview_content.js";
|
||||
|
||||
// preview.js has browser-only top-level bindings, so exercise the integration contract
|
||||
// through its shipped source and markup while keeping this test dependency-free.
|
||||
const previewJs = readFileSync(new URL("../web/static/js/preview.js", import.meta.url), "utf8");
|
||||
const pageHtml = readFileSync(new URL("../web/static/dev.html", import.meta.url), "utf8");
|
||||
|
||||
test("HTML is a renderable category rather than plain text", () => {
|
||||
assert.match(previewJs, /html:\s+new Set\(\["html","htm"\]\)/);
|
||||
assert.match(previewJs, /frame\.setAttribute\("sandbox", ""\)/);
|
||||
assert.match(previewJs, /frame\.srcdoc = htmlPreviewDocument\(text\)/);
|
||||
});
|
||||
|
||||
test("HTML preview blocks scripts, navigation, and external resources", () => {
|
||||
const document = htmlPreviewDocument("<!doctype html><html><head><title>x</title></head><body>x</body></html>");
|
||||
assert.match(document, /<head><meta http-equiv="Content-Security-Policy"/);
|
||||
assert.match(document, /default-src 'none'/);
|
||||
assert.match(document, /form-action 'none'/);
|
||||
assert.match(document, /base-uri 'none'/);
|
||||
assert.ok(document.indexOf("Content-Security-Policy") < document.indexOf("<title>"));
|
||||
assert.doesNotMatch(previewJs, /allow-scripts/);
|
||||
});
|
||||
|
||||
test("HTML fragments receive a restrictive head before their content", () => {
|
||||
const document = htmlPreviewDocument("<h1>报告</h1>");
|
||||
assert.match(document, /^<head><meta http-equiv="Content-Security-Policy"/);
|
||||
assert.match(document, /<\/head><h1>报告<\/h1>$/);
|
||||
});
|
||||
|
||||
test("main and mini previews expose preview/source mode controls", () => {
|
||||
for (const prefix of ["fp", "mp"]) {
|
||||
assert.match(pageHtml, new RegExp(`id="${prefix}-mode-preview"`));
|
||||
assert.match(pageHtml, new RegExp(`id="${prefix}-mode-source"`));
|
||||
assert.match(previewJs, new RegExp(`_showRenderableText\\("${prefix}", cat, text\\)`));
|
||||
}
|
||||
});
|
||||
|
|
@ -1251,7 +1251,18 @@
|
|||
flex: 1; font-weight: 500; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.preview-modes {
|
||||
display: inline-flex; align-items: center; gap: 2px; padding: 2px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel-muted);
|
||||
}
|
||||
.preview-modes[hidden] { display: none; }
|
||||
.preview-modes button { border: 0; background: transparent; }
|
||||
.preview-modes button.active {
|
||||
color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
#file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; }
|
||||
#file-preview-modal .body.html-preview,
|
||||
#mini-preview-modal .body.html-preview { padding: 0; overflow: hidden; background: #fff; }
|
||||
/* 预览缩放比例徽标(挂在 .card 上,放大滚动时不跟随内容滚走) */
|
||||
.zoom-badge {
|
||||
position: absolute; right: 14px; bottom: 12px; z-index: 3;
|
||||
|
|
@ -1276,6 +1287,7 @@
|
|||
max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none;
|
||||
}
|
||||
#file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; }
|
||||
iframe.preview-html-frame { background: #fff; }
|
||||
#file-preview-modal .body pre.preview-text {
|
||||
margin: 0; padding: 8px; background: var(--code-bg);
|
||||
border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word;
|
||||
|
|
@ -1971,6 +1983,10 @@
|
|||
<div class="hdr">
|
||||
<span class="name" id="fp-name"></span>
|
||||
<span class="small muted" id="fp-meta"></span>
|
||||
<span class="preview-modes" id="fp-modes" hidden aria-label="查看模式">
|
||||
<button class="small" id="fp-mode-preview" type="button">预览</button>
|
||||
<button class="small" id="fp-mode-source" type="button">源文件</button>
|
||||
</span>
|
||||
<button class="small" id="fp-download" title="下载原文件">下载</button>
|
||||
<button class="small" id="fp-close" title="关闭 (Esc)">×</button>
|
||||
</div>
|
||||
|
|
@ -1984,6 +2000,10 @@
|
|||
<div class="hdr">
|
||||
<span class="name" id="mp-name"></span>
|
||||
<span class="small muted" id="mp-meta"></span>
|
||||
<span class="preview-modes" id="mp-modes" hidden aria-label="查看模式">
|
||||
<button class="small" id="mp-mode-preview" type="button">预览</button>
|
||||
<button class="small" id="mp-mode-source" type="button">源文件</button>
|
||||
</span>
|
||||
<button class="small" id="mp-download" title="下载原文件">下载</button>
|
||||
<button class="small" id="mp-close" title="关闭 (Esc)">×</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+
|
||||
// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+
|
||||
// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。
|
||||
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
|
||||
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
|
||||
|
|
@ -6,6 +6,7 @@ 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";
|
||||
|
||||
|
|
@ -44,10 +45,11 @@ const _EXT_GROUPS = {
|
|||
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",
|
||||
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
|
||||
"xml","css","scss","sh","bash","zsh","sql","conf","env",
|
||||
]),
|
||||
docx: new Set(["docx"]),
|
||||
xlsx: new Set(["xlsx","xls"]),
|
||||
|
|
@ -62,6 +64,67 @@ export function _categorize(rel) {
|
|||
|
||||
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 泄漏。
|
||||
|
|
@ -181,6 +244,7 @@ export async function openFilePreview(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>`;
|
||||
|
|
@ -203,13 +267,13 @@ export async function openFilePreview(rel) {
|
|||
const blob = await r.blob();
|
||||
$("fp-meta").textContent = humanSize(blob.size);
|
||||
|
||||
if (cat === "text" || cat === "md") {
|
||||
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") _showMarkdown(text);
|
||||
if (cat === "md" || cat === "html") _showRenderableText("fp", cat, text);
|
||||
else _showText(text);
|
||||
return;
|
||||
}
|
||||
|
|
@ -293,22 +357,7 @@ async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlob
|
|||
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);
|
||||
}
|
||||
function _showText(text) { _showSource($("fp-body"), text); }
|
||||
|
||||
async function _showDocx(blob) {
|
||||
const body = $("fp-body");
|
||||
|
|
@ -404,6 +453,7 @@ export function closeFilePreview() {
|
|||
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;
|
||||
|
|
@ -431,6 +481,7 @@ async function openMiniFilePreview(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>`;
|
||||
|
|
@ -449,23 +500,14 @@ async function openMiniFilePreview(rel) {
|
|||
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 (cat === "text" || cat === "md" || cat === "html") {
|
||||
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);
|
||||
}
|
||||
if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text);
|
||||
else _showSource(body, text);
|
||||
return;
|
||||
}
|
||||
if (blob.size > PREVIEW_BIN_MAX) {
|
||||
|
|
@ -518,6 +560,7 @@ function _showMiniFallback(msg) {
|
|||
export function closeMiniPreview() {
|
||||
$("mini-preview-modal").classList.remove("show");
|
||||
_clearZoom($("mp-body"));
|
||||
_resetTextModes("mp");
|
||||
$("mp-body").innerHTML = "";
|
||||
_flushMiniBlobUrls();
|
||||
_mpCurrentRel = null;
|
||||
|
|
@ -534,11 +577,15 @@ _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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
// 可独立测试的预览内容安全策略。HTML 预览只允许文件内的静态样式和内嵌媒体,
|
||||
// 不允许脚本、表单提交、外部资源请求或 base URL 改写。
|
||||
export const HTML_PREVIEW_CSP = "default-src 'none'; img-src data: blob:; media-src data: blob:; " +
|
||||
"font-src data:; style-src 'unsafe-inline'; form-action 'none'; base-uri 'none'";
|
||||
|
||||
export function htmlPreviewDocument(text) {
|
||||
const meta = `<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`;
|
||||
const source = String(text || "");
|
||||
if (/<head(?:\s[^>]*)?>/i.test(source)) {
|
||||
return source.replace(/<head(?:\s[^>]*)?>/i, (head) => head + meta);
|
||||
}
|
||||
return `<head>${meta}</head>${source}`;
|
||||
}
|
||||
Loading…
Reference in New Issue