@spzxg/cli-ui 0.6.6 → 0.6.8

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +90 -21
  3. package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +121 -51
  34. package/src/chat/Composer.tsx +54 -22
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +143 -46
  37. package/src/chat/draft.ts +12 -4
  38. package/src/chat/useExecutionConfig.ts +93 -0
  39. package/src/components.tsx +1 -1
  40. package/src/core/blocks.ts +2 -1
  41. package/src/core/client.ts +65 -61
  42. package/src/core/config.ts +6 -2
  43. package/src/core/documentUpload.ts +196 -0
  44. package/src/core/execution.ts +24 -0
  45. package/src/core/store.ts +81 -20
  46. package/src/core/types.ts +3 -1
  47. package/src/index.ts +4 -0
  48. package/src/msglist/CliMsgList.tsx +9 -2
  49. package/src/operator/node/Chat.tsx +11 -9
  50. package/src/operator/node/Composer.tsx +103 -75
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +31 -28
  54. package/src/operator/node/Transcript.tsx +21 -9
  55. package/src/operator/node/boardBackend.ts +0 -19
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/useTaskStream.ts +3 -1
  63. package/src/operator.base.css +150 -43
  64. package/src/operator.css +150 -43
  65. package/src/styles.css +253 -39
  66. package/src/ui/Markdown.tsx +2 -2
  67. package/src/ui/Popover.tsx +25 -12
  68. package/src/ui/QueuedMessages.tsx +69 -0
  69. package/src/ui/ReferenceCard.tsx +28 -0
  70. package/src/ui/TextBlock.tsx +6 -4
  71. package/src/ui/Transcript.tsx +20 -10
  72. package/src/ui/UploadProgress.tsx +46 -0
  73. package/src/ui/WorkspaceDrawer.tsx +64 -0
  74. package/src/ui/WorkspaceTabs.tsx +60 -0
  75. package/src/ui/format.ts +3 -1
  76. package/src/ui/reference.ts +40 -0
  77. package/src/ui/useChatScrollbar.ts +29 -0
  78. package/src/ui/useComposerAutosize.ts +54 -0
  79. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -1,12 +1,14 @@
1
1
  // 工作区文件:面包屑 + 列表。目录点进去,文件点开预览;行尾可直接下载。
2
2
  // 列表随任务进展自动刷新(CliBoard 给 refreshKey),这次刷新里新出现 / 改过的文件标出来。
3
3
  import { useEffect, useRef, useState } from "react";
4
- import { CenterError, type BoardBackend } from "../core/client";
4
+ import { createPortal } from "react-dom";
5
+ import type { BoardBackend } from "../core/client";
5
6
  import type { FileEntry } from "../core/types";
6
7
  import { Icon } from "../ui/icons";
7
8
  import { relTime } from "../ui/format";
8
9
  import { downloadFile } from "./fileOps";
9
10
  import { fmtSize } from "./fileKind";
11
+ import { retainFileScope } from "./fileScope";
10
12
  import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload";
11
13
 
12
14
  export interface FilesPaneProps {
@@ -17,15 +19,64 @@ export interface FilesPaneProps {
17
19
  onOpen: (entry: FileEntry) => void;
18
20
  refreshKey: number;
19
21
  /** 列表读到了(看板用来判断打开着的文件有没有更新)。 */
22
+ toolbarHost?: HTMLElement | null;
20
23
  onListed?: (dir: string, entries: FileEntry[]) => void;
21
24
  }
22
25
 
23
- export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed }: FilesPaneProps) {
26
+ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed, toolbarHost }: FilesPaneProps) {
24
27
  const [entries, setEntries] = useState<FileEntry[] | null>(null);
25
28
  const [truncated, setTruncated] = useState(false);
26
29
  const [total, setTotal] = useState<number | undefined>(undefined);
27
30
  const [error, setError] = useState<string | null>(null);
28
31
  const [loading, setLoading] = useState(false);
32
+ const [retry, setRetry] = useState(0);
33
+ const [nextCursor, setNextCursor] = useState<string | undefined>();
34
+ const [moreLoading, setMoreLoading] = useState(false);
35
+ const [scrollTop, setScrollTop] = useState(0);
36
+ const [viewport, setViewport] = useState(640);
37
+ const [inputDirectory, setInputDirectory] = useState<string | null>(null);
38
+ const inInputs = !!dir && (dir === inputDirectory || /\/(?:uploads|attachments)\/[^/]+\/?$/.test(dir));
39
+ useEffect(() => {
40
+ const controller = new AbortController(); setInputDirectory(null);
41
+ void backend.uploadDirectory?.(jobId, controller.signal).then(path => { if (!controller.signal.aborted) setInputDirectory(path); }, () => {});
42
+ return () => controller.abort();
43
+ }, [backend, jobId]);
44
+ const listRef = useRef<HTMLDivElement>(null);
45
+ const pageAbort = useRef<AbortController | null>(null);
46
+ const generation = useRef(0);
47
+ const entriesRef = useRef(entries); entriesRef.current = entries;
48
+ const rowHeight = 52;
49
+ const first = Math.min(Math.max(0, (entries?.length ?? 0) - 1), Math.max(0, Math.floor(scrollTop / rowHeight) - 4));
50
+ const end = Math.min(entries?.length ?? 0, first + Math.min(80, Math.ceil(viewport / rowHeight) + 8));
51
+ const visiblePaths = (entries ?? []).slice(first, end).filter(file => file.type === "file").map(file => file.path);
52
+ const scopeKey = JSON.stringify(visiblePaths);
53
+ useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: () => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError("工作区访问权限已失效,请重新登录或检查任务权限"); } }), [backend, jobId, dir, scopeKey]);
54
+ useEffect(() => {
55
+ if (!listRef.current || typeof ResizeObserver === "undefined") return;
56
+ const observer = new ResizeObserver(([box]) => { if (box.contentRect.height) setViewport(box.contentRect.height); });
57
+ observer.observe(listRef.current); return () => observer.disconnect();
58
+ }, [!!entries]);
59
+ const page = (cursor: string | undefined, signal: AbortSignal) => backend.listFiles
60
+ ? backend.listFiles(jobId, dir || ".", { cursor, limit: 200, signal })
61
+ : backend.stat(jobId, dir || ".");
62
+ const loadMore = async () => {
63
+ if (!nextCursor || moreLoading || loading || !backend.listFiles) return;
64
+ const token = generation.current, ctl = new AbortController(); pageAbort.current = ctl;
65
+ setMoreLoading(true);
66
+ try {
67
+ const result = await page(nextCursor, ctl.signal);
68
+ if (ctl.signal.aborted || token !== generation.current || result.type !== "dir") return;
69
+ const previous = entriesRef.current ?? [], paths = new Set(previous.map(file => file.path));
70
+ const joined = [...previous, ...result.entries.filter(file => !paths.has(file.path))];
71
+ setEntries(joined); setNextCursor(result.nextCursor); setTruncated(result.truncated); setTotal(result.total);
72
+ onListedRef.current?.(dir, joined);
73
+ } catch (error) {
74
+ if (ctl.signal.aborted || token !== generation.current) return;
75
+ if ((error as { status?: number }).status === 409) { setRetry(value => value + 1); return; }
76
+ setError(error instanceof Error ? error.message : String(error));
77
+ } finally { if (token === generation.current) setMoreLoading(false); }
78
+ };
79
+ const crumbsRef = useRef<HTMLDivElement>(null);
29
80
  // 这个目录上一次看到的 mtime:用来标「新」。第一次打开目录不标。
