@spzxg/cli-ui 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/{cli-ui-embed-CXdmrwo1.js → cli-ui-embed-Dk2QGHxn.js} +12709 -12459
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/CliBoard.tsx +2 -2
- package/src/board/FileViewer.tsx +168 -35
- package/src/board/fileKind.ts +31 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/chat/CliChat.tsx +15 -9
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +50 -11
- package/src/core/client.ts +47 -20
- package/src/core/config.ts +18 -5
- package/src/core/xlsx.ts +142 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/dist/cli-ui-index-BZppwn-v.js +0 -13528
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { a } from "./cli-ui-embed-Dk2QGHxn.js";
|
|
2
|
+
import { r as p } from "./cli-ui-jszip.min-B9tqMA-e.js";
|
|
3
|
+
function f(t, n) {
|
|
4
|
+
for (var o = 0; o < n.length; o++) {
|
|
5
|
+
const e = n[o];
|
|
6
|
+
if (typeof e != "string" && !Array.isArray(e)) {
|
|
7
|
+
for (const r in e)
|
|
8
|
+
if (r !== "default" && !(r in t)) {
|
|
9
|
+
const i = Object.getOwnPropertyDescriptor(e, r);
|
|
10
|
+
i && Object.defineProperty(t, r, i.get ? i : {
|
|
11
|
+
enumerable: !0,
|
|
12
|
+
get: () => e[r]
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return Object.freeze(Object.defineProperty(t, Symbol.toStringTag, { value: "Module" }));
|
|
18
|
+
}
|
|
19
|
+
var s = p();
|
|
20
|
+
const c = /* @__PURE__ */ a(s), l = /* @__PURE__ */ f({
|
|
21
|
+
__proto__: null,
|
|
22
|
+
default: c
|
|
23
|
+
}, [s]);
|
|
24
|
+
export {
|
|
25
|
+
l as j
|
|
26
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const I = "http://schemas.openxmlformats.org/spreadsheetml/2006/main", X = "http://schemas.openxmlformats.org/officeDocument/2006/relationships", j = /* @__PURE__ */ new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47, 50, 57]), b = (e) => new DOMParser().parseFromString(e, "application/xml"), g = (e, t) => Array.from(e.getElementsByTagNameNS(I, t));
|
|
2
|
+
function _(e) {
|
|
3
|
+
let t = 0;
|
|
4
|
+
for (const n of e) {
|
|
5
|
+
const o = n.toUpperCase().charCodeAt(0);
|
|
6
|
+
if (o < 65 || o > 90) break;
|
|
7
|
+
t = t * 26 + (o - 64);
|
|
8
|
+
}
|
|
9
|
+
return t - 1;
|
|
10
|
+
}
|
|
11
|
+
function B(e) {
|
|
12
|
+
const t = e.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "").replace(/\\./g, "");
|
|
13
|
+
return /[ymdhs]/i.test(t) && !/^[#0.,%E+\-\s]*$/i.test(t);
|
|
14
|
+
}
|
|
15
|
+
function L(e) {
|
|
16
|
+
const t = Math.round((e - 25569) * 864e5), n = new Date(t);
|
|
17
|
+
if (Number.isNaN(n.getTime())) return String(e);
|
|
18
|
+
const o = (w) => String(w).padStart(2, "0"), m = `${n.getUTCFullYear()}-${o(n.getUTCMonth() + 1)}-${o(n.getUTCDate())}`;
|
|
19
|
+
if (!(n.getUTCHours() * 3600 + n.getUTCMinutes() * 60 + n.getUTCSeconds())) return m;
|
|
20
|
+
const d = `${o(n.getUTCHours())}:${o(n.getUTCMinutes())}${n.getUTCSeconds() ? `:${o(n.getUTCSeconds())}` : ""}`;
|
|
21
|
+
return e < 1 ? d : `${m} ${d}`;
|
|
22
|
+
}
|
|
23
|
+
function v(e) {
|
|
24
|
+
return g(e, "t").filter((t) => {
|
|
25
|
+
var n;
|
|
26
|
+
return ((n = t.parentElement) == null ? void 0 : n.localName) !== "rPh";
|
|
27
|
+
}).map((t) => t.textContent ?? "").join("");
|
|
28
|
+
}
|
|
29
|
+
async function R(e) {
|
|
30
|
+
var E;
|
|
31
|
+
const t = await import("./cli-ui-jszip.min-DIavJzUG.js").then((s) => s.j), o = await (t.default ?? t).loadAsync(e), m = async (s) => {
|
|
32
|
+
const a = o.file(s);
|
|
33
|
+
return a ? a.async("string") : null;
|
|
34
|
+
}, S = await m("xl/workbook.xml");
|
|
35
|
+
if (!S) throw new Error("不是 Excel 工作簿");
|
|
36
|
+
const d = /* @__PURE__ */ new Map(), w = await m("xl/_rels/workbook.xml.rels");
|
|
37
|
+
if (w)
|
|
38
|
+
for (const s of Array.from(b(w).getElementsByTagName("Relationship"))) {
|
|
39
|
+
const a = s.getAttribute("Target") ?? "";
|
|
40
|
+
d.set(s.getAttribute("Id") ?? "", a.startsWith("/") ? a.slice(1) : `xl/${a}`);
|
|
41
|
+
}
|
|
42
|
+
const T = [], $ = await m("xl/sharedStrings.xml");
|
|
43
|
+
if ($) for (const s of g(b($), "si")) T.push(v(s));
|
|
44
|
+
const y = /* @__PURE__ */ new Set(), C = /* @__PURE__ */ new Set(), U = await m("xl/styles.xml");
|
|
45
|
+
if (U) {
|
|
46
|
+
const s = b(U), a = new Map(g(s, "numFmt").map((l) => [Number(l.getAttribute("numFmtId")), l.getAttribute("formatCode") ?? ""])), N = g(s, "cellXfs")[0];
|
|
47
|
+
N && Array.from(N.children).filter((l) => l.localName === "xf").forEach((l, h) => {
|
|
48
|
+
const i = Number(l.getAttribute("numFmtId") ?? 0), r = a.get(i);
|
|
49
|
+
j.has(i) || r !== void 0 && B(r) ? y.add(h) : (i === 9 || i === 10 || r !== void 0 && r.replace(/"[^"]*"/g, "").includes("%")) && C.add(h);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const x = [];
|
|
53
|
+
for (const s of g(b(S), "sheet")) {
|
|
54
|
+
const a = s.getAttribute("name") ?? `Sheet${x.length + 1}`, N = d.get(s.getAttributeNS(X, "id") ?? s.getAttribute("r:id") ?? "") ?? `xl/worksheets/sheet${x.length + 1}.xml`, l = await m(N);
|
|
55
|
+
if (!l) continue;
|
|
56
|
+
const h = [];
|
|
57
|
+
for (const r of g(b(l), "row")) {
|
|
58
|
+
const D = Number(r.getAttribute("r")) || h.length + 1, F = [];
|
|
59
|
+
let M = 0;
|
|
60
|
+
for (const u of Array.from(r.children).filter((A) => A.localName === "c")) {
|
|
61
|
+
const A = u.getAttribute("r"), k = A ? _(A) : M;
|
|
62
|
+
M = k + 1;
|
|
63
|
+
const p = u.getAttribute("t"), c = ((E = g(u, "v")[0]) == null ? void 0 : E.textContent) ?? "";
|
|
64
|
+
let f;
|
|
65
|
+
p === "s" ? f = T[Number(c)] ?? "" : p === "inlineStr" ? f = v(u) : p === "b" ? f = c === "1" ? "TRUE" : c === "0" ? "FALSE" : c : p === "str" || p === "e" || !c ? f = c : y.has(Number(u.getAttribute("s") ?? -1)) && Number.isFinite(Number(c)) ? f = L(Number(c)) : C.has(Number(u.getAttribute("s") ?? -1)) && Number.isFinite(Number(c)) ? f = `${Number((Number(c) * 100).toPrecision(12))}%` : f = c, F[k] = f;
|
|
66
|
+
}
|
|
67
|
+
h[D - 1] = Array.from(F, (u) => u ?? "");
|
|
68
|
+
}
|
|
69
|
+
const i = Array.from(h, (r) => r ?? []);
|
|
70
|
+
for (; i.length && i[i.length - 1].every((r) => !r); ) i.pop();
|
|
71
|
+
for (const r of i) for (; r.length && !r[r.length - 1]; ) r.pop();
|
|
72
|
+
x.push({ name: a, hidden: (s.getAttribute("state") ?? "visible") !== "visible", rows: i });
|
|
73
|
+
}
|
|
74
|
+
return x;
|
|
75
|
+
}
|
|
76
|
+
const P = (e) => /[",\n\r]/.test(e) ? `"${e.replace(/"/g, '""')}"` : e;
|
|
77
|
+
function H(e) {
|
|
78
|
+
return e.map((t) => {
|
|
79
|
+
const n = `## 工作表:${t.name}${t.hidden ? "(隐藏)" : ""}(${t.rows.length} 行)`;
|
|
80
|
+
return t.rows.length ? `${n}
|
|
81
|
+
${t.rows.map((o) => o.map(P).join(",")).join(`
|
|
82
|
+
`)}` : `${n}
|
|
83
|
+
(空)`;
|
|
84
|
+
}).join(`
|
|
85
|
+
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
export {
|
|
89
|
+
_ as columnIndex,
|
|
90
|
+
L as excelDate,
|
|
91
|
+
R as readXlsx,
|
|
92
|
+
H as sheetsToText
|
|
93
|
+
};
|
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-Dk2QGHxn.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spzxg/cli-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"vitest": "^2.1.8"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"jszip": "^3.10.2",
|
|
47
48
|
"mammoth": "^1.13.0",
|
|
48
49
|
"react-markdown": "^10.1.0",
|
|
49
50
|
"rehype-highlight": "^7.0.2",
|
package/src/board/CliBoard.tsx
CHANGED
|
@@ -264,9 +264,9 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, defaultTab, re
|
|
|
264
264
|
useEffect(() => {
|
|
265
265
|
if (!jobId || !autoView) return;
|
|
266
266
|
store.setViewing(jobId, viewingPath
|
|
267
|
-
? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text:
|
|
267
|
+
? { id: `file:${viewingPath}`, kind: "file", label: viewingPath.split("/").pop() || viewingPath, text: `用户当前在看板里打开着工作区文件 ${viewingPath}` }
|
|
268
268
|
: viewingPage
|
|
269
|
-
? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text:
|
|
269
|
+
? { id: "browser:page", kind: "browser", label: viewingPage.title || viewingPage.url, text: `用户当前在看板里看着任务浏览器页面:${viewingPage.title ? `${viewingPage.title} — ` : ""}${viewingPage.url}` }
|
|
270
270
|
: null, owner);
|
|
271
271
|
}, [store, jobId, autoView, viewingPath, viewingPage?.url, viewingPage?.title, owner]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
272
272
|
useEffect(() => () => { if (jobId) store.setViewing(jobId, null, owner); }, [store, jobId, owner]);
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -6,14 +6,17 @@
|
|
|
6
6
|
// 流式加载、支持断点,不把文件读进浏览器内存。中枢太旧发不了短期链接时才先 fetch 成 blob,这时超大的文件不预览、只给下载。
|
|
7
7
|
// · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
|
|
8
8
|
// · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
|
|
9
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
9
|
+
import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
10
10
|
import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
|
|
11
11
|
import { Icon } from "../ui/icons.js";
|
|
12
12
|
import { Markdown } from "../ui/Markdown.js";
|
|
13
13
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
14
|
-
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind.js";
|
|
14
|
+
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, splitMarkdown, type PreviewKind } from "./fileKind.js";
|
|
15
|
+
import { loadText, refreshText, type LoadedText } from "./textLoad.js";
|
|
16
|
+
import { decodeText } from "../core/textDecode.js";
|
|
15
17
|
import { relTime } from "../ui/format.js";
|
|
16
18
|
import { downloadFile, mimeOf } from "./fileOps.js";
|
|
19
|
+
import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote.js";
|
|
17
20
|
|
|
18
21
|
export { downloadFile };
|
|
19
22
|
|
|
@@ -26,14 +29,19 @@ export interface FileAction {
|
|
|
26
29
|
onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// 预览能看多大(实测 Chromium:32 MB 纯文本排版约 3 秒,60 MB 的 HTML 报告约 12 秒显示出来):
|
|
33
|
+
/** 文本(代码、日志、Markdown、CSV、HTML 源文件)一次最多读这么多;更大的看开头 / 末尾。 */
|
|
34
|
+
export const TEXT_MAX = 32 * 1024 * 1024;
|
|
35
|
+
/** HTML 预览(能选中引用)最多读这么多进来;更大的直接按地址打开(能看,不能选中引用)。 */
|
|
36
|
+
export const HTML_QUOTE_MAX = 100 * 1024 * 1024;
|
|
37
|
+
/** 代码高亮到这么大(约 1 MB/秒);更大的按纯文本显示(照样能选中引用)。 */
|
|
38
|
+
const HIGHLIGHT_MAX = 512 * 1024;
|
|
39
|
+
/** 大 Markdown 分段渲染,每段这么多字(渲染耗时随长度超线性增长:300 KB 约 0.7 秒,3 MB 一次渲染要 30 秒)。 */
|
|
40
|
+
const MARKDOWN_SECTION = 128 * 1024;
|
|
41
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的图片 / PDF 不预览、不在浏览器里拼下载。 */
|
|
42
|
+
const BLOB_PREVIEW_MAX = 100 * 1024 * 1024;
|
|
35
43
|
/** Word 在浏览器里转换,太大的只给下载。 */
|
|
36
|
-
const DOCX_PREVIEW_MAX =
|
|
44
|
+
const DOCX_PREVIEW_MAX = 50 * 1024 * 1024;
|
|
37
45
|
/** 短期链接 30 分钟有效:每 5 分钟问一次(客户端缓存着,快到期才真的换),长时间开着的预览不会失效。 */
|
|
38
46
|
const LINK_RECHECK_MS = 5 * 60_000;
|
|
39
47
|
|
|
@@ -97,7 +105,7 @@ function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled:
|
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
type Loaded =
|
|
100
|
-
| { kind: "text"
|
|
108
|
+
| ({ kind: "text" } & LoadedText)
|
|
101
109
|
| { kind: "docx"; html: string }
|
|
102
110
|
| { kind: "none" };
|
|
103
111
|
|
|
@@ -118,7 +126,7 @@ export interface FileViewerProps {
|
|
|
118
126
|
actions?: FileAction[];
|
|
119
127
|
/** 隐藏头部(宿主自己做)。 */
|
|
120
128
|
bare?: boolean;
|
|
121
|
-
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 /
|
|
129
|
+
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
|
|
122
130
|
onQuoteText?: (text: string) => void;
|
|
123
131
|
}
|
|
124
132
|
|
|
@@ -149,23 +157,79 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
149
157
|
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
|
150
158
|
}, [big]);
|
|
151
159
|
const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
|
|
160
|
+
// 这个预览的口令:iframe 里的选区桥报上来的消息只认它(见 frameQuote.ts)。
|
|
161
|
+
const token = useMemo(frameToken, []);
|
|
152
162
|
const links = useFileLinks(backend, jobId);
|
|
153
163
|
const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
154
|
-
|
|
164
|
+
// 读大文件时的进度(已收到的字节数)。
|
|
165
|
+
const [progress, setProgress] = useState(0);
|
|
166
|
+
// HTML 要能选中引用:整个读进来(最多 HTML_QUOTE_MAX)、插上选区桥,用 srcdoc 显示(见 frameQuote.ts)。
|
|
167
|
+
// 更大的、读失败的退回原来的 src 方式(能看,不能选中引用)。相对资源按文件地址解析;中枢太旧拿不到地址时相对资源显示不了(和以前一样)。
|
|
168
|
+
// 文件地址在读到的那一刻定下来:短期链接定时换新时不重载页面。
|
|
169
|
+
const linksRef = useRef(links);
|
|
170
|
+
linksRef.current = links;
|
|
171
|
+
const htmlTooBig = size !== undefined && size > HTML_QUOTE_MAX;
|
|
172
|
+
const htmlBridge = !!onQuoteText && kind === "html" && mode === "preview" && !links.pending && !htmlTooBig;
|
|
173
|
+
const [htmlDoc, setHtmlDoc] = useState<{ srcDoc: string } | "fallback" | "toobig" | null>(null);
|
|
174
|
+
// 走 srcdoc 时不用再按地址取(中枢太旧时那是又整个读一遍)。
|
|
175
|
+
const bridged = htmlBridge && htmlDoc !== "fallback" && htmlDoc !== "toobig";
|
|
176
|
+
const src = useFileSrc(backend, jobId, path, needsSrc && !bridged, version, knownSize, links, attempt);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!htmlBridge) return;
|
|
179
|
+
const ctl = new AbortController();
|
|
180
|
+
let over = false;
|
|
181
|
+
setHtmlDoc(null);
|
|
182
|
+
setProgress(0);
|
|
183
|
+
backend.readBuffer(jobId, path, {
|
|
184
|
+
signal: ctl.signal,
|
|
185
|
+
// 大小事先不知道(从对话里打开的):读着读着超过上限就停下,改成直接打开。
|
|
186
|
+
onBytes: (n) => { setProgress(n); if (n > HTML_QUOTE_MAX && !over) { over = true; ctl.abort(); } },
|
|
187
|
+
}).then((buffer) => {
|
|
188
|
+
if (ctl.signal.aborted) return;
|
|
189
|
+
const url = linksRef.current.url?.(path);
|
|
190
|
+
const base = url ? withVersion(new URL(url, window.location.href).href, version) : undefined;
|
|
191
|
+
setHtmlDoc({ srcDoc: bridgeDoc(decodeText(new Uint8Array(buffer)).text, { token, base }) });
|
|
192
|
+
}, () => { if (over) setHtmlDoc("toobig"); else if (!ctl.signal.aborted) setHtmlDoc("fallback"); });
|
|
193
|
+
return () => { if (!over) ctl.abort(); };
|
|
194
|
+
}, [htmlBridge, backend, jobId, path, version, attempt, token]);
|
|
155
195
|
|
|
156
196
|
// 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
|
|
157
197
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
198
|
+
const loadedRef = useRef(loaded);
|
|
199
|
+
loadedRef.current = loaded;
|
|
200
|
+
const textKey = useRef("");
|
|
158
201
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
159
202
|
const stick = useRef(false);
|
|
160
203
|
// 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
|
|
161
|
-
|
|
204
|
+
// frame = 选区在 HTML / Word 预览的 iframe 里(由插进去的小脚本报上来,见 frameQuote.ts)。
|
|
205
|
+
const [sel, setSel] = useState<{ text: string; x: number; y: number; frame?: boolean } | null>(null);
|
|
206
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!onQuoteText) return;
|
|
209
|
+
const onMessage = (e: MessageEvent) => {
|
|
210
|
+
const frame = frameRef.current;
|
|
211
|
+
const root = rootRef.current;
|
|
212
|
+
const data = e.data as FrameSelMessage | null;
|
|
213
|
+
if (!frame || !root || e.source !== frame.contentWindow || !data || data.cliUiSel !== token) return;
|
|
214
|
+
const text = data.scroll ? "" : (data.text ?? "").trim();
|
|
215
|
+
if (!text) { setSel((cur) => (cur?.frame ? null : cur)); return; }
|
|
216
|
+
const box = root.getBoundingClientRect();
|
|
217
|
+
const fr = frame.getBoundingClientRect();
|
|
218
|
+
const r = data.rect ?? { left: fr.width / 2, top: 48, width: 0 };
|
|
219
|
+
setSel({ text, frame: true, x: Math.min(Math.max(8, fr.left - box.left + r.left + r.width / 2), box.width - 8), y: Math.max(40, fr.top - box.top + r.top) });
|
|
220
|
+
};
|
|
221
|
+
window.addEventListener("message", onMessage);
|
|
222
|
+
return () => window.removeEventListener("message", onMessage);
|
|
223
|
+
}, [onQuoteText, token]);
|
|
162
224
|
useEffect(() => {
|
|
163
225
|
if (!onQuoteText) return;
|
|
164
226
|
const read = () => {
|
|
165
227
|
const s = window.getSelection();
|
|
166
228
|
const body = bodyRef.current;
|
|
167
229
|
const root = rootRef.current;
|
|
168
|
-
|
|
230
|
+
// 宿主页面上没有选区:iframe 里的选区(另一个文档)不在这里,交给上面的消息处理,不清掉。
|
|
231
|
+
if (!s || s.isCollapsed || !s.rangeCount) { setSel((cur) => (cur?.frame ? cur : null)); return; }
|
|
232
|
+
if (!body || !root || !body.contains(s.anchorNode) || !body.contains(s.focusNode)) { setSel(null); return; }
|
|
169
233
|
const text = s.toString().trim();
|
|
170
234
|
if (!text) { setSel(null); return; }
|
|
171
235
|
const box = root.getBoundingClientRect();
|
|
@@ -175,14 +239,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
175
239
|
setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
|
|
176
240
|
};
|
|
177
241
|
const onUp = () => setTimeout(read, 0);
|
|
178
|
-
const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel(null); };
|
|
242
|
+
const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel((cur) => (cur?.frame ? cur : null)); };
|
|
179
243
|
const body = bodyRef.current;
|
|
180
244
|
document.addEventListener("mouseup", onUp);
|
|
181
245
|
document.addEventListener("keyup", onUp);
|
|
182
246
|
document.addEventListener("selectionchange", onChange);
|
|
183
247
|
const onScroll = () => setSel(null);
|
|
248
|
+
// 选区在 iframe 里时,点宿主页面别处(不是这个按钮)就收起。
|
|
249
|
+
const onDown = (e: MouseEvent) => { if (!(e.target instanceof Element && e.target.closest(".cli-fv-selq"))) setSel((cur) => (cur?.frame ? null : cur)); };
|
|
184
250
|
body?.addEventListener("scroll", onScroll, { passive: true });
|
|
251
|
+
document.addEventListener("mousedown", onDown);
|
|
185
252
|
return () => {
|
|
253
|
+
document.removeEventListener("mousedown", onDown);
|
|
186
254
|
document.removeEventListener("mouseup", onUp);
|
|
187
255
|
document.removeEventListener("keyup", onUp);
|
|
188
256
|
document.removeEventListener("selectionchange", onChange);
|
|
@@ -193,7 +261,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
193
261
|
if (!sel || !onQuoteText) return;
|
|
194
262
|
const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
|
|
195
263
|
onQuoteText(text);
|
|
196
|
-
|
|
264
|
+
if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear: token }, "*");
|
|
265
|
+
else window.getSelection()?.removeAllRanges();
|
|
197
266
|
setSel(null);
|
|
198
267
|
};
|
|
199
268
|
useEffect(() => {
|
|
@@ -202,7 +271,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
202
271
|
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
203
272
|
if (kind === "docx") {
|
|
204
273
|
if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
|
|
205
|
-
|
|
274
|
+
setProgress(0);
|
|
275
|
+
backend.readBuffer(jobId, path, { onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
|
|
206
276
|
const mammoth = await import("mammoth");
|
|
207
277
|
const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
|
|
208
278
|
?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
|
|
@@ -213,12 +283,18 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
213
283
|
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
214
284
|
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
215
285
|
} else {
|
|
216
|
-
|
|
286
|
+
// 只是文件变了(同一个文件、同样看开头 / 末尾):能接上就只取新增的部分(见 textLoad.ts),接不上再整个重读。
|
|
287
|
+
const key = `${path}|${tail}|${attempt}`;
|
|
288
|
+
const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
|
|
289
|
+
textKey.current = key;
|
|
290
|
+
if (!prev) setProgress(0);
|
|
291
|
+
const full = () => loadText(backend, jobId, path, { max: TEXT_MAX, tail, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
|
|
292
|
+
(prev ? refreshText(backend, jobId, path, prev, TEXT_MAX).then((r) => r ?? full(), full) : full()).then(
|
|
217
293
|
(r) => {
|
|
218
294
|
if (!alive) return;
|
|
219
295
|
const box = bodyRef.current;
|
|
220
296
|
stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
|
|
221
|
-
setLoaded({ kind: "text",
|
|
297
|
+
setLoaded({ kind: "text", ...r });
|
|
222
298
|
},
|
|
223
299
|
(e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
|
|
224
300
|
);
|
|
@@ -251,11 +327,13 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
251
327
|
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
252
328
|
const shownSize = knownSize;
|
|
253
329
|
const text = loaded?.kind === "text" ? loaded : null;
|
|
254
|
-
|
|
255
|
-
const mdTooBig = !!text && kind === "markdown" && text.text.length > MARKDOWN_RENDER_MAX;
|
|
256
|
-
const loading = !error && !src.error && (needsSrc ? !src.url : !loaded);
|
|
330
|
+
const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
|
|
257
331
|
const slow = useSlow(loading);
|
|
258
|
-
|
|
332
|
+
// 大文件显示读到哪了;很久没读完提示网络慢。
|
|
333
|
+
const progressText = progress > 0 ? `已读取 ${fmtSize(progress)}${shownSize ? ` / ${fmtSize(shownSize)}` : ""}` : "";
|
|
334
|
+
const slowNote = progressText || slow
|
|
335
|
+
? <div className="cli-fv-slow">{slow ? `网络较慢或执行节点忙,仍在读取${progressText ? `(${progressText})` : shownSize ? `(${fmtSize(shownSize)})` : ""}…` : progressText}</div>
|
|
336
|
+
: null;
|
|
259
337
|
|
|
260
338
|
return (
|
|
261
339
|
<>
|
|
@@ -303,16 +381,25 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
303
381
|
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
304
382
|
</div>
|
|
305
383
|
) : needsSrc ? (
|
|
306
|
-
|
|
384
|
+
bridged && htmlDoc === null ? <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
385
|
+
// 可以选中引用:srcdoc + 选区桥。沙箱同样不给同源,页面自己的脚本被插入的 CSP 挡住,只有桥脚本能跑。
|
|
386
|
+
: bridged && htmlDoc ? <iframe ref={frameRef} className="cli-fv-frame" srcDoc={htmlDoc.srcDoc} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
387
|
+
: !src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
307
388
|
: kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
|
|
308
389
|
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
309
390
|
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
|
|
310
391
|
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
311
|
-
:
|
|
392
|
+
: <>
|
|
393
|
+
{onQuoteText && (htmlTooBig || htmlDoc === "toobig") && <div className="cli-fv-trunc">文件超过 {fmtSize(HTML_QUOTE_MAX)},直接打开显示,不能选中文字引用(可以用「引用到对话」引用整个文件)。</div>}
|
|
394
|
+
<iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
395
|
+
</>
|
|
312
396
|
) : !loaded ? (
|
|
313
397
|
<div className="cli-fv-note">加载中…{slowNote}</div>
|
|
314
398
|
) : loaded.kind === "docx" ? (
|
|
315
|
-
|
|
399
|
+
// Word:转出来的 HTML 放进沙箱。能选中引用时插上选区桥(只放行桥脚本,文档里不会有别的脚本能跑)。
|
|
400
|
+
onQuoteText
|
|
401
|
+
? <iframe ref={frameRef} className="cli-fv-frame doc" title={name} sandbox="allow-scripts" srcDoc={bridgeDoc(docxPage(loaded.html), { token })} />
|
|
402
|
+
: <iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
|
|
316
403
|
) : text ? (
|
|
317
404
|
<>
|
|
318
405
|
{(text.truncated || tail) && (
|
|
@@ -322,11 +409,10 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
322
409
|
<button className="cli-fv-btn" onClick={() => setTail(!tail)}>{tail ? "看开头" : "看末尾"}</button>
|
|
323
410
|
</div>
|
|
324
411
|
)}
|
|
325
|
-
{
|
|
326
|
-
{kind === "markdown" && mode === "preview" && !mdTooBig ? (
|
|
412
|
+
{kind === "markdown" && mode === "preview" ? (
|
|
327
413
|
<FileLinkContext.Provider value={mdResolve}>
|
|
328
414
|
<FileOpenContext.Provider value={mdOpen}>
|
|
329
|
-
<div className="cli-fv-md"><
|
|
415
|
+
<div className="cli-fv-md"><MarkdownSections text={text.text} scrollRef={bodyRef} /></div>
|
|
330
416
|
</FileOpenContext.Provider>
|
|
331
417
|
</FileLinkContext.Provider>
|
|
332
418
|
) : kind === "csv" && mode === "preview" ? (
|
|
@@ -334,7 +420,8 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
334
420
|
) : kind === "code" && text.text.length <= HIGHLIGHT_MAX ? (
|
|
335
421
|
<div className="cli-fv-code"><Markdown>{fenced(text.text, codeLang(path))}</Markdown></div>
|
|
336
422
|
) : (
|
|
337
|
-
|
|
423
|
+
// 追加的内容各是一个文字节点:文件往后长的时候前面的不用重新排版。
|
|
424
|
+
<pre className="cli-fv-pre">{text.parts.map((p, i) => <Fragment key={i}>{p}</Fragment>)}</pre>
|
|
338
425
|
)}
|
|
339
426
|
</>
|
|
340
427
|
) : null}
|
|
@@ -398,17 +485,58 @@ function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?
|
|
|
398
485
|
}
|
|
399
486
|
|
|
400
487
|
function SourceView({ backend, jobId, path, version }: { backend: BoardBackend; jobId: string; path: string; version: number }) {
|
|
401
|
-
const [
|
|
488
|
+
const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
|
|
402
489
|
useEffect(() => {
|
|
403
490
|
let alive = true;
|
|
404
|
-
backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive)
|
|
491
|
+
backend.readText(jobId, path, TEXT_MAX).then((r) => { if (alive) setState({ text: r.text, size: r.size, truncated: r.truncated }); }, (e: unknown) => { if (alive) setState({ text: `读取失败:${e instanceof Error ? e.message : String(e)}` }); });
|
|
405
492
|
return () => { alive = false; };
|
|
406
493
|
}, [backend, jobId, path, version]);
|
|
407
|
-
|
|
494
|
+
if (!state) return <div className="cli-fv-note">加载中…</div>;
|
|
495
|
+
return (
|
|
496
|
+
<>
|
|
497
|
+
{state.truncated && <div className="cli-fv-trunc">文件较大({fmtSize(state.size ?? 0)}),源文件只显示前 {fmtSize(TEXT_MAX)};完整内容请下载。</div>}
|
|
498
|
+
<pre className="cli-fv-pre">{state.text}</pre>
|
|
499
|
+
</>
|
|
500
|
+
);
|
|
408
501
|
}
|
|
409
502
|
|
|
503
|
+
/** 一段 Markdown:内容没变就不重新渲染(文件追加时只有最后一段在变)。 */
|
|
504
|
+
const MarkdownSection = memo(function MarkdownSection({ text }: { text: string }) {
|
|
505
|
+
return <Markdown>{text}</Markdown>;
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 大 Markdown 分段渲染:先渲染前几段,滚到快看完的地方再接着渲染后面的(每段约 MARKDOWN_SECTION 字)。
|
|
510
|
+
* 小文件就是一段,和整篇渲染一样。没有 IntersectionObserver 的环境(老浏览器、服务端)一次全部渲染。
|
|
511
|
+
*/
|
|
512
|
+
function MarkdownSections({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
|
|
513
|
+
const sections = useMemo(() => splitMarkdown(text, MARKDOWN_SECTION), [text]);
|
|
514
|
+
const lazy = typeof IntersectionObserver !== "undefined";
|
|
515
|
+
const [shown, setShown] = useState(2);
|
|
516
|
+
const sentinel = useRef<HTMLDivElement | null>(null);
|
|
517
|
+
const more = shown < sections.length && lazy;
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
const el = sentinel.current;
|
|
520
|
+
if (!more || !el) return;
|
|
521
|
+
const io = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) setShown((n) => n + 2); }, { root: scrollRef.current, rootMargin: "0px 0px 2000px 0px" });
|
|
522
|
+
io.observe(el);
|
|
523
|
+
return () => io.disconnect();
|
|
524
|
+
}, [more, shown, scrollRef]);
|
|
525
|
+
const visible = lazy ? sections.slice(0, shown) : sections;
|
|
526
|
+
return (
|
|
527
|
+
<>
|
|
528
|
+
{visible.map((s, i) => <MarkdownSection key={i} text={s} />)}
|
|
529
|
+
{more && <div ref={sentinel} className="cli-fv-slow">还有 {sections.length - shown} 段,继续往下滚动显示…</div>}
|
|
530
|
+
</>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** 表格先显示这么多行,「再显示」每次多这么多(几十万行一次画出来页面会卡住)。 */
|
|
535
|
+
const CSV_PAGE = 2_000;
|
|
536
|
+
|
|
410
537
|
function CsvTable({ text }: { text: string }) {
|
|
411
|
-
const
|
|
538
|
+
const [limit, setLimit] = useState(CSV_PAGE);
|
|
539
|
+
const { rows, more } = useMemo(() => parseCsv(text, limit + 1), [text, limit]);
|
|
412
540
|
if (!rows.length) return <div className="cli-fv-note">空文件。</div>;
|
|
413
541
|
const [head, ...body] = rows;
|
|
414
542
|
return (
|
|
@@ -417,7 +545,12 @@ function CsvTable({ text }: { text: string }) {
|
|
|
417
545
|
<thead><tr>{head.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
|
|
418
546
|
<tbody>{body.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
|
|
419
547
|
</table>
|
|
420
|
-
{more &&
|
|
548
|
+
{more && (
|
|
549
|
+
<div className="cli-fv-trunc">已显示前 {body.length.toLocaleString()} 行。
|
|
550
|
+
<button className="cli-fv-btn" onClick={() => setLimit((n) => n + CSV_PAGE * 5)}>再显示 {(CSV_PAGE * 5).toLocaleString()} 行</button>
|
|
551
|
+
(也可以切到「源文件」看全部文字)
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
421
554
|
</div>
|
|
422
555
|
);
|
|
423
556
|
}
|
package/src/board/fileKind.ts
CHANGED
|
@@ -79,3 +79,34 @@ export function parseCsv(text: string, maxRows = 500): { rows: string[][]; more:
|
|
|
79
79
|
if (cell || row.length) { row.push(cell); rows.push(row); }
|
|
80
80
|
return { rows: rows.slice(0, maxRows), more: rows.length > maxRows };
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 大 Markdown 分段(每段约 size 字):在代码块外的空行处切,各段单独渲染(整篇几 MB 一次渲染会卡住页面几十秒)。
|
|
85
|
+
* 一直没有空行的超长内容到 4 倍大小也切(在代码块里切时补上收尾、下一段重新开头)。
|
|
86
|
+
*/
|
|
87
|
+
export function splitMarkdown(text: string, size: number): string[] {
|
|
88
|
+
if (text.length <= size) return [text];
|
|
89
|
+
const out: string[] = [];
|
|
90
|
+
const lines = text.split("\n");
|
|
91
|
+
let cur: string[] = [];
|
|
92
|
+
let len = 0;
|
|
93
|
+
let fence: string | null = null;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
96
|
+
if (m) {
|
|
97
|
+
if (!fence) fence = line.trim();
|
|
98
|
+
else if (line.trim().startsWith(fence.slice(0, 3)) && line.trim().replace(/[`~]/g, "") === "") fence = null;
|
|
99
|
+
}
|
|
100
|
+
cur.push(line);
|
|
101
|
+
len += line.length + 1;
|
|
102
|
+
const atBreak = !fence && line.trim() === "" && len >= size;
|
|
103
|
+
if (atBreak || len >= size * 4) {
|
|
104
|
+
if (fence) cur.push(fence.replace(/[^`~].*$/, ""));
|
|
105
|
+
out.push(cur.join("\n"));
|
|
106
|
+
cur = fence ? [fence] : [];
|
|
107
|
+
len = 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (cur.length && cur.some((l) => l !== "")) out.push(cur.join("\n"));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// HTML / Word 预览里「选中文字 → 引用到对话」。
|
|
2
|
+
//
|
|
3
|
+
// 这两种预览放在沙箱 iframe 里(不给同源,页面碰不到宿主),宿主反过来也读不到 iframe 里的选区。
|
|
4
|
+
// 所以预览改成 srcdoc:在文档最前面插一段小脚本,选区变了用 postMessage 报给宿主(带口令,宿主只认自己这个 iframe)。
|
|
5
|
+
// · 页面自己的脚本照旧不让跑(和执行节点返回 HTML 时的 CSP 一致):插入的 CSP 只放行带 nonce 的这一段;
|
|
6
|
+
// · 相对路径的样式 / 图片按文件原地址解析(<base>),文档里的 #锚点 在页内滚动(不然会按 <base> 整页跳走)。
|
|
7
|
+
|
|
8
|
+
/** iframe → 宿主:选区(text 为空 = 取消了选择)或滚动了(收起按钮)。 */
|
|
9
|
+
export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean }
|
|
10
|
+
|
|
11
|
+
const randomId = () => Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12);
|
|
12
|
+
|
|
13
|
+
/** 每个预览一个口令 / nonce。 */
|
|
14
|
+
export const frameToken = randomId;
|
|
15
|
+
|
|
16
|
+
function bridgeScript(token: string) {
|
|
17
|
+
const t = JSON.stringify(token);
|
|
18
|
+
return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
|
|
19
|
+
function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
|
|
20
|
+
if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
|
|
21
|
+
function later(){setTimeout(send,0)}
|
|
22
|
+
document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
|
|
23
|
+
document.addEventListener("selectionchange",function(){var s=getSelection();if(!s||s.isCollapsed)post({text:""})});
|
|
24
|
+
addEventListener("scroll",function(){post({scroll:true})},true);
|
|
25
|
+
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
|
|
26
|
+
document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
|
|
27
|
+
var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 给 HTML 文档插上选区桥:CSP(页面脚本不跑、只能从文件所在地址取资源)、<base>、桥脚本。
|
|
34
|
+
* base = 文件的绝对地址(相对资源按它解析);不给(Word 转出来的,图片都内嵌)时只允许 data: 资源。
|
|
35
|
+
*/
|
|
36
|
+
export function bridgeDoc(html: string, opts: { token: string; base?: string }) {
|
|
37
|
+
let origin = "";
|
|
38
|
+
if (opts.base) { try { origin = new URL(opts.base).origin; } catch { origin = ""; } }
|
|
39
|
+
const src = origin ? `${origin} ` : "";
|
|
40
|
+
const csp = `default-src 'none'; script-src 'nonce-${opts.token}'; img-src ${src}data: blob:; style-src ${src}'unsafe-inline'; font-src ${src}data:; media-src ${src}data: blob:`;
|
|
41
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
|
|
42
|
+
+ (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
|
|
43
|
+
+ `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
|
|
44
|
+
// 插在 <head> 里最前面;没有 <head> 就插在 doctype 后面(解析器会自动补出 head),不放到 doctype 前面免得变成怪异模式。
|
|
45
|
+
const headTag = /<head(\s[^>]*)?>/i.exec(html);
|
|
46
|
+
if (headTag) { const at = headTag.index + headTag[0].length; return html.slice(0, at) + head + html.slice(at); }
|
|
47
|
+
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
48
|
+
if (doctype) return doctype[0] + head + html.slice(doctype[0].length);
|
|
49
|
+
return head + html;
|
|
50
|
+
}
|