// 阶段 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+=') 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() }) })