fix(editor): preserve atomic Markdown syntax during formatting
This commit is contained in:
1 parent
0861be681b
commit
8b2d32a3be
2 files changed
+43
-7
No files matched your search
@@ -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('&')
|
||||||
|
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('<p><strong>&</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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in new issue
Block a user