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
|
||||
|
||||
Reference in new issue
Block a user