diff --git a/web/package-lock.json b/web/package-lock.json index e84a22f..87fbded 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,6 +24,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@codemirror/commands": "^6.11.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.0", diff --git a/web/package.json b/web/package.json index cc7283a..3cc377b 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@codemirror/commands": "^6.11.0", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.0", diff --git a/web/tests/editor-boundaries.test.ts b/web/tests/editor-boundaries.test.ts new file mode 100644 index 0000000..8bfbf94 --- /dev/null +++ b/web/tests/editor-boundaries.test.ts @@ -0,0 +1,449 @@ +// P3 加固回归(方案 §7.2 / §7.3 / §7.4、§12.4): +// 1) 文首边界:文档以标题/列表/任务/围栏/水平线开头; +// 2) 文末边界:文档以水平线/图片/围栏/任务结尾(对应 CM issue #625「文末块 widget 消失」一类的回归面); +// 3) 跨原子区间选区:全选替换、跨隐藏标记的选区替换、只选中可见内容时标记不被破坏; +// 4) 不变量:原子区间不跨行、有序不重叠、不含可编辑内容、隐藏标记必在原子区间内; +// 5) 性能基准:3000 行文档的装饰计算耗时(§7.4 目标 < 16ms/帧)。 +// +// 说明:happy-dom 下 CodeMirror 可以真实创建 EditorView 并渲染装饰 DOM, +// 因此本文件用真实 view 断言,而不是只看纯函数输出。 + +import { afterEach, describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { deleteCharBackward } from '@codemirror/commands' +import { livePreview } from '../src/editor/livePreview' +import { collectSpecs } from '../src/editor/syntax' +import { buildDecorations, visibleSyntaxTree } from '../src/editor/decorations' + +const views: EditorView[] = [] + +function mount(doc: string, anchor = 0, focus = true): EditorView { + const state = EditorState.create({ + doc, + selection: { anchor }, + extensions: [markdown({ base: markdownLanguage }), livePreview()], + }) + const parent = document.createElement('div') + document.body.appendChild(parent) + const view = new EditorView({ state, parent }) + if (focus) view.focus() + views.push(view) + return view +} + +/** 通过公开 facet 读出当前生效的原子区间(升序)。 */ +function atomicRanges(view: EditorView): Array<[number, number]> { + const out: Array<[number, number]> = [] + for (const fn of view.state.facet(EditorView.atomicRanges)) { + fn(view).between(0, view.state.doc.length, (from, to) => { + out.push([from, to]) + }) + } + return out.sort((a, b) => a[0] - b[0] || a[1] - b[1]) +} + +function lineTexts(view: EditorView): string[] { + return [...view.dom.querySelectorAll('.cm-line')].map((el) => el.textContent ?? '') +} + +function replaceDoc(view: EditorView, from: number, to: number, insert: string): void { + view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } }) +} + +afterEach(() => { + for (const v of views.splice(0)) v.destroy() + document.body.innerHTML = '' +}) + +describe('文首边界', () => { + it('以标题开头:标记隐藏、整行 h1、标记区间原子化', () => { + const doc = '# 标题\n\n正文' + const view = mount(doc, doc.length) + expect(lineTexts(view)[0]).toBe('标题') + expect(view.dom.querySelectorAll('.cm-md-h1')).toHaveLength(1) + expect(atomicRanges(view)).toContainEqual([0, 2]) + }) + + it('光标停在文档起点回退:不抛错、内容不变、光标不越界', () => { + const doc = '# 标题\n\n正文' + const view = mount(doc, 0) + deleteCharBackward(view) + expect(view.state.doc.toString()).toBe(doc) + expect(view.state.selection.main.head).toBe(0) + }) + + it('以任务项开头:复选框渲染在文首、标记让位、列表标记原子化', () => { + const doc = '- [ ] 第一项\n\n正文' + const view = mount(doc, doc.length) + expect(view.dom.querySelectorAll('.cm-md-task')).toHaveLength(1) + expect(lineTexts(view)[0]).not.toContain('-') + expect(atomicRanges(view)).toContainEqual([0, 2]) // `- ` + }) + + it('以围栏开头:语言标签在文首渲染,块内不解析行内语法', () => { + const doc = '```js\n**原样**\n```\n\n正文' + const view = mount(doc, doc.length) + expect(view.dom.querySelector('.cm-md-fence-lang')?.textContent).toBe('js') + expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0) + }) + + it('以水平线开头:hr widget 渲染在文首', () => { + const doc = '---\n\n正文' + const view = mount(doc, doc.length) + expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1) + expect(lineTexts(view)[0]).toBe('') + }) +}) + +describe('文末边界', () => { + it('以水平线结尾:hr widget 渲染在最后一行', () => { + const doc = '正文\n\n---' + const view = mount(doc, 0) + expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1) + const last = lineTexts(view).at(-1) + expect(last).toBe('') + expect(atomicRanges(view)).toContainEqual([doc.length - 3, doc.length]) + }) + + it('文档只有一条水平线:不抛错且渲染 hr', () => { + const view = mount('---', 0) + expect(view.state.doc.length).toBe(3) + expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1) + }) + + it('以图片结尾:img widget 在文末,src 与 alt 正确', () => { + const doc = '正文\n\n![配图](/api/images/9)' + const view = mount(doc, 0) + const img = view.dom.querySelector('.cm-md-image') as HTMLImageElement | null + expect(img).not.toBeNull() + expect(img?.getAttribute('src')).toBe('/api/images/9') + expect(img?.getAttribute('alt')).toBe('配图') + }) + + it('以围栏结尾:闭围栏隐藏,末行仍带 fenced 类', () => { + const doc = '正文\n\n```js\nconst a = 1\n```' + const view = mount(doc, 0) + expect(lineTexts(view).at(-1)).toBe('') + expect(lineTexts(view).at(-2)).toBe('const a = 1') + expect(view.dom.querySelectorAll('.cm-md-fenced')).toHaveLength(3) + }) + + it('以任务项结尾:复选框渲染在最后一行', () => { + const doc = '正文\n\n- [x] 末项' + const view = mount(doc, 0) + const tasks = view.dom.querySelectorAll('.cm-md-task') + expect(tasks).toHaveLength(1) + expect((tasks[0] as HTMLInputElement).checked).toBe(true) + expect(lineTexts(view).at(-1)).not.toContain('[x]') + }) + + it('文末追加内容后装饰正确重建(不给文末留残影)', () => { + const doc = '正文\n\n---' + const view = mount(doc, 0) + view.dispatch({ changes: { from: doc.length, insert: '\n\n## 新小节\n\n新段落' } }) + expect(lineTexts(view).some((t) => t === '新小节')).toBe(true) + expect(view.dom.querySelectorAll('.cm-md-h2')).toHaveLength(1) + expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1) + }) + + it('文末整体清空(全选删除)不抛错', () => { + const doc = '# 标题\n\n- A\n\n![x](/api/images/1)\n\n---\n\n```js\nx\n```\n' + const view = mount(doc, 0) + replaceDoc(view, 0, doc.length, '') + expect(view.state.doc.toString()).toBe('') + }) +}) + +describe('跨原子区间选区', () => { + const doc = '# 标题\n\n- 甲\n\n段落 **粗体** 结尾\n' + + it('全选替换:跨多个隐藏标记的选区可整体替换', () => { + const view = mount(doc, doc.length) + replaceDoc(view, 0, doc.length, 'X') + expect(view.state.doc.toString()).toBe('X') + }) + + it('选区横跨隐藏标记:替换结果不残留标记碎片', () => { + const view = mount(doc, doc.length) + const boldStart = doc.indexOf('**') + replaceDoc(view, 0, boldStart + 2, '替换') + expect(view.state.doc.toString()).toBe('替换粗体** 结尾\n') + }) + + it('只选中可见内容时替换,标记完整保留', () => { + const doc2 = '段落 **粗体** 结尾' + const view = mount(doc2, doc2.length) + const from = doc2.indexOf('粗体') + replaceDoc(view, from, from + 2, 'X') + expect(view.state.doc.toString()).toBe('段落 **X** 结尾') + }) + + it('选中「标记 + 内容 + 标记」整体删除后,只剩两侧文字', () => { + const doc2 = '段落 **粗体** 结尾' + const view = mount(doc2, doc2.length) + replaceDoc(view, 3, 9, '') + expect(view.state.doc.toString()).toBe('段落 结尾') + expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0) + }) + + it('删除跨行的选区(含列表行与引用行)后装饰重建正确', () => { + const doc2 = '前言\n\n- 甲\n- 乙\n\n> 引用\n\n结尾' + const view = mount(doc2, doc2.length) + replaceDoc(view, 2, doc2.indexOf('结尾'), '') + const left = view.state.doc.toString() + expect(left).toContain('前言') + expect(left).toContain('结尾') + expect(left).not.toContain('甲') + expect(left).not.toContain('引用') + expect(view.dom.querySelectorAll('.cm-md-bullet')).toHaveLength(0) + expect(view.dom.querySelectorAll('.cm-md-quote-bar')).toHaveLength(0) + }) +}) + +describe('装饰层不变量', () => { + const docs = [ + '# 标题\n\n正文 **粗** 与 *斜* 与 `码` 与 [链](https://a.example)\n', + '- [ ] 待办\n- [x] 完成\n - 嵌套\n1. 有序\n', + '> 一\n> > 二\n\n---\n\n```js\ncode\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n', + '![图](/api/images/1)\n\n结尾\n', + ] + + it('原子区间不跨行(CM6 硬性约束)', () => { + for (const doc of docs) { + for (const anchor of [0, doc.length]) { + const view = mount(doc, anchor) + for (const [from, to] of atomicRanges(view)) { + expect(view.state.doc.lineAt(from).number).toBe(view.state.doc.lineAt(to).number) + } + } + } + }) + + it('原子区间有序且互不重叠', () => { + for (const doc of docs) { + const view = mount(doc, doc.length) + const ranges = atomicRanges(view) + for (let i = 1; i < ranges.length; i++) { + expect(ranges[i][0]).toBeGreaterThanOrEqual(ranges[i - 1][1]) + } + } + }) + + it('隐藏标记一定落在原子区间内(退格可整段删净)', () => { + const doc = '# 标题\n\n- 甲\n> 乙\n\n**粗**\n' + const view = mount(doc, doc.length) + const ranges = atomicRanges(view) + const tree = syntaxTree(view.state) + for (const spec of collectSpecs(tree, view.state.doc, 0, doc.length)) { + if (spec.kind !== 'hide') continue + expect(ranges).toContainEqual([spec.from, spec.to]) + } + }) + + it('强调 / 行内代码 / 链接的内容区间绝不被原子化(§4.2)', () => { + const doc = '文字 **粗体** 与 `代码` 与 [链接](https://a.example) 结束\n' + const view = mount(doc, doc.length) + const ranges = atomicRanges(view) + const tree = syntaxTree(view.state) + const contentSpecs = collectSpecs(tree, view.state.doc, 0, doc.length).filter( + (s) => s.kind === 'mark' && ['cm-md-strong', 'cm-md-code', 'cm-md-link'].includes(s.cls), + ) + expect(contentSpecs).toHaveLength(3) + for (const spec of contentSpecs) { + for (const [from, to] of ranges) { + expect(spec.from >= to || spec.to <= from).toBe(true) + } + } + }) + + it('reveal 契约:光标进入某行后该行原子区间消失,离开后恢复', () => { + const doc = '# 标题\n\n正文 **粗** 结束\n' + const view = mount(doc, doc.length) // 光标在末行 + expect(atomicRanges(view)).toContainEqual([0, 2]) + expect(lineTexts(view)[0]).toBe('标题') + + view.dispatch({ selection: { anchor: 1 } }) // 光标进入标题行 + expect(atomicRanges(view)).not.toContainEqual([0, 2]) + expect(lineTexts(view)[0]).toBe('# 标题') + + view.dispatch({ selection: { anchor: doc.length } }) // 离开 + expect(atomicRanges(view)).toContainEqual([0, 2]) + expect(lineTexts(view)[0]).toBe('标题') + }) + + it('失焦(未聚焦)时不展开任何行,整篇按渲染态呈现', () => { + const doc = '# 标题\n\n正文\n' + const view = mount(doc, 5, false) // 不调用 focus() + expect(view.hasFocus).toBe(false) + expect(lineTexts(view)[0]).toBe('标题') + expect(atomicRanges(view)).toContainEqual([0, 2]) + }) +}) + +describe('长文档:惰性解析与装饰补齐', () => { + function buildDoc(lines: number): string { + const out: string[] = [] + for (let i = 0; i < lines; i++) { + if (i % 7 === 0) out.push(`## 小节 ${i}`) + else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`) + else if (i % 11 === 0) out.push('> 引用一行内容') + else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`) + } + return out.join('\n') + } + + const doc = buildDoc(3000) + const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] }) + + it('如实记录:syntaxTree 对长文档只返回已解析的前段(lezer 惰性增量)', () => { + const partial = syntaxTree(state).length + // eslint-disable-next-line no-console + console.log(`[parse] 3000 行文档(${doc.length} 字符):syntaxTree 首次仅覆盖 ${partial} 字符`) + expect(partial).toBeLessThan(doc.length) + expect(partial).toBeGreaterThan(0) + }) + + it('visibleSyntaxTree 在给定预算内把可见区间补齐,装饰不再缺失', () => { + const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to } + const cold = syntaxTree(state).length + const tree = visibleSyntaxTree(state, [win], 2000) + // eslint-disable-next-line no-console + console.log(`[parse] 补解析窗口 [${win.from}, ${win.to}]:树覆盖 ${cold} → ${tree.length}`) + expect(tree.length).toBeGreaterThanOrEqual(win.to) + const { decorations } = buildDecorations(state, [win], false, tree) + expect(decorations.size).toBeGreaterThan(50) + }) + + it('预算受限时越界区间保持源码态,且不会抛错(等解析推进后自动补齐)', () => { + const win = { from: state.doc.line(2900).from, to: state.doc.line(2950).to } + const tree = visibleSyntaxTree(state, [win], 20) + const { decorations } = buildDecorations(state, [win], false, tree) + if (tree.length < win.from) expect(decorations.size).toBe(0) + else expect(decorations.size).toBeGreaterThanOrEqual(0) + }) + + it('装饰不会越过已解析边界(避免对半截节点做出错误渲染)', () => { + const partialLen = syntaxTree(state).length + const { decorations } = buildDecorations(state, [{ from: 0, to: doc.length }], false, syntaxTree(state)) + let maxTo = 0 + decorations.between(0, doc.length, (_f, to) => { + if (to > maxTo) maxTo = to + }) + expect(maxTo).toBeLessThanOrEqual(partialLen) + }) + + it('光标行远在可见区间之外时仍被装饰(CM 会单独渲染选区所在块)', () => { + // 选区停在文档开头(第 1 行是 `## 小节 0`),可见区间取远端窗口 + const win = { from: state.doc.line(2500).from, to: state.doc.line(2510).to } + const { decorations } = buildDecorations(state, [win], false) + let startLineClass: string | null = null + decorations.between(0, state.doc.line(1).to, (_f, _t, value) => { + if (typeof value.spec.class === 'string') startLineClass = value.spec.class + }) + expect(startLineClass).toBe('cm-md-h2') + + // 远端窗口同样被装饰:补解析 + 采集覆盖了两段 + let farCount = 0 + decorations.between(win.from, win.to, () => { + farCount++ + }) + expect(farCount).toBeGreaterThan(0) + }) +}) + +describe('性能基准(§7.4:3000 行文档单帧 < 16ms)', () => { + function buildDoc(lines: number): string { + const out: string[] = [] + for (let i = 0; i < lines; i++) { + if (i % 7 === 0) out.push(`## 小节 ${i}`) + else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`) + else if (i % 11 === 0) out.push('> 引用一行内容') + else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`) + } + return out.join('\n') + } + + const median = (xs: number[]): number => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)] + + it('视口窗口(约 100 行)装饰计算中位数 < 16ms', () => { + const doc = buildDoc(3000) + const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] }) + const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to } + const tree = visibleSyntaxTree(state, [win], 5000) // 预热到窗口可用 + + const samples: number[] = [] + for (let i = 0; i < 40; i++) { + const t0 = performance.now() + const specs = collectSpecs(tree, state.doc, win.from, win.to) + samples.push(performance.now() - t0) + expect(specs.length).toBeGreaterThan(50) + } + const med = median(samples) + // eslint-disable-next-line no-console + console.log( + `[perf] 3000 行 / 视口 100 行:collectSpecs 中位数 ${med.toFixed(3)}ms,p95 ${median(samples.slice().sort((a, b) => a - b).slice(-2)).toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`, + ) + expect(med).toBeLessThan(16) + }) + + it('整篇作为单一可见区间(最坏情况)仍远低于一帧量级', () => { + const doc = buildDoc(3000) + const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] }) + const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000) + expect(tree.length).toBe(doc.length) + + const samples: number[] = [] + for (let i = 0; i < 10; i++) { + const t0 = performance.now() + const specs = collectSpecs(tree, state.doc, 0, doc.length) + samples.push(performance.now() - t0) + expect(specs.length).toBeGreaterThan(3000) + } + const med = median(samples) + // eslint-disable-next-line no-console + console.log( + `[perf] 3000 行 / 全篇单区间:collectSpecs 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`, + ) + expect(med).toBeLessThan(100) + }) + + it('冷启动成本实测:首次强制解析整篇 3000 行', () => { + const doc = buildDoc(3000) + const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] }) + const t0 = performance.now() + const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000) + const ms = performance.now() - t0 + // eslint-disable-next-line no-console + console.log(`[perf] 3000 行(${doc.length} 字符)全量解析耗时 ${ms.toFixed(1)}ms(树覆盖 ${tree.length} 字符)`) + expect(tree.length).toBe(doc.length) + }) + + it('最坏选区:Cmd+A 整篇选区 + 视口窗口,装饰构建仍在一帧预算内', () => { + const doc = buildDoc(3000) + // 整篇选区 + 聚焦(触发 reveal,展开区间多达 3000 个) + const state = EditorState.create({ + doc, + selection: { anchor: 0, head: doc.length }, + extensions: [markdown({ base: markdownLanguage })], + }) + const win = { from: state.doc.line(1500).from, to: state.doc.line(1560).to } + const tree = visibleSyntaxTree(state, [win], 10000) + + const samples: number[] = [] + for (let i = 0; i < 10; i++) { + const t0 = performance.now() + const { decorations } = buildDecorations(state, [win], true, tree) + samples.push(performance.now() - t0) + expect(decorations.size).toBeGreaterThan(0) + } + const med = median(samples) + // eslint-disable-next-line no-console + console.log( + `[perf] 最坏选区(整篇选中):buildDecorations 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`, + ) + expect(med).toBeLessThan(16) + }) +}) diff --git a/web/tests/editor-live-preview.test.ts b/web/tests/editor-live-preview.test.ts new file mode 100644 index 0000000..e6d85c3 --- /dev/null +++ b/web/tests/editor-live-preview.test.ts @@ -0,0 +1,311 @@ +// 即时渲染装饰层单测(方案 §8): +// 1) syntax.ts 区间识别:标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 / +// 任务 / 围栏 / 水平线 / 表格,覆盖嵌套、代码块内不解析、中文标点; +// 2) reveal.ts:行粒度展开只撤销「标记替代物」,样式类保留; +// 3) decorations.ts:白名单外的图片 src 退回源码文本;widget DOM 无注入面。 +import { describe, expect, it } from 'vitest' +import { EditorState } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { collectSpecs, type Spec } from '../src/editor/syntax' +import { activeLineRanges, applyReveal, intersectsAny, selectionEndpointLines } from '../src/editor/reveal' +import { buildDecorations, IMAGE_SRC_ALLOW } from '../src/editor/decorations' +import { FenceLangWidget, ImageWidget, TaskWidget } from '../src/editor/widgets' + +function makeState(src: string, cursor = 0) { + return EditorState.create({ + doc: src, + selection: { anchor: cursor }, + extensions: [markdown({ base: markdownLanguage })], + }) +} + +/** 采集全文档 specs,按位置排序,便于断言。 */ +function collect(src: string): Spec[] { + const state = makeState(src) + const specs = collectSpecs(syntaxTree(state), state.doc, 0, src.length) + return specs.slice().sort((a, b) => a.from - b.from || a.to - b.to) +} + +/** 断言用的简化视图:`kind:原文`。 */ +function digest(src: string, specs = collect(src)): string[] { + return specs.map((s) => { + if (s.kind === 'line') return `line(#${s.line}:${s.cls})` + const text = src.slice(s.from, s.to) + const extra = + s.kind === 'bullet' || s.kind === 'quote' ? ` d${s.depth}` : s.kind === 'mark' ? ` ${s.cls}` : '' + return `${s.kind}(${JSON.stringify(text)}${extra})` + }) +} + +describe('syntax.ts 区间识别', () => { + it('标题:隐藏 `# `,整行上 h1 类,行内语法继续解析', () => { + const src = '# 标题 **粗**' + const d = digest(src) + expect(d).toContain('line(#1:cm-md-h1)') + expect(d).toContain('hide("# ")') + expect(d).toContain('hide("**")') + expect(d).toContain('mark("粗" cm-md-strong)') + }) + + it('强调:隐藏定界符,内容上样式(含嵌套)', () => { + const d = digest('a **粗** b *斜* c ~~删~~') + expect(d).toEqual([ + 'hide("**")', + 'mark("粗" cm-md-strong)', + 'hide("**")', + 'hide("*")', + 'mark("斜" cm-md-em)', + 'hide("*")', + 'hide("~~")', + 'mark("删" cm-md-del)', + 'hide("~~")', + ]) + }) + + it('行内代码:隐藏反引号,内容不二次解析(`*x*` 保持字面量)', () => { + const d = digest('`*x*`') + expect(d).toEqual(['hide("`")', 'mark("*x*" cm-md-code)', 'hide("`")']) + }) + + it('链接:隐藏 `[` 与 `](url)`,文本上链接类;不产出 ', () => { + const src = '[标题](https://a.example/x)' + const d = digest(src) + expect(d).toEqual(['hide("[")', 'mark("标题" cm-md-link)', 'hide("](https://a.example/x)")']) + }) + + it('图片:整段替换为 image spec,带 alt 与 src', () => { + const specs = collect('![配图](/api/images/7)') + const img = specs.find((s) => s.kind === 'image') + expect(img).toMatchObject({ alt: '配图', src: '/api/images/7' }) + }) + + it('引用:每个 QuoteMark 一个竖线 widget,嵌套深度递增,同行去重加 cm-md-quote', () => { + const src = '> 一\n> > 二' + const d = digest(src) + expect(d.filter((x) => x.startsWith('quote('))).toEqual(['quote(">" d1)', 'quote(">" d1)', 'quote(">" d2)']) + // 第 2 行有两个 QuoteMark,原样各出一条行类;构建阶段按键去重后每行只剩一条 + expect(d.filter((x) => x === 'line(#1:cm-md-quote)')).toHaveLength(1) + expect(d.filter((x) => x === 'line(#2:cm-md-quote)')).toHaveLength(2) + + const state = makeState(src) + const { decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false) + const lineClasses: unknown[] = [] + decorations.between(0, src.length, (_f, _t, value) => { + if (value.spec.class) lineClasses.push(value.spec.class) + }) + expect(lineClasses.filter((c) => c === 'cm-md-quote')).toHaveLength(2) + }) + + it('无序列表:圆点 widget 覆盖「标记 + 空格」,嵌套深度递增', () => { + const d = digest('- A\n - B') + expect(d.filter((x) => x.startsWith('bullet('))).toEqual(['bullet("- " d1)', 'bullet("- " d2)']) + }) + + it('有序列表:保留数字,仅上样式,不替换不原子(决策 E3)', () => { + const d = digest('1. 一\n2. 二') + expect(d).toEqual(['mark("1." cm-md-ol-mark)', 'mark("2." cm-md-ol-mark)']) + }) + + it('任务项:标记让位给复选框,不画圆点;checked 正确', () => { + const d = digest('- [ ] 待办\n- [x] 完成') + expect(d.some((x) => x.startsWith('bullet('))).toBe(false) + expect(d).toContain('hide("- ")') + expect(d.filter((x) => x.startsWith('task('))).toEqual(['task("[ ]")', 'task("[x]")']) + const specs = collect('- [x] 完成') + expect(specs.find((s) => s.kind === 'task')).toMatchObject({ checked: true }) + }) + + it('围栏:语言标签 + 隐藏闭围栏 + 每行 fenced 类;块内不解析行内语法', () => { + const src = '```js\n**not bold**\n```' + const d = digest(src) + expect(d).toContain('fenceLang("```js")') + expect(collect(src).find((s) => s.kind === 'fenceLang')).toMatchObject({ lang: 'js' }) + expect(d).toContain('line(#1:cm-md-fenced cm-md-fenced-first)') + expect(d).toContain('line(#3:cm-md-fenced cm-md-fenced-last)') + expect(d.some((x) => x.includes('cm-md-strong'))).toBe(false) + }) + + it('无语言围栏:起始围栏直接隐藏', () => { + const d = digest('```\nx\n```') + expect(d.some((x) => x.startsWith('fenceLang('))).toBe(false) + expect(d).toContain('hide("```")') + }) + + it('水平线:整行替换', () => { + const d = digest('---') + expect(d).toEqual(['hr("---")']) + }) + + it('表格:每行 cm-md-table,定界符淡化(分隔行两级)', () => { + const src = '| a | b |\n| --- | --- |\n| 1 | 2 |' + const d = digest(src) + expect(d.filter((x) => x.startsWith('line('))).toEqual([ + 'line(#1:cm-md-table)', + 'line(#2:cm-md-table)', + 'line(#3:cm-md-table)', + ]) + const specs = collect(src) + const delims = specs.filter((s): s is Extract => s.kind === 'tableMark') + expect(delims.some((s) => s.delimiter)).toBe(true) + expect(delims.filter((s) => s.delimiter)).toHaveLength(1) // 只有分隔行是两级淡化 + expect(delims).toHaveLength(7) // 4 行共 7 个竖线标记 + }) + + it('中文标点与紧邻标记不误判', () => { + const d = digest('他说:「**重点**」。') + expect(d).toEqual(['hide("**")', 'mark("重点" cm-md-strong)', 'hide("**")']) + }) + + it('转义星号不触发强调', () => { + const d = digest('\\*不是斜体\\*') + expect(d).toEqual([]) + }) + + it('可见区间裁剪:只采集区间内节点', () => { + const src = '# 一\n\n# 二' + const state = makeState(src) + const line3 = state.doc.line(3) + const specs = collectSpecs(syntaxTree(state), state.doc, line3.from, line3.to) + expect(specs.filter((s) => s.kind === 'line')).toHaveLength(1) + expect(specs.find((s) => s.kind === 'line')).toMatchObject({ line: 3 }) + }) +}) + +describe('reveal.ts 光标处展开(行粒度)', () => { + it('intersectsAny 只认真正的相交', () => { + expect(intersectsAny([{ from: 0, to: 5 }], 5, 6)).toBe(true) + expect(intersectsAny([{ from: 0, to: 5 }], 6, 8)).toBe(false) + expect(intersectsAny([{ from: 0, to: 5 }], 3, 3)).toBe(false) + }) + + it('光标行上的标记替代物被撤销,样式类保留', () => { + const src = 'a **粗** b' + const state = makeState(src, 5) // 光标落在粗体所在行 + const specs = collect(src) + const shown = applyReveal(specs, activeLineRanges(state)) + expect(shown.some((s) => s.kind === 'hide')).toBe(false) + expect(shown.some((s) => s.kind === 'mark' && s.cls === 'cm-md-strong')).toBe(true) + }) + + it('光标在别的行时标记保持隐藏', () => { + const src = '第一行\n第二行 **粗**' + const state = makeState(src, 0) + const specs = collect(src) + const shown = applyReveal(specs, activeLineRanges(state)) + expect(shown.filter((s) => s.kind === 'hide')).toHaveLength(2) + }) + + it('选区横跨多行时,涉及的行全部展开', () => { + const src = '> 一\n> > 二' + const state = EditorState.create({ + doc: src, + selection: { anchor: 0, head: src.length }, + extensions: [markdown({ base: markdownLanguage })], + }) + const shown = applyReveal(collect(src), activeLineRanges(state)) + expect(shown.filter((s) => s.kind === 'quote')).toHaveLength(0) + }) + + it('二分优化与线性扫描结果完全一致(含空/重叠/乱序/相邻区间)', () => { + const src = '# 标题 **粗** 与 `码` 与 [链](https://a.example)\n\n- 甲\n> 乙\n\n---\n' + const specs = collect(src) + const cases: { from: number; to: number }[][] = [ + [], + [{ from: 0, to: 2 }], + [{ from: 0, to: 2 }, { from: 2, to: 4 }], + [{ from: 5, to: 9 }, { from: 0, to: 3 }], // 乱序 + [{ from: 0, to: 10 }, { from: 4, to: 6 }], // 重叠 + [{ from: 0, to: src.length }], // 整篇 + [{ from: 3, to: 3 }], // 零长 + ] + for (const ranges of cases) { + const linear = specs.filter((s) => !(s.reveal && intersectsAny(ranges, s.from, s.to))) + expect(applyReveal(specs, ranges)).toEqual(linear) + } + }) + + it('selectionEndpointLines 只取选区端点行(Cmd+A 不退化为全文档装饰)', () => { + const src = '第 1 行\n第 2 行\n第 3 行\n第 4 行' + const state = EditorState.create({ + doc: src, + selection: { anchor: 0, head: src.length }, + extensions: [markdown({ base: markdownLanguage })], + }) + expect(activeLineRanges(state)).toHaveLength(4) + const endpoints = selectionEndpointLines(state) + expect(endpoints).toHaveLength(2) + expect(endpoints[0].from).toBe(0) + expect(endpoints[1].to).toBe(state.doc.line(4).to) + }) +}) + +describe('decorations.ts 构建与安全', () => { + const src = '# 标题\n\n- A\n\n![x](/api/images/1)\n' + + it('生成装饰集且原子区间与隐藏区间数量一致', () => { + const state = makeState(src) + const { decorations, atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true) + expect(decorations.size).toBeGreaterThan(0) + // hide(1: `# `) + hide(1: `- `) = 2 个原子区间 + expect(atomic.size).toBe(2) + }) + + it('光标行上的标记不进入原子区间', () => { + const state = makeState(src, 0) // 光标在标题行 + const { atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true) + expect(atomic.size).toBe(2) // 标题标记已展开,只剩列表标记 + 图片替换 + }) + + it('未聚焦(reveal=false)时整篇按渲染态呈现,光标行的标记也隐藏(决策 E11)', () => { + const state = makeState(src, 0) + const { atomic, decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false) + expect(atomic.size).toBe(3) // 标题 + 列表 + 图片全部原子化 + const hidden: string[] = [] + decorations.between(0, src.length, (from, to) => { + if (to > from) hidden.push(src.slice(from, to)) + }) + expect(hidden).toContain('# ') + }) + + it('图片 src 白名单:站内接口放行,javascript:/外链降级为源码文本', () => { + expect(IMAGE_SRC_ALLOW.test('/api/images/12')).toBe(true) + expect(IMAGE_SRC_ALLOW.test('data:image/png;base64,AAAA')).toBe(true) + expect(IMAGE_SRC_ALLOW.test('javascript:alert(1)')).toBe(false) + expect(IMAGE_SRC_ALLOW.test('https://evil.example/x.png')).toBe(false) + + const bad = '![x](javascript:alert(1))' + const badState = makeState(bad) + const badSet = buildDecorations(badState, [{ from: 0, to: bad.length }], true).decorations + expect(badSet.size).toBe(0) // 不产出 ,源码原样可见 + + const goodState = makeState(src) + const goodSet = buildDecorations(goodState, [{ from: 0, to: src.length }], true).decorations + const widgets: unknown[] = [] + goodSet.between(0, src.length, (_from, _to, value) => { + widgets.push(value.spec.widget) + }) + expect(widgets.some((w) => w instanceof ImageWidget)).toBe(true) + }) + + it('widget DOM 走 textContent,语言名里的 HTML 不被解析', () => { + const dom = new FenceLangWidget('').toDOM() + expect(dom.children).toHaveLength(0) + expect(dom.innerHTML).not.toContain('') + }) + + it('图片 widget 不写入 innerHTML,只设置属性', () => { + const dom = new ImageWidget('/api/images/1', '">').toDOM() + expect(dom.querySelector('script')).toBeNull() + expect(dom.getAttribute('src')).toBe('/api/images/1') + expect(dom.getAttribute('alt')).toBe('">') + }) + + it('任务 widget 挂载时不产生额外 DOM 面', () => { + const dom = new TaskWidget(0, 3, false).toDOM({ dispatch() {} } as never) as HTMLInputElement + expect(dom.tagName).toBe('INPUT') + expect(dom.getAttribute('type')).toBe('checkbox') + expect(dom.checked).toBe(false) + }) +})