30
81
  const seen = useRef<{ dir: string; mtimes: Map<string, number> | null }>({ dir, mtimes: null });
31
82
  const [fresh, setFresh] = useState<Set<string>>(new Set());
@@ -74,10 +125,26 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
74
125
 
75
126
  useEffect(() => {
76
127
  let alive = true;
128
+ const ctl = new AbortController();
129
+ const token = ++generation.current; pageAbort.current?.abort(); pageAbort.current = ctl; setMoreLoading(false);
77
130
  setLoading(true);
78
- if (seen.current.dir !== dir) { seen.current = { dir, mtimes: null }; setEntries(null); setFresh(new Set()); }
79
- backend.stat(jobId, dir || ".").then((res) => {
80
- if (!alive) return;
131
+ const sameDirectory = seen.current.dir === dir;
132
+ const wanted = sameDirectory ? Math.max(200, entriesRef.current?.length ?? 0) : 200;
133
+ if (!sameDirectory) { seen.current = { dir, mtimes: null }; setEntries(null); setFresh(new Set()); setScrollTop(0); if (listRef.current) listRef.current.scrollTop = 0; }
134
+ const reload = async () => {
135
+ let result = await page(undefined, ctl.signal);
136
+ if (result.type !== "dir") return result;
137
+ const combined = [...result.entries];
138
+ // Preserve only pages the user has already requested, never preload the directory.
139
+ while (backend.listFiles && result.nextCursor && combined.length < wanted && !ctl.signal.aborted) {
140
+ const next = await page(result.nextCursor, ctl.signal);
141
+ if (next.type !== "dir") break;
142
+ combined.push(...next.entries); result = next;
143
+ }
144
+ return { ...result, entries: combined };
145
+ };
146
+ reload().then((res) => {
147
+ if (!alive || ctl.signal.aborted || token !== generation.current) return;
81
148
  setLoading(false);
82
149
  if (res.type !== "dir") { setError("不是目录"); return; }
83
150
  setError(null);
@@ -90,34 +157,52 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
90
157
  setEntries(res.entries);
91
158
  setTruncated(res.truncated);
92
159
  setTotal(res.total);
160
+ setNextCursor(res.nextCursor);
93
161
  onListedRef.current?.(dir, res.entries);
94
162
  }, (e: unknown) => {
95
- if (!alive) return;
163
+ if (!alive || ctl.signal.aborted || token !== generation.current) return;
96
164
  setLoading(false);
165
+ if ((e as { status?: number }).status === 409) { setNextCursor(undefined); setEntries(null); entriesRef.current = null; setError("目录在加载时发生变化,请重新加载"); return; }
97
166
  // 正在看的目录被删了(任务清理了输出):回到上一级。
98
- if (dir && e instanceof CenterError && e.status === 404) { onDirRef.current(dir.split("/").slice(0, -1).join("/")); return; }
167
+ if (dir && (e as { status?: number }).status === 404) { onDirRef.current(inInputs ? "" : dir.split("/").slice(0, -1).join("/")); return; }
99
168
  setError(e instanceof Error ? e.message : String(e));
100
169
  });
101
- return () => { alive = false; };
102
- }, [backend, jobId, dir, refreshKey]);
170
+ return () => { alive = false; ctl.abort(); pageAbort.current?.abort(); };
171
+ }, [backend, jobId, dir, refreshKey, retry]);
172
+ useEffect(() => { if (crumbsRef.current) crumbsRef.current.scrollLeft = crumbsRef.current.scrollWidth; }, [dir, !!entries]);
103
173
 
