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

105
tests/chunk-load.test.ts Normal file
View File

@@ -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<Editor> {
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)
})