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 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { availableCommands, formatKeys } from './commands'
|
||||
import { availableCommands, commandKeys, formatKeys } from './commands'
|
||||
import { fuzzyMatch, segments } from './fuzzy'
|
||||
import Icon from './Icon.vue'
|
||||
import { dirname, NOTE_EXT, noteTitle } from './paths'
|
||||
@@ -116,7 +116,7 @@ function commandItems(q: string): Item[] {
|
||||
key: c.id,
|
||||
icon: 'command',
|
||||
title: c.title,
|
||||
detail: c.keys ? formatKeys(c.keys[0]) : '',
|
||||
detail: commandKeys(c).length ? formatKeys(commandKeys(c)[0]) : '',
|
||||
titleHits: new Set(m!.positions),
|
||||
detailHits: new Set(),
|
||||
run: () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, ApiError } from './api'
|
||||
import { allCommands, formatKeys, runCommand } from './commands'
|
||||
import { runCommand } from './commands'
|
||||
import Icon, { type IconName } from './Icon.vue'
|
||||
import AppearanceSettings from './AppearanceSettings.vue'
|
||||
import PluginsPanel from './PluginsPanel.vue'
|
||||
import { installApp, installPrompt } from './pwa'
|
||||
import { CONTENT_WIDTHS, TAB_SIZES, loadSettings, settings, updateSettings, type Settings } from './settings'
|
||||
import ShortcutSettings from './ShortcutSettings.vue'
|
||||
import SharePanel from './SharePanel.vue'
|
||||
import TokensPanel from './TokensPanel.vue'
|
||||
import TrashPanel from './TrashPanel.vue'
|
||||
@@ -27,27 +28,10 @@ const SECTIONS: { id: SettingsSection; title: string; icon: IconName }[] = [
|
||||
const buildVersion = import.meta.env.DEV ? '开发版' : document.querySelector<HTMLMetaElement>('meta[name="rawmark-version"]')?.content
|
||||
if (!buildVersion) throw new Error('缺少应用构建版本信息')
|
||||
|
||||
// 编辑器内置的按键(不在命令注册表中)
|
||||
const EDITOR_KEYS = [
|
||||
{ title: '粗体', keys: 'Mod+B' },
|
||||
{ title: '斜体', keys: 'Mod+I' },
|
||||
{ title: '插入链接', keys: 'Mod+K' },
|
||||
{ title: '撤销', keys: 'Mod+Z' },
|
||||
{ title: '重做', keys: 'Mod+Shift+Z' },
|
||||
{ title: '缩进 / 取消缩进', keys: 'Tab / Shift+Tab' },
|
||||
{ title: '全选(代码块内先选中整个代码块)', keys: 'Mod+A' },
|
||||
]
|
||||
|
||||
const section = computed(() => (ws.settings === 'index' ? 'appearance' : ws.settings!))
|
||||
// 手机上一次只显示分组列表或分组内容
|
||||
const showList = ref(ws.settings === 'index')
|
||||
const current = computed(() => SECTIONS.find((s) => s.id === section.value)!)
|
||||
const shortcuts = computed(() => [
|
||||
...allCommands()
|
||||
.filter((c) => c.keys?.length)
|
||||
.map((c) => ({ title: c.title, keys: c.keys!.map(formatKeys).join(' / ') })),
|
||||
...EDITOR_KEYS.map((k) => ({ title: k.title, keys: k.keys.split(' / ').map(formatKeys).join(' / ') })),
|
||||
])
|
||||
|
||||
function open(id: SettingsSection) {
|
||||
ws.settings = id
|
||||
@@ -181,12 +165,7 @@ async function changePassword() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-else-if="section === 'shortcuts'" class="rm-shortcuts">
|
||||
<tr v-for="s in shortcuts" :key="s.title">
|
||||
<td>{{ s.title }}</td>
|
||||
<td><kbd>{{ s.keys }}</kbd></td>
|
||||
</tr>
|
||||
</table>
|
||||
<ShortcutSettings v-else-if="section === 'shortcuts'" />
|
||||
|
||||
<SharePanel v-else-if="section === 'shares'" :path="null" />
|
||||
<TokensPanel v-else-if="section === 'tokens'" />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { allCommands, commandKeys, formatKeys, shortcutConflict, type Command } from './commands'
|
||||
import { recordedKeys } from '../keys'
|
||||
import { loadSettings, settings, updateSettings } from './settings'
|
||||
import { toast } from './workspace'
|
||||
import Icon from './Icon.vue'
|
||||
|
||||
const commands = computed(allCommands)
|
||||
const recording = ref<string | null>(null)
|
||||
const recorder = ref<HTMLInputElement>()
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function save(shortcuts: Record<string, string[]>) {
|
||||
busy.value = true
|
||||
try { await updateSettings({ shortcuts }) }
|
||||
catch (cause) {
|
||||
toast('保存快捷键失败:' + (cause instanceof Error ? cause.message : String(cause)))
|
||||
await loadSettings().catch((cause) => toast('重新读取设置失败:' + String(cause)))
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function record(id: string) {
|
||||
error.value = ''
|
||||
recording.value = id
|
||||
await nextTick()
|
||||
recorder.value?.focus()
|
||||
}
|
||||
|
||||
async function capture(event: KeyboardEvent, command: Command) {
|
||||
if (event.key === 'Escape') { recording.value = null; return }
|
||||
const key = recordedKeys(event)
|
||||
if (!key) return
|
||||
const conflict = shortcutConflict(command.id, [key])
|
||||
if (conflict) { error.value = `与“${conflict.title}”冲突,请换一个快捷键`; return }
|
||||
const keys = [...new Set([...commandKeys(command), key])]
|
||||
recording.value = null
|
||||
await save({ ...settings.shortcuts, [command.id]: keys })
|
||||
}
|
||||
|
||||
async function reset(command: Command) {
|
||||
const conflict = shortcutConflict(command.id, command.keys ?? [])
|
||||
if (conflict) { error.value = `默认快捷键与“${conflict.title}”冲突,请先调整该命令`; return }
|
||||
const next = { ...settings.shortcuts }
|
||||
delete next[command.id]
|
||||
error.value = ''
|
||||
await save(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-shortcut-settings">
|
||||
<button class="rm-btn" :disabled="busy" @click="recording = null; error = ''; save({})">重置全部快捷键</button>
|
||||
<p class="rm-share-note">点击添加后按下组合键,Esc 取消;× 移除绑定,重置恢复默认。设置在所有设备间同步。</p>
|
||||
<p v-if="error" role="alert" class="rm-auth-error">{{ error }}</p>
|
||||
<div v-for="command in commands" :key="command.id" class="rm-shortcut-row">
|
||||
<span>{{ command.title }}</span>
|
||||
<div class="rm-shortcut-bindings">
|
||||
<span v-for="(key, index) in commandKeys(command)" :key="key" class="rm-shortcut-chip">
|
||||
<kbd>{{ formatKeys(key) }}</kbd>
|
||||
<button class="rm-icon-btn" :disabled="busy" :aria-label="`移除${command.title}的${formatKeys(key)}`" @click="save({ ...settings.shortcuts, [command.id]: commandKeys(command).filter((_, i) => i !== index) })"><Icon name="close" :size="12" /></button>
|
||||
</span>
|
||||
<input v-if="recording === command.id" :ref="(el) => { recorder = el as HTMLInputElement }" data-shortcut-recorder readonly aria-label="按下快捷键" placeholder="按下快捷键…" @keydown.prevent.stop="capture($event, command)" @blur="recording = null" />
|
||||
<button v-else class="rm-btn" :disabled="busy" @click="record(command.id)">添加</button>
|
||||
<button class="rm-btn" :disabled="busy" @click="reset(command)">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -538,3 +538,9 @@ button {
|
||||
|
||||
.rm-font-input input { width: 80px; padding: 7px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); }
|
||||
.rm-tab-menu { right: auto; width: max-content; max-width: calc(100vw - 8px); }
|
||||
|
||||
.rm-shortcut-settings { padding: 12px 20px; }
|
||||
.rm-shortcut-row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--rm-divider); align-items: center; font-size: 13px; }
|
||||
.rm-shortcut-bindings { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.rm-shortcut-chip { display: inline-flex; align-items: center; gap: 3px; padding-left: 6px; border: 1px solid var(--rm-divider); border-radius: 5px; }
|
||||
.rm-shortcut-bindings input { width: 130px; padding: 8px; color: var(--rm-text); background: var(--rm-bg); border: 1px solid var(--rm-accent); }
|
||||
+10
-17
@@ -1,5 +1,8 @@
|
||||
// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
|
||||
import { shallowReactive } from 'vue'
|
||||
import { settings } from './settings'
|
||||
import { matchKeys, physicalKeys } from '../keys'
|
||||
export { formatKeys, matchKeys } from '../keys'
|
||||
|
||||
export interface Command {
|
||||
id: string
|
||||
@@ -42,30 +45,20 @@ export function runCommand(id: string) {
|
||||
if (cmd && (cmd.when?.() ?? true)) cmd.run()
|
||||
}
|
||||
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
|
||||
export function matchKeys(e: KeyboardEvent, keys: string): boolean {
|
||||
const parts = keys.split('+')
|
||||
const key = parts.pop()!.toLowerCase()
|
||||
return (
|
||||
e.key.toLowerCase() === key &&
|
||||
(isMac ? e.metaKey : e.ctrlKey) === parts.includes('Mod') &&
|
||||
e.shiftKey === parts.includes('Shift') &&
|
||||
e.altKey === parts.includes('Alt')
|
||||
)
|
||||
export function commandKeys(command: Command): string[] {
|
||||
return settings.shortcuts[command.id] ?? command.keys ?? []
|
||||
}
|
||||
|
||||
export function formatKeys(keys: string): string {
|
||||
const parts = keys.split('+')
|
||||
if (isMac) return parts.map((p) => ({ Mod: '⌘', Shift: '⇧', Alt: '⌥' })[p] ?? p).join('')
|
||||
return parts.map((p) => (p === 'Mod' ? 'Ctrl' : p)).join('+')
|
||||
export function shortcutConflict(id: string, keys: string[]): Command | undefined {
|
||||
return allCommands().find((command) => command.id !== id && commandKeys(command).some((existing) => keys.some((key) => physicalKeys(existing) === physicalKeys(key))))
|
||||
}
|
||||
|
||||
/** 全局快捷键分发;编辑器等已处理的按键(defaultPrevented)和输入法组字中的按键不处理 */
|
||||
export function handleShortcut(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || e.isComposing) return
|
||||
if (e.defaultPrevented || e.isComposing || e.getModifierState('AltGraph') || (e.target instanceof Element && e.target.closest('[data-shortcut-recorder]'))) return
|
||||
for (const cmd of availableCommands()) {
|
||||
if (cmd.keys?.some((k) => matchKeys(e, k))) {
|
||||
if (cmd.id.startsWith('editor.') && !(e.target instanceof Element && e.target.closest('.cm-content'))) continue
|
||||
if (commandKeys(cmd).some((k) => matchKeys(e, k))) {
|
||||
e.preventDefault()
|
||||
cmd.run()
|
||||
return
|
||||
|
||||
+14
-1
@@ -2,6 +2,7 @@
|
||||
// 本地缓存上次的值,启动时先套用,避免页面先按默认值显示再跳变
|
||||
import { reactive } from 'vue'
|
||||
import { api } from './api'
|
||||
import { normalizeKeys } from '../keys'
|
||||
import { applyAppearance, appearancePaused, sanitizeAppearance, type Appearance } from '../themes/appearance'
|
||||
|
||||
export const TAB_SIZES = [2, 4, 8]
|
||||
@@ -16,7 +17,7 @@ export const CONTENT_WIDTHS = [
|
||||
export const SIDEBAR_WIDTH = { min: 200, max: 480 }
|
||||
|
||||
const DEFAULTS = { fontSize: 16, contentWidth: 760, sidebarWidth: 260, spellcheck: false, tabSize: 4 }
|
||||
export type Settings = typeof DEFAULTS & Appearance
|
||||
export type Settings = typeof DEFAULTS & Appearance & { shortcuts: Record<string, string[]> }
|
||||
|
||||
const CACHE_KEY = 'rawmark.settings'
|
||||
|
||||
@@ -27,6 +28,7 @@ function sanitize(raw: Record<string, unknown>): Settings {
|
||||
const pick = <T>(v: unknown, allowed: T[], def: T) => (allowed.includes(v as T) ? (v as T) : def)
|
||||
return {
|
||||
fontSize: typeof raw.fontSize === 'number' && Number.isFinite(raw.fontSize) && raw.fontSize > 0 ? raw.fontSize : DEFAULTS.fontSize,
|
||||
shortcuts: sanitizeShortcuts(raw.shortcuts),
|
||||
spellcheck: typeof raw.spellcheck === 'boolean' ? raw.spellcheck : DEFAULTS.spellcheck,
|
||||
tabSize: pick(raw.tabSize, TAB_SIZES, DEFAULTS.tabSize),
|
||||
...sanitizeAppearance(raw),
|
||||
@@ -42,6 +44,17 @@ function sanitize(raw: Record<string, unknown>): Settings {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeShortcuts(raw: unknown): Record<string, string[]> {
|
||||
if (raw === undefined) return {}
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('快捷键设置格式无效')
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [id, keys] of Object.entries(raw)) {
|
||||
if (!Array.isArray(keys) || keys.some((key) => typeof key !== 'string')) throw new Error('快捷键设置格式无效')
|
||||
out[id] = keys.map(normalizeKeys)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let currentTheme = ''
|
||||
let currentCSS = ''
|
||||
let appearanceRequest = 0
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { nextTick, reactive, watch } from 'vue'
|
||||
import { editorCommands } from '../editor/shortcuts'
|
||||
import { refreshImages } from '../editor/livepreview'
|
||||
import { createEditorState, insertImageFiles, pasteImages, languageCompartment, pluginCompartment, pluginEditorExtensions, pluginLanguage, preferencesCompartment } from '../editor'
|
||||
import { handleShortcut, registerCommand } from './commands'
|
||||
@@ -181,6 +182,7 @@ function buildState(path: string, doc: string): EditorState {
|
||||
noteRefs.set(path, ref)
|
||||
return createEditorState({
|
||||
doc,
|
||||
shortcuts: [],
|
||||
spellcheck: settings.spellcheck,
|
||||
tabSize: settings.tabSize,
|
||||
resolveImage: (src) => {
|
||||
@@ -1021,7 +1023,7 @@ export async function startWorkspace() {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!readyToReload()) event.preventDefault()
|
||||
})
|
||||
window.addEventListener('keydown', handleShortcut)
|
||||
window.addEventListener('keydown', handleShortcut, true)
|
||||
// 软键盘弹出、编辑区变矮后,让光标保持可见
|
||||
trackVisualViewport(() => {
|
||||
if (view?.hasFocus) view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'nearest', yMargin: 24 }) })
|
||||
@@ -1032,6 +1034,10 @@ export async function startWorkspace() {
|
||||
registerBuiltinCommands()
|
||||
|
||||
function registerBuiltinCommands() {
|
||||
for (const command of editorCommands) registerCommand({ ...command,
|
||||
when: () => ws.settings === null && ws.active !== null && !!view,
|
||||
run: () => { if (view) { command.run(view); view.focus() } },
|
||||
})
|
||||
const hasNote = () => ws.active !== null
|
||||
const toggleSwitcher = (initial: string) => {
|
||||
ws.switcher = ws.switcher === null ? initial : null
|
||||
|
||||
@@ -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 })))
|
||||
@@ -0,0 +1,16 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { matchKeys, recordedKeys } from './keys'
|
||||
|
||||
describe('快捷键匹配与录入', () => {
|
||||
it('精确匹配 Ctrl+Alt 数字,允许 Alt 改变字符而不改变物理数字键', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: '¡', code: 'Digit1', ctrlKey: true, altKey: true })
|
||||
expect(matchKeys(event, 'Ctrl+Alt+1')).toBe(true)
|
||||
expect(matchKeys(event, 'Ctrl+1')).toBe(false)
|
||||
expect(matchKeys(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, metaKey: true }), 'Ctrl+B')).toBe(false)
|
||||
})
|
||||
it('不会把单独的修饰键录成快捷键', () => {
|
||||
expect(recordedKeys(new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true }))).toBeNull()
|
||||
expect(recordedKeys(new KeyboardEvent('keydown', { key: '+', ctrlKey: true, shiftKey: true }))).toContain('Plus')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// 应用、编辑器和快捷键录入共用同一套按键表示。
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
const modifiers = ['Mod', 'Ctrl', 'Meta', 'Alt', 'Shift']
|
||||
|
||||
export function normalizeKeys(value: string): string {
|
||||
const parts = value.split('+')
|
||||
const key = parts.pop()
|
||||
if (!key || parts.some((part) => !modifiers.includes(part)) || new Set(parts).size !== parts.length || modifiers.includes(key)) throw new Error('快捷键格式无效')
|
||||
return [...modifiers.filter((part) => parts.includes(part)), key.length === 1 ? key.toUpperCase() : key].join('+')
|
||||
}
|
||||
|
||||
export function physicalKeys(value: string): string {
|
||||
return normalizeKeys(value.replace(/\bMod\b/g, isMac ? 'Meta' : 'Ctrl'))
|
||||
}
|
||||
|
||||
export function matchKeys(event: KeyboardEvent, value: string): boolean {
|
||||
const parts = physicalKeys(value).split('+')
|
||||
const key = parts.pop()!
|
||||
const actual = event.key === ' ' ? 'Space' : event.key === '+' ? 'Plus' : event.key
|
||||
const digit = /^Digit[0-9]$/.test(event.code) ? event.code.slice(5) : null
|
||||
return (actual.toUpperCase() === key.toUpperCase() || digit === key) &&
|
||||
event.ctrlKey === parts.includes('Ctrl') && event.metaKey === parts.includes('Meta') &&
|
||||
event.altKey === parts.includes('Alt') && event.shiftKey === parts.includes('Shift')
|
||||
}
|
||||
|
||||
export function recordedKeys(event: KeyboardEvent): string | null {
|
||||
if (['Control', 'Meta', 'Alt', 'Shift', 'AltGraph', 'Dead', 'Process', 'Unidentified'].includes(event.key) || event.isComposing) return null
|
||||
const parts: string[] = []
|
||||
if (event.ctrlKey) parts.push(isMac ? 'Ctrl' : 'Mod')
|
||||
if (event.metaKey) parts.push(isMac ? 'Mod' : 'Meta')
|
||||
if (event.altKey) parts.push('Alt')
|
||||
if (event.shiftKey) parts.push('Shift')
|
||||
const key = /^Digit[0-9]$/.test(event.code) ? event.code.slice(5) : event.key === ' ' ? 'Space' : event.key === '+' ? 'Plus' : event.key
|
||||
return normalizeKeys([...parts, key].join('+'))
|
||||
}
|
||||
|
||||
export function formatKeys(value: string): string {
|
||||
return value.split('+').map((part) => ({ Mod: isMac ? '⌘' : 'Ctrl', Meta: isMac ? '⌘' : 'Win', Ctrl: isMac ? '⌃' : 'Ctrl', Alt: isMac ? '⌥' : 'Alt', Shift: isMac ? '⇧' : 'Shift', Space: '空格', Plus: '+' })[part] ?? part).join(isMac ? '' : '+')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { reactive } from 'vue'
|
||||
import { api, type InstalledPlugin } from '../app/api'
|
||||
import { allCommands, registerCommand } from '../app/commands'
|
||||
import { allCommands, registerCommand, shortcutConflict } from '../app/commands'
|
||||
import type { AsyncCleanup, JSONValue, PluginAPI, PluginView, PluginWorkspace, VaultEvents } from '../plugin-api'
|
||||
import { editorExtensions, markdownSyntax, pluginEvents, pluginsPaused, registerCodeRenderer, registerEntry, settingTabs, sidebarViews } from './registry'
|
||||
|
||||
@@ -98,7 +98,7 @@ async function createAPI(instance: Instance): Promise<PluginAPI> {
|
||||
check()
|
||||
const commandID = localID(instance, command.id)
|
||||
if (!command.title.trim() || typeof command.run !== 'function' || allCommands().some((c) => c.id === commandID)) throw new Error('命令无效或重复注册')
|
||||
if (command.keys?.some((key) => allCommands().some((c) => c.keys?.some((existing) => existing.toLowerCase() === key.toLowerCase())))) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`)
|
||||
if (shortcutConflict(commandID, command.keys ?? [])) adapter.notice(`插件“${name}”部分快捷键与其他命令冲突,请调整`)
|
||||
return track(instance, registerCommand({ ...command, id: commandID,
|
||||
when: () => {
|
||||
if (instance.stopped) return false
|
||||
|
||||
Reference in new issue
Block a user