refactor(web): adopt modular Vue architecture and isolate bearer sessions
This commit is contained in:
1 parent
1b2b2b12ff
commit
3cd641a380
379 files changed
+19736
-8712
No files matched your search
@@ -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,
|
||||
]
|
||||
@@ -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('看[|]()')
|
||||
})
|
||||
@@ -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, ''))
|
||||
? `.replace(/>/g, '%3E')}>)`
|
||||
: alt
|
||||
},
|
||||
})
|
||||
return service.turndown(fragment)
|
||||
}
|
||||
@@ -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&b', 'amp', '&'],
|
||||
['a*b', '42', '*'],
|
||||
['a*b', 'x2a', '*'],
|
||||
['a:smile:b', 'smile', ':smile:'],
|
||||
['a[^note]b\n\n[^note]: footnote', 'note', '[^note]'],
|
||||
['a<https://example.com>b', 'example', '<https://example.com>'],
|
||||
])('局部选择语法单元时整体设置外观:%s', (doc, selection, atom) => {
|
||||
const e = editor(doc, selection)
|
||||
expect(e.run(applyInlineStyles({ color: '#0000ff' }))).toBe(true)
|
||||
expect(e.doc).toBe(doc.replace(atom, `<span style="color: #0000ff">${atom}</span>`))
|
||||
expect(renderMarkdown(e.doc).replace('<span style="color: #0000ff">', '').replace('</span>', '')).toBe(renderMarkdown(doc))
|
||||
expect(e.target.state.sliceDoc(e.target.state.selection.main.from, e.target.state.selection.main.to)).toBe(selection)
|
||||
e.run(clearInlineStyles)
|
||||
expect(e.doc).toBe(doc)
|
||||
})
|
||||
it('局部选择转义字符后切换下划线,不改变正文含义,且可撤销', () => {
|
||||
const e = editor('a\\*b', '*')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('a<u>\\*</u>b')
|
||||
expect(renderMarkdown(e.doc)).toBe('<p>a<u>*</u>b</p>\n')
|
||||
e.run(toggleUnderline)
|
||||
expect(e.doc).toBe('a\\*b')
|
||||
expect(undo(e.target)).toBe(true)
|
||||
expect(e.doc).toBe('a<u>\\*</u>b')
|
||||
})
|
||||
it('同一字符实体内多个选区只格式化一次并保留反向选区', () => {
|
||||
const e = editor('&')
|
||||
e.target.dispatch({ selection: EditorSelection.create([EditorSelection.range(2, 1), EditorSelection.range(3, 4)]) })
|
||||
e.run(toggleSelectionMark('StrongEmphasis'))
|
||||
expect(e.doc).toBe('**&**')
|
||||
expect(renderMarkdown(e.doc)).toBe('<p><strong>&</strong></p>\n')
|
||||
expect(e.target.state.selection.ranges.map(range => e.target.state.sliceDoc(range.from, range.to))).toEqual(['a', 'p'])
|
||||
expect(e.target.state.selection.main.anchor).toBeGreaterThan(e.target.state.selection.main.head)
|
||||
})
|
||||
it('下划线可切换,并能和 Markdown 粗体叠加', () => {
|
||||
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	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('取消的粘贴')
|
||||
})
|
||||
})
|
||||
@@ -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 => ``).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, ``, '')
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user