155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
// 阶段 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<Editor> {
|
||
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)
|
||
})
|
||
})
|