From 2d0843dd9783923c7e7d663e4b60caaf85007be8 Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 15:16:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=A4=A7=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=88=86=E7=89=87=E8=BD=BD=E5=85=A5=EF=BC=88?= =?UTF-8?q?docs/20=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit >6 万字符文档按顶层结构安全切分:第一片立即建编辑器,其余片逐片解析 追加、片间让出主线程,窗格左下角显示载入进度;载入期间可切标签、编辑 其他文档。追加期静音回写、状态保持磁盘完整原文,载完一次性对齐。 分片与整篇解析的序列化等价有测试锁定(tests/chunk-load.test.ts)。 此前试做的已关闭重开缓存已按用户要求整笔撤回。 --- docs/20-async-loading.md | 48 +++++++++++++ src/components/EditorPane.tsx | 43 +++++++++++- src/editor/chunkLoader.ts | 127 ++++++++++++++++++++++++++++++++++ src/styles.css | 3 + tests/chunk-load.test.ts | 105 ++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 docs/20-async-loading.md create mode 100644 src/editor/chunkLoader.ts create mode 100644 tests/chunk-load.test.ts diff --git a/docs/20-async-loading.md b/docs/20-async-loading.md new file mode 100644 index 0000000..bf548e3 --- /dev/null +++ b/docs/20-async-loading.md @@ -0,0 +1,48 @@ +# 大文档异步载入(2026-11-14,取代已撤回的「重开缓存」方案) + +## 需求(用户口径) + +大文件打开慢可以接受,但不能阻塞其他操作:打开要在后台进行,给出加载中 +状态,用户能切标签、编辑别的文档、点菜单。 + +## 为什么不是真 Web Worker 线程 + +实测探针:Milkdown 的解析管线离不开 DOM——headless Editor 在 Node 里 +create() 直接抛 `document is not defined`(core 的 editorView 插件在装配期 +就引用 document.body,其后 ProseMirror 视图构建也需要真实 DOM)。 +把整套 DOM 模拟进 Worker 不可靠,故采用主线程分片让行达到同样的 +「不阻塞」效果:每片工作控制在几十毫秒量级,片间让出主线程。 + +## 方案(src/editor/chunkLoader.ts + EditorPane) + +- 阈值:内容 > 60,000 字符且可切分时走分片;否则维持同步打开。 +- 切分(splitMarkdownChunks):围栏感知扫描,只在「空行 + ATX 标题 / + 围栏开始 / 分隔线」处切——这些位置之前的块必然已闭合,片段独立解析 + 与整篇解析等价(等价性有自动化测试锁定);按约 24,000 字符攒片。 + 文档含链接引用定义或脚注定义时不切(定义可位于文末被前文引用), + 整篇一片同步解析(与此前行为一致)。 +- 载入:第一片直接建编辑器(马上能看、能滚);其余片借一台隐藏的同厂 + 编辑器取 parserCtx 逐片解析,经 JSON 转交给目标编辑器(两台编辑器的 + schema 是不同实例,直接插节点会被 ProseMirror 静默丢弃——实测踩过, + 已加「追加未生效即抛错」的断言),追加到文末(不进撤销历史),每片后 + setTimeout(0) 让行并更新进度。 +- 进度:窗格左下角徽标「正在载入文档… N%(可切换到其他文档继续工作)」, + 不遮挡已载入内容;切到别的标签时它在隐藏窗格里继续载完。 +- 状态一致性:追加期间静音序列化回写,标签状态里始终是磁盘上的完整 + 原文(自动保存/恢复/外部变更检测都不受影响);载完一次性以最终序列化 + 对齐(不标脏)。载入中编辑已载入部分是允许的,最终对齐会把编辑一并 + 收进去。若某片解析/追加失败:解除静音、把已载入部分如实回写为 + 未保存状态,绝不冒充完整文档。 + +## 验证 + +- tests/chunk-load.test.ts 5 例:切分拼接逐字还原、围栏内假标题不切、 + 引用定义整篇一片、小文档不分片;66K 字符富结构文档(标题/宽松列表/ + 任务/表格/围栏/Mermaid/公式/引用/分隔线)分片载入的序列化与整篇解析 + **逐字相等**。全量 vitest 94 通过 + 2 跳过;tsc 0;构建通过。 +- 无头浏览器实测(596K 字符文档):徽标从 13% 起显示;载入中真实鼠标 + 点击切到其他标签立即生效(徽标在其隐藏窗格里继续涨到 22%…),在其 + 编辑器里打字实时落字;全程无页面错误;载完 180 个二级标题齐全。 + 总耗时约 10.6 秒为本机无头慢速口径,WebView2 真机应明显更快; + 关键不在总时长,而在全程界面可操作。 +- 待真机验:桌面模式打开 100K+ 文档时的进度徽标与切换手感。 diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index 8d306cb..9bd7db7 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -7,6 +7,7 @@ import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/ed import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' +import { prepareChunkLoad } from '../editor/chunkLoader' export interface EditorPaneApi { editor: Editor | null @@ -60,6 +61,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal const editorObjRef = useRef(null) const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null) const [ready, setReady] = useState(false) + const [loadProgress, setLoadProgress] = useState<{ done: number; total: number } | null>(null) const tableElRef = useRef(null) const updateTableBar = useCallback(() => { @@ -77,6 +79,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal useEffect(() => { if (!hostRef.current) return + let cancelled = false + // 大文档分片载入(docs/20):第一片建编辑器,其余逐片追加;追加期间静音 + // 序列化回写——标签状态里始终是磁盘上的完整原文,载完再一次性对齐。 + const chunkLoad = prepareChunkLoad(initialRef.current) + let muted = chunkLoad !== null const centerCursorLine = () => { // 打字机模式:把光标行滚到编辑区垂直中央附近 window.requestAnimationFrame(() => { @@ -96,13 +103,14 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal } catch { /* 视图未就绪时跳过 */ } }) } - const editor = createMarkdownEditor(hostRef.current, initialRef.current, { + const editor = createMarkdownEditor(hostRef.current, chunkLoad ? chunkLoad.firstChunk : initialRef.current, { onMarkdown: (md) => { // 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算, // 杜绝替换打在偏移后的错误位置上(审计高危)。 if (findRef.current.matches.length > 0) { findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts } } + if (muted) return // 分片追加期间的部分序列化不回写(状态里是完整原文) callbacksRef.current.onMarkdown(tabId, md) }, onCursor: (c) => { @@ -230,12 +238,39 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) } catch { /* 忽略 */ } callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind }) + if (chunkLoad) { + setLoadProgress({ done: 1, total: chunkLoad.totalChunks }) + void chunkLoad.appendRest( + editor, + (done, total) => { if (!cancelled) setLoadProgress({ done, total }) }, + () => cancelled, + ).then(() => { + if (cancelled) return + muted = false + setLoadProgress(null) + try { + // 载完一次性对齐:编辑器最终序列化与状态里的原文对齐(不标脏) + const full = getMarkdown(editor) + if (callbacksRef.current.onSyncInitial) callbacksRef.current.onSyncInitial(tabId, full) + else callbacksRef.current.onMarkdown(tabId, full) + } catch { /* 忽略 */ } + }).catch((e) => { + if (cancelled) return + // 载入失败:解除静音并把已载入的部分如实回写为未保存状态, + // 绝不冒充完整文档(关窗会提示、保存需用户确认)。 + muted = false + setLoadProgress(null) + console.error('分片载入失败', e) + try { callbacksRef.current.onMarkdown(tabId, getMarkdown(editor)) } catch { /* 忽略 */ } + }) + } }) .catch((e) => console.error('编辑器创建失败', e)) const scrollParent = wrapRef.current?.closest('.editor-scroll') const onScroll = () => { if (editorObjRef.current) updateTableBar() } scrollParent?.addEventListener('scroll', onScroll, { passive: true }) return () => { + cancelled = true scrollParent?.removeEventListener('scroll', onScroll) editorObjRef.current = null setTableBar(null) @@ -271,6 +306,12 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal 正在打开文档… )} + {ready && loadProgress && ( +
+ 正在载入文档… {Math.round((loadProgress.done / loadProgress.total) * 100)}%(可切换到其他文档继续工作) +
+
+ )}
) } diff --git a/src/editor/chunkLoader.ts b/src/editor/chunkLoader.ts new file mode 100644 index 0000000..f50d3b4 --- /dev/null +++ b/src/editor/chunkLoader.ts @@ -0,0 +1,127 @@ +// 大文档分片异步载入(docs/20):整篇同步解析会把主线程占死、期间什么都点不动。 +// 改为按顶层结构切成小片:第一片直接建编辑器(立刻有内容可看),其余片逐片解析、 +// 逐片追加,每片之间让出主线程,用户可以切标签、编辑已载入部分、点菜单; +// 窗格上显示载入进度。解析仍走与整篇相同的 Milkdown 管线(借一台隐藏的同厂 +// 编辑器取 parserCtx),不另造解析器。 +import { parserCtx, editorViewCtx, type Editor } from '@milkdown/kit/core' +import { createMarkdownEditor } from './editor' + +/** 超过这个字符数走分片载入(小文档同步解析本来就快) */ +export const ASYNC_LOAD_THRESHOLD = 60_000 +/** 每片目标大小(字符):单片解析控制在几十毫秒量级 */ +const CHUNK_TARGET = 24_000 + +const FENCE_RE = /^\s{0,3}(```+|~~~+)/ +const HEADING_RE = /^#{1,6}\s/ +const HR_RE = /^\s{0,3}(\*\*\*+|---+|___+)\s*$/ +const LINK_DEF_RE = /^\s{0,3}\[[^\]]+\]:\s*\S+/ +const FOOTNOTE_DEF_RE = /^\[\^[^\]]+\]:/ + +/** + * 把 Markdown 切成可独立解析的顶层片段,拼接回去与原文逐字相同。 + * 只在「空行 + ATX 标题 / 围栏开始 / 分隔线」处切:这些位置前面的块必然已经 + * 闭合(标题、围栏、分隔线都不会被列表/引用等懒延续吞进去),片段内结构自洽。 + * 文档含链接引用定义或脚注定义时不切(定义可能在文末、被前文引用),整篇一片。 + */ +export function splitMarkdownChunks(markdown: string): string[] { + const lines = markdown.split('\n') + for (const line of lines) { + if (LINK_DEF_RE.test(line) || FOOTNOTE_DEF_RE.test(line)) return [markdown] + } + const cuts: number[] = [] // 切点 = 行索引(在该行之前切开) + let inFence = false + let fenceChar = '' + let fenceLen = 0 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const fence = FENCE_RE.exec(line) + if (fence) { + const marker = fence[1] + if (!inFence) { inFence = true; fenceChar = marker[0]; fenceLen = marker.length } + else if (marker[0] === fenceChar && marker.length >= fenceLen) { inFence = false } + continue + } + if (inFence) continue + if (line.trim() !== '' || i + 1 >= lines.length) continue + const next = lines[i + 1] + if (HEADING_RE.test(next) || FENCE_RE.test(next) || HR_RE.test(next)) cuts.push(i + 1) + } + if (cuts.length === 0) return [markdown] + // 按目标大小合并相邻片段:切点很多时攒够一片再切,避免碎成几百片 + const chunks: string[] = [] + let startLine = 0 + let startOffset = 0 + const lineOffset = (idx: number) => { + let off = 0 + for (let i = 0; i < idx; i++) off += lines[i].length + 1 + return off + } + for (const cut of cuts) { + const off = lineOffset(cut) + if (off - startOffset >= CHUNK_TARGET && cut > startLine) { + chunks.push(markdown.slice(startOffset, off)) + startLine = cut + startOffset = off + } + } + chunks.push(markdown.slice(startOffset)) + return chunks.filter((c) => c.length > 0) +} + +let parserEditorPromise: Promise | null = null + +/** 隐藏的解析专用编辑器(与正式编辑器同厂,保证解析管线与 schema 完全一致) */ +function ensureParserEditor(): Promise { + if (!parserEditorPromise) { + const host = document.createElement('div') + host.style.display = 'none' + document.body.appendChild(host) + const editor = createMarkdownEditor(host, '', {}) + parserEditorPromise = editor.create().then(() => editor) + } + return parserEditorPromise +} + +const yieldToMain = () => new Promise((resolve) => { window.setTimeout(resolve, 0) }) + +export interface ChunkLoadHandle { + /** 第一片(拿去建可见编辑器)与其余片总数 */ + firstChunk: string + totalChunks: number + /** 把剩余片逐片解析并追加到 editor 末尾;cancelled 返回 true 时中止 */ + appendRest: (editor: Editor, onProgress: (done: number, total: number) => void, isCancelled: () => boolean) => Promise +} + +/** 为大文档准备分片载入;小文档或不可切分时返回 null(调用方走同步路径) */ +export function prepareChunkLoad(markdown: string): ChunkLoadHandle | null { + if (markdown.length <= ASYNC_LOAD_THRESHOLD) return null + const chunks = splitMarkdownChunks(markdown) + if (chunks.length <= 1) return null + return { + firstChunk: chunks[0], + totalChunks: chunks.length, + appendRest: async (editor, onProgress, isCancelled) => { + const parser = await ensureParserEditor() + for (let i = 1; i < chunks.length; i++) { + if (isCancelled()) return + const fragDoc = parser.action((ctx) => ctx.get(parserCtx)(chunks[i])) + // 跨编辑器传递必须走 JSON:两台编辑器各自建了一套 schema 实例, + // 直接插对方的节点会被 ProseMirror 静默丢弃(实测踩过)。 + const json = fragDoc.toJSON() as unknown + editor.action((ctx) => { + const view = ctx.get(editorViewCtx) + const node = view.state.schema.nodeFromJSON(json) + const before = view.state.doc.childCount + const tr = view.state.tr.insert(view.state.doc.content.size, node.content) + tr.setMeta('addToHistory', false) + view.dispatch(tr) + if (view.state.doc.childCount <= before && node.childCount > 0) { + throw new Error('分片追加未生效') + } + }) + onProgress(i + 1, chunks.length) + await yieldToMain() + } + }, + } +} diff --git a/src/styles.css b/src/styles.css index a13f2b3..a02b133 100644 --- a/src/styles.css +++ b/src/styles.css @@ -111,6 +111,9 @@ button { font: inherit; color: inherit; } .pane-loading { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 12px; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; background: var(--doc-paper); z-index: 5; } .pane-spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: pane-spin 0.8s linear infinite; } @keyframes pane-spin { to { transform: rotate(360deg); } } +.pane-loading-badge { position: absolute; left: 14px; bottom: 14px; z-index: 6; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 9px; padding: 8px 12px; font-size: 12.5px; color: var(--text-dim); box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); display: flex; flex-direction: column; gap: 6px; pointer-events: none; } +.pane-progress { width: 180px; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; } +.pane-progress > div { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.15s ease; } .editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; } .source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); } .source-cm .cm-editor { height: 100%; outline: none; } diff --git a/tests/chunk-load.test.ts b/tests/chunk-load.test.ts new file mode 100644 index 0000000..7d8bc93 --- /dev/null +++ b/tests/chunk-load.test.ts @@ -0,0 +1,105 @@ +// 分片载入(docs/20):切分安全性 + 分片解析拼接与整篇解析的序列化等价。 +import { describe, it, expect, afterEach } from 'vitest' +import type { Editor } from '@milkdown/kit/core' +import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' +import { splitMarkdownChunks, prepareChunkLoad } from '../src/editor/chunkLoader' + +let editors: Editor[] = [] +afterEach(async () => { + for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } } + editors = [] + document.body.innerHTML = '' +}) + +async function load(md: string): Promise { + const root = document.createElement('div') + document.body.appendChild(root) + const editor = createMarkdownEditor(root, md) + editors.push(editor) + await editor.create() + return editor +} + +/** 生成超过阈值的富结构文档:多标题、宽松列表、任务、表格、公式、Mermaid、含空行围栏 */ +function bigDoc(): string { + const parts: string[] = [] + for (let s = 1; s <= 20; s++) { + parts.push(`## 第 ${s} 节`) + parts.push('') + parts.push(`第 ${s} 节的正文段落,包含**加粗**、*斜体*与行内公式 $a_${s}+b$,以及足够长的中文填充文本。`.repeat(60)) + parts.push('') + parts.push('- 条目甲') + parts.push('') + parts.push('- [ ] 任务乙') + parts.push('') + parts.push('| 列一 | 列二 |') + parts.push('| --- | --- |') + parts.push(`| 值 ${s} | ${s} |`) + parts.push('') + parts.push('```text') + parts.push(`code ${s} 第一行`) + parts.push('') + parts.push('# 这行在围栏里不是标题') + parts.push('') + parts.push('```') + parts.push('') + parts.push('> 引用第一段') + parts.push('>') + parts.push('> 引用第二段') + parts.push('') + parts.push('```mermaid') + parts.push('graph TD') + parts.push(' A-->B') + parts.push('```') + parts.push('') + parts.push(`$$\nE_${s} = mc^2\n$$`) + parts.push('') + parts.push('---') + parts.push('') + } + return parts.join('\n') +} + +describe('splitMarkdownChunks', () => { + it('拼接回去与原文逐字相同,片数 > 1', () => { + const md = bigDoc() + expect(md.length).toBeGreaterThan(60_000) + const chunks = splitMarkdownChunks(md) + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('')).toBe(md) + }) + + it('围栏内的空行与 # 行不触发切分', () => { + const md = `# 顶\n\n\`\`\`text\n第一行\n\n# 假标题\n\n\`\`\`\n\n正文 ${'填充'.repeat(40)}` + const chunks = splitMarkdownChunks(md) + expect(chunks.join('')).toBe(md) + // 没有合法切点时整篇一片(小文档本来也一片) + expect(chunks.length).toBe(1) + }) + + it('含链接引用定义时整篇一片(定义可能在文末被前文引用)', () => { + const md = `${bigDoc()}\n\n[ref1]: https://example.com\n` + expect(splitMarkdownChunks(md).length).toBe(1) + expect(prepareChunkLoad(md)).toBeNull() + }) + + it('小文档不走分片', () => { + expect(prepareChunkLoad('# 小\n\n正文')).toBeNull() + }) +}) + +describe('分片载入与整篇解析等价', () => { + it('逐片追加后的序列化 == 整篇解析的序列化', async () => { + const md = bigDoc() + const whole = getMarkdown(await load(md)) + const handle = prepareChunkLoad(md) + expect(handle).not.toBeNull() + if (!handle) return + const editor = await load(handle.firstChunk) + const progress: number[] = [] + await handle.appendRest(editor, (done) => { progress.push(done) }, () => false) + expect(progress[progress.length - 1]).toBe(handle.totalChunks) + const chunked = getMarkdown(editor) + expect(chunked).toBe(whole) + }, 60_000) +})