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

+2 -2
View File
@@ -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: () => {
+3 -24
View File
@@ -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'" />
+70
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -1
View File
@@ -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
+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 })))
+16
View File
@@ -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')
})
})
+39
View File
@@ -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 ? '' : '+')
}
+2 -2
View File
@@ -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