feat: 阶段1-3 桌面编辑器(Tauri 骨架/编辑核心深化/复杂内容)

This commit is contained in:
Muse
2026-11-13 21:19:23 +08:00
parent cbd7ce4b44
commit cbe641e21a
36 changed files with 10059 additions and 0 deletions

154
tests/stage2.test.ts Normal file
View File

@@ -0,0 +1,154 @@
// 阶段 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)
})
})