fix(editor): preserve atomic Markdown syntax during formatting

This commit is contained in:
eezd committed 2026-10-24 19:38:40 +08:00
1 parent 0861be681b
commit 8b2d32a3be
2 files changed
+43 -7

No files matched your search

+36
View File
@@ -19,6 +19,42 @@ function editor(doc: string, text: string = doc) {
} }
describe('局部外观', () => { 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]'],
['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 粗体叠加', () => { it('下划线可切换,并能和 Markdown 粗体叠加', () => {
const e = editor('hello') const e = editor('hello')
e.run(toggleUnderline) e.run(toggleUnderline)
+7 -7
View File
@@ -6,7 +6,7 @@ import type { FormatCommand } from './format'
interface Wrapper { marks: { from: number; to: number }[] } interface Wrapper { marks: { from: number; to: number }[] }
interface Segment { interface Segment {
from: number; to: number; styles: InlineStyles; underline: boolean; formats: string[]; wrappers: Wrapper[] from: number; to: number; atomic: boolean; styles: InlineStyles; underline: boolean; formats: string[]; wrappers: Wrapper[]
} }
const TEXT_CONTAINERS = new Set(['Paragraph', 'TableCell', 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'Highlight', 'Superscript', 'Subscript', 'Underline', 'InlineStyle']) const TEXT_CONTAINERS = new Set(['Paragraph', 'TableCell', 'Emphasis', 'StrongEmphasis', 'Strikethrough', 'Highlight', 'Superscript', 'Subscript', 'Underline', 'InlineStyle'])
const PROTECTED = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'InlineMath', 'BlockMath', 'FrontMatter', 'HTMLBlock', 'Image']) const PROTECTED = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'InlineMath', 'BlockMath', 'FrontMatter', 'HTMLBlock', 'Image'])
@@ -24,9 +24,9 @@ function formattingTree(state: EditorState) {
function segments(state: EditorState): Segment[] { function segments(state: EditorState): Segment[] {
const result: Segment[] = [] const result: Segment[] = []
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) { function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink'].includes(node.name)) { if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink', 'Escape', 'Entity'].includes(node.name)) {
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。 // 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
result.push({ from: node.from, to: node.to, styles, underline, formats, wrappers }) result.push({ from: node.from, to: node.to, atomic: true, styles, underline, formats, wrappers })
return return
} }
if (PROTECTED.has(node.name)) return if (PROTECTED.has(node.name)) return
@@ -49,7 +49,7 @@ function segments(state: EditorState): Segment[] {
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。 // 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
const contentFrom = Math.max(start, prefixEnd) const contentFrom = Math.max(start, prefixEnd)
if (contentFrom < end) result.push({ from: contentFrom, to: end, styles, underline, formats, wrappers }) if (contentFrom < end) result.push({ from: contentFrom, to: end, atomic: false, styles, underline, formats, wrappers })
} }
} }
let pos = node.from let pos = node.from
@@ -62,8 +62,7 @@ function segments(state: EditorState): Segment[] {
for (let child = node.firstChild; child; child = child.nextSibling) { for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.to <= pos || child.from >= end) continue if (child.to <= pos || child.from >= end) continue
if (textContainer && child.from > pos) add(pos, child.from) if (textContainer && child.from > pos) add(pos, child.from)
if (child.name === 'Escape' || child.name === 'Entity') add(child.from, child.to) walk(child, styles, underline, formats, wrappers)
else walk(child, styles, underline, formats, wrappers)
pos = child.to pos = child.to
} }
if (textContainer && pos < end) add(pos, end) if (textContainer && pos < end) add(pos, end)
@@ -122,7 +121,8 @@ function editAppearance(update: (styles: InlineStyles, underline: boolean, forma
wrappers.add(wrapper) wrappers.add(wrapper)
for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' }) for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
} }
const cuts = [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b) // 部分选中转义字符等语法单元时,格式应用于整个单元,不能把标签插进语法内部。
const cuts = run.atomic ? [run.from, run.to] : [...new Set([run.from, run.to, ...state.selection.ranges.flatMap(range => [Math.max(run.from, Math.min(run.to, range.from)), Math.max(run.from, Math.min(run.to, range.to))])])].sort((a, b) => a - b)
let insert = '' let insert = ''
for (let i = 0; i < cuts.length - 1; i++) { for (let i = 0; i < cuts.length - 1; i++) {
const from = cuts[i], to = cuts[i + 1] const from = cuts[i], to = cuts[i + 1]