feat(editor): replace format bar with selection appearance controls

This commit is contained in:
eezd committed 2026-10-24 18:11:57 +08:00
1 parent 19aab631ec
commit 5abc99b89e
15 files changed
+634 -130

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
// Markdown 格式命令:电脑上的快捷键与手机键盘上方的工具条共用
// Markdown 格式命令:快捷键与选区工具栏共用
import { EditorSelection, type ChangeSpec, type EditorState, type TransactionSpec } from '@codemirror/state'
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
+125
View File
@@ -0,0 +1,125 @@
import { EditorSelection, EditorState, Transaction, type TransactionSpec } from '@codemirror/state'
import { undo, redo } from '@codemirror/commands'
import { describe, expect, it } from 'vitest'
import { createEditorState } from './index'
import { applyInlineStyles, canFormatSelection, clearInlineStyles, selectionAppearance, toggleSelectionMark, toggleUnderline } from './inlineStyles'
import { toggleBold, type FormatCommand } from './format'
import { renderMarkdown } from '../markdown/html'
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('下划线可切换,并能和 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>')
})
})
+177
View File
@@ -0,0 +1,177 @@
import { ensureSyntaxTree, language } from '@codemirror/language'
import { EditorSelection, type EditorState } from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common'
import { inlineStyleText, stylesFromTag, type InlineStyles } from '../markdown/inlineStyles'
import type { FormatCommand } from './format'
interface Wrapper { marks: { from: number; to: number }[] }
interface Segment {
from: number; to: number; styles: InlineStyles; underline: boolean; formats: string[]; wrappers: Wrapper[]
}
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'])
type InlineMark = 'StrongEmphasis' | 'Emphasis' | 'Strikethrough' | 'Highlight'
const MARKS: Record<InlineMark, string> = { StrongEmphasis: '**', Emphasis: '*', Strikethrough: '~~', Highlight: '==' }
const MARK_NODES: Record<InlineMark, string> = { StrongEmphasis: 'EmphasisMark', Emphasis: 'EmphasisMark', Strikethrough: 'StrikethroughMark', Highlight: 'HighlightMark' }
function formattingTree(state: EditorState) {
const to = state.selection.ranges.at(-1)!.to
// 后台标签页或长文末尾的选区可能尚未完成增量解析;操作不能悄悄漏掉文字。
return ensureSyntaxTree(state, to, 50) ?? state.facet(language)!.parser.parse(state.doc.toString())
}
/** 从当前语法树取文字片段,保留 Markdown 的标记、链接地址和块级结构。 */
function segments(state: EditorState): Segment[] {
const result: Segment[] = []
function walk(node: SyntaxNode, styles: InlineStyles = {}, underline = false, formats: string[] = [], wrappers: Wrapper[] = []) {
if (['InlineCode', 'InlineMath', 'Image', 'FootnoteReference', 'Emoji', 'Autolink'].includes(node.name)) {
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
result.push({ from: node.from, to: node.to, styles, underline, formats, wrappers })
return
}
if (PROTECTED.has(node.name)) return
if (node.name === 'InlineStyle' || node.name === 'Underline') {
const marks = node.getChildren(node.name + 'Mark')
if (node.name === 'InlineStyle') styles = { ...styles, ...stylesFromTag(state.sliceDoc(marks[0].from, marks[0].to)) }
else underline = true
wrappers = [...wrappers, { marks }]
}
if (node.name in MARKS) {
formats = [...formats, node.name]
wrappers = [...wrappers, { marks: node.getChildren(MARK_NODES[node.name as InlineMark]) }]
}
if (node.name === 'Link') formats = [...formats, 'Link']
const textContainer = TEXT_CONTAINERS.has(node.name) || /^(ATX|Setext)Heading\d$/.test(node.name) || node.name === 'Link'
const add = (from: number, to: number) => {
for (const match of state.sliceDoc(from, to).matchAll(/[^\n]+/g)) {
const start = from + match.index, end = start + match[0].length
const line = state.doc.lineAt(start)
// 连续段落里的下一行仍可能带列表缩进或引用前缀,标签必须写在前缀之后。
const prefixEnd = line.from + /^(?:[\t ]*>[\t ]?)*[\t ]*/.exec(line.text)![0].length
const contentFrom = Math.max(start, prefixEnd)
if (contentFrom < end) result.push({ from: contentFrom, to: end, styles, underline, formats, wrappers })
}
}
let pos = node.from
let end = node.to
if (node.name === 'Link') {
const marks = node.getChildren('LinkMark')
pos = marks[0].to
end = marks.find(mark => state.sliceDoc(mark.from, mark.to) === ']')!.from
}
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.to <= pos || child.from >= end) continue
if (textContainer && child.from > pos) add(pos, child.from)
if (child.name === 'Escape' || child.name === 'Entity') add(child.from, child.to)
else walk(child, styles, underline, formats, wrappers)
pos = child.to
}
if (textContainer && pos < end) add(pos, end)
}
walk(formattingTree(state).topNode)
return result
}
export function canFormatSelection(state: EditorState): boolean {
if (state.selection.main.empty) return false
let allowed = true
for (const range of state.selection.ranges) formattingTree(state).iterate({ from: range.from, to: range.to,
enter(node) { if (range.from < node.to && range.to > node.from && PROTECTED.has(node.name)) { allowed = false; return false } },
})
return allowed
}
function selected(state: EditorState, segment: Segment) {
return state.selection.ranges.some(range => range.from < segment.to && range.to > segment.from)
}
export function selectionAppearance(state: EditorState) {
const runs = segments(state).filter(segment => selected(state, segment))
const styles: InlineStyles = { ...runs[0]?.styles }
for (const key of Object.keys(styles) as (keyof InlineStyles)[]) {
if (runs.some(run => run.styles[key] !== styles[key])) delete styles[key]
}
const all = (format: string) => runs.length > 0 && runs.every(run => run.formats.includes(format))
return { styles, underline: runs.length > 0 && runs.every(run => run.underline), bold: all('StrongEmphasis'), italic: all('Emphasis'), strike: all('Strikethrough'), highlight: all('Highlight'), link: all('Link') }
}
function wrap(text: string, styles: InlineStyles, underline: boolean, formats: string[]) {
const css = inlineStyleText(styles)
const leading = text.match(/^\s*/u)![0], trailing = text.match(/\s*$/u)![0]
const content = text.slice(leading.length, text.length - trailing.length)
const marks = content ? (['Strikethrough', 'Highlight', 'StrongEmphasis', 'Emphasis'] as const).filter(mark => formats.includes(mark)).map(mark => MARKS[mark]) : []
const open = marks.join(''), close = marks.reverse().join('')
const prefix = (css ? `<span style="${css}">` : '') + (underline ? '<u>' : '')
return { prefix, leading: leading.length, trailing: trailing.length, marks: open.length, text: prefix + (content ? leading + open + content + close + trailing : text) + (underline ? '</u>' : '') + (css ? '</span>' : '') }
}
/** 只重写选中文字及包围它的外观标签;被拆开的其余文字保留原样式。 */
function editAppearance(update: (styles: InlineStyles, underline: boolean, formats: string[]) => { styles: InlineStyles; underline: boolean; formats: string[] }): FormatCommand {
return ({ state, dispatch }) => {
if (!canFormatSelection(state)) return false
const runs = segments(state)
const affected = runs.filter(run => selected(state, run))
const wrappers = new Set(affected.flatMap(run => run.wrappers))
const changes: { from: number; to: number; insert: string }[] = []
const offsets: { from: number; to: number; start: number; leading: number; trailing: number; marks: number; changeFrom: number }[] = []
for (const wrapper of wrappers) for (const mark of wrapper.marks) changes.push({ from: mark.from, to: mark.to, insert: '' })
for (const run of runs) {
if (!selected(state, run) && !run.wrappers.some(wrapper => wrappers.has(wrapper))) continue
// 同一外层标签内的兄弟标签也要展开,重新写回合并后的有效样式。
for (const wrapper of run.wrappers) if (!wrappers.has(wrapper)) {
wrappers.add(wrapper)
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)
let insert = ''
for (let i = 0; i < cuts.length - 1; i++) {
const from = cuts[i], to = cuts[i + 1]
const next = selected(state, { ...run, from, to }) ? update({ ...run.styles }, run.underline, run.formats) : run
const part = wrap(state.sliceDoc(from, to), next.styles, next.underline, next.formats)
offsets.push({ from, to, start: insert.length + part.prefix.length, leading: part.leading, trailing: part.trailing, marks: part.marks, changeFrom: run.from })
insert += part.text
}
if (insert !== state.sliceDoc(run.from, run.to)) changes.push({ from: run.from, to: run.to, insert })
}
if (!changes.length) return false
const changeSet = state.changes(changes.sort((a, b) => a.from - b.from))
const mapped = (pos: number, end: boolean) => {
const piece = offsets.find(piece => end ? piece.from < pos && pos <= piece.to : piece.from <= pos && pos < piece.to)
if (!piece) return changeSet.mapPos(pos, end ? -1 : 1)
const relative = pos - piece.from, bodyEnd = piece.to - piece.from - piece.trailing
const marks = relative < piece.leading || end && relative === piece.leading ? 0
: relative > bodyEnd || !end && relative === bodyEnd ? piece.marks * 2 : piece.marks
return changeSet.mapPos(piece.changeFrom, -1) + piece.start + relative + marks
}
const selection = EditorSelection.create(state.selection.ranges.map(range => {
const from = mapped(range.from, false), to = mapped(range.to, true)
return range.anchor <= range.head ? EditorSelection.range(from, to) : EditorSelection.range(to, from)
}), state.selection.mainIndex)
dispatch({ changes: changeSet, selection, scrollIntoView: true, userEvent: 'input.format' })
return true
}
}
export function applyInlineStyles(patch: InlineStyles): FormatCommand {
return editAppearance((styles, underline, formats) => {
for (const [key, value] of Object.entries(patch) as [keyof InlineStyles, string | undefined][]) {
if (value) styles[key] = value
else delete styles[key]
}
return { styles, underline, formats }
})
}
export const clearInlineStyles = editAppearance((_styles, underline, formats) => ({ styles: {}, underline, formats }))
export const toggleUnderline: FormatCommand = target => {
const remove = selectionAppearance(target.state).underline
return editAppearance((styles, _underline, formats) => ({ styles, underline: !remove, formats }))(target)
}
/** 跨段落格式化按语法片段处理,不把列表标记或链接地址包进 Markdown 定界符。 */
export function toggleSelectionMark(mark: InlineMark): FormatCommand {
return target => {
const runs = segments(target.state).filter(run => selected(target.state, run))
const remove = runs.length > 0 && runs.every(run => run.formats.includes(mark))
return editAppearance((styles, underline, formats) => ({ styles, underline, formats: remove ? formats.filter(value => value !== mark) : [...new Set([...formats, mark])] }))(target)
}
}
+12
View File
@@ -3,6 +3,7 @@ import { StateEffect, Facet, type EditorState, type Range } from '@codemirror/st
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks'
import { inlineStyleText, stylesFromTag } from '../markdown/inlineStyles'
import {
BulletWidget,
CheckboxWidget,
@@ -140,8 +141,19 @@ function buildDecorations(view: EditorView): DecorationSet {
case 'SuperscriptMark':
case 'SubscriptMark':
case 'HighlightMark':
case 'UnderlineMark':
case 'InlineStyleMark':
if (!touches(node.parent!)) hide(node.from, node.to)
return
case 'Underline':
out.push(Decoration.mark({ class: 'cm-rm-underline' }).range(node.from, node.to))
return
case 'InlineStyle': {
const mark = node.getChild('InlineStyleMark')!
const styles = stylesFromTag(state.sliceDoc(mark.from, mark.to))!
out.push(Decoration.mark({ attributes: { style: inlineStyleText(styles) } }).range(mark.to, node.getChildren('InlineStyleMark').at(-1)!.from))
return
}
case 'Highlight':
out.push(highlightText.range(node.from, node.to))
return