104
- const segs = dir ? dir.split("/").filter(Boolean) : [];
105
- return (
106
- <div className="cli-files">
107
- <div className="cli-crumbs">
108
- <button className={`cli-crumb ${segs.length ? "" : "on"}`} onClick={() => onDir("")}>{Icon.folder()} 工作区</button>
109
- {segs.map((s, i) => (
110
- <span key={i} className="cli-crumb-w">
111
- <span className="cli-crumb-sep">/</span>
112
- <button className={`cli-crumb ${i === segs.length - 1 ? "on" : ""}`} onClick={() => onDir(segs.slice(0, i + 1).join("/"))}>{s}</button>
113
- </span>
114
- ))}
115
- <span className="cli-fv-sp" />
174
+ const segs = inInputs ? ["输入文件"] : dir ? dir.split("/").filter(Boolean) : [];
175
+ const compact = toolbarHost !== undefined;
176
+ const toolbar = <>
116
177
  {loading && entries && <span className="cli-files-sync" title="正在同步">同步中…</span>}
117
178
  {entries && entries.length > 0 && !zip && (
118
- <button className="cli-fv-icon" title={`把「${dir || "工作区"}」打包成 ZIP 下载`} onClick={() => zipDir(dir)}>{Icon.download()}</button>
179
+ <button className="cli-fv-icon" title={`把「${dir || "工作区"}」打包成 ZIP 下载`} aria-label={`打包下载 ${dir || "工作区"}`} onClick={() => zipDir(dir)}>{Icon.download()}</button>
119
180
  )}
120
- </div>
181
+ </>;
182
+ return (
183
+ <div className="cli-files">
184
+ {compact && toolbarHost && createPortal(toolbar, toolbarHost)}
185
+ {!compact && <div className="cli-crumbs">
186
+ <div className="cli-crumb-path" role="navigation" aria-label="工作区路径" ref={crumbsRef}>
187
+ <button className={`cli-crumb ${segs.length ? "" : "on"}`} title="工作区" aria-current={segs.length ? undefined : "location"} onClick={() => onDir("")}>{Icon.folder()}<span className="cli-crumb-name">工作区</span></button>
188
+ {segs.map((s, i) => (
189
+ <span key={i} className="cli-crumb-w">
190
+ <span className="cli-crumb-sep">/</span>
191
+ <button className={`cli-crumb ${i === segs.length - 1 ? "on" : ""}`} title={segs.slice(0, i + 1).join("/")} aria-current={i === segs.length - 1 ? "location" : undefined} onClick={() => onDir(inInputs ? dir : segs.slice(0, i + 1).join("/"))}><span className="cli-crumb-name">{s}</span></button>
192
+ </span>
193
+ ))}
194
+ </div>
195
+ {toolbar}
196
+
197
+ </div>}
198
+ {dir && (
199
+ <button className="cli-frow" title="返回上一级目录" onClick={() => onDir(inInputs ? "" : segs.slice(0, -1).join("/"))}>
200
+ <span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">{inInputs ? "返回工作区" : "返回上一级"}{compact && <small> · {inInputs ? "输入文件" : dir}</small>}</span>
201
+ </button>
202
+ )}
203
+ {!dir && inputDirectory && <button className="cli-frow" onClick={() => onDir(inputDirectory)} title="当前会话上传的文件">
204
+ <span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">输入文件</span><span className="cli-frow-meta">{Icon.chevRight()}</span>
205
+ </button>}
121
206
  {zip && (
122
207
  <div className="cli-fv-trunc cli-zip-bar">
123
208
  {zip.progress.phase === "listing" ? `正在列出「${zip.dir || "工作区"}」里的文件…`
@@ -136,36 +221,40 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
136
221
  <button className="cli-fv-btn" onClick={() => setNotice(null)}>知道了</button>
137
222
  </div>
138
223
  )}
139
- {error && entries && <div className="cli-fv-trunc">同步失败:{error}(下面是上次读到的列表,有进展时会自动再试,也可以点右上角 ↻)</div>}
224
+ {error && entries && <div className="cli-fv-trunc" role="alert">同步失败:{error}。已保留上次读到的列表。
225
+ <button className="cli-fv-btn" disabled={loading} onClick={() => setRetry(value => value + 1)}>{loading ? "重试中…" : "重新加载"}</button>
226
+ </div>}
140
227
  {error && !entries ? (
141
- <div className="cli-fv-note err">{error}<br /><span style={{ color: "var(--ink-3)" }}>有进展时会自动再试,也可以点右上角 ↻ 重试。</span></div>
228
+ <div className="cli-fv-note err" role="alert">{error}<br /><button className="cli-fv-btn" disabled={loading} onClick={() => setRetry(value => value + 1)}>{loading ? "重试中…" : "重新加载文件"}</button></div>
142
229
  ) : !entries ? (
143
230
  <div className="cli-fv-note">读取文件…</div>
144
231
  ) : entries.length === 0 ? (
145
- <div className="cli-fv-note">{dir ? "这个目录是空的。" : "工作区里还没有文件。任务生成的文件会出现在这里。"}</div>
232
+ <div className="cli-fv-note">{inInputs ? "还没有上传文件。" : dir ? "这个目录是空的。" : inputDirectory ? "任务生成的文件会出现在这里。" : "工作区里还没有文件。任务生成的文件会出现在这里。"}</div>
146
233
  ) : (
147
- <div className="cli-files-list" role="list">
148
- {dir && (
149
- <button className="cli-frow" onClick={() => onDir(segs.slice(0, -1).join("/"))}>
150
- <span className="cli-frow-ic">{Icon.folder()}</span><span className="cli-frow-name">..</span>
151
- </button>
152
- )}
153
- {entries.map((e) => (
154
- <div key={e.path} className={`cli-frow ${e.type} ${fresh.has(e.path) ? "fresh" : ""}`} role="listitem">
234
+ <div className="cli-files-list" role="list" ref={listRef} style={{ overflowAnchor: "none" }} onScroll={event => {
235
+ const el = event.currentTarget; setScrollTop(el.scrollTop);
236
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 300) void loadMore();
237
+ }}>
238
+ {first > 0 && <div aria-hidden="true" style={{ height: first * rowHeight }} />}
239
+ {entries.slice(first, end).map((e, index) => (
240
+ <div key={e.path} className={`cli-frow ${e.type} ${fresh.has(e.path) ? "fresh" : ""}`} role="listitem" aria-posinset={first + index + 1} aria-setsize={total ?? entries.length} style={{ height: rowHeight }}>
155
241
  <button className="cli-frow-main" onClick={() => (e.type === "dir" ? onDir(e.path) : onOpen(e))} title={e.path}>
156
242
  <span className="cli-frow-ic">{e.type === "dir" ? Icon.folder() : Icon.doc()}</span>
157
- <span className="cli-frow-name">{e.name}</span>
158
- {fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}
159
- <span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
243
+ <span className="cli-frow-body">
244
+ <span className="cli-frow-title"><span className="cli-frow-name">{e.name}</span>{fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}</span>
245
+ <span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
246
+ </span>
160
247
  </button>
161
248
  {e.type === "file" ? (
162
- <button className="cli-frow-dl" title="下载" onClick={() => downloadOne(e.path, e.size)}>{Icon.download()}</button>
249
+ <button className="cli-frow-dl" title="下载" aria-label={`下载 ${e.name}`} onClick={() => downloadOne(e.path, e.size)}>{Icon.download()}</button>
163
250
  ) : (
164
- <button className="cli-frow-dl" title="打包成 ZIP 下载" disabled={!!zip} onClick={() => zipDir(e.path)}>{Icon.download()}</button>
251
+ <button className="cli-frow-dl" title="打包成 ZIP 下载" aria-label={`打包下载 ${e.name}`} disabled={!!zip} onClick={() => zipDir(e.path)}>{Icon.download()}</button>
165
252
  )}
166
253
  </div>
167
254
  ))}
168
- {truncated && <div className="cli-fv-trunc">文件太多{total ? `(共 ${total.toLocaleString()} 个)` : ""},只列出前 {entries.length.toLocaleString()} 个。</div>}
255
+ {end < entries.length && <div aria-hidden="true" style={{ height: (entries.length - end) * rowHeight }} />}
256
+ {nextCursor && <div className="cli-fv-trunc"><button className="cli-fv-btn" disabled={loading || moreLoading} onClick={() => void loadMore()}>{moreLoading ? "加载中…" : "加载更多"}</button><span>已加载 {entries.length.toLocaleString()}{total ? ` / ${total.toLocaleString()}` : ""} 项</span></div>}
257
+ {truncated && !nextCursor && <div className="cli-fv-trunc">文件太多{total ? `(共 ${total.toLocaleString()} 个)` : ""},只列出前 {entries.length.toLocaleString()} 个。</div>}
169
258
  </div>
170
259
  )}
171
260
  </div>
@@ -0,0 +1,52 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import { readHtmlSections, type HtmlReadingBlock, type HtmlReadingResult } from "./htmlReading";
3
+
4
+ function ReadingBlock({ block }: { block: HtmlReadingBlock }) {
5
+ if (block.kind === "heading") return block.level === 1 ? <h1>{block.text}</h1> : block.level === 2 ? <h2>{block.text}</h2> : <h3>{block.text}</h3>;
6
+ if (block.kind === "code") return <pre className="cli-fv-pre"><code>{block.text}</code></pre>;
7
+ if (block.kind === "table-row") return <p className="cli-html-row">{block.text}</p>;
8
+ if (block.kind === "image") return <p className="cli-html-image">{block.text || "图片"}</p>;
9
+ return <p>{block.text}</p>;
10
+ }
11
+
12
+ /** A bounded reading page: original markup, CSS, scripts and image URLs never enter the DOM. */
13
+ export function HtmlReadingPreview({ text, complete, version, onBusyChange, abortRef, scrollRef, onMore, onSource, onOriginal }: {
14
+ text: string; complete: boolean; version: string;
15
+ onBusyChange: (busy: boolean) => void;
16
+ abortRef: RefObject<AbortController | null>; scrollRef: RefObject<HTMLDivElement | null>;
17
+ onMore?: () => void; onSource: () => void; onOriginal: () => void;
18
+ }) {
19
+ const [state, setState] = useState<{ text: string; version: string; page: number; result: HtmlReadingResult } | null>(null);
20
+ const [error, setError] = useState<string | null>(null), [page, setPage] = useState(0);
21
+ useEffect(() => {
22
+ const ctl = new AbortController(); abortRef.current = ctl;
23
+ setError(null); onBusyChange(true);
24
+ const cancel = () => onBusyChange(false); ctl.signal.addEventListener("abort", cancel, { once: true });
25
+ void readHtmlSections(text, { signal: ctl.signal, eof: complete, page }).then(result => {
26
+ if (!ctl.signal.aborted) setState({ text, version, page, result });
27
+ }, (error: unknown) => { if (!ctl.signal.aborted) setError(error instanceof Error ? error.message : String(error)); })
28
+ .finally(() => { if (!ctl.signal.aborted) onBusyChange(false); });
29
+ return () => { ctl.signal.removeEventListener("abort", cancel); ctl.abort(); onBusyChange(false); };
30
+ }, [text, complete, version, page, onBusyChange, abortRef]);
31
+ const result = state?.text === text && state.version === version && state.page === page ? state.result : null;
32
+ const current = result?.pageOffset ?? 0;
33
+ const move = (next: number) => { setPage(next); if (scrollRef.current) scrollRef.current.scrollTop = 0; };
34
+ if (error) return <div className="cli-fv-note" role="alert">此文档暂时无法整理为阅读内容。<button className="cli-fv-btn" onClick={onSource}>查看源文件</button></div>;
35
+ if (!result) return null;
36
+ return <>
37
+ <div className="cli-fv-trunc cli-html-reading-note">分段阅读 · 已简化原件布局、图片及交互,文件内容不变。</div>
38
+ <div className="cli-fv-md md cli-html-reading">
39
+ {result.pages[0]?.blocks.map((block, index) => <ReadingBlock key={index} block={block} />)}
40
+ {!result.pages.length && <div className="cli-fv-note">这一部分尚无可显示的正文,可能包含样式、图片或脚本。</div>}
41
+ </div>
42
+ {result.limited && <div className="cli-fv-trunc" role="status">{result.reason === "depth" ? "文档嵌套层级过多" : "此部分含超长标记或密集空结构"},已显示可读取部分。<button className="cli-fv-btn" onClick={onSource}>查看源文件</button><button className="cli-fv-btn" onClick={onOriginal}>原样预览</button></div>}
43
+ <nav className="cli-fv-trunc cli-html-page-nav" aria-label="HTML阅读翻页">
44
+ <button className="cli-fv-btn" disabled={!current} onClick={() => move(current - 1)}>上一页</button>
45
+ <span>第 {current + 1} / {Math.max(1, result.pageCount)} 页{!complete ? "(已读取部分)" : ""}</span>
46
+ <button className="cli-fv-btn" disabled={current + 1 >= result.pageCount && (!onMore || result.limited)} onClick={() => {
47
+ if (current + 1 < result.pageCount) move(current + 1);
48
+ else { setPage(current + 1); onMore?.(); }
49
+ }}>{current + 1 < result.pageCount ? "下一页" : onMore && !result.limited ? "继续读取" : "已到结尾"}</button>
50
+ </nav>
51
+ </>;
52
+ }
@@ -0,0 +1,134 @@
1
+ import { Component, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import { Markdown } from "../ui/Markdown";
3
+ import { splitMarkdownParts, type MarkdownPart } from "./fileKind";
4
+
5
+ /** Character and structure budgets both matter: a short table may contain thousands of cells. */
6
+ export const MARKDOWN_PREVIEW_CHARS = 16 * 1024;
7
+ type Schedule = (work: () => void) => () => void;
8
+
9
+ function useRenderQueue(): Schedule {
10
+ const pending = useRef(new Map<object, () => void>()), timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
11
+ const schedule = useCallback<Schedule>(work => {
12
+ const key = {}; pending.current.set(key, work);
13
+ const next = () => {
14
+ timer.current = undefined;
15
+ const first = pending.current.entries().next().value;
16
+ if (first) { pending.current.delete(first[0]); first[1](); }
17
+ // One render tree per turn, leaving input and painting a chance between trees.
18
+ if (pending.current.size) timer.current = setTimeout(next, 16);
19
+ };
20
+ if (timer.current === undefined) timer.current = setTimeout(next, 16);
21
+ return () => { pending.current.delete(key); if (!pending.current.size) { clearTimeout(timer.current); timer.current = undefined; } };
22
+ }, []);
23
+ useEffect(() => () => { clearTimeout(timer.current); pending.current.clear(); }, []);
24
+ return schedule;
25
+ }
26
+
27
+ class SegmentBoundary extends Component<{ source: string; children: ReactNode }, { failed: boolean }> {
28
+ state = { failed: false };
29
+ static getDerivedStateFromError() { return { failed: true }; }
30
+ componentDidUpdate(previous: { source: string }) { if (previous.source !== this.props.source && this.state.failed) this.setState({ failed: false }); }
31
+ render() {
32
+ return this.state.failed ? <>
33
+ <div className="cli-fv-trunc" role="status">此段格式无法渲染,已显示原文;其他内容可继续阅读。</div>
34
+ <pre className="cli-fv-pre">{this.props.source}</pre>
35
+ </> : this.props.children;
36
+ }
37
+ }
38
+
39
+ const RenderPart = memo(function RenderPart({ part }: { part: MarkdownPart }) {
40
+ if (part.plain) return <>
41
+ {part.simplified && <div className="cli-fv-trunc" role="status">{part.simplified}</div>}
42
+ <pre className="cli-fv-pre">{part.text}</pre>
43
+ </>;
44
+ if (part.code) return <>
45
+ <div className="cli-fv-trunc">{part.code.language || "代码"} · 分段显示</div>
46
+ <pre className="cli-fv-pre"><code>{part.text.slice(part.code.start - part.start, part.code.end - part.start)}</code></pre>
47
+ </>;
48
+ return <>
49
+ {part.prefix && <div className="cli-fv-trunc">表格续页 · 表头已重复</div>}
50
+ <Markdown highlight={false}>{(part.prefix ?? "") + part.text + (part.definitions ? `\n\n${part.definitions}` : "")}</Markdown>
51
+ </>;
52
+ });
53
+
54
+ function PreviewWindow({ part, index, scrollRef, schedule }: { part: MarkdownPart; index: number; scrollRef: RefObject<HTMLDivElement | null>; schedule: Schedule }) {
55
+ const host = useRef<HTMLDivElement>(null), height = useRef(Math.max(80, Math.ceil(part.text.length / 100) * 22));
56
+ const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined" || index === 0);
57
+ const token = useMemo(() => ({}), [part.text, part.plain, part.prefix, part.definitions, part.simplified, part.code?.start, part.code?.end, part.code?.language]);
58
+ const [ready, setReady] = useState<object | null>(null);
59
+ useEffect(() => {
60
+ const node = host.current;
61
+ if (!node || typeof IntersectionObserver === "undefined") return;
62
+ const observer = new IntersectionObserver(([entry]) => {
63
+ if (!entry.isIntersecting && node.firstElementChild) height.current = node.getBoundingClientRect().height || height.current;
64
+ setVisible(entry.isIntersecting);
65
+ }, { root: scrollRef.current, rootMargin: "600px 0px" });
66
+ observer.observe(node); return () => observer.disconnect();
67
+ }, [scrollRef]);
68
+ useEffect(() => {
69
+ if (!visible) { setReady(null); return; }
70
+ return schedule(() => setReady(token));
71
+ }, [visible, token, schedule]);
72
+ const shown = visible && ready === token;
73
+ useLayoutEffect(() => { if (shown && host.current) height.current = host.current.getBoundingClientRect().height || height.current; }, [shown, token]);
74
+ return <div ref={host} data-source-start={part.start} data-source-end={part.end} style={shown ? undefined : { height: height.current }} aria-hidden={!visible || undefined} aria-busy={visible && !shown || undefined}>
75
+ {shown ? <SegmentBoundary source={part.text}><RenderPart part={part} /></SegmentBoundary> : visible && <div className="cli-fv-note" role="status">正在排版…</div>}
76
+ </div>;
77
+ }
78
+
79
+ /** Distant groups need one placeholder, not an observer and React tree for every fragment. */
80
+ function PreviewGroup({ parts, index, scrollRef, schedule }: { parts: MarkdownPart[]; index: number; scrollRef: RefObject<HTMLDivElement | null>; schedule: Schedule }) {
81
+ const host = useRef<HTMLDivElement>(null), [visible, setVisible] = useState(index === 0);
82
+ const height = useRef(parts.reduce((total, part) => total + Math.max(80, Math.ceil(part.text.length / 100) * 22), 0));
83
+ useEffect(() => {
84
+ const node = host.current; if (!node) return;
85
+ const observer = new IntersectionObserver(([entry]) => {
86
+ if (!entry.isIntersecting && node.firstElementChild) height.current = node.getBoundingClientRect().height || height.current;
87
+ setVisible(entry.isIntersecting);
88
+ }, { root: scrollRef.current, rootMargin: "1000px 0px" });
89
+ observer.observe(node); return () => observer.disconnect();
90
+ }, [scrollRef]);
91
+ return <div ref={host} data-preview-group={index} style={visible ? undefined : { height: height.current }} aria-hidden={!visible || undefined}>
92
+ {visible && parts.map((part, index) => <PreviewWindow key={part.start} part={part} index={index} scrollRef={scrollRef} schedule={schedule} />)}
93
+ </div>;
94
+ }
95
+
96
+ function PreviewParts({ parts, scrollRef }: { parts: MarkdownPart[]; scrollRef: RefObject<HTMLDivElement | null> }) {
97
+ const schedule = useRenderQueue(), [page, setPage] = useState(0);
98
+ const paged = typeof IntersectionObserver === "undefined", currentPage = Math.min(page, Math.max(0, Math.ceil(parts.length / 2) - 1));
99
+ const groups = useMemo(() => { const result: MarkdownPart[][] = []; for (let index = 0; index < parts.length; index += 32) result.push(parts.slice(index, index + 32)); return result; }, [parts]);
100
+ return <>
101
+ {paged ? parts.slice(currentPage * 2, currentPage * 2 + 2).map((part, index) => <PreviewWindow key={part.start} part={part} index={index} scrollRef={scrollRef} schedule={schedule} />)
102
+ : groups.map((group, index) => <PreviewGroup key={group[0].start} parts={group} index={index} scrollRef={scrollRef} schedule={schedule} />)}
103
+ {paged && parts.length > 2 && <div className="cli-fv-trunc">
104
+ <button disabled={!currentPage} className="cli-fv-btn" onClick={() => setPage(currentPage - 1)}>上一部分</button>
105
+ <button disabled={(currentPage + 1) * 2 >= parts.length} className="cli-fv-btn" onClick={() => setPage(currentPage + 1)}>下一部分</button>
106
+ </div>}
107
+ </>;
108
+ }
109
+
110
+ export function MarkdownPreview({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
111
+ const parts = useMemo(() => splitMarkdownParts(text, MARKDOWN_PREVIEW_CHARS, { preview: true }), [text]);
112
+ const first = parts[0];
113
+ // Ordinary small documents do not need scheduling or viewport placeholders.
114
+ const immediate = parts.length === 1 && !first.plain && !first.simplified
115
+ && text.length + (first.prefix?.length ?? 0) + (first.definitions?.length ?? 0) <= 4096
116
+ && (text.match(/[|*_\[\]<>`~]/g)?.length ?? 0) <= 400;
117
+ if (immediate) return <SegmentBoundary source={text}><RenderPart part={first} /></SegmentBoundary>;
118
+ return <PreviewParts parts={parts} scrollRef={scrollRef} />;
119
+ }
120
+
121
+ /** Source views use the same cancellable render queue and viewport bounds, without Markdown parsing. */
122
+ export function BoundedTextPreview({ text, scrollRef }: { text: string; scrollRef: RefObject<HTMLDivElement | null> }) {
123
+ const parts = useMemo(() => {
124
+ const result: MarkdownPart[] = [];
125
+ for (let start = 0; start < text.length;) {
126
+ let end = Math.min(text.length, start + MARKDOWN_PREVIEW_CHARS);
127
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
128
+ result.push({ text: text.slice(start, end), start, end, plain: true }); start = end;
129
+ }
130
+ return result;
131
+ }, [text]);
132
+ if (text.length <= MARKDOWN_PREVIEW_CHARS) return <pre className="cli-fv-pre">{text}</pre>;
133
+ return <PreviewParts parts={parts} scrollRef={scrollRef} />;
134
+ }
@@ -1,23 +1,35 @@
1
- import { useState } from 'react';
1
+ import { useState, type Ref } from 'react';
2
2
  import { Icon } from '../ui/icons';
3
3
  import { Popover } from '../ui/Popover';
4
- export function PanelTab({ id, label, active, onSelect, onClose }: {
5
- id: string; label: string; active: boolean; onSelect: () => void; onClose: () => void;
4
+ /** The same board can place its tabs in the chat header without duplicating its content. */
5
+ export interface InlineBoardLayout {
6
+ tabsHost: HTMLElement | null;
7
+ active: boolean;
8
+ onActivate: () => void;
9
+ onChat: () => void;
10
+ }
11
+ export function PanelTab({ id, label, title, active, onSelect, onClose }: {
12
+ id: string; label: string; title?: string; active: boolean; onSelect: () => void; onClose: () => void;
6
13
  }) {
7
- return <span className={`rail-tab rail-ftab ${active ? 'on' : ''}`} onClick={onSelect}>
8
- <button className="rail-ftab-l" role="tab" aria-selected={active} aria-label={label} data-panel-id={id} onClick={event => { event.stopPropagation(); onSelect(); }}>{label}</button>
9
- <button className="rail-ftab-x" aria-label={id.startsWith("file:") ? `关闭 ${label}` : `关闭${label}面板`} title={id === 'browser' ? '隐藏浏览器面板,浏览器任务继续运行' : `关闭${label}面板,可从添加模块重新打开`}
14
+ return <span className={`rail-tab rail-ftab ${active ? 'on' : ''}`} title={title ?? label} onClick={onSelect}>
15
+ <button className="rail-ftab-l" role="tab" aria-selected={active} aria-label={label} data-panel-id={id} onClick={event => { event.stopPropagation(); onSelect(); }}><span className="rail-ftab-text">{label}</span></button>
16
+ <button className="rail-ftab-x" aria-label={id.startsWith("file:") ? `关闭 ${label}` : `关闭${label}面板`} title={id === 'browser' ? '隐藏浏览器面板,浏览器任务继续运行' : id.startsWith('file:') ? '关闭文件标签,可从工作区重新打开' : `关闭${label}面板,可从添加模块重新打开`}
10
17
  onClick={event => { event.stopPropagation(); onClose(); }}>{Icon.x()}</button>
11
18
  </span>;
12
19
  }
13
- export function PanelControls({ available, open, label, onShow, onClose }: {
20
+ export function PanelControls({ available, open, label, onShow, onClose, toolbarRef, upload }: {
21
+ toolbarRef?: Ref<HTMLSpanElement>;
22
+ upload?: { onChoose: () => void; disabled?: boolean; hint?: string };
14
23
  available: readonly string[]; open: readonly string[]; label: (id: string) => string;
15
24
  onShow: (id: string) => void; onClose: (id: string) => void;
16
25
  }) {
17
26
  const [expanded, setExpanded] = useState(false);
18
27
  return <div className="cli-panel-controls">
28
+ {toolbarRef && <span className="cli-board-content-actions" ref={toolbarRef} />}
19
29
  <button className="rail-collapse" aria-label="管理看板模块" title="添加或关闭看板模块" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{Icon.plus()}</button>
20
30
  {expanded && <Popover onClose={() => setExpanded(false)}>
31
+ {upload && <button className="pop-item" disabled={upload.disabled} title={upload.hint}
32
+ onClick={() => { setExpanded(false); upload.onChoose(); }}>{Icon.clip()}<span>上传文件</span></button>}
21
33
  <div className="pop-sec">看板模块 · 可独立打开或关闭</div>
22
34
  {available.map(id => <button key={id} className="pop-item" aria-label={`${open.includes(id) ? '关闭' : '打开'}${label(id)}面板`} aria-pressed={open.includes(id)}
23
35
  onClick={() => { if (open.includes(id)) onClose(id); else onShow(id); setExpanded(false); }}>
@@ -110,3 +110,141 @@ export function splitMarkdown(text: string, size: number): string[] {
110
110
  if (cur.length && cur.some((l) => l !== "")) out.push(cur.join("\n"));
111
111
  return out;
112
112
  }
113
+
114
+ export interface MarkdownPart {
115
+ /** Exact source slice; offsets use JavaScript string positions, not UTF-8 bytes. */
116
+ text: string;
117
+ plain: boolean;
118
+ start: number;
119
+ end: number;
120
+ /** Ordinary link definitions for the renderer, never inserted into source text. */
121
+ definitions?: string;
122
+ /** Preview-only formatting; source slices and offsets always remain exact. */
123
+ code?: { start: number; end: number; language: string };
124
+ prefix?: string;
125
+ simplified?: string;
126
+ }
127
+
128
+ /** Bound each render tree without inventing closing fences or splitting a giant structure into misleading Markdown. */
129
+ export function splitMarkdownParts(text: string, size: number, options: { preview?: boolean } = {}): MarkdownPart[] {
130
+ if (!Number.isSafeInteger(size) || size < 2) throw new RangeError("Markdown分段大小必须至少为2");
131
+ const parts: MarkdownPart[] = [], definitions = new Map<string, string>();
132
+ let pendingStart = 0, pendingEnd = 0, pendingCost = 0, blockStart = 0, cursor = 0, fence: { char: string; length: number; body: number; language: string } | null = null;
133
+ let definitionSize = 0, tooManyDefinitions = false;
134
+ const push = (start: number, end: number, plain: boolean, extra: Partial<MarkdownPart> = {}) => { if (end > start) parts.push({ text: text.slice(start, end), plain, start, end, ...extra }); };
135
+ const flush = () => { push(pendingStart, pendingEnd, false); pendingStart = pendingEnd; pendingCost = 0; };
136
+ const boundedEnd = (start: number, end: number) => {
137
+ let next = Math.min(end, start + size);
138
+ const previous = text.charCodeAt(next - 1), following = text.charCodeAt(next);
139
+ if (next < end && previous >= 0xd800 && previous <= 0xdbff && following >= 0xdc00 && following <= 0xdfff) next--;
140
+ return next;
141
+ };
142
+ const block = (start: number, end: number, code?: MarkdownPart["code"]) => {
143
+ if (end <= start) return;
144
+ if (options.preview && code) {
145
+ flush();
146
+ while (start < end) {
147
+ let next = boundedEnd(start, end);
148
+ const newline = text.lastIndexOf("\n", next - 1);
149
+ if (next < end && newline >= start && newline + 1 > start + size / 2) next = newline + 1;
150
+ push(start, next, false, { code: { ...code, start: Math.min(next, Math.max(start, code.start)), end: Math.max(start, Math.min(next, code.end)) } }); start = next;
151
+ }
152
+ pendingStart = pendingEnd = end; return;
153
+ }
154
+ if (options.preview) {
155
+ const firstEnd = text.indexOf("\n", start), secondEnd = firstEnd < 0 ? -1 : text.indexOf("\n", firstEnd + 1);
156
+ const headerEnd = secondEnd < 0 ? end : secondEnd + 1;
157
+ const delimiter = firstEnd >= start && firstEnd < end ? text.slice(firstEnd + 1, Math.min(headerEnd, end)).trim() : "";
158
+ // GFM tables are bounded by rows as well as characters: short cells can
159
+ // otherwise produce thousands of elements from a small source slice.
160
+ if (firstEnd >= start && firstEnd < end && headerEnd <= end && headerEnd - start <= Math.min(size / 2, 4096)
161
+ && /^\|?\s*:?-{1,}:?\s*(?:\|\s*:?-{1,}:?\s*)+\|?$/.test(delimiter)) {
162
+ const columns = delimiter.replace(/^\|/, "").replace(/\|$/, "").split("|").length;
163
+ if (columns <= 32) {
164
+ flush(); const header = text.slice(start, headerEnd); let at = start;
165
+ while (at < end) {
166
+ const first = at === start, bodyStart = first ? headerEnd : at, budget = size - (first ? 0 : header.length);
167
+ let next = bodyStart, rows = 0;
168
+ while (next < end && (rows + 2) * columns <= 384) {
169
+ const newline = text.indexOf("\n", next), rowEnd = newline < 0 ? end : Math.min(end, newline + 1);
170
+ if (rowEnd - at > budget) break;
171
+ next = rowEnd; rows++;
172
+ }
173
+ if (next > at && (first || rows)) push(at, next, false, first ? {} : { prefix: header });
174
+ else {
175
+ const newline = text.indexOf("\n", at), rowEnd = newline < 0 ? end : Math.min(end, newline + 1);
176
+ while (at < rowEnd) { next = boundedEnd(at, rowEnd); push(at, next, true, { simplified: "此表格行较长,以原文显示;文件内容保持完整。" }); at = next; }
177
+ }
178
+ at = next;
179
+ }
180
+ pendingStart = pendingEnd = end; return;
181
+ }
182
+ flush();
183
+ while (start < end) { const next = boundedEnd(start, end); push(start, next, true, { simplified: "此表格列数较多,以原文分段显示;文件内容保持完整。" }); start = next; }
184
+ pendingStart = pendingEnd = end; return;
185
+ }
186
+ }
187
+ if (end - start <= size) {
188
+ const source = options.preview ? text.slice(start, end) : "";
189
+ const cost = options.preview ? (source.match(/[|*_\[\]<>`~]/g)?.length ?? 0) : 0;
190
+ if (options.preview && (cost > 2000 || /\[\^[^\]\r\n]+\]/.test(source))) {
191
+ flush(); push(start, end, true, { simplified: cost > 2000 ? "此段格式较复杂,以原文显示;文件内容保持完整。" : "跨段脚注以原文显示,避免遗漏注释内容。" }); pendingStart = pendingEnd = end; return;
192
+ }
193
+ if (pendingEnd - pendingStart + end - start > size || pendingCost + cost > 2000) flush();
194
+ if (pendingEnd === pendingStart) pendingStart = start;
195
+ pendingEnd = end; pendingCost += cost;
196
+ return;
197
+ }
198
+ flush();
199
+ while (start < end) {
200
+ const next = boundedEnd(start, end);
201
+ push(start, next, true, options.preview ? { simplified: "此段内容较长,以原文分段显示;文件内容保持完整。" } : {}); start = next;
202
+ }
203
+ pendingStart = pendingEnd = end;
204
+ };
205
+ while (cursor < text.length) {
206
+ const newline = text.indexOf("\n", cursor), lineEnd = newline < 0 ? text.length : newline, next = newline < 0 ? text.length : newline + 1;
207
+ // Never pass a 100MiB single line into regex/trim/Markdown parsing.
208
+ const bounded = lineEnd - cursor <= size;
209
+ const line = text.slice(cursor, bounded ? lineEnd : Math.min(lineEnd, cursor + 512));
210
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
211
+ if (fence) {
212
+ const closing = /^ {0,3}(`{3,}|~{3,})[ \t\r]*$/.exec(line);
213
+ if (bounded && closing && closing[1][0] === fence.char && closing[1].length >= fence.length) {
214
+ block(blockStart, next, { start: fence.body, end: cursor, language: fence.language }); fence = null; blockStart = next;
215
+ }
216
+ } else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
217
+ block(blockStart, cursor); blockStart = cursor; fence = { char: opening[1][0], length: opening[1].length, body: next, language: opening[2].trim().split(/\s/)[0].slice(0, 80) };
218
+ } else {
219
+ if (bounded) {
220
+ const reference = /^ {0,3}\[(?!\^)([^\]\r\n]+)\]:[ \t]*(?:<[^>\r\n]+>|[^\s\r\n]+)(?:[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?[ \t\r]*$/.exec(line);
221
+ if (reference) {
222
+ const label = reference[1].trim().replace(/\s+/g, " ").toLowerCase();
223
+ if (!definitions.has(label)) {
224
+ definitionSize += line.length + 1;
225
+ if (definitionSize <= Math.min(size, 256 * 1024)) definitions.set(label, line); else tooManyDefinitions = true;
226
+ }
227
+ }
228
+ if (/^[ \t\r]*$/.test(line)) { block(blockStart, next); blockStart = next; }
229
+ }
230
+ }
231
+ cursor = next;
232
+ }
233
+ block(blockStart, text.length, fence ? { start: fence.body, end: text.length, language: fence.language } : undefined); flush();
234
+ if (!parts.length) parts.push({ text, plain: false, start: 0, end: text.length });
235
+ const context = [...definitions.values()].join("\n");
236
+ for (const part of parts) {
237
+ if (options.preview) {
238
+ if (part.plain || part.code) continue;
239
+ const used = new Set<string>(); let length = 0;
240
+ for (const match of part.text.matchAll(/\[([^\]\r\n]+)\](?:\[([^\]\r\n]*)\])?/g)) {
241
+ const label = (match[2] || match[1]).trim().replace(/\s+/g, " ").toLowerCase(), definition = definitions.get(label);
242
+ if (definition && !used.has(definition)) { used.add(definition); length += definition.length + 1; }
243
+ }
244
+ if (length > 4096 || (tooManyDefinitions && part.text.includes("["))) { part.plain = true; part.simplified = "引用定义较多,以原文显示,避免显示不完整的链接。"; }
245
+ else if (used.size) part.definitions = [...used].join("\n");
246
+ } else if (tooManyDefinitions) part.plain = true;
247
+ else if (!part.plain && context) part.definitions = context;
248
+ }
249
+ return parts;
250
+ }