C5 粘贴:handleDOMEvents.paste 前置拦截无 HTML/无文件的多行纯文本按段落 插入(真机菜单粘贴走 execCommand 兜底、与 Ctrl+V 同中 clipboard 插件的 Markdown 解析);插入改开放 Slice,消除段中空段。 E3 相对导出:先选保存位置,再按导出目录重写图片相对路径。 E4 复制为 HTML:剪贴板变体公式只留 MathML、Mermaid 栅格化 PNG。 E6 HTML 导入:DOM 预处理剥 head/脚本/SVG、自家 KaTeX 还原 $ 源码、 GFM 插件加载失败降级。vitest 79 通过 + 2 跳过。
161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
// 审计修复回归测试(2026-11-13,对应 docs/11 的高危/中危项)
|
||
import { describe, it, expect } from 'vitest'
|
||
import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor'
|
||
import { editorViewCtx } from '@milkdown/kit/core'
|
||
import { AllSelection, NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
|
||
import { buildTextIndex, findInIndex } from '../src/editor/search'
|
||
import { insertPlainText } from '../src/editor/paste'
|
||
import { findBlockIndex } from '../src/editor/cursorSync'
|
||
import { getMarkdown } from '../src/editor/editor'
|
||
|
||
async function makeEditor(markdown: string) {
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const editor = createMarkdownEditor(host, markdown)
|
||
await editor.create()
|
||
return { host, editor }
|
||
}
|
||
|
||
function docOf(editor: ReturnType<typeof createMarkdownEditor>) {
|
||
return editor.action((ctx) => ctx.get(editorViewCtx).state.doc)
|
||
}
|
||
|
||
describe('cursorFromSelection 的 depth=0 选区(审计高危 6)', () => {
|
||
it('全选时不抛 RangeError', async () => {
|
||
const { host, editor } = await makeEditor('第一段\n\n第二段')
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
expect(() => cursorFromSelection(new AllSelection(view.state.doc))).not.toThrow()
|
||
})
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('选中公式块(NodeSelection)时不抛 RangeError', async () => {
|
||
const { host, editor } = await makeEditor('前文\n\n$$\nx=1\n$$\n\n后文')
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
let mathPos = -1
|
||
view.state.doc.descendants((node, pos) => {
|
||
if (node.type.name === 'math_block') mathPos = pos
|
||
return mathPos < 0
|
||
})
|
||
expect(mathPos).toBeGreaterThanOrEqual(0)
|
||
const info = cursorFromSelection(new NodeSelection(view.state.doc.resolve(mathPos)))
|
||
expect(info.line).toBeGreaterThanOrEqual(1)
|
||
})
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
})
|
||
|
||
describe('查找索引修复(审计)', () => {
|
||
it('正则命中横跨块间分隔符时被跳过', async () => {
|
||
const { host, editor } = await makeEditor('foo\n\nbar baz')
|
||
const index = buildTextIndex(docOf(editor) as never)
|
||
const cross = findInIndex(index, 'foo\\s+bar', { caseSensitive: false, wholeWord: false, regex: true })
|
||
expect(cross).toHaveLength(0)
|
||
const within = findInIndex(index, 'bar', { caseSensitive: false, wholeWord: false, regex: false })
|
||
expect(within).toHaveLength(1)
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('整词对正则模式同样生效', async () => {
|
||
const { host, editor } = await makeEditor('hello world hello shell')
|
||
const index = buildTextIndex(docOf(editor) as never)
|
||
expect(findInIndex(index, 'hello', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(2)
|
||
expect(findInIndex(index, 'hell', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(0)
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('含 İ 的文档不区分大小写查找位置不偏移', async () => {
|
||
const { host, editor } = await makeEditor('İ x hello')
|
||
const index = buildTextIndex(docOf(editor) as never)
|
||
const hits = findInIndex(index, 'hello', { caseSensitive: false, wholeWord: false, regex: false })
|
||
expect(hits).toHaveLength(1)
|
||
// 段落内容在文档中从位置 1 开始,'İ x ' 占 4 个字符,hello 应落在 5
|
||
expect(hits[0].from).toBe(5)
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
})
|
||
|
||
describe('粘贴与锚点修复(审计)', () => {
|
||
it('多行纯文本粘贴按段落插入,不塞进单个文本节点', async () => {
|
||
const { host, editor } = await makeEditor('开头')
|
||
insertPlainText(editor, '第一行\n第二行')
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('第一行')
|
||
expect(md).toContain('第二行')
|
||
// 两行应成为两个段落(序列化里是空行分隔),而不是一行里的字面换行
|
||
expect(md.replace(/\n+$/, '')).toMatch(/第一行\n\n/)
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('锚点兜底匹配不被空段落劫持', () => {
|
||
const blocks = ['', '目标文本 abc', '其他']
|
||
expect(findBlockIndex(blocks, { blockText: '目标文本', offsetInBlock: 0 })).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe('验收回修回归', () => {
|
||
it('任务复选框点击后视觉 checked 与文档同步(C3)', async () => {
|
||
const { host, editor } = await makeEditor('- [ ] 待办一\n- [x] 已办')
|
||
const box = host.querySelector<HTMLInputElement>('input.task-checkbox')
|
||
expect(box).not.toBeNull()
|
||
expect(box!.checked).toBe(false)
|
||
box!.click()
|
||
expect(box!.checked).toBe(true)
|
||
expect(getMarkdown(editor)).toContain('- [x] 待办一')
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('HTML 导入保留 GFM 表格(E6)', async () => {
|
||
const { htmlToMarkdown } = await import('../src/services/exportHtml')
|
||
const md = await htmlToMarkdown('<h1>标题</h1><table><thead><tr><th>列一</th><th>列二</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
|
||
expect(md).toContain('# 标题')
|
||
expect(md).toContain('| 列一 | 列二 |')
|
||
expect(md).toMatch(/\| -+ \| -+ \|/)
|
||
expect(md).toContain('| 1 | 2 |')
|
||
})
|
||
})
|
||
|
||
describe('二轮验收回归', () => {
|
||
it('Ctrl+V 粘贴记事本三行纯文本分成三段(C5)', async () => {
|
||
const { host, editor } = await makeEditor('已有文字')
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 5)))
|
||
})
|
||
const view = editor.action((ctx) => ctx.get(editorViewCtx))
|
||
const event = new Event('paste', { bubbles: true, cancelable: true })
|
||
Object.defineProperty(event, 'clipboardData', {
|
||
value: { files: [], getData: (type: string) => (type === 'text/plain' ? '第一行\r\n第二行\r\n第三行' : '') },
|
||
})
|
||
view.dom.dispatchEvent(event)
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('第一行\n\n第二行\n\n第三行')
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
|
||
it('段落中间粘贴多行不产生空段落(C5)', async () => {
|
||
const { host, editor } = await makeEditor('前后文')
|
||
editor.action((ctx) => {
|
||
const view = ctx.get(editorViewCtx)
|
||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)))
|
||
})
|
||
const { insertPlainText } = await import('../src/editor/paste')
|
||
insertPlainText(editor, '第一行\n第二行')
|
||
const md = getMarkdown(editor)
|
||
expect(md).toContain('前第一行\n\n第二行后文')
|
||
expect(md).not.toContain('<br />')
|
||
await editor.destroy()
|
||
host.remove()
|
||
})
|
||
})
|