@spzxg/cli-ui 0.6.6 → 0.6.7
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/CHANGELOG.md +33 -1
- package/README.md +79 -20
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +259 -132
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -76
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +10 -8
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +14 -39
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +38 -6
- package/src/chat/CliChat.tsx +86 -46
- package/src/chat/Composer.tsx +52 -20
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +44 -57
- package/src/core/documentUpload.ts +196 -0
- package/src/core/store.ts +71 -14
- package/src/core/types.ts +1 -1
- package/src/index.ts +3 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +21 -9
- package/src/operator/node/boardBackend.ts +0 -19
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/useTaskStream.ts +3 -1
- package/src/operator.base.css +141 -38
- package/src/operator.css +141 -38
- package/src/styles.css +248 -34
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +25 -12
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +20 -10
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
package/src/board/FileViewer.tsx
CHANGED
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
// 看板和对话里的弹窗共用。文件经 center 转发读取(浏览器不直连执行节点)。
|
|
3
3
|
//
|
|
4
4
|
// 远端、实时、超大文件:
|
|
5
|
-
// ·
|
|
6
|
-
//
|
|
7
|
-
// · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
|
|
5
|
+
// · HTML 使用经授权的不可变修订与持久块缓存重组;图片 / PDF / 显式下载保留直接文件地址。
|
|
6
|
+
// · Markdown 按需增加已加载范围并窗口渲染;其他文本可切到末尾。GBK 导出按 GB18030 解。
|
|
8
7
|
// · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
|
|
9
|
-
import { Fragment,
|
|
8
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
10
|
import { fileUrlFromBase, type BoardBackend } from "../core/client";
|
|
11
11
|
import { Icon } from "../ui/icons";
|
|
12
|
+
import { Popover } from "../ui/Popover";
|
|
12
13
|
import { Markdown } from "../ui/Markdown";
|
|
14
|
+
import { MarkdownPreview, BoundedTextPreview } from "./MarkdownPreview";
|
|
15
|
+
import { HtmlReadingPreview } from "./HtmlReadingPreview";
|
|
13
16
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
14
|
-
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative,
|
|
17
|
+
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind";
|
|
15
18
|
import { loadText, refreshText, type LoadedText } from "./textLoad";
|
|
16
19
|
import { decodeText } from "../core/textDecode";
|
|
17
20
|
import { relTime } from "../ui/format";
|
|
18
21
|
import { downloadFile, mimeOf, readBoundedBuffer, FileReadLimitError } from "./fileOps";
|
|
19
22
|
import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote";
|
|
23
|
+
import { syncFile } from "./fileSync";
|
|
24
|
+
import { retainFileScope } from "./fileScope";
|
|
25
|
+
import { isTaskUploadPath } from "../core/documentUpload";
|
|
20
26
|
|
|
21
27
|
export { downloadFile };
|
|
22
28
|
|
|
@@ -29,42 +35,49 @@ export interface FileAction {
|
|
|
29
35
|
onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
//
|
|
38
|
+
// 原样预览保留完整排版;分段阅读按需加载,限制单次创建的 DOM。
|
|
33
39
|
/** 文本(代码、日志、Markdown、CSV、HTML 源文件)一次最多读这么多;更大的看开头 / 末尾。 */
|
|
34
40
|
export const TEXT_MAX = 32 * 1024 * 1024;
|
|
35
41
|
/** HTML 预览(能选中引用)最多读这么多进来;更大的直接按地址打开(能看,不能选中引用)。 */
|
|
36
42
|
export const HTML_QUOTE_MAX = 100 * 1024 * 1024;
|
|
37
43
|
/** 代码高亮到这么大(约 1 MB/秒);更大的按纯文本显示(照样能选中引用)。 */
|
|
38
44
|
const HIGHLIGHT_MAX = 512 * 1024;
|
|
39
|
-
|
|
40
|
-
const
|
|
45
|
+
const MARKDOWN_STEP = 512 * 1024;
|
|
46
|
+
const nextTextLimit = (limit: number) => Math.min(HTML_QUOTE_MAX, limit + Math.min(limit, 8 * 1024 * 1024));
|
|
41
47
|
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的图片 / PDF 不预览、不在浏览器里拼下载。 */
|
|
42
48
|
const BLOB_PREVIEW_MAX = 100 * 1024 * 1024;
|
|
43
49
|
/** Word 在浏览器里转换,太大的只给下载。 */
|
|
44
50
|
const DOCX_PREVIEW_MAX = 50 * 1024 * 1024;
|
|
45
|
-
/** 短期链接 30 分钟有效:每 5 分钟问一次(客户端缓存着,快到期才真的换),长时间开着的预览不会失效。 */
|
|
46
|
-
const LINK_RECHECK_MS = 5 * 60_000;
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* 浏览器能直接打开的文件地址。directLinks(同源代理)时马上有;否则等中枢发的短期链接(pending = 还在换);
|
|
50
54
|
* 中枢发不了(太旧)时 url 为 null、blob = true,调用方退回先 fetch 再显示。
|
|
51
55
|
*/
|
|
52
|
-
function useFileLinks(backend: BoardBackend, jobId: string): { url: ((path: string, download?: boolean) => string) | null; pending: boolean; blob: boolean } {
|
|
56
|
+
function useFileLinks(backend: BoardBackend, jobId: string, path: string, version: number, attempt: number, reloadKey: number | string | undefined, useBlocks = false): { url: ((path: string, download?: boolean) => string) | null; pending: boolean; blob: boolean } {
|
|
53
57
|
const [linkBase, setLinkBase] = useState<string | null | undefined>(undefined);
|
|
54
58
|
useEffect(() => {
|
|
55
|
-
if (backend.directLinks || !backend.fileLinkBase) return;
|
|
56
|
-
let alive = true;
|
|
57
|
-
const check = () =>
|
|
59
|
+
if (useBlocks || backend.directLinks || !backend.fileLinkBase) return;
|
|
60
|
+
let alive = true, pending = false;
|
|
61
|
+
const check = () => {
|
|
62
|
+
if (!alive || pending) return;
|
|
63
|
+
pending = true;
|
|
64
|
+
void Promise.resolve().then(() => alive ? backend.fileLinkBase!(jobId) : null).then(
|
|
65
|
+
base => { if (alive) setLinkBase(old => base ?? old ?? null); },
|
|
66
|
+
() => { if (alive) setLinkBase(old => old ?? null); },
|
|
67
|
+
).finally(() => { pending = false; });
|
|
68
|
+
};
|
|
69
|
+
const wake = () => { if (document.visibilityState === "visible") check(); };
|
|
58
70
|
void check();
|
|
59
|
-
|
|
60
|
-
return () => { alive = false;
|
|
61
|
-
}, [backend, jobId]);
|
|
71
|
+
document.addEventListener("visibilitychange", wake); window.addEventListener("online", wake);
|
|
72
|
+
return () => { alive = false; document.removeEventListener("visibilitychange", wake); window.removeEventListener("online", wake); };
|
|
73
|
+
}, [backend, jobId, path, version, attempt, reloadKey, useBlocks]);
|
|
62
74
|
return useMemo(() => {
|
|
75
|
+
if (useBlocks) return { url: null, pending: false, blob: true };
|
|
63
76
|
if (backend.directLinks) return { url: (path: string, download?: boolean) => backend.fileUrl(jobId, path, { download }), pending: false, blob: false };
|
|
64
77
|
if (!backend.fileLinkBase || linkBase === null) return { url: null, pending: false, blob: true };
|
|
65
78
|
if (linkBase === undefined) return { url: null, pending: true, blob: false };
|
|
66
79
|
return { url: (path: string, download?: boolean) => fileUrlFromBase(linkBase, path, download), pending: false, blob: false };
|
|
67
|
-
}, [backend, jobId, linkBase]);
|
|
80
|
+
}, [backend, jobId, linkBase, useBlocks]);
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
const withVersion = (url: string, version: number, attempt = 0) => {
|
|
@@ -114,6 +127,8 @@ export interface FileViewerProps {
|
|
|
114
127
|
backend: BoardBackend;
|
|
115
128
|
jobId: string;
|
|
116
129
|
path: string;
|
|
130
|
+
/** 服务端保留的上传原名;实际访问仍使用 path。 */
|
|
131
|
+
displayName?: string;
|
|
117
132
|
/** 文件信息(列表里已有的话省一次请求)。 */
|
|
118
133
|
size?: number;
|
|
119
134
|
mtime?: number;
|
|
@@ -126,11 +141,14 @@ export interface FileViewerProps {
|
|
|
126
141
|
onClose?: () => void;
|
|
127
142
|
/** 头部左侧额外内容(看板放「返回」按钮)。 */
|
|
128
143
|
lead?: ReactNode;
|
|
144
|
+
onBack?: () => void;
|
|
129
145
|
actions?: FileAction[];
|
|
130
146
|
/** 隐藏头部(宿主自己做)。 */
|
|
131
147
|
bare?: boolean;
|
|
148
|
+
/** 看板页签栏提供操作容器,避免重复标题行。 */
|
|
149
|
+
toolbarHost?: HTMLElement | null;
|
|
132
150
|
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
|
|
133
|
-
onQuoteText?: (text: string) => void;
|
|
151
|
+
onQuoteText?: (text: string, context?: { revision: string }) => void;
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
/** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
|
|
@@ -141,65 +159,118 @@ export function FileViewer(props:FileViewerProps) {
|
|
|
141
159
|
let id=viewerBackendIds.get(props.backend);if(id===undefined){id=nextViewerBackendId++;viewerBackendIds.set(props.backend,id);}
|
|
142
160
|
return <FileViewerInner key={JSON.stringify([id,props.jobId,props.path])} {...props}/>;
|
|
143
161
|
}
|
|
144
|
-
function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, actions = [], bare, onQuoteText }: FileViewerProps) {
|
|
145
|
-
const name = path.split("/").pop() || path;
|
|
162
|
+
function FileViewerInner({ backend, jobId, path, displayName, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, onBack, actions = [], bare, toolbarHost, onQuoteText }: FileViewerProps) {
|
|
163
|
+
const name = displayName || path.split("/").pop() || path;
|
|
146
164
|
const kind: PreviewKind = previewKind(path);
|
|
147
165
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
148
166
|
const [error, setError] = useState<string | null>(null);
|
|
149
167
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
150
168
|
const [copied, setCopied] = useState(false);
|
|
169
|
+
const [copying, setCopying] = useState(false);
|
|
170
|
+
const [copyError, setCopyError] = useState<string | null>(null);
|
|
171
|
+
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
172
|
+
const [actionsOpen, setActionsOpen] = useState(false);
|
|
173
|
+
const moreRef = useRef<HTMLButtonElement>(null);
|
|
151
174
|
const [busy, setBusy] = useState(false);
|
|
152
175
|
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
176
|
+
const [htmlPresentation, setHtmlPresentation] = useState<"reading" | "original">("original");
|
|
177
|
+
const htmlReading = kind === "html" && mode === "preview" && htmlPresentation === "reading";
|
|
153
178
|
// 超大文本:看开头还是看末尾(日志看末尾)。
|
|
154
179
|
const [tail, setTail] = useState(false);
|
|
180
|
+
const progressiveText = !!backend.fileSync && (kind === "text" || kind === "code");
|
|
181
|
+
const [textLimit, setTextLimit] = useState(kind === "html" || backend.fileSync && (kind === "markdown" || progressiveText) ? MARKDOWN_STEP : TEXT_MAX);
|
|
182
|
+
const [syncing, setSyncing] = useState(false);
|
|
183
|
+
const [parsing, setParsing] = useState(false);
|
|
184
|
+
const [cancelled, setCancelled] = useState(false);
|
|
185
|
+
const sourceBusy = useCallback((value: boolean) => { setSyncing(value); if (value) setCancelled(false); }, []);
|
|
186
|
+
const readingBusy = useCallback((value: boolean) => { setParsing(value); if (value) setCancelled(false); }, []);
|
|
187
|
+
const revisionRef = useRef<string | undefined>(undefined);
|
|
188
|
+
const fileReadAbort = useRef<AbortController | null>(null);
|
|
189
|
+
const readingAbort = useRef<AbortController | null>(null);
|
|
190
|
+
const cancelRead = () => { fileReadAbort.current?.abort(); readingAbort.current?.abort(); setSyncing(false); setParsing(false); setCancelled(true); };
|
|
191
|
+
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: () => {
|
|
192
|
+
fileReadAbort.current?.abort(); readingAbort.current?.abort(); revisionRef.current = undefined;
|
|
193
|
+
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError("文件访问权限已失效,请重新登录或检查任务权限");
|
|
194
|
+
} }), [backend, jobId, path]);
|
|
155
195
|
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
156
196
|
const [big, setBig] = useState(false);
|
|
197
|
+
const previousBig = useRef(big);
|
|
198
|
+
useLayoutEffect(() => {
|
|
199
|
+
if (previousBig.current !== big && toolbarHost !== undefined) moreRef.current?.focus({ preventScroll: true });
|
|
200
|
+
previousBig.current = big;
|
|
201
|
+
}, [big, toolbarHost]);
|
|
157
202
|
// 点「重试」:重新读(图片 / PDF 换一个地址参数,绕开浏览器缓存的失败)。
|
|
158
203
|
const [attempt, setAttempt] = useState(0);
|
|
204
|
+
useEffect(() => () => { if (copiedTimer.current !== null) clearTimeout(copiedTimer.current); }, []);
|
|
159
205
|
const retry = () => { setError(null); setLoaded(null); setAttempt((n) => n + 1); };
|
|
160
206
|
useEffect(() => {
|
|
161
207
|
if (!big) return;
|
|
162
|
-
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setBig(false); };
|
|
208
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented && !actionsOpen) setBig(false); };
|
|
163
209
|
const prev = document.body.style.overflow;
|
|
164
210
|
document.body.style.overflow = "hidden";
|
|
165
211
|
window.addEventListener("keydown", onKey);
|
|
166
212
|
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
|
167
|
-
}, [big]);
|
|
168
|
-
const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
|
|
213
|
+
}, [big, actionsOpen]);
|
|
214
|
+
const needsSrc = kind === "image" || kind === "pdf" || kind === "html" && !htmlReading;
|
|
169
215
|
// 这个预览的口令:iframe 里的选区桥报上来的消息只认它(见 frameQuote.ts)。
|
|
170
216
|
const token = useMemo(frameToken, []);
|
|
171
|
-
const links = useFileLinks(backend, jobId);
|
|
217
|
+
const links = useFileLinks(backend, jobId, path, version, attempt, reloadKey, !!backend.fileSync && isTaskUploadPath(path));
|
|
172
218
|
const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
173
219
|
// 读大文件时的进度(已收到的字节数)。
|
|
174
220
|
const [progress, setProgress] = useState(0);
|
|
175
|
-
// HTML
|
|
176
|
-
//
|
|
221
|
+
// HTML 默认原样预览,完整重组后插选区桥;加载或排版较慢时可切分段阅读。
|
|
222
|
+
// 新同步协议失败时保留旧完整修订并提示重试;只有自定义旧后端保留原来的地址预览。
|
|
177
223
|
// 文件地址在读到的那一刻定下来:短期链接定时换新时不重载页面。
|
|
178
224
|
const linksRef = useRef(links);
|
|
179
225
|
linksRef.current = links;
|
|
180
226
|
const htmlTooBig = size !== undefined && size > HTML_QUOTE_MAX;
|
|
181
|
-
const htmlBridge = !!onQuoteText && kind === "html" && mode === "preview" && !links.pending && !htmlTooBig;
|
|
182
|
-
const [htmlDoc, setHtmlDoc] = useState<{ srcDoc: string } | "fallback" | "toobig" | null>(null);
|
|
227
|
+
const htmlBridge = (!!backend.fileSync || !!onQuoteText) && kind === "html" && mode === "preview" && !htmlReading && !links.pending && !htmlTooBig;
|
|
228
|
+
const [htmlDoc, setHtmlDoc] = useState<{ srcDoc: string; token: string; revision?: string } | "fallback" | "toobig" | null>(null);
|
|
229
|
+
useEffect(() => { if (htmlReading || mode === "source") setHtmlDoc(null); }, [htmlReading, mode]);
|
|
230
|
+
const [frameState, setFrameState] = useState<"rendering" | "slow" | "ready">("rendering");
|
|
183
231
|
// 走 srcdoc 时不用再按地址取(中枢太旧时那是又整个读一遍)。
|
|
184
232
|
const bridged = htmlBridge && htmlDoc !== "fallback" && htmlDoc !== "toobig";
|
|
185
|
-
const src = useFileSrc(backend, jobId, path, needsSrc && !bridged, version, knownSize, links, attempt);
|
|
233
|
+
const src = useFileSrc(backend, jobId, path, needsSrc && !bridged && !(kind === "html" && mode === "source"), version, knownSize, links, attempt);
|
|
186
234
|
useEffect(() => {
|
|
187
235
|
if (!htmlBridge) return;
|
|
188
236
|
const ctl = new AbortController();
|
|
189
|
-
|
|
190
|
-
setProgress(0);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
237
|
+
fileReadAbort.current = ctl;
|
|
238
|
+
setError(null); setCancelled(false); setSyncing(true); setProgress(0);
|
|
239
|
+
const loading = backend.fileSync
|
|
240
|
+
? (async () => {
|
|
241
|
+
const transport = backend.fileSync!, options = { signal: ctl.signal, preview: true, fresh: attempt > 0, onProgress: (progress: { loadedBytes: number }) => setProgress(progress.loadedBytes) };
|
|
242
|
+
const result = await syncFile(transport, jobId, path, options);
|
|
243
|
+
return { text: result.text ?? decodeText(result.bytes).text, revision: result.revision, size: result.size };
|
|
244
|
+
})()
|
|
245
|
+
: readBoundedBuffer(backend, jobId, path, HTML_QUOTE_MAX, { signal: ctl.signal, onBytes: setProgress }).then(buffer => ({ text: decodeText(new Uint8Array(buffer)).text, revision: undefined }));
|
|
246
|
+
loading.then(result => {
|
|
196
247
|
if (ctl.signal.aborted) return;
|
|
197
248
|
const url = linksRef.current.url?.(path);
|
|
198
|
-
const base = url ?
|
|
199
|
-
|
|
200
|
-
|
|
249
|
+
const base = url ? new URL(url, window.location.href).href : undefined;
|
|
250
|
+
revisionRef.current = result.revision;
|
|
251
|
+
setHtmlDoc(previous => {
|
|
252
|
+
if (result.revision && previous && typeof previous === "object" && previous.revision === result.revision) return previous;
|
|
253
|
+
const bridgeToken = frameToken();
|
|
254
|
+
return { srcDoc: bridgeDoc(result.text, { token: bridgeToken, base }), token: bridgeToken, revision: result.revision };
|
|
255
|
+
});
|
|
256
|
+
}, (error: unknown) => {
|
|
257
|
+
if (ctl.signal.aborted) return;
|
|
258
|
+
if (backend.fileSync) {
|
|
259
|
+
if ([401, 403, 404].includes((error as { status?: number }).status ?? 0)) { setHtmlDoc(null); revisionRef.current = undefined; }
|
|
260
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
261
|
+
} else if (error instanceof FileReadLimitError) setHtmlDoc("toobig");
|
|
262
|
+
else setHtmlDoc("fallback");
|
|
263
|
+
}).finally(() => { if (!ctl.signal.aborted) setSyncing(false); });
|
|
201
264
|
return () => ctl.abort();
|
|
202
|
-
}, [htmlBridge, backend, jobId, path, version, attempt,
|
|
265
|
+
}, [htmlBridge, backend, jobId, path, version, reloadKey, attempt, htmlPresentation]);
|
|
266
|
+
const activeToken = htmlDoc && typeof htmlDoc === "object" ? htmlDoc.token : token;
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!bridged || !htmlDoc || typeof htmlDoc !== "object") return;
|
|
269
|
+
setFrameState("rendering");
|
|
270
|
+
const timer = setTimeout(() => setFrameState(state => state === "ready" ? state : "slow"), 12_000);
|
|
271
|
+
return () => clearTimeout(timer);
|
|
272
|
+
}, [bridged, htmlDoc]);
|
|
273
|
+
|
|
203
274
|
|
|
204
275
|
// 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
|
|
205
276
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -207,28 +278,34 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
207
278
|
loadedRef.current = loaded;
|
|
208
279
|
const textKey = useRef("");
|
|
209
280
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
281
|
+
const selectionButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
210
282
|
const stick = useRef(false);
|
|
211
283
|
// 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
|
|
212
284
|
// frame = 选区在 HTML / Word 预览的 iframe 里(由插进去的小脚本报上来,见 frameQuote.ts)。
|
|
213
|
-
const [sel, setSel] = useState<{ text: string; x: number; y: number; frame?: boolean } | null>(null);
|
|
285
|
+
const [sel, setSel] = useState<{ text: string; x: number; y: number; frame?: boolean; revision?: string } | null>(null);
|
|
214
286
|
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
287
|
+
const frameScroll = useRef({ top: 0, left: 0 });
|
|
288
|
+
useEffect(() => { setSel(null); }, [htmlDoc, loaded, mode]);
|
|
289
|
+
const sourceRevision = useCallback((revision: string | undefined) => { revisionRef.current = revision; setSel(null); }, []);
|
|
215
290
|
useEffect(() => {
|
|
216
|
-
if (!onQuoteText) return;
|
|
217
291
|
const onMessage = (e: MessageEvent) => {
|
|
218
292
|
const frame = frameRef.current;
|
|
219
293
|
const root = rootRef.current;
|
|
220
294
|
const data = e.data as FrameSelMessage | null;
|
|
221
|
-
if (!frame || !root || e.source !== frame.contentWindow || !data || data.cliUiSel !==
|
|
295
|
+
if (!frame || !root || e.source !== frame.contentWindow || !data || data.cliUiSel !== activeToken) return;
|
|
296
|
+
if (data.ready) { setFrameState("ready"); return; }
|
|
297
|
+
if (data.scroll) frameScroll.current = { top: Math.max(0, data.top ?? 0), left: Math.max(0, data.left ?? 0) };
|
|
298
|
+
if (!onQuoteText) return;
|
|
222
299
|
const text = data.scroll ? "" : (data.text ?? "").trim();
|
|
223
300
|
if (!text) { setSel((cur) => (cur?.frame ? null : cur)); return; }
|
|
224
301
|
const box = root.getBoundingClientRect();
|
|
225
302
|
const fr = frame.getBoundingClientRect();
|
|
226
303
|
const r = data.rect ?? { left: fr.width / 2, top: 48, width: 0 };
|
|
227
|
-
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) });
|
|
304
|
+
setSel({ text, frame: true, revision: revisionRef.current, 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) });
|
|
228
305
|
};
|
|
229
306
|
window.addEventListener("message", onMessage);
|
|
230
307
|
return () => window.removeEventListener("message", onMessage);
|
|
231
|
-
}, [onQuoteText,
|
|
308
|
+
}, [onQuoteText, activeToken]);
|
|
232
309
|
useEffect(() => {
|
|
233
310
|
if (!onQuoteText) return;
|
|
234
311
|
const read = () => {
|
|
@@ -244,7 +321,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
244
321
|
// 取不到选区位置的环境(老浏览器、测试环境):按钮放在预览顶部中间。
|
|
245
322
|
const range = s.getRangeAt(0);
|
|
246
323
|
const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
|
|
247
|
-
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) });
|
|
324
|
+
setSel({ text, revision: revisionRef.current, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
|
|
248
325
|
};
|
|
249
326
|
let selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
250
327
|
const onUp = () => {
|
|
@@ -271,19 +348,37 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
271
348
|
body?.removeEventListener("scroll", onScroll);
|
|
272
349
|
};
|
|
273
350
|
}, [onQuoteText, loaded]);
|
|
351
|
+
useLayoutEffect(() => {
|
|
352
|
+
const root = rootRef.current, button = selectionButtonRef.current;
|
|
353
|
+
if (!sel || !root || !button) return;
|
|
354
|
+
const place = () => {
|
|
355
|
+
const bounds = root.getBoundingClientRect(), size = button.getBoundingClientRect();
|
|
356
|
+
if (bounds.width <= 0) return;
|
|
357
|
+
const half = size.width / 2;
|
|
358
|
+
const headerHeight = root.querySelector(".cli-fv-h")?.getBoundingClientRect().height ?? 0;
|
|
359
|
+
const minimumTop = Math.max(8, headerHeight) + size.height + 6;
|
|
360
|
+
button.style.left = `${Math.max(half + 8, Math.min(sel.x, bounds.width - half - 8))}px`;
|
|
361
|
+
button.style.top = `${Math.max(minimumTop, Math.min(sel.y, bounds.height - 8))}px`;
|
|
362
|
+
};
|
|
363
|
+
place();
|
|
364
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(place);
|
|
365
|
+
observer?.observe(root); observer?.observe(button);
|
|
366
|
+
return () => observer?.disconnect();
|
|
367
|
+
}, [sel]);
|
|
274
368
|
const quoteSelection = () => {
|
|
275
369
|
if (!sel || !onQuoteText) return;
|
|
276
370
|
const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
|
|
277
|
-
onQuoteText(text);
|
|
278
|
-
if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear:
|
|
371
|
+
if (sel.revision) onQuoteText(text, { revision: sel.revision }); else onQuoteText(text);
|
|
372
|
+
if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear: activeToken }, "*");
|
|
279
373
|
else window.getSelection()?.removeAllRanges();
|
|
280
374
|
setSel(null);
|
|
281
375
|
};
|
|
282
376
|
useEffect(() => {
|
|
283
377
|
let alive = true;
|
|
284
378
|
const ctl=new AbortController();
|
|
285
|
-
setError(null);
|
|
379
|
+
setError(null); setCancelled(false);
|
|
286
380
|
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
381
|
+
fileReadAbort.current = ctl;
|
|
287
382
|
if (kind === "docx") {
|
|
288
383
|
if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
|
|
289
384
|
setProgress(0);
|
|
@@ -300,24 +395,30 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
300
395
|
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
301
396
|
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
302
397
|
} else {
|
|
303
|
-
//
|
|
398
|
+
// A refresh verifies the immutable revision and reuses matching blocks.
|
|
304
399
|
const key = `${path}|${tail}|${attempt}|${reloadKey??""}`;
|
|
305
400
|
const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
|
|
306
401
|
textKey.current = key;
|
|
307
402
|
if (!prev) setProgress(0);
|
|
308
|
-
|
|
309
|
-
|
|
403
|
+
setSyncing(true);
|
|
404
|
+
const max = htmlReading || backend.fileSync && (kind === "markdown" || progressiveText) ? textLimit : TEXT_MAX;
|
|
405
|
+
const full = () => loadText(backend, jobId, path, { max, tail, preview: true, fresh: attempt > 0, signal: ctl.signal, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
|
|
406
|
+
(prev ? refreshText(backend, jobId, path, prev, max, { signal: ctl.signal, preview: true, fresh: attempt > 0 }).then((r) => r ?? full()) : full()).then(
|
|
310
407
|
(r) => {
|
|
311
|
-
if (!alive) return;
|
|
408
|
+
if (!alive || ctl.signal.aborted) return;
|
|
312
409
|
const box = bodyRef.current;
|
|
313
410
|
stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
|
|
411
|
+
revisionRef.current = r.revision;
|
|
314
412
|
setLoaded({ kind: "text", ...r });
|
|
315
413
|
},
|
|
316
|
-
(e: unknown) => { if (alive
|
|
317
|
-
|
|
414
|
+
(e: unknown) => { if (alive && !ctl.signal.aborted) {
|
|
415
|
+
if ([401, 403, 404].includes((e as { status?: number }).status ?? 0)) { setLoaded(null); revisionRef.current = undefined; }
|
|
416
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
417
|
+
} },
|
|
418
|
+
).finally(() => { if (alive) setSyncing(false); });
|
|
318
419
|
}
|
|
319
420
|
return () => { alive = false; ctl.abort(); };
|
|
320
|
-
}, [backend, jobId, path, kind, needsSrc, version, reloadKey, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
421
|
+
}, [backend, jobId, path, kind, needsSrc, version, reloadKey, tail, attempt, textLimit, htmlReading]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
321
422
|
useLayoutEffect(() => {
|
|
322
423
|
const box = bodyRef.current;
|
|
323
424
|
if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
|
|
@@ -333,9 +434,24 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
333
434
|
try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setDownloadError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
334
435
|
};
|
|
335
436
|
const openUrl = links.url ? links.url(path) : src.url;
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
437
|
+
const canOpenExternal = !!openUrl && !(backend.fileSync && isTaskUploadPath(path));
|
|
438
|
+
const openNew = () => { if (canOpenExternal && openUrl) window.open(openUrl, "_blank", "noopener"); };
|
|
439
|
+
const copyPath = async () => {
|
|
440
|
+
if (copying) return;
|
|
441
|
+
setCopying(true); setCopyError(null); setCopied(false);
|
|
442
|
+
try {
|
|
443
|
+
if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持自动复制,可从文件操作菜单选择路径手动复制");
|
|
444
|
+
await navigator.clipboard.writeText(path);
|
|
445
|
+
setCopied(true);
|
|
446
|
+
if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
|
|
447
|
+
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
|
|
448
|
+
} catch (error) { setCopyError(`复制路径失败:${error instanceof Error ? error.message : "请重试"}`); }
|
|
449
|
+
finally { setCopying(false); }
|
|
450
|
+
};
|
|
451
|
+
const performAction = (action: () => void) => {
|
|
452
|
+
setActionsOpen(false);
|
|
453
|
+
moreRef.current?.focus({ preventScroll: true });
|
|
454
|
+
action();
|
|
339
455
|
};
|
|
340
456
|
|
|
341
457
|
// Markdown 里的相对链接 / 图片:相对这个文件所在目录。
|
|
@@ -343,6 +459,7 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
343
459
|
const mdOpen = useMemo(() => (onOpenPath ? (href: string) => { onOpenPath(resolveRelative(path, href)); return true; } : null), [onOpenPath, path]);
|
|
344
460
|
|
|
345
461
|
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
462
|
+
const visibleActions = actions.filter(action => !action.when || action.when({ path, name }));
|
|
346
463
|
const shownSize = knownSize;
|
|
347
464
|
const text = loaded?.kind === "text" ? loaded : null;
|
|
348
465
|
const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
|
|
@@ -353,16 +470,40 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
353
470
|
? <div className="cli-fv-slow">{slow ? `网络较慢或执行节点忙,仍在读取${progressText ? `(${progressText})` : shownSize ? `(${fmtSize(shownSize)})` : ""}…` : progressText}</div>
|
|
354
471
|
: null;
|
|
355
472
|
|
|
473
|
+
const embedded = toolbarHost !== undefined && !big;
|
|
474
|
+
const toolbar = <>
|
|
475
|
+
{(copying || copied || busy) && <span className="cli-fv-status" role="status">{copying ? "正在复制路径…" : copied ? "路径已复制" : "正在下载…"}</span>}
|
|
476
|
+
<div className="cli-fv-actions">
|
|
477
|
+
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
478
|
+
<button ref={moreRef} className="cli-fv-icon cli-fv-more" title="更多文件操作" aria-label="更多文件操作" aria-haspopup="dialog" aria-expanded={actionsOpen} onClick={() => setActionsOpen(open => !open)}>{Icon.dots()}</button>
|
|
479
|
+
{actionsOpen && <Popover className="cli-fv-menu" ariaLabel="文件操作" returnFocusRef={moreRef} onClose={() => setActionsOpen(false)}>
|
|
480
|
+
<div className="cli-fv-menu-head"><strong>文件操作</strong><span title={path}>{path}</span></div>
|
|
481
|
+
<button className="pop-item" disabled={syncing || parsing} onClick={() => performAction(() => setAttempt(value => value + 1))}>刷新内容</button>
|
|
482
|
+
{onBack && <button className="pop-item" title="返回文件列表" onClick={() => performAction(onBack)}>返回文件列表</button>}
|
|
483
|
+
{visibleActions.length > 0 && <div className="cli-fv-menu-custom">{visibleActions.map(action => (
|
|
484
|
+
<button key={action.label} className="pop-item" title={action.title} onClick={() => performAction(() => action.onClick(fileInfo(), jobId))}>{action.label}</button>
|
|
485
|
+
))}</div>}
|
|
486
|
+
{canSource && <button className="pop-item" onClick={() => performAction(() => setMode(mode === "preview" ? "source" : "preview"))} title={mode === "preview" ? "查看源文件" : "查看预览"}>{Icon.doc()} {mode === "preview" ? "源文件" : "预览"}</button>}
|
|
487
|
+
{kind === "html" && <button className="pop-item" onClick={() => performAction(() => { setMode("preview"); setHtmlPresentation(htmlReading ? "original" : "reading"); })}>{htmlReading ? "原样预览" : "分段阅读"}</button>}
|
|
488
|
+
<button className="pop-item" disabled={copying} onClick={() => performAction(() => void copyPath())} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()} {copying ? "复制中…" : copied ? "路径已复制" : "复制路径"}</button>
|
|
489
|
+
<button className="pop-item" onClick={() => performAction(() => setBig((b) => !b))} title={big ? "还原(Esc)" : "放大查看"} aria-pressed={big}>{big ? "⤡" : "⤢"} {big ? "还原大小" : "放大查看"}</button>
|
|
490
|
+
<button className="pop-item" onClick={() => performAction(openNew)} title={openUrl && !canOpenExternal ? "此附件可在看板预览或下载" : "在新窗口打开"} disabled={!canOpenExternal}>{Icon.external()} 在新窗口打开</button>
|
|
491
|
+
<button className="pop-item" onClick={() => performAction(() => void download())} title="下载" disabled={busy}>{Icon.download()} {busy ? "下载中…" : "下载"}</button>
|
|
492
|
+
</Popover>}
|
|
493
|
+
</div>
|
|
494
|
+
</>;
|
|
495
|
+
|
|
356
496
|
return (
|
|
357
497
|
<>
|
|
358
498
|
{big && <div className="cli-ui cli-fv-backdrop" onClick={() => setBig(false)} />}
|
|
359
|
-
<div className={`cli-fv ${big ? "cli-fv-big" : ""}`} ref={rootRef}>
|
|
499
|
+
<div className={`cli-fv ${big ? "cli-fv-big" : ""}`} ref={rootRef} role={big ? "dialog" : undefined} aria-modal={big || undefined} aria-label={big ? name : undefined}>
|
|
360
500
|
{sel && (
|
|
361
|
-
<button className="cli-fv-selq" style={{ left: sel.x, top: sel.y }} onMouseDown={(e) => e.preventDefault()} onClick={quoteSelection} title=
|
|
362
|
-
{Icon.board()} 引用到对话
|
|
501
|
+
<button ref={selectionButtonRef} className="cli-fv-selq" style={{ left: sel.x, top: sel.y }} onPointerDown={event => event.preventDefault()} onMouseDown={(e) => e.preventDefault()} onClick={quoteSelection} title={`把选中的${sel.text.length > QUOTE_SELECTION_MAX ? `前 ${QUOTE_SELECTION_MAX}` : sel.text.length}字带进下一条消息`}>
|
|
502
|
+
{Icon.board()} 引用到对话
|
|
363
503
|
</button>
|
|
364
504
|
)}
|
|
365
|
-
{!bare && (
|
|
505
|
+
{!bare && embedded && toolbarHost && createPortal(toolbar, toolbarHost)}
|
|
506
|
+
{!bare && !embedded && (
|
|
366
507
|
<div className="cli-fv-h">
|
|
367
508
|
{lead}
|
|
368
509
|
<span className="cli-fv-ic">{Icon.doc()}</span>
|
|
@@ -373,27 +514,27 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
373
514
|
{text?.encoding && text.encoding !== "utf-8" ? ` · ${text.encoding.toUpperCase()}` : ""}
|
|
374
515
|
</span>
|
|
375
516
|
<span className="cli-fv-sp" />
|
|
376
|
-
{
|
|
377
|
-
<button className="cli-fv-btn" onClick={() => setMode(mode === "preview" ? "source" : "preview")} title={mode === "preview" ? "查看源文件" : "查看预览"}>
|
|
378
|
-
{mode === "preview" ? "源文件" : "预览"}
|
|
379
|
-
</button>
|
|
380
|
-
)}
|
|
381
|
-
{actions.filter((a) => !a.when || a.when({ path, name })).map((a) => (
|
|
382
|
-
<button key={a.label} className="cli-fv-btn" title={a.title} onClick={() => a.onClick(fileInfo(), jobId)}>{a.label}</button>
|
|
383
|
-
))}
|
|
384
|
-
<button className="cli-fv-icon" onClick={copyPath} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()}</button>
|
|
385
|
-
<button className="cli-fv-icon cli-fv-zoom" onClick={() => setBig((b) => !b)} title={big ? "还原(Esc)" : "放大查看"} aria-pressed={big}>{big ? "⤡" : "⤢"}</button>
|
|
386
|
-
<button className="cli-fv-icon" onClick={openNew} title="在新窗口打开" disabled={!openUrl}>{Icon.external()}</button>
|
|
387
|
-
<button className="cli-fv-icon" onClick={() => void download()} title="下载" disabled={busy}>{Icon.download()}</button>
|
|
517
|
+
{toolbar}
|
|
388
518
|
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
389
519
|
</div>
|
|
390
520
|
)}
|
|
521
|
+
{copyError && <div className="cli-fv-trunc" role="alert">{copyError}
|
|
522
|
+
<button className="cli-fv-btn" disabled={copying} onClick={() => void copyPath()}>重试复制</button>
|
|
523
|
+
<button className="cli-fv-btn" onClick={() => setCopyError(null)} aria-label="关闭复制错误">关闭</button>
|
|
524
|
+
</div>}
|
|
391
525
|
{downloadError && <div className="cli-fv-trunc" role="alert">下载失败:{downloadError}
|
|
392
526
|
<button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
|
|
393
527
|
<button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
|
|
394
528
|
</div>}
|
|
395
|
-
<div className=
|
|
396
|
-
|
|
529
|
+
{error && (loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object")) && <div className="cli-fv-trunc" role="alert">同步失败:{error}。保留上次完整内容。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
|
|
530
|
+
{(syncing || parsing) && <div className="cli-fv-sync" role="status">{!syncing && parsing ? "正在整理正文…" : loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object") ? "正在核对更新…" : "正在读取文件…"}<button className="cli-fv-btn" onClick={cancelRead}>暂停加载</button>{kind === "html" && mode === "preview" && !htmlReading && <button className="cli-fv-btn" onClick={() => setHtmlPresentation("reading")}>分段阅读</button>}</div>}
|
|
531
|
+
{cancelled && <div className="cli-fv-trunc" role="status">已暂停加载,已读取的内容仍然保留。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>继续加载</button></div>}
|
|
532
|
+
{bridged && htmlDoc && typeof htmlDoc === "object" && frameState !== "ready" && <div className="cli-fv-trunc cli-fv-render" role="status">{frameState === "slow" ? "原样预览排版较慢,可切换分段阅读。" : "正在显示页面…"}<button className="cli-fv-btn" onClick={() => setHtmlPresentation("reading")}>分段阅读</button></div>}
|
|
533
|
+
<div className={`cli-fv-b k-${kind}${kind === "html" && (htmlReading || mode === "source") ? " cli-fv-flow" : ""}`} ref={bodyRef} onScroll={event => {
|
|
534
|
+
const el = event.currentTarget;
|
|
535
|
+
if (kind === "markdown" && backend.fileSync && loaded?.kind === "text" && loaded.truncated && !syncing && !error && el.scrollHeight - el.scrollTop - el.clientHeight < 500) setTextLimit(limit => nextTextLimit(limit));
|
|
536
|
+
}}>
|
|
537
|
+
{(error && loaded?.kind !== "text" && !(htmlDoc && typeof htmlDoc === "object")) || src.error ? (
|
|
397
538
|
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
398
539
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
399
540
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
@@ -403,20 +544,21 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
403
544
|
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
404
545
|
</div>
|
|
405
546
|
) : needsSrc ? (
|
|
406
|
-
|
|
547
|
+
kind === "html" && mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={`${version}:${reloadKey ?? ""}:${attempt}`} fresh={attempt > 0} onBusyChange={sourceBusy} onRevision={sourceRevision} abortRef={fileReadAbort} scrollRef={bodyRef} />
|
|
548
|
+
: bridged && htmlDoc === null ? (cancelled ? null : <div className="cli-fv-note">加载中…{slowNote}</div>)
|
|
407
549
|
// 可以选中引用:srcdoc + 选区桥。沙箱同样不给同源,页面自己的脚本被插入的 CSP 挡住,只有桥脚本能跑。
|
|
408
|
-
: bridged && htmlDoc ? <iframe ref={frameRef} className="cli-fv-frame" srcDoc={htmlDoc.srcDoc} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
550
|
+
: bridged && htmlDoc ? <iframe ref={frameRef} className="cli-fv-frame" srcDoc={htmlDoc.srcDoc} title={name} onLoad={() => frameRef.current?.contentWindow?.postMessage({ cliUiRestore: htmlDoc.token, ...frameScroll.current }, "*")} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
409
551
|
: !src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
410
552
|
: kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
|
|
411
553
|
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
412
|
-
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
|
|
554
|
+
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={`${version}:${reloadKey ?? ""}:${attempt}`} fresh={attempt > 0} onBusyChange={sourceBusy} onRevision={sourceRevision} abortRef={fileReadAbort} scrollRef={bodyRef} />
|
|
413
555
|
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
414
556
|
: <>
|
|
415
557
|
{onQuoteText && (htmlTooBig || htmlDoc === "toobig") && <div className="cli-fv-trunc">文件超过 {fmtSize(HTML_QUOTE_MAX)},直接打开显示,不能选中文字引用(可以用「引用到对话」引用整个文件)。</div>}
|
|
416
558
|
<iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
417
559
|
</>
|
|
418
560
|
) : !loaded ? (
|
|
419
|
-
<div className="cli-fv-note">加载中…{slowNote}</div>
|
|
561
|
+
cancelled ? null : <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
420
562
|
) : loaded.kind === "docx" ? (
|
|
421
563
|
// Word:转出来的 HTML 放进沙箱。能选中引用时插上选区桥(只放行桥脚本,文档里不会有别的脚本能跑)。
|
|
422
564
|
onQuoteText
|
|
@@ -424,17 +566,20 @@ function FileViewerInner({ backend, jobId, path, size, mtime, version = 0, reloa
|
|
|
424
566
|
: <iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
|
|
425
567
|
) : text ? (
|
|
426
568
|
<>
|
|
427
|
-
{(text.truncated || tail) && (
|
|
569
|
+
{(text.truncated || tail) && !htmlReading && !(kind === "markdown" && backend.fileSync) && (
|
|
428
570
|
<div className="cli-fv-trunc">
|
|
429
571
|
{tail && !text.tail ? "执行节点不支持只读末尾,下面仍是开头;" : ""}
|
|
430
|
-
文件较大({fmtSize(text.size)}),只显示{text.tail ? "最后" : "前"} {fmtSize(TEXT_MAX)};完整内容请下载。
|
|
431
|
-
<button className="cli-fv-btn" onClick={() =>
|
|
572
|
+
文件较大({fmtSize(text.size)}),只显示{text.tail ? "最后" : "前"} {fmtSize(progressiveText ? text.end - text.start : TEXT_MAX)};完整内容请下载。
|
|
573
|
+
{progressiveText && text.truncated && textLimit < TEXT_MAX && <button className="cli-fv-btn" disabled={syncing || !!error} onClick={() => setTextLimit(limit => Math.min(TEXT_MAX, nextTextLimit(limit)))}>{syncing ? "读取中…" : "继续读取"}</button>}
|
|
574
|
+
<button className="cli-fv-btn" onClick={() => { if (progressiveText) setTextLimit(MARKDOWN_STEP); setTail(!tail); }}>{tail ? "看开头" : "看末尾"}</button>
|
|
432
575
|
</div>
|
|
433
576
|
)}
|
|
434
|
-
{
|
|
577
|
+
{htmlReading ? <HtmlReadingPreview text={text.text} complete={!text.truncated} version={`${version}:${reloadKey ?? ""}:${attempt}:${text.revision ?? ""}`} onBusyChange={readingBusy} abortRef={readingAbort} scrollRef={bodyRef}
|
|
578
|
+
onSource={() => setMode("source")} onOriginal={() => setHtmlPresentation("original")} onMore={!syncing && !cancelled && text.truncated && textLimit < HTML_QUOTE_MAX ? () => setTextLimit(limit => nextTextLimit(limit)) : undefined} /> : kind === "markdown" && mode === "preview" ? (
|
|
435
579
|
<FileLinkContext.Provider value={mdResolve}>
|
|
436
580
|
<FileOpenContext.Provider value={mdOpen}>
|
|
437
|
-
<div className="cli-fv-md"><
|
|
581
|
+
<div className="cli-fv-md"><MarkdownPreview text={text.text} scrollRef={bodyRef} /></div>
|
|
582
|
+
{text.truncated && backend.fileSync && <div className="cli-fv-trunc"><button className="cli-fv-btn" disabled={syncing} onClick={() => setTextLimit(limit => nextTextLimit(limit))}>{syncing ? "读取中…" : "继续读取"}</button>已读取 {fmtSize(text.end)} / {fmtSize(text.size)}</div>}
|
|
438
583
|
</FileOpenContext.Provider>
|
|
439
584
|
</FileLinkContext.Provider>
|
|
440
585
|
) : kind === "csv" && mode === "preview" ? (
|
|
@@ -506,52 +651,34 @@ function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?
|
|
|
506
651
|
);
|
|
507
652
|
}
|
|
508
653
|
|
|
509
|
-
function SourceView({ backend, jobId, path, version }: { backend: BoardBackend; jobId: string; path: string; version:
|
|
510
|
-
const [state, setState] = useState<
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
backend.readText(jobId, path, TEXT_MAX, { signal: ctl.signal }).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)}` }); });
|
|
515
|
-
return () => { alive = false; ctl.abort(); };
|
|
516
|
-
}, [backend, jobId, path, version]);
|
|
517
|
-
if (!state) return <div className="cli-fv-note">加载中…</div>;
|
|
518
|
-
return (
|
|
519
|
-
<>
|
|
520
|
-
{state.truncated && <div className="cli-fv-trunc">文件较大({fmtSize(state.size ?? 0)}),源文件只显示前 {fmtSize(TEXT_MAX)};完整内容请下载。</div>}
|
|
521
|
-
<pre className="cli-fv-pre">{state.text}</pre>
|
|
522
|
-
</>
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** 一段 Markdown:内容没变就不重新渲染(文件追加时只有最后一段在变)。 */
|
|
527
|
-
const MarkdownSection = memo(function MarkdownSection({ text }: { text: string }) {
|
|
528
|
-
return <Markdown>{text}</Markdown>;
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* 大 Markdown 分段渲染:先渲染前几段,滚到快看完的地方再接着渲染后面的(每段约 MARKDOWN_SECTION 字)。
|
|
533
|
-
* 小文件就是一段,和整篇渲染一样。没有 IntersectionObserver 的环境(老浏览器、服务端)一次全部渲染。
|
|
534
|
-
*/
|
|
535
|
-
function MarkdownSections({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
|
|
536
|
-
const sections = useMemo(() => splitMarkdown(text, MARKDOWN_SECTION), [text]);
|
|
537
|
-
const lazy = typeof IntersectionObserver !== "undefined";
|
|
538
|
-
const [shown, setShown] = useState(2);
|
|
539
|
-
const sentinel = useRef<HTMLDivElement | null>(null);
|
|
540
|
-
const more = shown < sections.length && lazy;
|
|
654
|
+
function SourceView({ backend, jobId, path, version, fresh, onBusyChange, onRevision, abortRef, scrollRef }: { backend: BoardBackend; jobId: string; path: string; version: string; fresh?: boolean; onBusyChange: (busy: boolean) => void; onRevision: (revision: string | undefined) => void; abortRef: RefObject<AbortController | null>; scrollRef: RefObject<HTMLDivElement | null> }) {
|
|
655
|
+
const [state, setState] = useState<LoadedText | null>(null);
|
|
656
|
+
const [error, setError] = useState<string | null>(null);
|
|
657
|
+
const [limit, setLimit] = useState(backend.fileSync ? MARKDOWN_STEP : TEXT_MAX);
|
|
658
|
+
const [busy, setBusy] = useState(false);
|
|
541
659
|
useEffect(() => {
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
660
|
+
const ctl = new AbortController(); abortRef.current = ctl;
|
|
661
|
+
const cancelled = () => { setBusy(false); onBusyChange(false); };
|
|
662
|
+
ctl.signal.addEventListener("abort", cancelled, { once: true });
|
|
663
|
+
setBusy(true); onBusyChange(true); setError(null);
|
|
664
|
+
loadText(backend, jobId, path, { max: limit, signal: ctl.signal, preview: true, fresh }).then(result => {
|
|
665
|
+
if (ctl.signal.aborted) return;
|
|
666
|
+
setState(result); onRevision(result.revision);
|
|
667
|
+
}, (error: unknown) => {
|
|
668
|
+
if (ctl.signal.aborted) return;
|
|
669
|
+
if ([401, 403, 404].includes((error as { status?: number }).status ?? 0)) { setState(null); onRevision(undefined); }
|
|
670
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
671
|
+
}).finally(() => { if (!ctl.signal.aborted) { setBusy(false); onBusyChange(false); } });
|
|
672
|
+
return () => { ctl.signal.removeEventListener("abort", cancelled); ctl.abort(); onBusyChange(false); };
|
|
673
|
+
}, [backend, jobId, path, version, limit, fresh, onBusyChange, onRevision, abortRef]);
|
|
674
|
+
return <>
|
|
675
|
+
{error && <div className="cli-fv-trunc" role="alert">读取失败:{error}</div>}
|
|
676
|
+
{!state && !error && busy && <div className="cli-fv-note">加载中…</div>}
|
|
677
|
+
{state?.truncated && <div className="cli-fv-trunc">已读取 {fmtSize(limit)} / {fmtSize(state.size ?? 0)}
|
|
678
|
+
{backend.fileSync && limit < HTML_QUOTE_MAX && <button className="cli-fv-btn" disabled={busy} onClick={() => setLimit(value => nextTextLimit(value))}>{busy ? '读取中…' : '继续读取'}</button>}
|
|
679
|
+
</div>}
|
|
680
|
+
{state && <BoundedTextPreview text={state.text} scrollRef={scrollRef} />}
|
|
681
|
+
</>;
|
|
555
682
|
}
|
|
556
683
|
|
|
557
684
|
/** 表格先显示这么多行,「再显示」每次多这么多(几十万行一次画出来页面会卡住)。 */
|