refactor(web): adopt modular Vue architecture and isolate bearer sessions

This commit is contained in:
eezd committed 2026-10-25 20:47:32 +08:00
1 parent 1b2b2b12ff
commit 3cd641a380
379 files changed
+19736 -8712

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view'
import { codeRenderers, notePath, pluginRefresh } from '../../../common/plugins/registry'
import { codeContentRange, fenceLanguage } from './codeblocks'
import { editorFocused, setEditorFocus } from './focus'
import { CodeRendererWidget, MathWidget } from './widgets'
// 公式块与 Mermaid 图表:未编辑时整块替换为渲染结果;光标在块内时显示源码,并在下方实时预览。
// 跨行的块级替换只能由 StateField 提供;与表格一样只处理顶层的块
/** 返回块的源码内容及其相对块起点的偏移(点击渲染结果时光标落在内容开头) */
function blockContent(state: EditorState, node: SyntaxNode): { text: string, offset: number, language?: string } | null {
if (node.name === 'BlockMath') {
const marks = node.getChildren('BlockMathMark')
const from = state.doc.lineAt(node.from).to + 1
const to = marks.length > 1 ? state.doc.lineAt(marks[marks.length - 1].from).from - 1 : node.to
return { text: from <= to ? state.sliceDoc(from, to) : '', offset: Math.min(from, node.to) - node.from }
}
if (node.name === 'FencedCode') {
const info = node.getChild('CodeInfo')
if (!info) return null
const language = fenceLanguage(state, info.from, info.to).toLowerCase()
if (!codeRenderers.has(language)) return null
const { from, to } = codeContentRange(state, node)
return { text: state.sliceDoc(from, to), offset: from - node.from, language }
}
return null
}
function buildBlocks(state: EditorState): DecorationSet {
const out: Range<Decoration>[] = []
const sel = state.field(editorFocused) ? state.selection.ranges : []
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
const content = blockContent(state, node)
if (!content) continue
const editing = sel.some(r => r.from <= node.to && r.to >= node.from)
const widget = (editOffset: number | null) =>
node.name === 'BlockMath' ? new MathWidget(content.text, true, editOffset) : new CodeRendererWidget(content.text, editOffset, content.language!, state.facet(notePath).path, codeRenderers.get(content.language!)!)
if (editing) out.push(Decoration.widget({ widget: widget(null), block: true, side: 1 }).range(node.to))
else out.push(Decoration.replace({ widget: widget(content.offset), block: true }).range(node.from, node.to))
}
return Decoration.set(out)
}
export const renderedBlocks = StateField.define<DecorationSet>({
create: buildBlocks,
update(value, tr) {
if (
tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus) || e.is(pluginRefresh))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
) {
return buildBlocks(tr.state)
}
return value
},
provide: f => EditorView.decorations.from(f),
})
@@ -0,0 +1,180 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import type { TokenRun } from '../../../common/highlight/client'
import { syntaxTree } from '@codemirror/language'
import { StateEffect, StateField } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
import { cachedTokens, requestTokens } from '../../../common/highlight/client'
interface CodeBlock {
// 代码内容(不含围栏行)在文档中的范围
from: number
to: number
lang: string
code: string
}
const setBlockTokens = StateEffect.define<{ from: number, to: number, decorations: Range<Decoration>[] }>()
// 高亮结果作为 StateField 保存并随编辑映射位置:Worker 返回新结果前沿用旧颜色,避免闪烁
const codeTokenField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
value = value.map(tr.changes)
for (const e of tr.effects) {
if (!e.is(setBlockTokens)) continue
value = value.update({
filterFrom: e.value.from,
filterTo: e.value.to,
filter: () => false,
add: e.value.decorations,
sort: true,
})
}
return value
},
provide: f => EditorView.decorations.from(f),
})
const markCache = new Map<string, Decoration>()
function tokenMark(classes: string): Decoration {
let deco = markCache.get(classes)
if (!deco) {
deco = Decoration.mark({ class: classes })
markCache.set(classes, deco)
}
return deco
}
function tokensToDecorations(block: CodeBlock, run: TokenRun | null): Range<Decoration>[] {
const out: Range<Decoration>[] = []
if (!run) return out
let at = block.from
function visit(tokens: TokenRun) {
for (const token of tokens) {
if (typeof token === 'string') {
at += token.length
} else {
const from = at
const index = out.length
visit(token.content)
if (at > from) out.splice(index, 0, tokenMark(token.classes).range(from, at))
}
}
}
visit(run)
return out
}
export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: number): string {
return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase()
}
// 围栏代码块的内容范围(不含围栏行);有闭合围栏时到闭合行之前为止,否则到块尾
export function codeContentRange(state: EditorState, node: SyntaxNode): { from: number, to: number } {
const open = state.doc.lineAt(node.from)
const marks = node.getChildren('CodeMark')
const to = marks.length > 1 ? state.doc.lineAt(node.to).from - 1 : node.to
return { from: Math.min(open.to + 1, to), to }
}
export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null {
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
if (node.name === 'FencedCode') return node
}
return null
}
// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选
export function selectCodeBlock(view: EditorView): boolean {
const { state } = view
const main = state.selection.main
const node = fencedCodeAt(state, main.head)
if (!node) return false
const { from, to } = codeContentRange(state, node)
if (to <= from || main.head < from || main.head > to || (main.from === from && main.to === to)) return false
view.dispatch({ selection: { anchor: from, head: to }, userEvent: 'select' })
return true
}
function visibleCodeBlocks(view: EditorView): CodeBlock[] {
const { state } = view
const blocks: CodeBlock[] = []
for (const { from, to } of view.visibleRanges) {
syntaxTree(state).iterate({
from,
to,
enter(node) {
if (node.name !== 'FencedCode') return
const info = node.node.getChild('CodeInfo')
if (info) {
const { from: start, to: end } = codeContentRange(state, node.node)
if (end > start) {
blocks.push({ from: start, to: end, lang: fenceLanguage(state, info.from, info.to), code: state.sliceDoc(start, end) })
}
}
return false
},
})
}
return blocks
}
const codeHighlighter = ViewPlugin.fromClass(
class {
// 已应用到各代码块的结果键,按块起点记录
applied = new Map<number, string>()
destroyed = false
constructor(readonly view: EditorView) {
this.schedule()
}
update(u: ViewUpdate) {
if (u.docChanged) {
const mapped = new Map<number, string>()
for (const [pos, key] of this.applied) mapped.set(u.changes.mapPos(pos), key)
this.applied = mapped
}
if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) this.schedule()
}
// 不能在 update 中同步 dispatch,推迟到微任务
schedule() {
queueMicrotask(() => this.sync())
}
destroy() {
this.destroyed = true
}
sync() {
if (this.destroyed) return
const effects: StateEffect<unknown>[] = []
for (const block of visibleCodeBlocks(this.view)) {
const key = `${block.lang}\0${block.code}`
if (this.applied.get(block.from) === key) continue
const cached = cachedTokens(block.lang, block.code)
if (cached !== undefined) {
this.applied.set(block.from, key)
effects.push(setBlockTokens.of({ from: block.from, to: block.to, decorations: tokensToDecorations(block, cached) }))
} else {
// 结果到达后再走一次 sync,届时命中缓存;期间文档若已变化,键不同则自然重算
requestTokens(block.lang, block.code).then(() => this.schedule()).catch((error) => {
// 本次内容失败后不随每次光标移动重试,修改代码或语言后重新请求。
if (!this.destroyed) this.applied.set(block.from, key)
console.error('代码高亮失败', error)
})
}
}
if (effects.length) this.view.dispatch({ effects })
}
},
)
export const codeBlockHighlight = [
codeTokenField,
codeHighlighter,
]
+42
View File
@@ -0,0 +1,42 @@
import type { EditorView, ViewUpdate } from '@codemirror/view'
import { StateEffect, StateField } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view'
// 编辑器焦点状态。块级渲染组件(表格、公式块、Mermaid)由 StateField 提供,拿不到视图的焦点,
// 由视图插件把焦点变化同步进状态:获得焦点且光标在块内时显示源码,否则渲染
// (打开笔记时光标默认在文首,不应因此展开开头的块)
export const setEditorFocus = StateEffect.define<boolean>()
export const editorFocused = StateField.define<boolean>({
create: () => false,
update(value, tr) {
for (const e of tr.effects) {
if (e.is(setEditorFocus)) value = e.value
}
return value
},
})
const focusTracker = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
this.sync()
}
update(u: ViewUpdate) {
if (u.focusChanged) this.sync()
}
// 不能在 update 中同步 dispatch,推迟到微任务
sync() {
queueMicrotask(() => {
const focused = this.view.hasFocus
if (this.view.state.field(editorFocused, false) !== undefined && this.view.state.field(editorFocused) !== focused) {
this.view.dispatch({ effects: setEditorFocus.of(focused) })
}
})
}
},
)
export const editorFocus = [editorFocused, focusTracker]
@@ -0,0 +1,76 @@
import type { FormatCommand } from './format'
import { EditorSelection, EditorState } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { cycleHeading, insertLink, toggleBlock, toggleBold, toggleCode, toggleItalic } from './format'
// 用 | 标记光标、[ ] 标记选区,执行命令后以同样的形式返回结果
function run(cmd: FormatCommand, doc: string): string {
const ranges: { anchor: number, head: number }[] = []
let text = ''
let start = -1
for (const ch of doc) {
if (ch === '|') {
ranges.push({ anchor: text.length, head: text.length })
} else if (ch === '[' && start < 0 && doc.includes(']')) {
start = text.length
} else if (ch === ']' && start >= 0) {
ranges.push({ anchor: start, head: text.length })
start = -2
} else {
text += ch
}
}
let state = EditorState.create({
doc: text,
selection: EditorSelection.create(ranges.map(r => EditorSelection.range(r.anchor, r.head))),
extensions: EditorState.allowMultipleSelections.of(true),
})
cmd({ state, dispatch: spec => (state = state.update(spec).state) })
let out = state.doc.toString()
for (const r of [...state.selection.ranges].reverse()) {
out = r.empty ? `${out.slice(0, r.from)}|${out.slice(r.from)}` : `${out.slice(0, r.from)}[${out.slice(r.from, r.to)}]${out.slice(r.to)}`
}
return out
}
describe('行内标记', () => {
it('加上与去掉', () => {
expect(run(toggleBold, 'a [bc] d')).toBe('a **[bc]** d')
expect(run(toggleBold, 'a **[bc]** d')).toBe('a [bc] d')
expect(run(toggleBold, 'a [**bc**] d')).toBe('a [bc] d')
expect(run(toggleCode, 'x|')).toBe('x`|`')
})
it('粗体与斜体叠加', () => {
expect(run(toggleItalic, '**[x]**')).toBe('***[x]***')
expect(run(toggleItalic, '***[x]***')).toBe('**[x]**')
expect(run(toggleBold, '***[x]***')).toBe('*[x]*')
expect(run(toggleBold, '*[x]*')).toBe('***[x]***')
})
})
describe('行首标记', () => {
it('列表、任务、引用:同类再按去掉,异类替换', () => {
expect(run(toggleBlock('bullet'), 'a|')).toBe('- a|')
expect(run(toggleBlock('bullet'), '- a|')).toBe('a|')
expect(run(toggleBlock('task'), '- a|')).toBe('- [ ] a|')
expect(run(toggleBlock('quote'), '## 标题|')).toBe('> 标题|')
expect(run(toggleBlock('bullet'), '|')).toBe('- |')
})
it('多行:有序列表按行编号,保留缩进', () => {
expect(run(toggleBlock('ordered'), '[a\n b\nc]')).toBe('[1. a\n 2. b\n3. c]')
expect(run(toggleBlock('ordered'), '[1. a\n2. b]')).toBe('[a\nb]')
// 部分行已是该类型时统一加上
expect(run(toggleBlock('bullet'), '[- a\nb]')).toBe('[- a\n- b]')
})
it('标题循环', () => {
expect(run(cycleHeading, '标题|')).toBe('# 标题|')
expect(run(cycleHeading, '# 标题|')).toBe('## 标题|')
expect(run(cycleHeading, '### 标题|')).toBe('标题|')
expect(run(cycleHeading, '- 项|')).toBe('# 项|')
})
})
it('链接', () => {
expect(run(insertLink, '看 [文档] 吧')).toBe('看 [文档](|) 吧')
expect(run(insertLink, '看|')).toBe('看[|]()')
})
+146
View File
@@ -0,0 +1,146 @@
import type { ChangeSpec, EditorState, TransactionSpec } from '@codemirror/state'
// Markdown 格式命令:快捷键与选区工具栏共用
import { EditorSelection } from '@codemirror/state'
/** 只依赖状态与派发,便于在没有 DOM 的环境中测试 */
export interface FormatTarget {
readonly state: EditorState
dispatch: (spec: TransactionSpec) => void
}
export type FormatCommand = (target: FormatTarget) => boolean
/**
* 切换行内标记(粗体 **、斜体 *、删除线 ~~、行内代码 `):选区两侧或选区首尾已有该标记时去掉,否则加上;
* 没有选中文字时插入一对标记,光标放在中间
*/
export function toggleInline(mark: string): FormatCommand {
const n = mark.length
const ch = mark[0]
// 选区两侧连续的标记字符数;* 同时用于斜体与粗体:*** 两者都有,单数个才有斜体
const runLength = (state: EditorState, pos: number, dir: 1 | -1) => {
let k = 0
while (state.sliceDoc(dir > 0 ? pos + k : pos - k - 1, dir > 0 ? pos + k + 1 : pos - k) === ch) k++
return k
}
const wrapped = (state: EditorState, from: number, to: number) => {
const k = Math.min(runLength(state, from, -1), runLength(state, to, 1))
return mark === '*' ? k % 2 === 1 : k >= n
}
return ({ state, dispatch }) => {
const tr = state.changeByRange((r) => {
if (wrapped(state, r.from, r.to)) {
return {
changes: [
{ from: r.from - n, to: r.from },
{ from: r.to, to: r.to + n },
],
range: EditorSelection.range(r.from - n, r.to - n),
}
}
const text = state.sliceDoc(r.from, r.to)
if (text.length >= 2 * n && text.startsWith(mark) && text.endsWith(mark)) {
return { changes: { from: r.from, to: r.to, insert: text.slice(n, -n) }, range: EditorSelection.range(r.from, r.to - 2 * n) }
}
return {
changes: [
{ from: r.from, insert: mark },
{ from: r.to, insert: mark },
],
range: EditorSelection.range(r.from + n, r.to + n),
}
})
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
return true
}
}
// 行首的块标记:缩进 + 标题 / 任务 / 无序列表 / 有序列表 / 引用(每行只识别一个)
const LINE_PREFIX = /^(\s*)(#{1,6} |[-*+] \[[ x]\] |[-*+] |\d+[.)] |> )?/i
type PrefixKind = 'heading' | 'task' | 'bullet' | 'ordered' | 'quote'
function kindOf(prefix: string): PrefixKind | null {
if (!prefix) return null
if (prefix.startsWith('#')) return 'heading'
if (prefix.startsWith('>')) return 'quote'
if (/^\d/.test(prefix)) return 'ordered'
return prefix.includes('[') ? 'task' : 'bullet'
}
/** 改写选区涉及的每一行的行首标记;next 返回新的标记 */
function editPrefixes({ state, dispatch }: FormatTarget, next: (kinds: (PrefixKind | null)[], index: number) => string): boolean {
const lineNumbers = new Set<number>()
for (const r of state.selection.ranges) {
const to = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to
for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(to).number; l <= end; l++) lineNumbers.add(l)
}
const lines = [...lineNumbers].sort((a, b) => a - b).map(l => state.doc.line(l))
const matches = lines.map(line => LINE_PREFIX.exec(line.text)!)
const kinds = matches.map(m => kindOf(m[2] ?? ''))
const specs: ChangeSpec[] = lines.map((line, i) => {
const from = line.from + matches[i][1].length
const current = matches[i][2] ?? ''
return { from, to: from + current.length, insert: next(kinds, i) }
})
const changes = state.changes(specs)
// 光标在行首时随新插入的标记后移;选区则向两端扩展,把新标记包含在内
const selection = EditorSelection.create(
state.selection.ranges.map((r) => {
if (r.empty) return EditorSelection.cursor(changes.mapPos(r.head, 1))
const [lo, hi] = [changes.mapPos(r.from, -1), changes.mapPos(r.to, 1)]
return r.anchor <= r.head ? EditorSelection.range(lo, hi) : EditorSelection.range(hi, lo)
}),
state.selection.mainIndex,
)
dispatch({ changes, selection, scrollIntoView: true, userEvent: 'input.format' })
return true
}
/** 切换列表或引用:所选各行都已是该类型时去掉,否则全部改为该类型(替换原有的其他块标记) */
export function toggleBlock(kind: Exclude<PrefixKind, 'heading'>): FormatCommand {
return target =>
editPrefixes(target, (kinds, i) => {
if (kinds.every(k => k === kind)) return ''
return { task: '- [ ] ', bullet: '- ', ordered: `${i + 1}. `, quote: '> ' }[kind]
})
}
/** 标题级别循环:正文 → 一级 → 二级 → 三级 → 正文;以第一行为准 */
export const cycleHeading: FormatCommand = (target) => {
const first = LINE_PREFIX.exec(target.state.doc.lineAt(target.state.selection.main.from).text)![2] ?? ''
const level = kindOf(first) === 'heading' ? first.length - 1 : 0
const prefix = level >= 3 ? '' : `${'#'.repeat(level + 1)} `
return editPrefixes(target, () => prefix)
}
/** 全部选中行已是指定标题时恢复正文,否则设置为指定级别。 */
export function toggleHeading(level: number): FormatCommand {
return (target) => {
const prefix = `${'#'.repeat(level)} `
const ranges = target.state.selection.ranges
const lines = new Set<number>()
for (const range of ranges) {
const end = range.to > range.from && target.state.doc.lineAt(range.to).from === range.to ? range.to - 1 : range.to
for (let n = target.state.doc.lineAt(range.from).number; n <= target.state.doc.lineAt(end).number; n++) lines.add(n)
}
const remove = [...lines].every(n => LINE_PREFIX.exec(target.state.doc.line(n).text)![2] === prefix)
return editPrefixes(target, () => remove ? '' : prefix)
}
}
/** 插入链接:选中文字作为链接文字,光标放到地址处;没有选中时光标放在文字处 */
export const insertLink: FormatCommand = ({ state, dispatch }) => {
const tr = state.changeByRange((r) => {
const text = state.sliceDoc(r.from, r.to)
const insert = `[${text}]()`
const cursor = text ? r.from + insert.length - 1 : r.from + 1
return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(cursor) }
})
dispatch({ ...tr, scrollIntoView: true, userEvent: 'input.format' })
return true
}
export const toggleBold = toggleInline('**')
export const toggleItalic = toggleInline('*')
export const toggleStrike = toggleInline('~~')
export const toggleCode = toggleInline('`')
@@ -0,0 +1,17 @@
import type { TransactionSpec } from '@codemirror/state'
import { EditorState } from '@codemirror/state'
import { expect, it } from 'vitest'
import { toggleHeading } from './format'
it('标题快捷键设置六级标题,同级再次执行恢复正文,选区末端不修改下一行', () => {
let state = EditorState.create({ doc: '第一行\n第二行', selection: { anchor: 0, head: 4 } })
const target = { get state() {
return state
}, dispatch: (spec: TransactionSpec) => {
state = state.update(spec).state
} }
toggleHeading(6)(target)
expect(state.doc.toString()).toBe('###### 第一行\n第二行')
toggleHeading(6)(target)
expect(state.doc.toString()).toBe('第一行\n第二行')
})
@@ -0,0 +1,84 @@
/* eslint-disable no-control-regex -- 粘贴链接必须识别并移除协议中的控制字符。 */
import TurndownService from 'turndown'
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
// template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
const template = document.createElement('template')
template.innerHTML = html
const fragment = template.content
const base = fragment.querySelector('base[href]')?.getAttribute('href')
fragment.querySelectorAll('script,style,iframe,object,embed,template,meta,link,base').forEach(node => node.remove())
if (base) {
for (const node of fragment.querySelectorAll('a[href],img[src]')) {
const attribute = node.tagName === 'A' ? 'href' : 'src'
try {
node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href)
} catch (error) {
onError(new Error('来源基址无效,已保留原链接', { cause: error }))
}
}
}
const images = new Map<string, HTMLImageElement[]>()
for (const image of fragment.querySelectorAll('img')) {
let src = image.getAttribute('src') ?? ''
if (src.startsWith('//')) {
src = `https:${src}`; image.setAttribute('src', src)
}
if (!/^https?:\/\//i.test(src)) continue
const group = images.get(src) ?? []
group.push(image)
images.set(src, group)
}
const sources = [...images]
// 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
for (let offset = 0; offset < sources.length; offset += 3) {
await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
try {
const link = await importImage(src)
for (const node of nodes) node.setAttribute('src', link)
} catch (error) {
onError(error)
}
}))
}
const service = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' })
service.addRule('strikethrough', { filter: node => ['DEL', 'S', 'STRIKE'].includes(node.tagName), replacement: content => `~~${content}~~` })
service.addRule('table', {
filter: 'table',
replacement: (_content, node) => {
const rows = [...node.querySelectorAll('tr')].filter(row => row.closest('table') === node).map(row =>
[...row.cells].map(cell =>
service.turndown(cell).replace(/\|/g, '\\|').replace(/\n+/g, ' '),
),
)
if (!rows.length) return ''
const columns = Math.max(...rows.map(row => row.length))
if (!columns) return ''
const line = (cells: string[]) => `| ${Array.from({ length: columns }, (_, index) => cells[index] ?? '').join(' | ')} |`
return `\n\n${[line(rows[0]), line(Array.from<string>({ length: columns }).fill('---')), ...rows.slice(1).map(line)].join('\n')}\n\n`
},
})
// 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
service.addRule('link', {
filter: 'a',
replacement: (content, node) => {
const href = node.getAttribute('href') ?? ''
return href && !/^[\u0000-\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?:javascript|vbscript|data|file):/i.test(href.replace(/[\u0000-\u0020]/g, ''))
? `[${content}](<${href.replace(/</g, '%3C').replace(/>/g, '%3E')}>)`
: content
},
})
service.addRule('image', {
filter: 'img',
replacement: (_content, node) => {
const src = node.getAttribute('src') ?? ''
const alt = (node.getAttribute('alt') ?? '').replace(/[[\]\\]/g, '\\$&').replace(/\n/g, ' ')
return src && !/^(?:javascript|vbscript|file):/i.test(src.replace(/[\u0000-\u0020]/g, ''))
? `![${alt}](<${src.replace(/</g, '%3C').replace(/>/g, '%3E')}>)`
: alt
},
})
return service.turndown(fragment)
}
+101
View File
@@ -0,0 +1,101 @@
import type { Extension } from '@codemirror/state'
import type { KeyBinding, ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown'
import { defaultKeymap, history, indentLess, indentMore, selectAll, selectParentSyntax, toggleComment } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { Language } from '@codemirror/language'
import { Compartment, EditorState, Prec } from '@codemirror/state'
import { drawSelection, EditorView, keymap } from '@codemirror/view'
import { typoraExtensions } from '../../../common/markdown/extensions'
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../../../common/plugins/registry'
import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
import { livePreview, openLink, resolveImageUrl } from './livepreview'
import { defaultEditorKeys } from './shortcuts'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
export { insertImageFiles, pasteImages } from './paste'
// 在 lang-markdown 的语法集(CommonMark + GFM + 上下标 + emoji)上加 Typora 扩展。
// 沿用其语言数据,markdownKeymap 中的命令据此识别 Markdown(isActiveAt 按语言数据判断)
export const pluginCompartment = new Compartment()
export const languageCompartment = new Compartment()
export const preferencesCompartment = new Compartment()
export const previewCompartment = new Compartment()
export function previewExtensions(sourceMode: boolean): Extension {
return sourceMode ? EditorView.contentAttributes.of({ class: 'cm-rm-source' }) : [livePreview, tableEditing, renderedBlocks]
}
export function pluginLanguage() {
return new Language(
markdownLanguage.data,
(markdownLanguage.parser as MarkdownParser).configure([typoraExtensions, ...[...markdownSyntax.values()].map(entry => entry.value)]),
[],
'markdown',
)
}
export function pluginEditorExtensions(): Extension[] {
return [...editorExtensions.values()].map(entry => entry.value)
}
export interface EditorStateOptions {
doc: string
spellcheck?: boolean
tabSize?: number
sourceMode?: boolean
shortcuts?: KeyBinding[]
resolveImage?: (src: string) => string
openLink?: (href: string) => void
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
onChange?: (update: ViewUpdate) => void
extensions?: Extension[]
}
// 每个打开的笔记各自持有一个 EditorState(含撤销历史),切换标签页时整体替换
export function createEditorState(opts: EditorStateOptions): EditorState {
const extensions: Extension[] = [
history(),
drawSelection(),
EditorView.lineWrapping,
// 不用 lang-markdown 的 markdown():它会静态引入 lang-html/css/javascript 用于嵌套 HTML 高亮,
// 首屏包 gzip 从约 108 KB 增至约 180 KB,而 Live Preview 不需要 HTML 高亮
languageCompartment.of(pluginLanguage()),
pluginCompartment.of(pluginEditorExtensions()),
EditorView.exceptionSink.of(reportEditorFailure),
Prec.high(keymap.of(markdownKeymap)),
Prec.high(keymap.of(opts.shortcuts ?? defaultEditorKeys)),
markdownTheme,
codeBlockHighlight,
editorFocus,
previewCompartment.of(previewExtensions(opts.sourceMode ?? false)),
// 可配置操作不再通过 CM 的默认绑定重复注册。
keymap.of(defaultKeymap.filter(binding => binding.run !== selectAll && binding.run !== indentLess && binding.run !== indentMore && binding.run !== selectParentSyntax && binding.run !== toggleComment)),
preferencesCompartment.of([
EditorState.tabSize.of(opts.tabSize ?? 4),
EditorView.contentAttributes.of({ spellcheck: String(opts.spellcheck ?? false) }),
]),
EditorView.contentAttributes.of({ autocapitalize: 'sentences' }),
]
if (opts.openLink) extensions.push(openLink.of(opts.openLink))
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
if (opts.onChange) {
const onChange = opts.onChange
extensions.push(EditorView.updateListener.of(u => u.docChanged && onChange(u)))
}
if (opts.extensions) extensions.push(...opts.extensions)
return EditorState.create({ doc: opts.doc, extensions })
}
export function createEditorView(parent: HTMLElement, state: EditorState): EditorView {
const view = new EditorView({ parent, state })
const dispatch = view.dispatch.bind(view)
view.dispatch = (...args: unknown[]) => {
try {
Reflect.apply(dispatch, view, args)
} catch (error) {
reportEditorFailure(error)
}
}
return view
}
@@ -0,0 +1,171 @@
import type { TransactionSpec } from '@codemirror/state'
import type { FormatCommand } from './format'
import { redo, undo } from '@codemirror/commands'
import { EditorSelection, EditorState, Transaction } from '@codemirror/state'
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../../../common/markdown/html'
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>')
})
})
@@ -0,0 +1,194 @@
import type { EditorState } from '@codemirror/state'
import type { SyntaxNode } from '@lezer/common'
import type { InlineStyles } from '../../../common/markdown/inline-styles'
import type { FormatCommand } from './format'
import { ensureSyntaxTree, language } from '@codemirror/language'
import { EditorSelection } from '@codemirror/state'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
interface Wrapper { marks: { from: number, to: number }[] }
interface Segment {
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 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', 'Escape', 'Entity'].includes(node.name)) {
// 不拆开原子语法;调整同一外层标签的其他文字时,仍须保留这些未选内容的原样式。
result.push({ from: node.from, to: node.to, atomic: true, 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; const 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, atomic: false, 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)
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]; const 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(''); const 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 = 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 = ''
for (let i = 0; i < cuts.length - 1; i++) {
const from = cuts[i]; const 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; const 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); const 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)
}
}
@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
describe('快捷键', () => {
it('windows 上 Ctrl+Y 为重做', async () => {
// CodeMirror 在模块加载时读取平台,需先设置再导入
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true })
const { undo } = await import('@codemirror/commands')
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
const { createEditorState } = await import('./index')
const view = new EditorView({ parent: document.body, state: createEditorState({ doc: '' }) })
view.dispatch({ changes: { from: 0, insert: 'abc' } })
undo(view)
expect(view.state.doc.toString()).toBe('')
const event = new KeyboardEvent('keydown', { key: 'y', code: 'KeyY', keyCode: 89, ctrlKey: true })
expect(runScopeHandlers(view, event, 'editor')).toBe(true)
expect(view.state.doc.toString()).toBe('abc')
view.destroy()
})
it('工作区自定义快捷键清空后,不回落到 CM 的缩进或其他冲突绑定', async () => {
const { EditorView, runScopeHandlers } = await import('@codemirror/view')
const { createEditorState } = await import('./index')
const view = new EditorView({ parent: document.body, state: createEditorState({ doc: 'text', shortcuts: [] }) })
try {
for (const [key, code] of [['[', 'BracketLeft'], [']', 'BracketRight'], ['i', 'KeyI'], ['/', 'Slash']]) {
expect(runScopeHandlers(view, new KeyboardEvent('keydown', { key, code, ctrlKey: true }), 'editor')).toBe(false)
}
expect(view.state.doc.toString()).toBe('text')
} finally {
view.destroy()
}
})
})
@@ -0,0 +1,395 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import type { TocEntry } from './widgets'
import { syntaxTree } from '@codemirror/language'
import { Facet, StateEffect } from '@codemirror/state'
import { Decoration, ViewPlugin } from '@codemirror/view'
import { inlineStyleText, stylesFromTag } from '../../../common/markdown/inline-styles'
import { codeContentRange, fencedCodeAt, fenceLanguage } from './codeblocks'
import {
BulletWidget,
CheckboxWidget,
CodeHeaderWidget,
EmojiWidget,
FootnoteRefWidget,
HorizontalRuleWidget,
ImageWidget,
MathWidget,
TocWidget,
} from './widgets'
// 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录)
export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({
combine: values => values[0] ?? (src => src),
})
export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] })
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
export const refreshImages = StateEffect.define<null>()
const hidden = Decoration.replace({})
const inlineCode = Decoration.mark({ class: 'cm-rm-inline-code' })
const linkText = Decoration.mark({ class: 'cm-rm-link' })
const highlightText = Decoration.mark({ class: 'cm-rm-highlight' })
const superscriptText = Decoration.mark({ class: 'cm-rm-sup' })
const subscriptText = Decoration.mark({ class: 'cm-rm-sub' })
const footnoteLabel = Decoration.mark({ class: 'cm-rm-footnote-label' })
const lineDecos = new Map<string, Decoration>()
function lineDeco(cls: string): Decoration {
let deco = lineDecos.get(cls)
if (!deco) lineDecos.set(cls, (deco = Decoration.line({ class: cls })))
return deco
}
// 块级标记(标题、引用、列表、围栏)按行展开源码,行内语法只展开光标所在的元素,
// 与 Typora/Obsidian 一致,避免长段落中光标一落整行跳动;编辑器失焦时整篇按渲染效果显示
function activeLines(view: EditorView): Set<number> {
const lines = new Set<number>()
if (!view.hasFocus) return lines
const { doc } = view.state
for (const r of view.state.selection.ranges) {
for (let n = doc.lineAt(r.from).number, end = doc.lineAt(r.to).number; n <= end; n++) lines.add(n)
}
return lines
}
function buildDecorations(view: EditorView): DecorationSet {
const { state } = view
const { doc } = state
const active = activeLines(view)
const resolveUrl = state.facet(resolveImageUrl)
const out: Range<Decoration>[] = []
const lineOf = (pos: number) => doc.lineAt(pos).number
const isActive = (from: number, to = from) => {
for (let n = lineOf(from), end = lineOf(to); n <= end; n++) {
if (active.has(n)) return true
}
return false
}
const ranges = view.hasFocus ? state.selection.ranges : []
const touches = (node: SyntaxNode) => ranges.some(r => r.from <= node.to && r.to >= node.from)
// 视图插件提供的替换装饰不能跨越换行
const hide = (from: number, to: number) => {
if (from < to && lineOf(from) === lineOf(to)) out.push(hidden.range(from, to))
}
const classLines = (from: number, to: number, cls: (line: number, first: number, last: number) => string) => {
const first = lineOf(from)
const last = lineOf(to)
for (const vr of view.visibleRanges) {
for (let n = Math.max(first, lineOf(vr.from)), end = Math.min(last, lineOf(vr.to)); n <= end; n++) {
out.push(lineDeco(cls(n, first, last)).range(doc.line(n).from))
}
}
}
const spacesAfter = (pos: number) => {
let end = pos
while (end < doc.length && state.sliceDoc(end, end + 1) === ' ') end++
return end
}
// 脚注定义(标签 → 内容)与目录条目,只在可见区域出现相应语法时才计算
let footnoteMap: Map<string, string> | undefined
function footnotes() {
if (!footnoteMap) {
footnoteMap = new Map()
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
if (n.name !== 'FootnoteDefinition') continue
const label = n.getChild('FootnoteLabel')!
footnoteMap.set(state.sliceDoc(label.from + 2, label.to - 2), state.sliceDoc(label.to, n.to).trim())
}
}
return footnoteMap
}
function tocEntries(): TocEntry[] {
const entries: TocEntry[] = []
for (let n = syntaxTree(state).topNode.firstChild; n; n = n.nextSibling) {
const m = /^(ATX|Setext)Heading(\d)$/.exec(n.name)
if (!m) continue
const raw = m[1] === 'ATX' ? state.sliceDoc(n.from, n.to).replace(/^#+\s*/, '').replace(/\s+#+\s*$/, '') : doc.lineAt(n.from).text
const text = raw.replace(/\*\*|__|==|~~|`/g, '').trim()
if (text) entries.push({ level: Number(m[2]), text, pos: n.from })
}
return entries
}
for (const { from, to } of view.visibleRanges) {
syntaxTree(state).iterate({
from,
to,
enter(ref) {
const node = ref.node
const name = node.name
if (name.startsWith('ATXHeading') || name.startsWith('SetextHeading')) {
out.push(lineDeco(`cm-rm-h${name.slice(-1)}`).range(doc.lineAt(node.from).from))
return
}
switch (name) {
case 'HeaderMark': {
if (isActive(node.from)) return
const parent = node.parent!
if (parent.name.startsWith('SetextHeading')) hide(node.from, node.to)
else if (node.from === parent.from) hide(node.from, spacesAfter(node.to))
else hide(trailingSpaceStart(state, node.from), node.to)
return
}
case 'EmphasisMark':
case 'StrikethroughMark':
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
case 'Superscript':
out.push(superscriptText.range(node.from, node.to))
return
case 'Subscript':
out.push(subscriptText.range(node.from, node.to))
return
case 'InlineMath':
if (!touches(node) && lineOf(node.from) === lineOf(node.to)) {
const widget = new MathWidget(state.sliceDoc(node.from + 1, node.to - 1), false, 1)
out.push(Decoration.replace({ widget }).range(node.from, node.to))
}
return false
case 'Emoji':
if (!touches(node)) out.push(Decoration.replace({ widget: new EmojiWidget(state.sliceDoc(node.from + 1, node.to - 1)) }).range(node.from, node.to))
return
case 'FootnoteReference':
if (!touches(node)) {
const label = state.sliceDoc(node.from + 2, node.to - 1)
out.push(Decoration.replace({ widget: new FootnoteRefWidget(label, footnotes().get(label)) }).range(node.from, node.to))
}
return
case 'FootnoteDefinition':
classLines(node.from, node.to, () => 'cm-rm-footnote')
return
case 'FootnoteLabel':
if (!isActive(node.from)) out.push(footnoteLabel.range(node.from, node.to))
return
case 'TableOfContents':
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new TocWidget(tocEntries()) }).range(node.from, node.to))
return
case 'FrontMatter':
classLines(node.from, node.to, (n, first, last) =>
n === first ? 'cm-rm-frontmatter cm-rm-frontmatter-begin' : n === last ? 'cm-rm-frontmatter cm-rm-frontmatter-end' : 'cm-rm-frontmatter')
return false
case 'BlockMath':
// 未编辑时由 blocks.ts 整块替换为渲染结果,这里只负责编辑时的源码样式
classLines(node.from, node.to, () => 'cm-rm-math-source')
return false
case 'InlineCode':
out.push(inlineCode.range(node.from, node.to))
return
case 'CodeMark':
if (node.parent?.name === 'InlineCode' && !touches(node.parent)) hide(node.from, node.to)
return
case 'Link':
decorateLink(node)
return
case 'Autolink':
if (!touches(node)) {
hide(node.from, node.from + 1)
hide(node.to - 1, node.to)
}
return
case 'Image':
decorateImage(node)
return false
case 'Escape':
if (!touches(node)) hide(node.from, node.from + 1)
return
case 'HorizontalRule':
if (!isActive(node.from)) out.push(Decoration.replace({ widget: new HorizontalRuleWidget() }).range(node.from, node.to))
return
case 'Blockquote':
classLines(node.from, node.to, () => 'cm-rm-quote')
return
case 'QuoteMark':
if (!isActive(node.from)) hide(node.from, Math.min(node.to + 1, spacesAfter(node.to)))
return
case 'ListMark':
decorateListMark(node)
return
case 'TaskMarker':
if (!isActive(node.from)) {
const checked = state.sliceDoc(node.from + 1, node.from + 2) !== ' '
out.push(Decoration.replace({ widget: new CheckboxWidget(checked) }).range(node.from, node.to))
}
return
case 'FencedCode':
decorateFencedCode(node)
return false
case 'CodeBlock':
classLines(node.from, node.to, () => 'cm-rm-code')
return false
case 'Table':
// 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐
classLines(node.from, node.to, () => 'cm-rm-table')
return false
case 'HTMLBlock':
case 'CommentBlock':
return false
}
},
})
}
function decorateLink(node: SyntaxNode) {
const marks = node.getChildren('LinkMark')
if (marks.length < 2) return
out.push(linkText.range(marks[0].to, marks[1].from))
if (touches(node)) return
hide(marks[0].from, marks[0].to)
hide(marks[1].from, node.to)
}
function decorateImage(node: SyntaxNode) {
const url = node.getChild('URL')
const marks = node.getChildren('LinkMark')
if (!url || marks.length < 2) return
const src = resolveUrl(state.sliceDoc(url.from, url.to).replace(/^<|>$/g, ''))
const widget = new ImageWidget(src, state.sliceDoc(marks[0].to, marks[1].from))
if (touches(node)) out.push(Decoration.widget({ widget, side: 1 }).range(node.to))
else if (lineOf(node.from) === lineOf(node.to)) out.push(Decoration.replace({ widget }).range(node.from, node.to))
}
function decorateListMark(node: SyntaxNode) {
if (isActive(node.from)) return
const item = node.parent!
if (item.getChild('Task')) hide(node.from, spacesAfter(node.to))
else if (item.parent?.name === 'BulletList') out.push(Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to))
}
function decorateFencedCode(node: SyntaxNode) {
const marks = node.getChildren('CodeMark')
const closed = marks.length > 1
classLines(node.from, node.to, (n, first, last) =>
n === first ? 'cm-rm-code cm-rm-code-begin' : n === last && closed ? 'cm-rm-code cm-rm-code-end' : 'cm-rm-code')
const info = node.getChild('CodeInfo')
const openLine = doc.lineAt(node.from)
const lang = info ? fenceLanguage(state, info.from, info.to) : ''
if (isActive(node.from, node.to)) {
// 编辑中显示围栏源码,复制按钮浮在行尾
out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to))
return
}
out.push(lineDeco('cm-rm-code-fence').range(openLine.from))
out.push(Decoration.replace({ widget: new CodeHeaderWidget(lang, true, codeAt) }).range(marks[0].from, openLine.to))
if (closed) {
const closeMark = marks[marks.length - 1]
out.push(lineDeco('cm-rm-code-fence').range(doc.lineAt(closeMark.from).from))
hide(closeMark.from, closeMark.to)
}
}
return Decoration.set(out, true)
}
function codeAt(view: EditorView, pos: number): string | null {
const node = fencedCodeAt(view.state, pos)
if (!node) return null
const { from, to } = codeContentRange(view.state, node)
return view.state.sliceDoc(from, to)
}
function trailingSpaceStart(state: EditorState, pos: number): number {
let start = pos
while (start > 0 && state.sliceDoc(start - 1, start) === ' ') start--
return start
}
// Ctrl/Cmd + 点击打开链接
function linkAt(state: EditorState, pos: number): string | null {
for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) {
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
if (node.name === 'Link' || node.name === 'Autolink') {
const url = node.getChild('URL')
if (url) return state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '')
const text = (n: SyntaxNode) => state.sliceDoc(n.from, n.to)
const label = node.getChild('LinkLabel')
const close = node.getChildren('LinkMark').find(mark => text(mark) === ']')
if (!close) return null
const normalize = (value: string) => value.replace(/^\[|\]$/g, '').trim().replace(/\s+/g, ' ').toUpperCase()
const key = normalize(label && text(label) !== '[]' ? text(label) : state.sliceDoc(node.from, close.to))
let destination: string | null = null
syntaxTree(state).iterate({ enter(ref) {
if (ref.name !== 'LinkReference') return
const refLabel = ref.node.getChild('LinkLabel'); const refUrl = ref.node.getChild('URL')
if (destination === null && refLabel && refUrl && normalize(text(refLabel)) === key) destination = text(refUrl).replace(/^<|>$/g, '')
return false
} })
return destination
}
}
return null
}
const livePreviewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
// 输入法组字期间只映射位置不重建,组字结束后再补一次重建
stale = false
constructor(view: EditorView) {
this.decorations = buildDecorations(view)
}
update(u: ViewUpdate) {
if (u.view.composing) {
this.decorations = this.decorations.map(u.changes)
this.stale = true
return
}
if (
this.stale
|| u.transactions.some(tr => tr.effects.some(effect => effect.is(refreshImages)))
|| u.docChanged
|| u.viewportChanged
|| u.selectionSet
|| u.focusChanged
|| syntaxTree(u.startState) !== syntaxTree(u.state)
) {
this.stale = false
this.decorations = buildDecorations(u.view)
}
}
},
{
decorations: v => v.decorations,
eventHandlers: {
mousedown(e, view) {
if (!(e.ctrlKey || e.metaKey)) return false
const pos = view.posAtCoords(e)
const href = pos === null ? null : linkAt(view.state, pos)
if (!href) return false
e.preventDefault()
const handler = view.state.facet(openLink)
if (handler) handler(href)
else if (/^(https?:|mailto:|tel:)/i.test(href)) window.open(href, '_blank', 'noopener')
return true
},
},
},
)
export const livePreview = [livePreviewPlugin]
@@ -0,0 +1,125 @@
import { history, undo } from '@codemirror/commands'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { htmlToMarkdown } from './html-paste'
import { pasteImages } from './paste'
let view: EditorView | undefined
afterEach(() => {
view?.destroy(); view = undefined; vi.restoreAllMocks()
})
function paste(html: string, text: string) {
const data = new DataTransfer()
data.setData('text/html', html)
data.setData('text/plain', text)
view!.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
}
function editor(importImage: (url: string) => Promise<string> = async () => {
throw new Error('此用例不下载图片')
}) {
let pending = 0
const errors: unknown[] = []
view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({
canPaste: () => true,
upload: async () => {
throw new Error('此用例不上传文件')
},
importImage,
update: (change) => {
const spec = change(view!.state); if (spec) view!.dispatch(spec)
},
onError: error => errors.push(error),
onPending: (delta) => {
pending += delta
},
})] }) })
return { pending: () => pending, errors }
}
describe('网页粘贴', () => {
it('保留标题、列表、链接、代码块、删除线和表格,并移除活动内容', async () => {
const result = await htmlToMarkdown('<h2>标题</h2><ul><li>一</li><li><del>二</del></li></ul><a href="https://example.com/a(b)">链接</a><pre><code class="language-js">const x = 1\n</code></pre><table><tr><th>名称</th><th>值</th></tr><tr><td><strong>甲</strong></td><td>a|b</td></tr></table><script>alert(1)</script><style>body{}</style><a href="java&#9;script:alert(1)">危险链接</a>', async () => '', () => {})
expect(result).toContain('## 标题')
expect(result).toMatch(/-\s+一/)
expect(result).toContain('~~二~~')
expect(result).toContain('[链接](<https://example.com/a(b)>)')
expect(result).toContain('```js\nconst x = 1\n```')
expect(result).toContain('| 名称 | 值 |\n| --- | --- |\n| **甲** | a\\|b |')
expect(result).not.toContain('alert(1)')
expect(result).not.toContain('body{}')
expect(result).toContain('危险链接')
})
it('相同来源只下载一次,失败保留原图链接并报告原始原因', async () => {
const failure = new Error('来源拒绝下载')
const errors: unknown[] = []
const urls: string[] = []
const result = await htmlToMarkdown('<base href="https://example.com/docs/"><img alt="图" src="../a.png"><img src="//example.com/a.png"><img src="bad.png">', async (url) => {
urls.push(url)
if (url.endsWith('bad.png')) throw failure
return '笔记.assets/hash.png'
}, error => errors.push(error))
expect(urls.sort()).toEqual(['https://example.com/a.png', 'https://example.com/docs/bad.png'])
expect(result.match(/笔记.assets\/hash.png/g)).toHaveLength(2)
expect(result).toContain('https://example.com/docs/bad.png')
expect(errors).toEqual([failure])
})
it('转换期间的新输入保持原样,撤销一次移除转换后的整次粘贴', async () => {
const state = editor()
paste('<h1>标题</h1>', '标题')
view!.dispatch({ changes: { from: 0, insert: '之后输入' }, userEvent: 'input.type' })
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('# 标题之后输入')
undo(view!)
expect(view!.state.doc.toString()).toBe('之后输入')
undo(view!)
expect(view!.state.doc.toString()).toBe('')
expect(state.errors).toEqual([])
})
it('用户已修改粘贴区域时不覆盖新输入,Shift 粘贴保持纯文本', async () => {
const state = editor()
view!.dispatch({ changes: { from: 0, insert: '原选区' }, selection: { anchor: 0, head: 3 } })
paste('<h1>标题</h1>', '标题')
view!.dispatch({ changes: { from: 1, to: 3, insert: '新输入' } })
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('原新输入')
view!.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', shiftKey: true, ctrlKey: true, bubbles: true }))
paste('<h1>纯文本</h1>', '纯文本')
expect(view!.state.doc.toString()).not.toContain('#')
expect(view!.state.doc.toString()).toContain('纯文本')
expect(state.pending()).toBe(0)
})
it('同一位置连续发起的异步粘贴保持发起顺序', async () => {
const state = editor()
paste('<b>第一段</b>', '第一段')
paste('<b>第二段</b>', '第二段')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('**第一段****第二段**')
})
it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => {
const downloads = new Map<string, (link: string) => void>()
const state = editor(url => new Promise(resolve => downloads.set(url, resolve)))
paste('<b>第一段</b><img src="https://example.com/1.png">', '第一段')
paste('<b>第二段</b><img src="https://example.com/2.png">', '第二段')
await vi.waitFor(() => expect(downloads.size).toBe(2))
downloads.get('https://example.com/2.png')!('n.assets/2.png')
await vi.waitFor(() => expect(view!.state.doc.toString()).toContain('第二段'))
downloads.get('https://example.com/1.png')!('n.assets/1.png')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString().indexOf('第一段')).toBeLessThan(view!.state.doc.toString().indexOf('第二段'))
paste('<b>取消的粘贴</b><img src="https://example.com/3.png">', '取消的粘贴')
await vi.waitFor(() => expect(downloads.size).toBe(3))
undo(view!)
downloads.get('https://example.com/3.png')!('n.assets/3.png')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).not.toContain('取消的粘贴')
})
})
+165
View File
@@ -0,0 +1,165 @@
import type { EditorState, Extension, TransactionSpec } from '@codemirror/state'
import { isolateHistory } from '@codemirror/commands'
import { Facet, StateEffect, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 文件移动期间不能开始异步粘贴或上传,避免响应中的资源链接属于旧路径。
canPaste: () => boolean
// 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise<string>
importImage: (url: string) => Promise<string>
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
update: (change: (state: EditorState) => TransactionSpec | undefined) => void
onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
onPending: (delta: 1 | -1) => void
}
const uploadOptions = Facet.define<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: v => v[0] })
const pasteRange = StateEffect.define<{ id: object, from: number, to: number } | { id: object }>()
const pendingPastes = StateField.define<Map<object, { from: number, to: number }>>({
create: () => new Map(),
update(value, transaction) {
const next = new Map<object, { from: number, to: number }>()
let completed: object | undefined
for (const effect of transaction.effects) {
if (effect.is(pasteRange) && !('from' in effect.value)) completed = effect.value.id
}
let afterCompleted = false
for (const [id, range] of value) {
if (id === completed) {
afterCompleted = true; continue
}
let edited = transaction.isUserEvent('undo')
transaction.changes.iterChangedRanges((from, to) => {
if ((from < range.to && to > range.from) || (from === to && from > range.from && from < range.to)) edited = true
})
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
}
for (const effect of transaction.effects) {
if (effect.is(pasteRange)) {
if ('from' in effect.value) next.set(effect.value.id, { from: effect.value.from, to: effect.value.to })
else next.delete(effect.value.id)
}
}
return next
},
})
function replaceText(opts: PasteImagesOptions, text: string, replacement: string) {
opts.update((state) => {
const at = state.doc.toString().indexOf(text)
if (at >= 0) return { changes: { from: at, to: at + text.length, insert: replacement } }
})
}
function insertHTML(view: EditorView, html: string, text: string, opts: PasteImagesOptions) {
const id = {}
const range = view.state.selection.main
opts.onPending(1)
view.dispatch({ effects: pasteRange.of({ id, from: range.from, to: range.to }) })
const insert = (markdown: string) => {
opts.update((state) => {
const pending = state.field(pendingPastes).get(id)
if (pending) {
return {
changes: { from: pending.from, to: pending.to, insert: markdown },
selection: state.selection.main.from === pending.from && state.selection.main.to === pending.to ? { anchor: pending.from + markdown.length } : undefined,
effects: pasteRange.of({ id }),
userEvent: 'input.paste',
annotations: isolateHistory.of('full'),
}
}
})
}
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
return text
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
}
/**
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
* 返回是否已处理图片插入(包括因操作冲突被拒绝);未配置上传时不处理
*/
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
const opts = view.state.facet(uploadOptions)
const images = files.filter(f => f.type.startsWith('image/'))
if (!opts || !images.length) return false
if (!opts.canPaste()) return true
const urls = images.map(f => URL.createObjectURL(f))
const text = urls.map(u => `![](${u})`).join('\n')
const range = view.state.selection.main
const from = pos ?? range.from
const to = pos ?? range.to
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.paste',
})
images.forEach((file, i) => {
opts.onPending(1)
opts
.upload(file)
.then(link => replaceText(opts, urls[i], link))
.catch((err) => {
replaceText(opts, `![](${urls[i]})`, '')
opts.onError(err)
})
.finally(() => {
opts.onPending(-1)
URL.revokeObjectURL(urls[i])
})
})
return true
}
// 粘贴或拖入图片
export function pasteImages(opts: PasteImagesOptions): Extension {
let plain = false
return [
uploadOptions.of(opts),
pendingPastes,
EditorView.domEventHandlers({
keydown(e) {
plain = e.shiftKey
},
keyup(e) {
plain = e.shiftKey
},
blur() {
plain = false
},
paste(e, view) {
if (view.state.readOnly || plain) return false
const html = e.clipboardData?.getData('text/html')
if (html) {
if (opts.canPaste()) insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts)
e.preventDefault()
return true
}
// 个别浏览器/来源只在 items 中提供图片
let files = [...(e.clipboardData?.files ?? [])]
if (!files.length) {
files = [...(e.clipboardData?.items ?? [])].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(f => f !== null)
}
if (!insertImageFiles(view, files)) return false
e.preventDefault()
return true
},
drop(e, view) {
if (view.state.readOnly) return false
const files = [...(e.dataTransfer?.files ?? [])]
const pos = view.posAtCoords(e)
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false
e.preventDefault()
return true
},
}),
]
}
@@ -0,0 +1,25 @@
import type { Command, KeyBinding } from '@codemirror/view'
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import { typoraKeys } from '../../../common/utils/typora-keys'
import { selectCodeBlock } from './codeblocks'
import { insertLink, toggleBlock, toggleBold, toggleCode, toggleHeading, toggleItalic } from './format'
import { toggleSelectionMark, toggleUnderline } from './inline-styles'
export const editorCommands: { id: string, title: string, keys: string[], run: Command }[] = [
{ id: 'editor.bold', title: '粗体', keys: typoraKeys('editor.bold'), run: toggleBold },
{ id: 'editor.italic', title: '斜体', keys: typoraKeys('editor.italic'), run: toggleItalic },
{ id: 'editor.underline', title: '下划线', keys: typoraKeys('editor.underline'), run: toggleUnderline },
{ id: 'editor.strike', title: '删除线', keys: typoraKeys('editor.strike'), run: toggleSelectionMark('Strikethrough') },
{ id: 'editor.highlight', title: '高亮', keys: [], run: toggleSelectionMark('Highlight') },
{ id: 'editor.code', title: '行内代码', keys: typoraKeys('editor.code'), run: toggleCode },
...(['task', 'bullet', 'ordered', 'quote'] as const).map(kind => ({ id: `editor.${kind}`, title: { task: '任务列表', bullet: '无序列表', ordered: '有序列表', quote: '引用' }[kind], keys: typoraKeys(`editor.${kind}`), run: toggleBlock(kind) })),
{ id: 'editor.link', title: '插入链接', keys: typoraKeys('editor.link'), run: insertLink },
{ id: 'editor.undo', title: '撤销', keys: typoraKeys('editor.undo'), run: undo },
{ id: 'editor.redo', title: '重做', keys: typoraKeys('editor.redo'), run: redo },
{ id: 'editor.indent', title: '增加缩进', keys: typoraKeys('editor.indent'), run: indentMore },
{ id: 'editor.outdent', title: '减少缩进', keys: typoraKeys('editor.outdent'), run: indentLess },
{ id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: typoraKeys('editor.selectAll'), run: view => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: typoraKeys(`editor.heading${i + 1}`), run: toggleHeading(i + 1) })),
]
export const defaultEditorKeys: KeyBinding[] = editorCommands.flatMap(command => command.keys.map(key => ({ key: key.replaceAll('+', '-').replace(/-[A-Z]$/, letter => letter.toLowerCase()), run: command.run, preventDefault: true })))
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { setEditorFocus } from './focus'
import { createEditorState } from './index'
import { tableField } from './table'
const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n正文'
describe('表格渲染', () => {
it('光标在文首但编辑器未聚焦时,开头的表格仍渲染', () => {
const state = createEditorState({ doc })
expect(state.selection.main.head).toBe(0)
expect(state.field(tableField).size).toBe(1)
})
it('编辑器聚焦且光标在表格内时显示源码,移出后恢复渲染', () => {
let state = createEditorState({ doc }).update({ effects: setEditorFocus.of(true) }).state
expect(state.field(tableField).size).toBe(0)
state = state.update({ selection: { anchor: doc.length } }).state
expect(state.field(tableField).size).toBe(1)
})
})
+454
View File
@@ -0,0 +1,454 @@
import type { EditorState, Range } from '@codemirror/state'
import type { DecorationSet } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { isolateHistory, redo, undo } from '@codemirror/commands'
import { syntaxTree } from '@codemirror/language'
import { StateField } from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { renderInlineMarkdown } from '../../../common/markdown/html'
import { hydrate } from '../../../common/markdown/render'
import { editorFocused, setEditorFocus } from './focus'
type Align = '' | 'left' | 'center' | 'right'
interface RowLine {
from: number
to: number
cells: string[]
}
interface TableModel {
from: number
to: number
aligns: Align[]
// rows[0] 为表头,其余为表体;分隔行单独由 aligns 表示
rows: RowLine[]
}
// ---- 解析与序列化 ----
// 按未转义的 | 切分一行,去掉首尾可选的竖线
function splitRow(line: string): string[] {
const cells: string[] = []
let cur = ''
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '\\' && i + 1 < line.length) {
cur += ch + line[++i]
} else if (ch === '|') {
cells.push(cur)
cur = ''
} else {
cur += ch
}
}
cells.push(cur)
if (/^\s*$/.test(cells[0]) && line.trimStart().startsWith('|')) cells.shift()
if (cells.length && /^\s*$/.test(cells[cells.length - 1]) && line.trimEnd().endsWith('|')) cells.pop()
return cells.map(c => c.trim())
}
function parseAlign(cell: string): Align {
const left = cell.startsWith(':')
const right = cell.endsWith(':')
return left && right ? 'center' : right ? 'right' : left ? 'left' : ''
}
function escapeCell(text: string): string {
return text.replace(/\n/g, ' ').replace(/(^|[^\\])\|/g, '$1\\|')
}
function serializeRow(cells: string[]): string {
return `| ${cells.join(' | ')} |`
}
function serializeDelimiter(aligns: Align[]): string {
const marks = { '': '---', 'left': ':---', 'center': ':---:', 'right': '---:' }
return `| ${aligns.map(a => marks[a]).join(' | ')} |`
}
function serializeTable(aligns: Align[], rows: string[][]): string {
const [header, ...body] = rows
return [serializeRow(header), serializeDelimiter(aligns), ...body.map(serializeRow)].join('\n')
}
function readTable(state: EditorState, node: SyntaxNode): TableModel {
const { doc } = state
const rows: RowLine[] = []
let aligns: Align[] = []
for (let n = doc.lineAt(node.from).number, end = doc.lineAt(node.to).number; n <= end; n++) {
const line = doc.line(n)
const cells = splitRow(line.text)
if (rows.length === 1 && aligns.length === 0) aligns = cells.map(parseAlign)
else rows.push({ from: line.from, to: line.to, cells })
}
const width = aligns.length
for (const r of rows) {
r.cells = r.cells.slice(0, width)
while (r.cells.length < width) r.cells.push('')
}
return { from: node.from, to: node.to, aligns, rows }
}
function findTable(state: EditorState, pos: number): SyntaxNode | null {
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
if (node.name === 'Table' && node.from === pos) return node
if (node.from > pos) break
}
return null
}
// ---- 渲染组件 ----
function renderCell(cell: HTMLElement, raw: string) {
cell.innerHTML = renderInlineMarkdown(raw)
hydrate(cell)
}
interface WidgetState {
model: TableModel
// 最近编辑的单元格,工具栏操作以它为准
current: { r: number, c: number }
// 结构变更后需要聚焦的单元格(DOM 重建后生效)
pendingFocus: { r: number, c: number } | null
}
const widgetStates = new WeakMap<HTMLElement, WidgetState>()
class TableWidget extends WidgetType {
readonly key: string
constructor(readonly model: TableModel) {
super()
this.key = JSON.stringify([model.aligns, model.rows.map(r => r.cells)])
}
eq(other: TableWidget) {
return other.key === this.key
}
get estimatedHeight() {
return this.model.rows.length * 34 + 44
}
toDOM(view: EditorView) {
const wrap = document.createElement('div')
wrap.className = 'cm-rm-table-widget'
widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null })
// 横向滚动放在内层,避免绝对定位的工具栏被裁剪
const scroller = document.createElement('div')
scroller.className = 'cm-rm-table-scroll'
scroller.append(this.buildTable(view, wrap))
wrap.append(this.toolbar(view, wrap), scroller)
return wrap
}
updateDOM(dom: HTMLElement, view: EditorView) {
const st = widgetStates.get(dom)
if (!st) return false
const old = st.model
st.model = this.model
const sameShape
= old.rows.length === this.model.rows.length && old.aligns.join() === this.model.aligns.join()
if (!sameShape || st.pendingFocus) {
// 重建前记下正在编辑的单元格(如撤销改变了行数),重建后恢复焦点
const editing = dom.querySelector<HTMLElement>('[contenteditable]')
const focus
= st.pendingFocus ?? (editing ? { r: Number(editing.dataset.r), c: Number(editing.dataset.c) } : null)
st.pendingFocus = null
dom.querySelector('table')!.replaceWith(this.buildTable(view, dom))
if (focus) {
const rows = this.model.rows.length
const cols = this.model.aligns.length
const cell = cellAt(dom, Math.min(focus.r, rows - 1), Math.min(focus.c, cols - 1))
if (cell) startEdit(cell, st)
}
return true
}
// 形状不变:只刷新内容变化的单元格;正在编辑的单元格若与文档一致则不动,以免打断输入
dom.querySelectorAll<HTMLElement>('[data-r]').forEach((cell) => {
const r = Number(cell.dataset.r)
const c = Number(cell.dataset.c)
const raw = this.model.rows[r].cells[c]
if (cell.isContentEditable) {
if (escapeCell(cell.textContent ?? '') !== raw) {
cell.textContent = raw
placeCaretAtEnd(cell)
}
} else if (old.rows[r].cells[c] !== raw) {
renderCell(cell, raw)
}
})
return true
}
ignoreEvent() {
return true
}
private buildTable(view: EditorView, wrap: HTMLElement): HTMLTableElement {
const table = document.createElement('table')
const thead = table.createTHead()
const tbody = table.createTBody()
this.model.rows.forEach((row, r) => {
const tr = (r === 0 ? thead : tbody).insertRow()
row.cells.forEach((raw, c) => {
const cell = document.createElement(r === 0 ? 'th' : 'td')
cell.dataset.r = String(r)
cell.dataset.c = String(c)
if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c]
renderCell(cell, raw)
bindCell(view, wrap, cell)
tr.append(cell)
})
})
return table
}
private toolbar(view: EditorView, wrap: HTMLElement): HTMLElement {
const bar = document.createElement('div')
bar.className = 'cm-rm-table-toolbar'
const tools = document.createElement('div')
tools.className = 'cm-rm-table-tools'
bar.append(tools)
const button = (icon: string, label: string, title: string, run: () => void, danger = false) => {
const b = document.createElement('button')
b.type = 'button'
b.title = title
if (danger) b.className = 'cm-rm-danger'
b.innerHTML = svg(icon)
b.append(label)
// mousedown 阻止默认行为,保持当前单元格的焦点与光标
b.addEventListener('mousedown', (e) => {
e.preventDefault()
run()
})
tools.append(b)
}
const separator = () => {
const sep = document.createElement('span')
sep.className = 'cm-rm-sep'
tools.append(sep)
}
button(ICONS.addRow, '行', '在下方插入行', () => insertRow(view, wrap))
button(ICONS.addColumn, '列', '在右侧插入列', () => insertColumn(view, wrap))
separator()
button(ICONS.deleteRow, '行', '删除当前行', () => deleteRow(view, wrap), true)
button(ICONS.deleteColumn, '列', '删除当前列', () => deleteColumn(view, wrap), true)
separator()
button(ICONS.source, '源码', '以 Markdown 源码编辑', () => {
const model = currentModel(view, wrap)
if (!model) return
view.dispatch({ selection: { anchor: model.from } })
view.focus()
})
return bar
}
}
const ICONS = {
addRow: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M12 14v7M8.5 17.5h7"/>',
addColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7M17.5 8.5v7"/>',
deleteRow: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M8.5 17.5h7"/>',
deleteColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7"/>',
source: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
}
function svg(body: string): string {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
}
// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格
function mutate(
view: EditorView,
wrap: HTMLElement,
f: (rows: string[][], aligns: Align[], cur: { r: number, c: number }) => { r: number, c: number },
) {
const st = widgetStates.get(wrap)!
const model = currentModel(view, wrap)
if (!model) return
const rows = model.rows.map(r => [...r.cells])
const aligns = [...model.aligns]
st.pendingFocus = f(rows, aligns, st.current)
// 结构变更单独成为一步撤销,不与前后的单元格输入合并
view.dispatch({
changes: { from: model.from, to: model.to, insert: serializeTable(aligns, rows) },
annotations: isolateHistory.of('full'),
})
}
// focusColumn 未指定时聚焦新行的同一列
function insertRow(view: EditorView, wrap: HTMLElement, focusColumn?: number) {
return mutate(view, wrap, (rows, aligns, cur) => {
rows.splice(cur.r + 1, 0, aligns.map(() => ''))
return { r: cur.r + 1, c: focusColumn ?? cur.c }
})
}
function insertColumn(view: EditorView, wrap: HTMLElement) {
return mutate(view, wrap, (rows, aligns, cur) => {
const c = cur.c + 1
aligns.splice(c, 0, '')
rows.forEach(row => row.splice(c, 0, ''))
return { r: cur.r, c }
})
}
function deleteRow(view: EditorView, wrap: HTMLElement) {
return mutate(view, wrap, (rows, _, cur) => {
// 表头行不能删除
if (cur.r === 0) return cur
rows.splice(cur.r, 1)
return { r: Math.min(cur.r, rows.length - 1), c: cur.c }
})
}
function deleteColumn(view: EditorView, wrap: HTMLElement) {
return mutate(view, wrap, (rows, aligns, cur) => {
if (aligns.length <= 1) return cur
aligns.splice(cur.c, 1)
rows.forEach(row => row.splice(cur.c, 1))
return { r: cur.r, c: Math.min(cur.c, aligns.length - 1) }
})
}
function currentModel(view: EditorView, wrap: HTMLElement): TableModel | null {
const node = findTable(view.state, view.posAtDOM(wrap))
return node ? readTable(view.state, node) : null
}
function cellAt(wrap: HTMLElement, r: number, c: number): HTMLElement | null {
return wrap.querySelector<HTMLElement>(`[data-r="${r}"][data-c="${c}"]`)
}
function placeCaretAtEnd(el: HTMLElement) {
const sel = el.ownerDocument.getSelection()
if (!sel) return
const range = el.ownerDocument.createRange()
range.selectNodeContents(el)
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
}
// 单元格编辑时显示该格的 Markdown 源码,失焦后恢复渲染
function startEdit(cell: HTMLElement, st: WidgetState) {
const r = Number(cell.dataset.r)
const c = Number(cell.dataset.c)
st.current = { r, c }
if (!cell.isContentEditable) {
cell.contentEditable = 'plaintext-only'
cell.textContent = st.model.rows[r].cells[c]
}
cell.focus()
placeCaretAtEnd(cell)
}
function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) {
const st = () => widgetStates.get(wrap)!
const r = () => Number(cell.dataset.r)
const c = () => Number(cell.dataset.c)
cell.addEventListener('mousedown', (e) => {
if (cell.isContentEditable) return
e.preventDefault()
startEdit(cell, st())
})
const commit = () => {
const model = currentModel(view, wrap)
if (!model) return
const row = model.rows[r()]
const cells = [...row.cells]
const next = escapeCell(cell.textContent ?? '')
if (cells[c()] === next) return
cells[c()] = next
view.dispatch({ changes: { from: row.from, to: row.to, insert: serializeRow(cells) }, userEvent: 'input.type' })
}
cell.addEventListener('input', (e) => {
if (!(e as InputEvent).isComposing) commit()
})
cell.addEventListener('compositionend', commit)
cell.addEventListener('blur', () => {
cell.removeAttribute('contenteditable')
renderCell(cell, st().model.rows[r()]?.cells[c()] ?? '')
})
const moveTo = (nr: number, nc: number) => {
const target = cellAt(wrap, nr, nc)
if (target) startEdit(target, st())
}
cell.addEventListener('keydown', (e) => {
if (e.isComposing) return
const { rows, aligns } = st().model
const cols = aligns.length
const mod = e.ctrlKey || e.metaKey
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault()
;(e.shiftKey ? redo : undo)(view)
} else if (mod && e.key.toLowerCase() === 'y') {
e.preventDefault()
redo(view)
} else if (e.key === 'Tab') {
e.preventDefault()
const idx = r() * cols + c() + (e.shiftKey ? -1 : 1)
if (idx >= 0 && idx < rows.length * cols) moveTo(Math.floor(idx / cols), idx % cols)
else if (idx >= rows.length * cols) insertRow(view, wrap, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
if (r() + 1 < rows.length) moveTo(r() + 1, c())
else insertRow(view, wrap)
} else if (e.key === 'ArrowUp' && r() > 0) {
e.preventDefault()
moveTo(r() - 1, c())
} else if (e.key === 'ArrowDown' && r() + 1 < rows.length) {
e.preventDefault()
moveTo(r() + 1, c())
} else if (e.key === 'Escape') {
// 退出表格,光标放到表格之后
e.preventDefault()
const model = currentModel(view, wrap)
if (model) view.dispatch({ selection: { anchor: Math.min(model.to + 1, view.state.doc.length) } })
view.focus()
}
})
}
// ---- 装饰 ----
// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。
// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(见 focus.ts)
function buildTables(state: EditorState): DecorationSet {
const out: Range<Decoration>[] = []
const sel = state.field(editorFocused) ? state.selection.ranges : []
// 只渲染顶层表格;引用、列表内的表格以源码显示
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
if (node.name !== 'Table') continue
if (sel.some(r => r.from <= node.to && r.to >= node.from)) continue
const model = readTable(state, node)
if (model.rows.length === 0 || model.aligns.length === 0) continue
out.push(Decoration.replace({ widget: new TableWidget(model), block: true }).range(node.from, node.to))
}
return Decoration.set(out)
}
export const tableField = StateField.define<DecorationSet>({
create: buildTables,
update(value, tr) {
if (
tr.docChanged
|| tr.selection
|| tr.effects.some(e => e.is(setEditorFocus))
|| syntaxTree(tr.startState) !== syntaxTree(tr.state)
) {
return buildTables(tr.state)
}
return value
},
provide: f => EditorView.decorations.from(f),
})
export const tableEditing = tableField
+16
View File
@@ -0,0 +1,16 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
// 编辑器排版样式在 themes/base.css 中以 CSS 变量定义,这里只做语法元素到类名的映射
const markdownHighlight = HighlightStyle.define([
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strong, fontWeight: '600' },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], fontWeight: '600' },
{ tag: t.processingInstruction, class: 'cm-rm-markup' },
{ tag: [t.url, t.link], class: 'cm-rm-url' },
{ tag: t.quote, class: 'cm-rm-quote-text' },
{ tag: t.monospace, class: 'cm-rm-mono' },
])
export const markdownTheme = syntaxHighlighting(markdownHighlight)
+377
View File
@@ -0,0 +1,377 @@
import type { OwnedRenderer } from '../../../common/plugins/registry'
import { EditorView, WidgetType } from '@codemirror/view'
import { emojiFor, renderMath } from '../../../common/markdown/render'
import { mountContent } from '../../../common/plugins/mount'
import { copyText } from '../../../common/utils/clipboard'
export class BulletWidget extends WidgetType {
eq() {
return true
}
toDOM() {
const el = document.createElement('span')
el.className = 'cm-rm-bullet'
el.textContent = '•'
return el
}
}
export class CheckboxWidget extends WidgetType {
constructor(readonly checked: boolean) {
super()
}
eq(other: CheckboxWidget) {
return other.checked === this.checked
}
toDOM(view: EditorView) {
const el = document.createElement('input')
el.type = 'checkbox'
el.className = 'cm-rm-task'
el.checked = this.checked
// 用 mousedown 并阻止默认行为:否则编辑器先移动光标,行被展开为源码,点击落空
el.addEventListener('mousedown', (e) => {
e.preventDefault()
const pos = view.posAtDOM(el)
const marker = view.state.sliceDoc(pos, pos + 3)
if (!/^\[[ x]\]$/i.test(marker)) return
view.dispatch({ changes: { from: pos + 1, to: pos + 2, insert: this.checked ? ' ' : 'x' } })
})
return el
}
ignoreEvent() {
return true
}
}
export class HorizontalRuleWidget extends WidgetType {
eq() {
return true
}
toDOM() {
const el = document.createElement('span')
el.className = 'cm-rm-hr'
return el
}
}
const COPY_ICON
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
const CHECK_ICON
= '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
// 代码块右上角:语言标签(未激活时)+ 复制按钮
export class CodeHeaderWidget extends WidgetType {
constructor(
readonly lang: string,
readonly showLang: boolean,
// 根据组件所在位置取出代码块内容,由调用方结合语法树实现
readonly codeAt: (view: EditorView, pos: number) => string | null,
) {
super()
}
eq(other: CodeHeaderWidget) {
return other.lang === this.lang && other.showLang === this.showLang
}
toDOM(view: EditorView) {
const el = document.createElement('span')
el.className = 'cm-rm-code-header'
if (this.showLang && this.lang) {
const label = document.createElement('span')
label.className = 'cm-rm-code-lang'
label.textContent = this.lang
el.append(label)
}
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'cm-rm-code-copy'
btn.title = '复制代码'
btn.innerHTML = COPY_ICON
btn.addEventListener('mousedown', e => e.preventDefault())
btn.addEventListener('click', async () => {
const code = this.codeAt(view, view.posAtDOM(el))
if (code === null || !(await copyText(code))) return
btn.innerHTML = CHECK_ICON
btn.classList.add('cm-rm-copied')
setTimeout(() => {
btn.innerHTML = COPY_ICON
btn.classList.remove('cm-rm-copied')
}, 1500)
})
el.append(btn)
return el
}
}
export class ImageWidget extends WidgetType {
constructor(
readonly src: string,
readonly alt: string,
) {
super()
}
eq(other: ImageWidget) {
return other.src === this.src && other.alt === this.alt
}
toDOM(view: EditorView) {
const wrap = document.createElement('span')
wrap.className = 'cm-rm-image'
const img = document.createElement('img')
img.src = this.src
img.alt = this.alt
img.loading = 'lazy'
img.decoding = 'async'
// 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位
img.addEventListener('load', () => view.requestMeasure())
// 组件内的事件默认被编辑器忽略,需手动把光标移到图片语法处以展开源码
wrap.addEventListener('mousedown', (e) => {
e.preventDefault()
// 先设光标再聚焦:focus() 会把编辑器选区写回 DOM,反之会读到残留在别处(如表格单元格)的 DOM 选区
view.dispatch({ selection: { anchor: view.posAtDOM(wrap) } })
view.focus()
})
img.addEventListener('error', () => {
wrap.classList.add('cm-rm-image-error')
wrap.textContent = this.alt || this.src
})
wrap.append(img)
return wrap
}
get estimatedHeight() {
return 200
}
}
// ---- 扩展语法(公式、Mermaid、emoji、脚注、目录) ----
// 点击渲染结果时把光标移到源码中(offset 为相对组件起点的位置)以展开编辑,做法同图片
function editOnMousedown(el: HTMLElement, view: EditorView, offset: number) {
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return
e.preventDefault()
view.dispatch({ selection: { anchor: Math.min(view.posAtDOM(el) + offset, view.state.doc.length) } })
view.focus()
})
}
function showRenderError(el: HTMLElement, err: unknown) {
const pre = document.createElement('pre')
pre.className = 'cm-rm-render-error'
pre.textContent = err instanceof Error ? err.message : String(err)
el.replaceChildren(pre)
}
/**
* 公式。editOffset 为 null 时是编辑中显示在源码下方的预览(不响应点击,内容变化时原地更新),
* 否则是替换源码的渲染结果,点击后光标移到源码 editOffset 处
*/
export class MathWidget extends WidgetType {
constructor(
readonly tex: string,
readonly display: boolean,
readonly editOffset: number | null,
) {
super()
}
eq(other: MathWidget) {
return other.tex === this.tex && other.display === this.display && other.editOffset === this.editOffset
}
toDOM(view: EditorView) {
const el = document.createElement(this.display ? 'div' : 'span')
el.className = this.display ? 'cm-rm-math-block' : 'cm-rm-math'
if (this.editOffset === null) el.classList.add('cm-rm-preview')
else editOnMousedown(el, view, this.editOffset)
this.render(el, view)
return el
}
// 只复用预览元素:从渲染结果切到编辑时,编辑器会尝试用新组件更新旧元素(旧元素带点击编辑的监听)
updateDOM(dom: HTMLElement, view: EditorView) {
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
this.render(dom, view)
return true
}
private render(el: HTMLElement, view: EditorView) {
if (!this.tex.trim()) {
el.textContent = '空公式'
el.classList.add('cm-rm-placeholder')
return
}
el.classList.remove('cm-rm-placeholder')
// 加载 KaTeX 前先显示源码
if (!el.firstChild) el.textContent = this.tex
void renderMath(el, this.tex, this.display).then(() => view.requestMeasure())
}
get estimatedHeight() {
return this.display ? 60 : -1
}
}
const rendererMounts = new WeakMap<HTMLElement, () => void>()
/** 通用代码块预览;editOffset 与公式组件使用相同的点击编辑约定。 */
export class CodeRendererWidget extends WidgetType {
constructor(
readonly code: string,
readonly editOffset: number | null,
readonly language: string,
readonly path: string,
readonly renderer: OwnedRenderer,
) {
super()
}
eq(other: CodeRendererWidget) {
return other.code === this.code && other.editOffset === this.editOffset && other.language === this.language && other.path === this.path && other.renderer === this.renderer
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'
if (this.editOffset === null) el.classList.add('cm-rm-preview')
else editOnMousedown(el, view, this.editOffset)
this.mount(el, view)
return el
}
updateDOM(dom: HTMLElement, view: EditorView) {
if (this.editOffset !== null || !dom.classList.contains('cm-rm-preview')) return false
dom.className = `${this.language === 'mermaid' ? 'cm-rm-mermaid' : 'cm-rm-code-render'} cm-rm-preview`
this.destroy(dom)
this.mount(dom, view)
return true
}
destroy(dom: HTMLElement) {
rendererMounts.get(dom)?.()
rendererMounts.delete(dom)
}
private mount(el: HTMLElement, view: EditorView) {
let stop = () => {}
const render = () => {
stop()
stop = mountContent(el, context => Promise.resolve(this.renderer.render({
...context,
source: this.code,
language: this.language,
path: this.path,
colorMode: document.documentElement.dataset.rmColorMode === 'dark' ? 'dark' : 'light',
})).then((cleanup) => {
requestAnimationFrame(() => view.requestMeasure()); return cleanup
}), (error) => {
showRenderError(el, error)
this.renderer.fail(error)
view.requestMeasure()
})
}
render()
window.addEventListener('rawmark-appearance', render)
rendererMounts.set(el, () => {
window.removeEventListener('rawmark-appearance', render); stop()
})
}
get estimatedHeight() {
return 240
}
}
export class EmojiWidget extends WidgetType {
constructor(readonly name: string) {
super()
}
eq(other: EmojiWidget) {
return other.name === this.name
}
toDOM(view: EditorView) {
const el = document.createElement('span')
el.className = 'cm-rm-emoji'
// 名称表加载前或名称未知时显示原文
el.textContent = `:${this.name}:`
void emojiFor(this.name).then((e) => {
if (e) el.textContent = e
})
editOnMousedown(el, view, 1)
return el
}
}
export class FootnoteRefWidget extends WidgetType {
constructor(
readonly label: string,
readonly definition: string | undefined,
) {
super()
}
eq(other: FootnoteRefWidget) {
return other.label === this.label && other.definition === this.definition
}
toDOM(view: EditorView) {
const el = document.createElement('sup')
el.className = 'cm-rm-footnote-ref'
el.textContent = this.label
el.title = this.definition ?? '未找到脚注定义'
editOnMousedown(el, view, 2)
return el
}
}
export interface TocEntry {
level: number
text: string
pos: number
}
export class TocWidget extends WidgetType {
constructor(readonly entries: TocEntry[]) {
super()
}
eq(other: TocWidget) {
return JSON.stringify(other.entries) === JSON.stringify(this.entries)
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = 'cm-rm-toc'
if (!this.entries.length) {
el.textContent = '目录(还没有标题)'
el.classList.add('cm-rm-placeholder')
}
const minLevel = Math.min(...this.entries.map(e => e.level))
for (const entry of this.entries) {
const item = document.createElement('a')
item.className = 'cm-rm-toc-item'
item.style.paddingLeft = `${(entry.level - minLevel) * 1.2}em`
item.textContent = entry.text
item.addEventListener('mousedown', (e) => {
e.preventDefault()
e.stopPropagation()
view.dispatch({ selection: { anchor: entry.pos }, effects: EditorView.scrollIntoView(entry.pos, { y: 'start', yMargin: 24 }) })
view.focus()
})
el.append(item)
}
// 点击目录空白处展开 [TOC] 源码
editOnMousedown(el, view, 0)
return el
}
}