feat(editor): 大文档异步分片载入(docs/20)
>6 万字符文档按顶层结构安全切分:第一片立即建编辑器,其余片逐片解析 追加、片间让出主线程,窗格左下角显示载入进度;载入期间可切标签、编辑 其他文档。追加期静音回写、状态保持磁盘完整原文,载完一次性对齐。 分片与整篇解析的序列化等价有测试锁定(tests/chunk-load.test.ts)。 此前试做的已关闭重开缓存已按用户要求整笔撤回。
This commit is contained in:
@@ -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
127
src/editor/chunkLoader.ts
Normal 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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user