feat(editor): 大文档异步分片载入(docs/20)

>6 万字符文档按顶层结构安全切分:第一片立即建编辑器,其余片逐片解析
追加、片间让出主线程,窗格左下角显示载入进度;载入期间可切标签、编辑
其他文档。追加期静音回写、状态保持磁盘完整原文,载完一次性对齐。
分片与整篇解析的序列化等价有测试锁定(tests/chunk-load.test.ts)。
此前试做的已关闭重开缓存已按用户要求整笔撤回。
This commit is contained in:
Muse
2026-11-14 15:16:23 +08:00
parent 2d1cd3045a
commit 2d0843dd97
5 changed files with 325 additions and 1 deletions

View File

@@ -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<Editor | null>(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<HTMLElement | null>(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
<span>正在打开文档…</span>
</div>
)}
{ready && loadProgress && (
<div className="pane-loading-badge">
<span>正在载入文档… {Math.round((loadProgress.done / loadProgress.total) * 100)}%(可切换到其他文档继续工作)</span>
<div className="pane-progress"><div style={{ width: `${(loadProgress.done / loadProgress.total) * 100}%` }} /></div>
</div>
)}
</div>
)
}

127
src/editor/chunkLoader.ts Normal file
View File

@@ -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<Editor> | null = null
/** 隐藏的解析专用编辑器(与正式编辑器同厂,保证解析管线与 schema 完全一致) */
function ensureParserEditor(): Promise<Editor> {
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<void>((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<void>
}
/** 为大文档准备分片载入;小文档或不可切分时返回 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()
}
},
}
}

View File

@@ -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; }