Allow custom command shortcuts and heading bindings
This commit is contained in:
1 parent
7344bb19dc
commit
fbce545d98
15 files changed
+226
-66
No files matched your search
@@ -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 }])),
|
||||
]
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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) }),
|
||||
|
||||
@@ -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 })))
|
||||
Reference in new issue
Block a user