Allow custom command shortcuts and heading bindings

This commit is contained in:
eezd committed 2026-10-21 16:02:58 +08:00
1 parent 7344bb19dc
commit fbce545d98
15 files changed
+226 -66

No files matched your search

+3 -4
View File
@@ -1,6 +1,6 @@
import { syntaxTree } from '@codemirror/language'
import { Prec, StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, keymap, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import type { SyntaxNode } from '@lezer/common'
import { cachedTokens, requestTokens, type TokenRun } from '../highlight/client'
@@ -80,7 +80,7 @@ export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null
}
// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选
function selectCodeBlock(view: EditorView): boolean {
export function selectCodeBlock(view: EditorView): boolean {
const { state } = view
const main = state.selection.main
const node = fencedCodeAt(state, main.head)
@@ -169,5 +169,4 @@ const codeHighlighter = ViewPlugin.fromClass(
export const codeBlockHighlight = [
codeTokenField,
codeHighlighter,
Prec.high(keymap.of([{ key: 'Mod-a', run: selectCodeBlock }])),
]
+17 -1
View File
@@ -70,7 +70,8 @@ function kindOf(prefix: string): PrefixKind | null {
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) {
for (let l = state.doc.lineAt(r.from).number, end = state.doc.lineAt(r.to).number; l <= end; l++) lineNumbers.add(l)
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)!)
@@ -111,6 +112,21 @@ export const cycleHeading: FormatCommand = (target) => {
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) => {
+12
View File
@@ -0,0 +1,12 @@
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: Parameters<EditorState['update']>[0]) => { state = state.update(spec).state } }
toggleHeading(6)(target)
expect(state.doc.toString()).toBe('###### 第一行\n第二行')
toggleHeading(6)(target)
expect(state.doc.toString()).toBe('第一行\n第二行')
})
+7 -14
View File
@@ -1,14 +1,14 @@
import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands'
import { defaultKeymap, history, selectAll } from '@codemirror/commands'
import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
import { Language } from '@codemirror/language'
import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state'
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
import { drawSelection, EditorView, keymap, type KeyBinding, type ViewUpdate } from '@codemirror/view'
import type { MarkdownParser } from '@lezer/markdown'
import { typoraExtensions } from '../markdown/extensions'
import { renderedBlocks } from './blocks'
import { codeBlockHighlight } from './codeblocks'
import { editorFocus } from './focus'
import { insertLink, toggleBold, toggleItalic } from './format'
import { defaultEditorKeys } from './shortcuts'
import { livePreview, resolveImageUrl } from './livepreview'
import { tableEditing } from './table'
import { markdownTheme } from './theme'
@@ -33,6 +33,7 @@ export interface EditorStateOptions {
doc: string
spellcheck?: boolean
tabSize?: number
shortcuts?: KeyBinding[]
resolveImage?: (src: string) => string
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
onChange?: (update: ViewUpdate) => void
@@ -51,23 +52,15 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
pluginCompartment.of(pluginEditorExtensions()),
EditorView.exceptionSink.of(reportEditorFailure),
Prec.high(keymap.of(markdownKeymap)),
// 与 Typora 一致的格式快捷键
Prec.high(
keymap.of([
{ key: 'Mod-b', run: toggleBold, preventDefault: true },
{ key: 'Mod-i', run: toggleItalic, preventDefault: true },
{ key: 'Mod-k', run: insertLink, preventDefault: true },
]),
),
Prec.high(keymap.of(opts.shortcuts ?? defaultEditorKeys)),
markdownTheme,
livePreview,
codeBlockHighlight,
editorFocus,
tableEditing,
renderedBlocks,
// CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销;
// 与 Typora、VS Code 保持一致,所有平台都支持
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]),
// 可配置操作不再通过 CM 的默认绑定重复注册。
keymap.of(defaultKeymap.filter((binding) => binding.run !== selectAll)),
preferencesCompartment.of([
EditorState.tabSize.of(opts.tabSize ?? 4),
EditorView.contentAttributes.of({ spellcheck: String(opts.spellcheck ?? false) }),
+18
View File
@@ -0,0 +1,18 @@
import { indentLess, indentMore, redo, selectAll, undo } from '@codemirror/commands'
import type { Command, KeyBinding } from '@codemirror/view'
import { selectCodeBlock } from './codeblocks'
import { insertLink, toggleBold, toggleHeading, toggleItalic } from './format'
export const editorCommands: { id: string; title: string; keys: string[]; run: Command }[] = [
{ id: 'editor.bold', title: '粗体', keys: ['Mod+B'], run: toggleBold },
{ id: 'editor.italic', title: '斜体', keys: ['Mod+I'], run: toggleItalic },
{ id: 'editor.link', title: '插入链接', keys: ['Mod+K'], run: insertLink },
{ id: 'editor.undo', title: '撤销', keys: ['Mod+Z'], run: undo },
{ id: 'editor.redo', title: '重做', keys: ['Mod+Shift+Z', 'Mod+Y'], run: redo },
{ id: 'editor.indent', title: '增加缩进', keys: ['Tab'], run: indentMore },
{ id: 'editor.outdent', title: '减少缩进', keys: ['Shift+Tab'], run: indentLess },
{ id: 'editor.selectAll', title: '全选(代码块内优先选中代码块)', keys: ['Mod+A'], run: (view) => selectCodeBlock(view) || selectAll(view) },
...Array.from({ length: 6 }, (_, i) => ({ id: `editor.heading${i + 1}`, title: `设置 ${i + 1} 级标题`, keys: [`Ctrl+Alt+${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 })))