feat(stage6): 专注/打字机模式、查找替换、全文搜索 UI、快捷键中心、多窗口
This commit is contained in:
56
tests/perf.test.ts
Normal file
56
tests/perf.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// 性能基准(阶段 6):默认跳过,PERF_BENCH=1 时运行。
|
||||
// 口径:jsdom + Node(无真实浏览器渲染管线),只测「解析建模 + 序列化」耗时,
|
||||
// 不是 WebView2 真机输入延迟;真机口径见 docs/10 §3 的 Windows 清单。
|
||||
declare const process: { env: Record<string, string | undefined> }
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
|
||||
|
||||
const BLOCK = `## 性能小节标题
|
||||
|
||||
这是一个包含**粗体**、*斜体*、[链接](https://example.com) 与 \`行内代码\` 的段落,
|
||||
用来模拟真实写作内容。第二句继续填充文本,让段落长度接近真实文档。
|
||||
|
||||
- 列表项一
|
||||
- 列表项二
|
||||
- 任务 [x] 已完成
|
||||
|
||||
\`\`\`ts
|
||||
const answer: number = 42
|
||||
console.log(answer)
|
||||
\`\`\`
|
||||
|
||||
| 列 A | 列 B |
|
||||
| --- | --- |
|
||||
| 1 | 2 |
|
||||
|
||||
`
|
||||
|
||||
function makeDoc(targetBytes: number): string {
|
||||
let out = '# 性能基准文档\n\n'
|
||||
while (new TextEncoder().encode(out).length < targetBytes) out += BLOCK
|
||||
return out
|
||||
}
|
||||
|
||||
const enabled = process.env.PERF_BENCH === '1'
|
||||
|
||||
describe.skipIf(!enabled)('性能基准(PERF_BENCH=1)', () => {
|
||||
it.each([100 * 1024, 1024 * 1024])('打开并序列化 %i 字节文档', async (size) => {
|
||||
const md = makeDoc(size)
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const t0 = performance.now()
|
||||
let serialized = ''
|
||||
const editor = createMarkdownEditor(host, md, {
|
||||
onMarkdown: (text) => { serialized = text },
|
||||
})
|
||||
await editor.create()
|
||||
const createMs = performance.now() - t0
|
||||
const t1 = performance.now()
|
||||
const out = getMarkdown(editor)
|
||||
const serializeMs = performance.now() - t1
|
||||
console.table([{ 目标大小KB: Math.round(size / 1024), 实际字符: md.length, 建模ms: Math.round(createMs), 序列化ms: Math.round(serializeMs), 首轮回调字符: serialized.length }])
|
||||
expect(out.length).toBeGreaterThan(md.length * 0.9)
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
}, 300_000)
|
||||
})
|
||||
126
tests/stage6.test.ts
Normal file
126
tests/stage6.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// 阶段 6 测试:文档查找索引与匹配(桩文档 + 真实编辑器)、快捷键注册表、专注模式装饰。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildTextIndex, findInIndex, type FindOptions } from '../src/editor/search'
|
||||
import { comboFromEvent, effectiveCombos, resolveShortcuts, prettyCombo, comboOwner, SHORTCUT_DEFS } from '../src/utils/shortcuts'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
|
||||
import { setFocusModeEnabled } from '../src/editor/plugins'
|
||||
|
||||
const OPTS: FindOptions = { caseSensitive: false, wholeWord: false, regex: false }
|
||||
|
||||
/** 桩文档:按 ProseMirror descendants 语义回放文本节点与位置。 */
|
||||
function stubDoc(segments: { text: string; pos: number }[]) {
|
||||
return {
|
||||
descendants: (fn: (node: { isText: boolean; text?: string }, pos: number) => void) => {
|
||||
for (const seg of segments) fn({ isText: true, text: seg.text }, seg.pos)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('查找索引与匹配', () => {
|
||||
it('跨文本节点命中能映射回连续文档位置', () => {
|
||||
// "Hello " 与 "World" 是两个文本节点(如中间有格式标记)
|
||||
const index = buildTextIndex(stubDoc([{ text: 'Hello ', pos: 1 }, { text: 'World', pos: 7 }]))
|
||||
const hits = findInIndex(index, 'o w', OPTS)
|
||||
expect(hits).toHaveLength(1)
|
||||
expect(hits[0].from).toBe(5)
|
||||
expect(hits[0].to).toBe(8)
|
||||
})
|
||||
|
||||
it('跨块命中被跳过(块间分隔符)', () => {
|
||||
const index = buildTextIndex(stubDoc([{ text: 'abc', pos: 1 }, { text: 'def', pos: 6 }]))
|
||||
expect(findInIndex(index, 'cdef', OPTS)).toHaveLength(0)
|
||||
expect(findInIndex(index, 'def', OPTS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('大小写/整词/正则选项', () => {
|
||||
const index = buildTextIndex(stubDoc([{ text: 'Cat cat scatter CAT', pos: 1 }]))
|
||||
expect(findInIndex(index, 'cat', OPTS)).toHaveLength(4)
|
||||
expect(findInIndex(index, 'cat', { ...OPTS, caseSensitive: true })).toHaveLength(2)
|
||||
expect(findInIndex(index, 'cat', { ...OPTS, wholeWord: true })).toHaveLength(3)
|
||||
expect(findInIndex(index, 'c.t', { ...OPTS, regex: true })).toHaveLength(4)
|
||||
expect(() => findInIndex(index, '(', { ...OPTS, regex: true })).toThrow(/正则表达式无效/)
|
||||
})
|
||||
|
||||
it('真实编辑器文档可查且替换后文本正确', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const editor = createMarkdownEditor(host, '# 标题\n\nhello 世界 hello\n')
|
||||
await editor.create()
|
||||
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||
const { TextSelection } = await import('@milkdown/kit/prose/state')
|
||||
const matches = editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
return findInIndex(buildTextIndex(view.state.doc), 'hello', OPTS)
|
||||
})
|
||||
expect(matches).toHaveLength(2)
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const m = matches[0]
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, m.from, m.to)))
|
||||
expect(view.state.doc.textBetween(m.from, m.to)).toBe('hello')
|
||||
view.dispatch(view.state.tr.insertText('HELLO', m.from, m.to))
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('HELLO 世界 hello')
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('快捷键注册表', () => {
|
||||
it('默认绑定完整且无组合键冲突', () => {
|
||||
const resolved = resolveShortcuts({})
|
||||
expect(resolved.get('ctrl+s')).toBe('save')
|
||||
expect(resolved.get('ctrl+f')).toBe('find')
|
||||
expect(resolved.get('ctrl+1')).toBe('heading1')
|
||||
const combos = SHORTCUT_DEFS.map((d) => d.defaultCombo)
|
||||
expect(new Set(combos).size).toBe(combos.length)
|
||||
})
|
||||
|
||||
it('覆盖、解绑与冲突查询', () => {
|
||||
const overrides = { find: 'ctrl+shift+f', save: '' }
|
||||
const resolved = resolveShortcuts(overrides)
|
||||
expect(resolved.get('ctrl+shift+f')).toBe('find')
|
||||
expect(resolved.get('ctrl+f')).toBeUndefined()
|
||||
expect(resolved.get('ctrl+s')).toBeUndefined()
|
||||
expect(effectiveCombos(overrides).has('save')).toBe(false) // 解绑后不在生效表内
|
||||
expect(comboOwner('ctrl+shift+f', {}, 'find')?.id).toBeUndefined
|
||||
expect(comboOwner('ctrl+s', {})?.id).toBe('save')
|
||||
// 改绑到他人组合键时覆盖优先
|
||||
expect(resolveShortcuts({ find: 'ctrl+s' }).get('ctrl+s')).toBe('find')
|
||||
})
|
||||
|
||||
it('事件组合键归一化与展示', () => {
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: 'S' })).toBe('ctrl+shift+s')
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+shift+=')
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, key: 'Control' })).toBeNull()
|
||||
expect(prettyCombo('ctrl+shift+s')).toBe('Ctrl+Shift+S')
|
||||
expect(prettyCombo('')).toBe('(未绑定)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('专注模式装饰', () => {
|
||||
it('开启后非当前块获得 focus-dim 类,关闭后消失', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
setFocusModeEnabled(true)
|
||||
const editor = createMarkdownEditor(host, '第一段文字。\n\n第二段文字。\n\n第三段文字。\n')
|
||||
await editor.create()
|
||||
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||
const { TextSelection } = await import('@milkdown/kit/prose/state')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
// 把光标放到第一段,触发一次选区变化让装饰重算
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)))
|
||||
})
|
||||
const dimmed = host.querySelectorAll('.focus-dim')
|
||||
expect(dimmed.length).toBe(2)
|
||||
setFocusModeEnabled(false)
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 3)))
|
||||
})
|
||||
expect(host.querySelectorAll('.focus-dim').length).toBe(0)
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user