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