Files
RawMark/web/src/pages/workspace/editor/inline-styles.test.ts
T
eezd 6b537c03e0 refactor(web): align resource APIs and forms with the starter conventions
Centralize typed resource functions under common/apis and return complete JSON envelopes while preserving raw responses, bearer session isolation, and single-owner errors.

Keep settings state and queues inside the setup store. Use Element Plus forms and dialogs for login, settings, imports, moves, and images; preserve editor interactions and themes. Update callers, tests, generated declarations, and request import constraints together.
2026-10-26 11:53:37 +08:00

172 lines
9.0 KiB
TypeScript

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&amp;b', 'amp', '&amp;'],
['a&#42;b', '42', '&#42;'],
['a&#x2a;b', 'x2a', '&#x2a;'],
['a:smile:b', 'smile', ':smile:'],
['a[^note]b\n\n[^note]: footnote', 'note', '[^note]'],
['a<https://example.com>b', 'example', '<https://example.com>'],
])('局部选择语法单元时整体设置外观:%s', (doc, selection, atom) => {
const e = editor(doc, selection)
expect(e.run(applyInlineStyles({ color: '#0000ff' }))).toBe(true)
expect(e.doc).toBe(doc.replace(atom, `<span style="color: #0000ff">${atom}</span>`))
expect(renderMarkdown(e.doc).replace('<span style="color: #0000ff">', '').replace('</span>', '')).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<u>\\*</u>b')
expect(renderMarkdown(e.doc)).toBe('<p>a<u>*</u>b</p>\n')
e.run(toggleUnderline)
expect(e.doc).toBe('a\\*b')
expect(undo(e.target)).toBe(true)
expect(e.doc).toBe('a<u>\\*</u>b')
})
it('同一字符实体内多个选区只格式化一次并保留反向选区', () => {
const e = editor('&amp;')
e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(2, 1), EditorSelection.range(3, 4)]) })
e.run(toggleSelectionMark('StrongEmphasis'))
expect(e.doc).toBe('**&amp;**')
expect(renderMarkdown(e.doc)).toBe('<p><strong>&amp;</strong></p>\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('<u>hello</u>')
expect(selectionAppearance(e.target.state).underline).toBe(true)
e.run(toggleBold)
expect(renderMarkdown(e.doc)).toBe('<p><u><strong>hello</strong></u></p>\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('<span style="color: #0000ff; font-size: 1.5em">hello</span>')
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('<span style="color: #ff0000">hello world</span>', 'world')
e.run(applyInlineStyles({ color: '#0000ff' }))
expect(e.doc).toBe('<span style="color: #ff0000">hello </span><span style="color: #0000ff">world</span>')
e.run(clearInlineStyles)
expect(e.doc).toBe('<span style="color: #ff0000">hello </span>world')
})
it('跨列表、引用和链接操作保留 Markdown 结构与地址', () => {
const e = editor('- **one**\n- [two](https://example.com)\n\n> three')
e.run(applyInlineStyles({ color: '#ff0000' }))
expect(e.doc).toBe('- <span style="color: #ff0000">**one**</span>\n- [<span style="color: #ff0000">two</span>](https://example.com)\n\n> <span style="color: #ff0000">three</span>')
e.run(clearInlineStyles)
expect(e.doc).toBe('- **one**\n- [two](https://example.com)\n\n> three')
})
it('清除嵌套外观时保留下划线、高亮和链接', () => {
const e = editor('<span style="color: #ff0000"><u>==one== <span style="font-size: 20px">[two](https://example.com)</span></u></span>')
e.run(clearInlineStyles)
expect(renderMarkdown(e.doc)).toBe('<p><u><mark>one</mark></u><u> </u><a href="https://example.com"><u>two</u></a></p>\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('<u>one</u> two <u>three</u>')
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)}<u>lastword</u>`)
})
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</${tag}>`)
expect(html).toContain(`<a href="https://example.com"><${tag}>two</${tag}></a>`)
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('<p><strong>one</strong> <strong>two</strong></p>\n')
e.select('two')
e.run(toggleSelectionMark('StrongEmphasis'))
expect(renderMarkdown(e.doc)).toBe('<p><strong>one</strong> two</p>\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('- <span style="color: #ff0000">first</span> \n <span style="color: #ff0000">continued</span>\n\n> <span style="color: #ff0000">quote</span>\n> <span style="color: #ff0000">next</span>')
const html = renderMarkdown(e.doc)
expect(html).toContain('<br />')
expect((html.match(/<li>/g) ?? []).length).toBe(1)
expect((html.match(/<blockquote>/g) ?? []).length).toBe(1)
})
it('局部修改保留同一外层格式内未选代码的样式', () => {
const bold = editor('**first `code` last**', 'first')
bold.run(applyInlineStyles({ color: '#0000ff' }))
expect(renderMarkdown(bold.doc)).toContain('<strong><code>code</code></strong>')
const color = editor('<span style="color: #ff0000">first `code` last</span>', 'first')
color.run(applyInlineStyles({ color: '#0000ff' }))
expect(renderMarkdown(color.doc)).toContain('<span style="color: #ff0000"><code>code</code></span>')
})
})