feat(editor): replace format bar with selection appearance controls
This commit is contained in:
1 parent
19aab631ec
commit
5abc99b89e
15 files changed
+634
-130
No files matched your search
@@ -1,4 +1,4 @@
|
||||
// Markdown 格式命令:电脑上的快捷键与手机键盘上方的工具条共用
|
||||
// Markdown 格式命令:快捷键与选区工具栏共用
|
||||
import { EditorSelection, type ChangeSpec, type EditorState, type TransactionSpec } from '@codemirror/state'
|
||||
|
||||
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
|
||||
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user