191 lines
7.0 KiB
TypeScript
191 lines
7.0 KiB
TypeScript
// 阶段 3 验收:表格操作、数学公式、Mermaid 视图、脚注/HTML 转正、图片命令、附件链接。
|
||
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 { editorCommands, insertMathBlock, insertMermaidBlock } from '../src/editor/commands'
|
||
import { insertLinkText } from '../src/editor/paste'
|
||
import { selectionInTable, isLastTableCell } from '../src/editor/plugins'
|
||
|
||
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
|
||
}
|
||
|
||
async function roundtrip(md: string): Promise<string> {
|
||
const once = getMarkdown(await load(md))
|
||
const twice = getMarkdown(await load(once))
|
||
expect(twice).toBe(once)
|
||
return once
|
||
}
|
||
|
||
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) => {
|
||
ctx.get(editorViewCtx).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
|
||
}
|
||
|
||
const TABLE_MD = '| 名称 | 数量 |\n| :--- | ---: |\n| 苹果 | 3 |\n| 香蕉 | 5 |\n'
|
||
|
||
describe('表格', () => {
|
||
it('插入 3×3 表格命令生成表头与数据行', async () => {
|
||
const editor = await load('正文。\n')
|
||
setSelection(editor, 2)
|
||
editorCommands.insertTable(editor, 3, 3)
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('|')
|
||
expect(md.split('\n').filter((l) => l.includes('|')).length).toBeGreaterThanOrEqual(4)
|
||
})
|
||
|
||
it('光标在单元格内可判定表格上下文与末格', async () => {
|
||
const editor = await load(TABLE_MD)
|
||
const pos = findTextPos(editor, '香蕉')
|
||
setSelection(editor, pos)
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
expect(selectionInTable(view.state.selection as never)).toBe(true)
|
||
// “香蕉”在最后一行第一列,不是末格;其右邻“5”所在格是末格
|
||
expect(isLastTableCell(view.state as never)).toBe(false)
|
||
})
|
||
const lastPos = findTextPos(editor, '5')
|
||
setSelection(editor, lastPos)
|
||
editor.action((ctx) => {
|
||
expect(isLastTableCell(ctx.get(editorViewCtx).state as never)).toBe(true)
|
||
})
|
||
})
|
||
|
||
it('加行/加列/删列改变结构且往返稳定', async () => {
|
||
const editor = await load(TABLE_MD)
|
||
setSelection(editor, findTextPos(editor, '苹果'))
|
||
editorCommands.addRowAfter(editor)
|
||
let md = getMarkdown(editor)
|
||
expect(md.split('\n').filter((l) => l.startsWith('|')).length).toBe(5) // 表头+分隔+3 数据行
|
||
editorCommands.addColAfter(editor)
|
||
md = getMarkdown(editor)
|
||
expect(md.split('\n')[0].split('|').length).toBeGreaterThanOrEqual(5) // 3 列
|
||
editorCommands.deleteCol(editor)
|
||
const twice = getMarkdown(await load(getMarkdown(editor)))
|
||
expect(twice).toBe(getMarkdown(editor))
|
||
})
|
||
|
||
it('对齐命令改变列对齐并保留到序列化', async () => {
|
||
const editor = await load(TABLE_MD)
|
||
setSelection(editor, findTextPos(editor, '苹果'))
|
||
editorCommands.setAlign(editor, 'center')
|
||
const md = getMarkdown(editor)
|
||
expect(md).toMatch(/:-+:/)
|
||
})
|
||
})
|
||
|
||
describe('数学公式(KaTeX 插件)', () => {
|
||
it('行内公式往返保留 LaTeX', async () => {
|
||
const once = await roundtrip('质能方程 $E = mc^2$ 是物理学常识。\n')
|
||
expect(once).toContain('E = mc^2')
|
||
expect(once).toContain('$')
|
||
})
|
||
|
||
it('块级公式往返保留', async () => {
|
||
const once = await roundtrip('$$\n\\frac{a}{b} + \\sqrt{x}\n$$\n')
|
||
expect(once).toContain('\\frac{a}{b}')
|
||
expect(once).toContain('$$')
|
||
})
|
||
|
||
it('插入公式块命令产出 $$ 块', async () => {
|
||
const editor = await load('正文。\n')
|
||
setSelection(editor, 2)
|
||
insertMathBlock(editor)
|
||
expect(getMarkdown(editor)).toContain('$$')
|
||
expect(getMarkdown(editor)).toContain('E = mc^2')
|
||
})
|
||
})
|
||
|
||
describe('Mermaid 与代码块视图', () => {
|
||
it('Mermaid 块渲染出节点视图容器,源码保留', async () => {
|
||
const source = '```mermaid\ngraph TD\n A[开始] --> B[结束]\n```\n'
|
||
const editor = await load(source)
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
expect(view.dom.querySelector('.mermaid-block')).not.toBeNull()
|
||
})
|
||
expect(getMarkdown(editor)).toContain('graph TD')
|
||
})
|
||
|
||
it('普通代码块仍是 pre>code 且内容可序列化', async () => {
|
||
const editor = await load('```ts\nconst v = 42;\n```\n')
|
||
editor.action((ctx) => {
|
||
const code = ctx.get(editorViewCtx).dom.querySelector('pre code')
|
||
expect(code?.textContent).toContain('const v = 42;')
|
||
})
|
||
expect(getMarkdown(editor)).toContain('const v = 42;')
|
||
})
|
||
|
||
it('插入 Mermaid 命令产出 mermaid 代码块', async () => {
|
||
const editor = await load('正文。\n')
|
||
setSelection(editor, 2)
|
||
insertMermaidBlock(editor)
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('```mermaid')
|
||
expect(md).toContain('graph TD')
|
||
})
|
||
})
|
||
|
||
describe('脚注与 HTML(阶段 0 探针转正)', () => {
|
||
it('脚注定义与引用往返稳定', async () => {
|
||
const once = await roundtrip('正文带脚注[^1],还有第二个[^note]。\n\n[^1]: 第一条脚注。\n\n[^note]: 第二条脚注。\n')
|
||
expect(once).toContain('[^1]')
|
||
expect(once).toContain('第一条脚注')
|
||
expect(once).toContain('[^note]')
|
||
})
|
||
|
||
it('行内与块级 HTML 往返保留', async () => {
|
||
const once = await roundtrip('一段<u>下划线</u>文本。\n\n<div class="note">原始 HTML 块</div>\n')
|
||
expect(once).toContain('<u>下划线</u>')
|
||
expect(once).toContain('原始 HTML 块')
|
||
})
|
||
})
|
||
|
||
describe('图片与附件', () => {
|
||
it('图片命令插入相对路径图片并往返保留', async () => {
|
||
const editor = await load('看图:\n')
|
||
setSelection(editor, 3)
|
||
editorCommands.image(editor, '文章.assets/image-1.png', '示意图')
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('文章.assets/image-1.png')
|
||
const twice = getMarkdown(await load(md))
|
||
expect(twice).toBe(md)
|
||
})
|
||
|
||
it('附件以链接形式插入并往返保留', async () => {
|
||
const editor = await load('附件:\n')
|
||
setSelection(editor, 3)
|
||
expect(insertLinkText(editor, '说明.pdf', '文档.assets/说明.pdf')).toBe(true)
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('说明.pdf')
|
||
expect(md).toContain('文档.assets/')
|
||
})
|
||
})
|