import type { TransactionSpec } from '@codemirror/state' import type { FormatCommand } from './format' import { renderMarkdown } from '@@/markdown/html' import { redo, undo } from '@codemirror/commands' import { EditorSelection, EditorState, Transaction } from '@codemirror/state' import { describe, expect, it } from 'vitest' import { toggleBold } from './format' import { createEditorState } from './index' import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inline-styles' function editor(doc: string, text: string = doc) { let state = createEditorState({ doc, extensions: [EditorState.allowMultipleSelections.of(true)] }) const select = (text: string) => { const from = state.doc.toString().indexOf(text) if (from < 0) throw new Error('找不到测试选区') state = state.update({ selection: EditorSelection.range(from, from + text.length) }).state } select(text) const target = { get state() { return state }, dispatch(spec: TransactionSpec | Transaction) { state = spec instanceof Transaction ? spec.state : state.update(spec).state } } return { target, select, run(command: FormatCommand) { return command(target) }, get doc() { return state.doc.toString() } } } describe('局部外观', () => { it.each([ ['a\\*b', '*', '\\*'], ['a&b', 'amp', '&'], ['a*b', '42', '*'], ['a*b', 'x2a', '*'], ['a:smile:b', 'smile', ':smile:'], ['a[^note]b\n\n[^note]: footnote', 'note', '[^note]'], ['ab', 'example', ''], ])('局部选择语法单元时整体设置外观:%s', (doc, selection, atom) => { const e = editor(doc, selection) expect(e.run(applyInlineStyles({ color: '#0000ff' }))).toBe(true) expect(e.doc).toBe(doc.replace(atom, `${atom}`)) expect(renderMarkdown(e.doc).replace('', '').replace('', '')).toBe(renderMarkdown(doc)) expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe(selection) e.run(clearInlineStyles) expect(e.doc).toBe(doc) }) it('局部选择转义字符后切换下划线,不改变正文含义,且可撤销', () => { const e = editor('a\\*b', '*') e.run(toggleUnderline) expect(e.doc).toBe('a\\*b') expect(renderMarkdown(e.doc)).toBe('

a*b

\n') e.run(toggleUnderline) expect(e.doc).toBe('a\\*b') expect(undo(e.target)).toBe(true) expect(e.doc).toBe('a\\*b') }) it('同一字符实体内多个选区只格式化一次并保留反向选区', () => { const e = editor('&') e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(2, 1), EditorSelection.range(3, 4)]) }) e.run(toggleSelectionMark('StrongEmphasis')) expect(e.doc).toBe('**&**') expect(renderMarkdown(e.doc)).toBe('

&

\n') expect(e.target.state.selection.ranges.map(range => e.target.state.sliceDoc(range.from, range.to))).toEqual(['a', 'p']) expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head) }) it('下划线可切换,并能和 Markdown 粗体叠加', () => { const e = editor('hello') e.run(toggleUnderline) expect(e.doc).toBe('hello') expect(selectionAppearance(e.target.state).underline).toBe(true) e.run(toggleBold) expect(renderMarkdown(e.doc)).toBe('

hello

\n') e.run(toggleUnderline) expect(e.doc).toBe('**hello**') }) it('重复修改同一选区会更新标签,撤销和重做保留选区', () => { const e = editor('hello') e.run(applyInlineStyles({ color: '#ff0000' })) e.run(applyInlineStyles({ color: '#0000ff' })) e.run(applyInlineStyles({ 'font-size': '1.5em' })) expect(e.doc).toBe('hello') expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe('hello') expect(undo(e.target)).toBe(true) expect(redo(e.target)).toBe(true) expect(e.doc).toContain('font-size: 1.5em') }) it('修改部分文字,不影响同一标签内其余内容', () => { const e = editor('hello world', 'world') e.run(applyInlineStyles({ color: '#0000ff' })) expect(e.doc).toBe('hello world') e.run(clearInlineStyles) expect(e.doc).toBe('hello world') }) it('跨列表、引用和链接操作保留 Markdown 结构与地址', () => { const e = editor('- **one**\n- [two](https://example.com)\n\n> three') e.run(applyInlineStyles({ color: '#ff0000' })) expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three') e.run(clearInlineStyles) expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three') }) it('清除嵌套外观时保留下划线、高亮和链接', () => { const e = editor('==one== [two](https://example.com)') e.run(clearInlineStyles) expect(renderMarkdown(e.doc)).toBe('

one two

\n') }) it('代码、公式及 YAML 选区不允许套用样式', () => { for (const doc of ['```js\nvalue\n```', '`value`', '$value$', '---\nvalue: true\n---']) { const e = editor(doc, 'value') expect(canFormatSelection(e.target.state)).toBe(false) expect(e.run(toggleUnderline)).toBe(false) expect(e.doc).toBe(doc) } }) it('反向和多选区保留方向与相互独立的样式', () => { const e = editor('one two three') e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(3, 0), EditorSelection.range(8, 13)]) }) e.run(toggleUnderline) expect(e.doc).toBe('one two three') expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head) }) it('未渲染的长文末尾也能格式化;默认外观不制造无效编辑', () => { const doc = `${'paragraph\n\n'.repeat(3000)}lastword` const e = editor(doc, 'lastword') expect(e.run(clearInlineStyles)).toBe(false) e.run(toggleUnderline) expect(e.doc).toBe(`${doc.slice(0, -8)}lastword`) }) it('跨段落的 Markdown 格式逐项切换,保留列表和链接', () => { for (const mark of ['StrongEmphasis', 'Emphasis', 'Strikethrough', 'Highlight'] as const) { const e = editor('- one\n- [two](https://example.com)\n\n> three') e.run(toggleSelectionMark(mark)) const html = renderMarkdown(e.doc) const tag = { StrongEmphasis: 'strong', Emphasis: 'em', Strikethrough: 'del', Highlight: 'mark' }[mark] expect(html).toContain(`<${tag}>one`) expect(html).toContain(`<${tag}>two`) e.run(toggleSelectionMark(mark)) expect(e.doc).toBe('- one\n- [two](https://example.com)\n\n> three') } }) it('混合格式选区统一加粗,局部取消不影响剩余文字', () => { const e = editor('**one** two') e.run(toggleSelectionMark('StrongEmphasis')) expect(renderMarkdown(e.doc)).toBe('

one two

\n') e.select('two') e.run(toggleSelectionMark('StrongEmphasis')) expect(renderMarkdown(e.doc)).toBe('

one two

\n') const whitespace = editor(' alpha ') whitespace.run(toggleSelectionMark('StrongEmphasis')) expect(whitespace.doc).toBe(' **alpha** ') expect(whitespace.target.state.sliceDoc(whitespace.target.state.selection.main.from, whitespace.target.state.selection.main.to)).toBe(' **alpha** ') }) it('多行列表缩进、引用前缀与硬换行保持不变', () => { const e = editor('- first \n continued\n\n> quote\n> next') e.run(applyInlineStyles({ color: '#ff0000' })) expect(e.doc).toBe('- first \n continued\n\n> quote\n> next') const html = renderMarkdown(e.doc) expect(html).toContain('
') expect((html.match(/
  • /g) ?? []).length).toBe(1) expect((html.match(/
    /g) ?? []).length).toBe(1) }) it('局部修改保留同一外层格式内未选代码的样式', () => { const bold = editor('**first `code` last**', 'first') bold.run(applyInlineStyles({ color: '#0000ff' })) expect(renderMarkdown(bold.doc)).toContain('code') const color = editor('first `code` last', 'first') color.run(applyInlineStyles({ color: '#0000ff' })) expect(renderMarkdown(color.doc)).toContain('code') }) })