// 阶段 2 验收:标记显隐装饰、代码块 Tab、任务列表勾选 DOM、列表命令、纯文本插入、IME 守卫判定。 import { describe, it, expect, afterEach } from 'vitest' import type { Editor } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core' import { TextSelection } from '@milkdown/kit/prose/state' import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' import { applyCodeBlockIndent } from '../src/editor/plugins' import { editorCommands } from '../src/editor/commands' import { insertPlainText } from '../src/editor/paste' import { isImeComposing } from '../src/utils/ime' 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 { const root = document.createElement('div') document.body.appendChild(root) const editor = createMarkdownEditor(root, md) editors.push(editor) await editor.create() return editor } function setSelection(editor: Editor, pos: number) { editor.action((ctx) => { const view = ctx.get(editorViewCtx) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos))) }) } function findTextPos(editor: Editor, text: string): number { let found = -1 editor.action((ctx) => { const view = ctx.get(editorViewCtx) view.state.doc.descendants((node, pos) => { if (found < 0 && node.isText && node.text?.includes(text)) { found = pos + node.text.indexOf(text) } return found < 0 }) }) return found } function markers(editor: Editor): string[] { let texts: string[] = [] editor.action((ctx) => { const view = ctx.get(editorViewCtx) texts = [...view.dom.querySelectorAll('.md-marker')].map((el) => el.textContent ?? '') }) return texts } describe('语法标记显隐', () => { it('光标在标题内显示 # 前缀,在粗体段内显示 **,移开后消失,且文档序列化不受影响', async () => { const md = '# 大标题\n\n普通段落里的**粗体字**在这里。\n' const editor = await load(md) // 初始选区在文档开头(标题内) expect(markers(editor).some((t) => t.includes('#'))).toBe(true) const boldPos = findTextPos(editor, '粗体字') expect(boldPos).toBeGreaterThan(0) setSelection(editor, boldPos + 1) const shown = markers(editor) expect(shown).toContain('**') expect(shown.some((t) => t.includes('#'))).toBe(false) // 移到段落末尾的纯文本处,标记应全部隐藏 const endPos = findTextPos(editor, '在这里') setSelection(editor, endPos + 2) expect(markers(editor)).toEqual([]) // 装饰不进文档:序列化与原文一致(往返稳定) expect(getMarkdown(editor)).toBe(md) }) it('光标在引用内显示 > 前缀', async () => { const editor = await load('前文。\n\n> 引用内容在此。\n') const pos = findTextPos(editor, '引用内容') setSelection(editor, pos + 1) expect(markers(editor)).toContain('> ') }) }) describe('代码块 Tab', () => { it('代码块内 Tab 插入两空格,Shift+Tab 去掉', async () => { const editor = await load('```ts\nconst a = 1;\n```\n') const pos = findTextPos(editor, 'const') setSelection(editor, pos) editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(applyCodeBlockIndent(view as never, false)).toBe(true) }) expect(getMarkdown(editor)).toContain(' const a = 1;') editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(applyCodeBlockIndent(view as never, true)).toBe(true) }) expect(getMarkdown(editor)).toContain('const a = 1;') expect(getMarkdown(editor)).not.toContain(' const') }) it('普通段落里不处理 Tab', async () => { const editor = await load('普通段落。\n') editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(applyCodeBlockIndent(view as never, false)).toBe(false) }) }) }) describe('任务列表与列表命令', () => { it('任务列表渲染出可点击 checkbox,点击切换并序列化回 [x]', async () => { const editor = await load('- [ ] 待办事项\n- [x] 已完成\n') let firstBox: HTMLInputElement | null = null editor.action((ctx) => { const view = ctx.get(editorViewCtx) const boxes = view.dom.querySelectorAll('input[type="checkbox"]') expect(boxes.length).toBe(2) expect((boxes[1] as HTMLInputElement).checked).toBe(true) firstBox = boxes[0] as HTMLInputElement }) firstBox!.click() expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s待办事项/) expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s已完成/) }) it('段落转无序列表命令生效', async () => { const editor = await load('变成列表项\n') setSelection(editor, 2) editorCommands.bulletList(editor) expect(getMarkdown(editor)).toContain('- 变成列表项') }) }) describe('纯文本插入与 IME 守卫', () => { it('insertPlainText 在选区处插入并可撤销序列保留', async () => { const editor = await load('前后\n') setSelection(editor, 2) expect(insertPlainText(editor, '中间')).toBe(true) expect(getMarkdown(editor)).toContain('前中间后') }) it('isImeComposing 判定', () => { expect(isImeComposing({ isComposing: true })).toBe(true) expect(isImeComposing({ keyCode: 229 })).toBe(true) expect(isImeComposing({ isComposing: false, keyCode: 13 })).toBe(false) expect(isImeComposing({})).toBe(false) }) })