@spzxg/cli-ui 0.4.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 +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- 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.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
package/src/board/FileViewer.tsx
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
// 文件预览:Markdown / 代码 / 文本 / CSV / 图片 / PDF / HTML(沙箱)/ Word,其余给下载。
|
|
2
2
|
// 看板和对话里的弹窗共用。文件经 center 转发读取(浏览器不直连执行节点)。
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
//
|
|
4
|
+
// 远端、实时、超大文件:
|
|
5
|
+
// · 图片 / PDF / HTML / 下载一律用浏览器能直接打开的地址(同源代理时就是文件接口;没有代理注入令牌时用中枢发的短期链接),
|
|
6
|
+
// 流式加载、支持断点,不把文件读进浏览器内存。中枢太旧发不了短期链接时才先 fetch 成 blob,这时超大的文件不预览、只给下载。
|
|
7
|
+
// · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
|
|
8
|
+
// · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
|
|
9
|
+
import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
10
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
|
|
5
11
|
import { Icon } from "../ui/icons.js";
|
|
6
12
|
import { Markdown } from "../ui/Markdown.js";
|
|
7
13
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
8
|
-
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";
|
|
9
17
|
import { relTime } from "../ui/format.js";
|
|
18
|
+
import { downloadFile, mimeOf } from "./fileOps.js";
|
|
19
|
+
import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote.js";
|
|
20
|
+
|
|
21
|
+
export { downloadFile };
|
|
10
22
|
|
|
11
23
|
/** 看板 / 预览里给文件加的自定义操作(例如「在对话里引用」「发到工作协同」)。 */
|
|
12
24
|
export interface FileAction {
|
|
@@ -17,56 +29,83 @@ export interface FileAction {
|
|
|
17
29
|
onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
43
|
+
/** Word 在浏览器里转换,太大的只给下载。 */
|
|
44
|
+
const DOCX_PREVIEW_MAX = 50 * 1024 * 1024;
|
|
45
|
+
/** 短期链接 30 分钟有效:每 5 分钟问一次(客户端缓存着,快到期才真的换),长时间开着的预览不会失效。 */
|
|
46
|
+
const LINK_RECHECK_MS = 5 * 60_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 浏览器能直接打开的文件地址。directLinks(同源代理)时马上有;否则等中枢发的短期链接(pending = 还在换);
|
|
50
|
+
* 中枢发不了(太旧)时 url 为 null、blob = true,调用方退回先 fetch 再显示。
|
|
51
|
+
*/
|
|
52
|
+
function useFileLinks(backend: BoardBackend, jobId: string): { url: ((path: string, download?: boolean) => string) | null; pending: boolean; blob: boolean } {
|
|
53
|
+
const [linkBase, setLinkBase] = useState<string | null | undefined>(undefined);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (backend.directLinks || !backend.fileLinkBase) return;
|
|
56
|
+
let alive = true;
|
|
57
|
+
const check = () => backend.fileLinkBase!(jobId).then((b) => { if (alive) setLinkBase(b); }, () => { if (alive) setLinkBase((old) => old ?? null); });
|
|
58
|
+
void check();
|
|
59
|
+
const timer = setInterval(check, LINK_RECHECK_MS);
|
|
60
|
+
return () => { alive = false; clearInterval(timer); };
|
|
61
|
+
}, [backend, jobId]);
|
|
62
|
+
return useMemo(() => {
|
|
63
|
+
if (backend.directLinks) return { url: (path: string, download?: boolean) => backend.fileUrl(jobId, path, { download }), pending: false, blob: false };
|
|
64
|
+
if (!backend.fileLinkBase || linkBase === null) return { url: null, pending: false, blob: true };
|
|
65
|
+
if (linkBase === undefined) return { url: null, pending: true, blob: false };
|
|
66
|
+
return { url: (path: string, download?: boolean) => fileUrlFromBase(linkBase, path, download), pending: false, blob: false };
|
|
67
|
+
}, [backend, jobId, linkBase]);
|
|
68
|
+
}
|
|
22
69
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
70
|
+
const withVersion = (url: string, version: number, attempt = 0) => {
|
|
71
|
+
const q = [version ? `v=${version}` : "", attempt ? `r=${attempt}` : ""].filter(Boolean).join("&");
|
|
72
|
+
return q ? `${url}${url.includes("?") ? "&" : "?"}${q}` : url;
|
|
26
73
|
};
|
|
74
|
+
/** 读了这么久还没好:提示「网络较慢」,不让人以为卡死了。 */
|
|
75
|
+
const SLOW_HINT_MS = 8_000;
|
|
76
|
+
function useSlow(loading: boolean) {
|
|
77
|
+
const [slow, setSlow] = useState(false);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setSlow(false);
|
|
80
|
+
if (!loading) return;
|
|
81
|
+
const t = setTimeout(() => setSlow(true), SLOW_HINT_MS);
|
|
82
|
+
return () => clearTimeout(t);
|
|
83
|
+
}, [loading]);
|
|
84
|
+
return slow;
|
|
85
|
+
}
|
|
27
86
|
|
|
28
|
-
/**
|
|
29
|
-
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number) {
|
|
87
|
+
/** 图片 / PDF / HTML 的地址:能直接用就用;只能 fetch 时先读成 blob(离开时释放),太大的不读。 */
|
|
88
|
+
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number, size: number | undefined, links: ReturnType<typeof useFileLinks>, attempt = 0) {
|
|
30
89
|
const [state, setState] = useState<{ url: string | null; error: string | null }>({ url: null, error: null });
|
|
90
|
+
const direct = enabled && links.url ? withVersion(links.url(path), version, attempt) : null;
|
|
31
91
|
useEffect(() => {
|
|
32
|
-
if (!enabled) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
setState({ url: version ? `${url}${url.includes("?") ? "&" : "?"}v=${version}` : url, error: null });
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
92
|
+
if (!enabled || !links.blob) return;
|
|
93
|
+
setState({ url: null, error: null });
|
|
94
|
+
if (size !== undefined && size > BLOB_PREVIEW_MAX) { setState({ url: null, error: `文件较大(${fmtSize(size)}),请下载后查看` }); return; }
|
|
38
95
|
let alive = true;
|
|
39
96
|
let objectUrl: string | null = null;
|
|
40
97
|
backend.readBuffer(jobId, path).then((buffer) => {
|
|
41
98
|
if (!alive) return;
|
|
42
|
-
|
|
43
|
-
objectUrl = URL.createObjectURL(new Blob([buffer], { type: MIME[ext] ?? "application/octet-stream" }));
|
|
99
|
+
objectUrl = URL.createObjectURL(new Blob([buffer], { type: mimeOf(path) }));
|
|
44
100
|
setState({ url: objectUrl, error: null });
|
|
45
101
|
}, (error: unknown) => { if (alive) setState({ url: null, error: error instanceof Error ? error.message : String(error) }); });
|
|
46
102
|
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
|
47
|
-
}, [backend, jobId, path, enabled, version]);
|
|
48
|
-
return state;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 下载:能直接用地址就用(不占内存),否则 fetch 成 blob 再存。 */
|
|
52
|
-
export async function downloadFile(backend: BoardBackend, jobId: string, path: string) {
|
|
53
|
-
const name = path.split("/").pop() || "file";
|
|
54
|
-
const a = document.createElement("a");
|
|
55
|
-
a.download = name;
|
|
56
|
-
let objectUrl: string | null = null;
|
|
57
|
-
if (backend.directLinks) a.href = backend.fileUrl(jobId, path, { download: true });
|
|
58
|
-
else {
|
|
59
|
-
objectUrl = URL.createObjectURL(new Blob([await backend.readBuffer(jobId, path)]));
|
|
60
|
-
a.href = objectUrl;
|
|
61
|
-
}
|
|
62
|
-
document.body.appendChild(a);
|
|
63
|
-
a.click();
|
|
64
|
-
a.remove();
|
|
65
|
-
if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl!), 30_000);
|
|
103
|
+
}, [backend, jobId, path, enabled, version, links.blob, size, attempt]);
|
|
104
|
+
return direct ? { url: direct, error: null } : state;
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
type Loaded =
|
|
69
|
-
| { kind: "text"
|
|
108
|
+
| ({ kind: "text" } & LoadedText)
|
|
70
109
|
| { kind: "docx"; html: string }
|
|
71
110
|
| { kind: "none" };
|
|
72
111
|
|
|
@@ -87,9 +126,14 @@ export interface FileViewerProps {
|
|
|
87
126
|
actions?: FileAction[];
|
|
88
127
|
/** 隐藏头部(宿主自己做)。 */
|
|
89
128
|
bare?: boolean;
|
|
129
|
+
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
|
|
130
|
+
onQuoteText?: (text: string) => void;
|
|
90
131
|
}
|
|
91
132
|
|
|
92
|
-
|
|
133
|
+
/** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
|
|
134
|
+
const QUOTE_SELECTION_MAX = 3_000;
|
|
135
|
+
|
|
136
|
+
export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onOpenPath, onClose, lead, actions = [], bare, onQuoteText }: FileViewerProps) {
|
|
93
137
|
const name = path.split("/").pop() || path;
|
|
94
138
|
const kind: PreviewKind = previewKind(path);
|
|
95
139
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
@@ -97,15 +141,138 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
97
141
|
const [copied, setCopied] = useState(false);
|
|
98
142
|
const [busy, setBusy] = useState(false);
|
|
99
143
|
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
144
|
+
// 超大文本:看开头还是看末尾(日志看末尾)。
|
|
145
|
+
const [tail, setTail] = useState(false);
|
|
146
|
+
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
147
|
+
const [big, setBig] = useState(false);
|
|
148
|
+
// 点「重试」:重新读(图片 / PDF 换一个地址参数,绕开浏览器缓存的失败)。
|
|
149
|
+
const [attempt, setAttempt] = useState(0);
|
|
150
|
+
const retry = () => { setError(null); setLoaded(null); setAttempt((n) => n + 1); };
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!big) return;
|
|
153
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setBig(false); };
|
|
154
|
+
const prev = document.body.style.overflow;
|
|
155
|
+
document.body.style.overflow = "hidden";
|
|
156
|
+
window.addEventListener("keydown", onKey);
|
|
157
|
+
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
|
158
|
+
}, [big]);
|
|
100
159
|
const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
|
|
101
|
-
|
|
160
|
+
// 这个预览的口令:iframe 里的选区桥报上来的消息只认它(见 frameQuote.ts)。
|
|
161
|
+
const token = useMemo(frameToken, []);
|
|
162
|
+
const links = useFileLinks(backend, jobId);
|
|
163
|
+
const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
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]);
|
|
102
195
|
|
|
196
|
+
// 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
|
|
197
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
198
|
+
const loadedRef = useRef(loaded);
|
|
199
|
+
loadedRef.current = loaded;
|
|
200
|
+
const textKey = useRef("");
|
|
201
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
202
|
+
const stick = useRef(false);
|
|
203
|
+
// 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
|
|
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]);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!onQuoteText) return;
|
|
226
|
+
const read = () => {
|
|
227
|
+
const s = window.getSelection();
|
|
228
|
+
const body = bodyRef.current;
|
|
229
|
+
const root = rootRef.current;
|
|
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; }
|
|
233
|
+
const text = s.toString().trim();
|
|
234
|
+
if (!text) { setSel(null); return; }
|
|
235
|
+
const box = root.getBoundingClientRect();
|
|
236
|
+
// 取不到选区位置的环境(老浏览器、测试环境):按钮放在预览顶部中间。
|
|
237
|
+
const range = s.getRangeAt(0);
|
|
238
|
+
const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
|
|
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) });
|
|
240
|
+
};
|
|
241
|
+
const onUp = () => setTimeout(read, 0);
|
|
242
|
+
const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel((cur) => (cur?.frame ? cur : null)); };
|
|
243
|
+
const body = bodyRef.current;
|
|
244
|
+
document.addEventListener("mouseup", onUp);
|
|
245
|
+
document.addEventListener("keyup", onUp);
|
|
246
|
+
document.addEventListener("selectionchange", onChange);
|
|
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)); };
|
|
250
|
+
body?.addEventListener("scroll", onScroll, { passive: true });
|
|
251
|
+
document.addEventListener("mousedown", onDown);
|
|
252
|
+
return () => {
|
|
253
|
+
document.removeEventListener("mousedown", onDown);
|
|
254
|
+
document.removeEventListener("mouseup", onUp);
|
|
255
|
+
document.removeEventListener("keyup", onUp);
|
|
256
|
+
document.removeEventListener("selectionchange", onChange);
|
|
257
|
+
body?.removeEventListener("scroll", onScroll);
|
|
258
|
+
};
|
|
259
|
+
}, [onQuoteText, loaded]);
|
|
260
|
+
const quoteSelection = () => {
|
|
261
|
+
if (!sel || !onQuoteText) return;
|
|
262
|
+
const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
|
|
263
|
+
onQuoteText(text);
|
|
264
|
+
if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear: token }, "*");
|
|
265
|
+
else window.getSelection()?.removeAllRanges();
|
|
266
|
+
setSel(null);
|
|
267
|
+
};
|
|
103
268
|
useEffect(() => {
|
|
104
269
|
let alive = true;
|
|
105
270
|
setError(null);
|
|
106
271
|
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
107
272
|
if (kind === "docx") {
|
|
108
|
-
|
|
273
|
+
if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
|
|
274
|
+
setProgress(0);
|
|
275
|
+
backend.readBuffer(jobId, path, { onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
|
|
109
276
|
const mammoth = await import("mammoth");
|
|
110
277
|
const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
|
|
111
278
|
?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
|
|
@@ -116,39 +283,67 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
116
283
|
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
117
284
|
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
118
285
|
} else {
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
293
|
+
(r) => {
|
|
294
|
+
if (!alive) return;
|
|
295
|
+
const box = bodyRef.current;
|
|
296
|
+
stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
|
|
297
|
+
setLoaded({ kind: "text", ...r });
|
|
298
|
+
},
|
|
121
299
|
(e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
|
|
122
300
|
);
|
|
123
301
|
}
|
|
124
302
|
return () => { alive = false; };
|
|
125
|
-
}, [backend, jobId, path, kind, needsSrc, version]);
|
|
303
|
+
}, [backend, jobId, path, kind, needsSrc, version, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
304
|
+
useLayoutEffect(() => {
|
|
305
|
+
const box = bodyRef.current;
|
|
306
|
+
if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
|
|
307
|
+
}, [loaded]);
|
|
126
308
|
|
|
127
309
|
const fileInfo = useCallback(() => ({
|
|
128
|
-
path, name, url: backend.fileUrl(jobId, path), downloadUrl: backend.fileUrl(jobId, path, { download: true }),
|
|
129
|
-
}), [backend, jobId, path, name]);
|
|
310
|
+
path, name, url: links.url?.(path) ?? backend.fileUrl(jobId, path), downloadUrl: links.url?.(path, true) ?? backend.fileUrl(jobId, path, { download: true }),
|
|
311
|
+
}), [backend, jobId, path, name, links]);
|
|
130
312
|
|
|
131
313
|
const download = async () => {
|
|
132
314
|
setBusy(true);
|
|
133
|
-
try { await downloadFile(backend, jobId, path); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
134
|
-
};
|
|
135
|
-
const openNew = () => {
|
|
136
|
-
if (backend.directLinks) window.open(backend.fileUrl(jobId, path), "_blank", "noopener");
|
|
137
|
-
else if (src.url) window.open(src.url, "_blank", "noopener");
|
|
315
|
+
try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
138
316
|
};
|
|
317
|
+
const openUrl = links.url ? links.url(path) : src.url;
|
|
318
|
+
const openNew = () => { if (openUrl) window.open(openUrl, "_blank", "noopener"); };
|
|
139
319
|
const copyPath = () => {
|
|
140
320
|
void navigator.clipboard?.writeText(path).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }, () => undefined);
|
|
141
321
|
};
|
|
142
322
|
|
|
143
323
|
// Markdown 里的相对链接 / 图片:相对这个文件所在目录。
|
|
144
|
-
const mdResolve = useMemo(() => (href: string) =>
|
|
324
|
+
const mdResolve = useMemo(() => (href: string) => links.url?.(resolveRelative(path, href)) ?? null, [links, path]);
|
|
145
325
|
const mdOpen = useMemo(() => (onOpenPath ? (href: string) => { onOpenPath(resolveRelative(path, href)); return true; } : null), [onOpenPath, path]);
|
|
146
326
|
|
|
147
327
|
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
148
|
-
const shownSize =
|
|
328
|
+
const shownSize = knownSize;
|
|
329
|
+
const text = loaded?.kind === "text" ? loaded : null;
|
|
330
|
+
const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
|
|
331
|
+
const slow = useSlow(loading);
|
|
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;
|
|
149
337
|
|
|
150
338
|
return (
|
|
151
|
-
|
|
339
|
+
<>
|
|
340
|
+
{big && <div className="cli-ui cli-fv-backdrop" onClick={() => setBig(false)} />}
|
|
341
|
+
<div className={`cli-fv ${big ? "cli-fv-big" : ""}`} ref={rootRef}>
|
|
342
|
+
{sel && (
|
|
343
|
+
<button className="cli-fv-selq" style={{ left: sel.x, top: sel.y }} onMouseDown={(e) => e.preventDefault()} onClick={quoteSelection} title="把选中的这段带进下一条消息">
|
|
344
|
+
{Icon.board()} 引用到对话{sel.text.length > 1 ? `(${sel.text.length > QUOTE_SELECTION_MAX ? `前 ${QUOTE_SELECTION_MAX}` : sel.text.length} 字)` : ""}
|
|
345
|
+
</button>
|
|
346
|
+
)}
|
|
152
347
|
{!bare && (
|
|
153
348
|
<div className="cli-fv-h">
|
|
154
349
|
{lead}
|
|
@@ -157,6 +352,7 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
157
352
|
<span className="cli-fv-meta">
|
|
158
353
|
{shownSize !== undefined && fmtSize(shownSize)}
|
|
159
354
|
{mtime && mtime > 0 ? ` · ${relTime(mtime)}更新` : ""}
|
|
355
|
+
{text?.encoding && text.encoding !== "utf-8" ? ` · ${text.encoding.toUpperCase()}` : ""}
|
|
160
356
|
</span>
|
|
161
357
|
<span className="cli-fv-sp" />
|
|
162
358
|
{canSource && (
|
|
@@ -168,14 +364,16 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
168
364
|
<button key={a.label} className="cli-fv-btn" title={a.title} onClick={() => a.onClick(fileInfo(), jobId)}>{a.label}</button>
|
|
169
365
|
))}
|
|
170
366
|
<button className="cli-fv-icon" onClick={copyPath} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()}</button>
|
|
171
|
-
<button className="cli-fv-icon" onClick={
|
|
367
|
+
<button className="cli-fv-icon cli-fv-zoom" onClick={() => setBig((b) => !b)} title={big ? "还原(Esc)" : "放大查看"} aria-pressed={big}>{big ? "⤡" : "⤢"}</button>
|
|
368
|
+
<button className="cli-fv-icon" onClick={openNew} title="在新窗口打开" disabled={!openUrl}>{Icon.external()}</button>
|
|
172
369
|
<button className="cli-fv-icon" onClick={() => void download()} title="下载" disabled={busy}>{Icon.download()}</button>
|
|
173
370
|
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
174
371
|
</div>
|
|
175
372
|
)}
|
|
176
|
-
<div className={`cli-fv-b k-${kind}`}>
|
|
373
|
+
<div className={`cli-fv-b k-${kind}`} ref={bodyRef}>
|
|
177
374
|
{error || src.error ? (
|
|
178
375
|
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
376
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
179
377
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
|
|
180
378
|
</div>
|
|
181
379
|
) : kind === "binary" ? (
|
|
@@ -183,51 +381,162 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
183
381
|
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
184
382
|
</div>
|
|
185
383
|
) : needsSrc ? (
|
|
186
|
-
|
|
187
|
-
|
|
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>
|
|
388
|
+
: kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
|
|
188
389
|
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
189
390
|
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
|
|
190
391
|
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
191
|
-
:
|
|
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
|
+
</>
|
|
192
396
|
) : !loaded ? (
|
|
193
|
-
<div className="cli-fv-note"
|
|
397
|
+
<div className="cli-fv-note">加载中…{slowNote}</div>
|
|
194
398
|
) : loaded.kind === "docx" ? (
|
|
195
|
-
|
|
196
|
-
|
|
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)} />
|
|
403
|
+
) : text ? (
|
|
197
404
|
<>
|
|
198
|
-
{
|
|
405
|
+
{(text.truncated || tail) && (
|
|
406
|
+
<div className="cli-fv-trunc">
|
|
407
|
+
{tail && !text.tail ? "执行节点不支持只读末尾,下面仍是开头;" : ""}
|
|
408
|
+
文件较大({fmtSize(text.size)}),只显示{text.tail ? "最后" : "前"} {fmtSize(TEXT_MAX)};完整内容请下载。
|
|
409
|
+
<button className="cli-fv-btn" onClick={() => setTail(!tail)}>{tail ? "看开头" : "看末尾"}</button>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
199
412
|
{kind === "markdown" && mode === "preview" ? (
|
|
200
413
|
<FileLinkContext.Provider value={mdResolve}>
|
|
201
414
|
<FileOpenContext.Provider value={mdOpen}>
|
|
202
|
-
<div className="cli-fv-md"><
|
|
415
|
+
<div className="cli-fv-md"><MarkdownSections text={text.text} scrollRef={bodyRef} /></div>
|
|
203
416
|
</FileOpenContext.Provider>
|
|
204
417
|
</FileLinkContext.Provider>
|
|
205
418
|
) : kind === "csv" && mode === "preview" ? (
|
|
206
|
-
<CsvTable text={
|
|
207
|
-
) : kind === "code" &&
|
|
208
|
-
<div className="cli-fv-code"><Markdown>{fenced(
|
|
419
|
+
<CsvTable text={text.text} />
|
|
420
|
+
) : kind === "code" && text.text.length <= HIGHLIGHT_MAX ? (
|
|
421
|
+
<div className="cli-fv-code"><Markdown>{fenced(text.text, codeLang(path))}</Markdown></div>
|
|
209
422
|
) : (
|
|
210
|
-
|
|
423
|
+
// 追加的内容各是一个文字节点:文件往后长的时候前面的不用重新排版。
|
|
424
|
+
<pre className="cli-fv-pre">{text.parts.map((p, i) => <Fragment key={i}>{p}</Fragment>)}</pre>
|
|
211
425
|
)}
|
|
212
426
|
</>
|
|
213
427
|
) : null}
|
|
214
428
|
</div>
|
|
215
429
|
</div>
|
|
430
|
+
</>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 图片:缺省适应宽度;可以放大缩小、看原始大小,Ctrl / ⌘ + 滚轮也能缩放。 */
|
|
435
|
+
function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?: () => void }) {
|
|
436
|
+
const [scale, setScale] = useState<number | null>(null); // null = 适应
|
|
437
|
+
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
|
438
|
+
// 加载失败(网络断了、链接过期、文件坏了 / 不是浏览器认识的图片):说清楚,给重试。
|
|
439
|
+
const [failed, setFailed] = useState(false);
|
|
440
|
+
const loadingImg = !natural && !failed;
|
|
441
|
+
const slow = useSlow(loadingImg);
|
|
442
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
443
|
+
const fitScale = () => {
|
|
444
|
+
const box = boxRef.current;
|
|
445
|
+
if (!box || !natural) return 1;
|
|
446
|
+
return Math.min(1, (box.clientWidth - 32) / natural.w);
|
|
447
|
+
};
|
|
448
|
+
const zoom = (factor: number) => setScale((s) => Math.min(8, Math.max(0.1, (s ?? fitScale()) * factor)));
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
const box = boxRef.current;
|
|
451
|
+
if (!box) return;
|
|
452
|
+
const onWheel = (e: WheelEvent) => {
|
|
453
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1);
|
|
456
|
+
};
|
|
457
|
+
box.addEventListener("wheel", onWheel, { passive: false });
|
|
458
|
+
return () => box.removeEventListener("wheel", onWheel);
|
|
459
|
+
}); // eslint-disable-line react-hooks/exhaustive-deps
|
|
460
|
+
const style = scale === null || !natural ? undefined : { width: Math.round(natural.w * scale), maxWidth: "none" };
|
|
461
|
+
return (
|
|
462
|
+
<div className="cli-fv-imgwrap">
|
|
463
|
+
<div className="cli-fv-imgbar">
|
|
464
|
+
<button className="cli-fv-btn" onClick={() => zoom(1 / 1.25)} title="缩小">−</button>
|
|
465
|
+
<span className="cli-fv-imgpct">{scale === null ? "适应" : `${Math.round(scale * 100)}%`}</span>
|
|
466
|
+
<button className="cli-fv-btn" onClick={() => zoom(1.25)} title="放大">+</button>
|
|
467
|
+
<button className="cli-fv-btn" onClick={() => setScale(null)} disabled={scale === null}>适应</button>
|
|
468
|
+
<button className="cli-fv-btn" onClick={() => setScale(1)} disabled={scale === 1}>原始大小</button>
|
|
469
|
+
{natural && <span className="cli-fv-imgpct">{natural.w} × {natural.h}</span>}
|
|
470
|
+
</div>
|
|
471
|
+
<div className={`cli-fv-img ${scale === null ? "" : "zoomed"}`} ref={boxRef}>
|
|
472
|
+
{failed ? (
|
|
473
|
+
<div className="cli-fv-img-err">图片没有加载出来(网络中断、链接过期,或文件损坏 / 格式不支持)。
|
|
474
|
+
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>重试</button>}
|
|
475
|
+
</div>
|
|
476
|
+
) : (
|
|
477
|
+
<>
|
|
478
|
+
<img src={src} alt={name} style={style} onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} onError={() => setFailed(true)} />
|
|
479
|
+
{slow && <div className="cli-fv-slow">图片较大或网络较慢,仍在加载…</div>}
|
|
480
|
+
</>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
216
484
|
);
|
|
217
485
|
}
|
|
218
486
|
|
|
219
487
|
function SourceView({ backend, jobId, path, version }: { backend: BoardBackend; jobId: string; path: string; version: number }) {
|
|
220
|
-
const [
|
|
488
|
+
const [state, setState] = useState<{ text: string; size?: number; truncated?: boolean } | null>(null);
|
|
221
489
|
useEffect(() => {
|
|
222
490
|
let alive = true;
|
|
223
|
-
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)}` }); });
|
|
224
492
|
return () => { alive = false; };
|
|
225
493
|
}, [backend, jobId, path, version]);
|
|
226
|
-
|
|
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
|
+
);
|
|
501
|
+
}
|
|
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
|
+
);
|
|
227
532
|
}
|
|
228
533
|
|
|
534
|
+
/** 表格先显示这么多行,「再显示」每次多这么多(几十万行一次画出来页面会卡住)。 */
|
|
535
|
+
const CSV_PAGE = 2_000;
|
|
536
|
+
|
|
229
537
|
function CsvTable({ text }: { text: string }) {
|
|
230
|
-
const
|
|
538
|
+
const [limit, setLimit] = useState(CSV_PAGE);
|
|
539
|
+
const { rows, more } = useMemo(() => parseCsv(text, limit + 1), [text, limit]);
|
|
231
540
|
if (!rows.length) return <div className="cli-fv-note">空文件。</div>;
|
|
232
541
|
const [head, ...body] = rows;
|
|
233
542
|
return (
|
|
@@ -236,7 +545,12 @@ function CsvTable({ text }: { text: string }) {
|
|
|
236
545
|
<thead><tr>{head.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
|
|
237
546
|
<tbody>{body.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
|
|
238
547
|
</table>
|
|
239
|
-
{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
|
+
)}
|
|
240
554
|
</div>
|
|
241
555
|
);
|
|
242
556
|
}
|