diff --git a/web/src/app/Icon.vue b/web/src/app/Icon.vue
index ade9fa2..2712354 100644
--- a/web/src/app/Icon.vue
+++ b/web/src/app/Icon.vue
@@ -14,6 +14,7 @@ const paths = {
restore: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5',
edit: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-4.35-4.35',
+ command: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 0 0 0-6z',
}
defineProps<{ name: keyof typeof paths; size?: number }>()
diff --git a/web/src/app/QuickSwitcher.vue b/web/src/app/QuickSwitcher.vue
index 83e0ee0..da90c83 100644
--- a/web/src/app/QuickSwitcher.vue
+++ b/web/src/app/QuickSwitcher.vue
@@ -1,30 +1,38 @@
@@ -51,12 +58,14 @@ onBeforeUnmount(stopWorkspace)
{{ title }}
-
+
+
-
+
+
diff --git a/web/src/app/app.css b/web/src/app/app.css
index 89d6d12..1701c61 100644
--- a/web/src/app/app.css
+++ b/web/src/app/app.css
@@ -217,6 +217,7 @@ button {
.rm-switcher-item.active { background: var(--rm-active); }
.rm-switcher-title { color: var(--rm-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-switcher-dir { flex: 1; min-width: 0; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }
+.rm-switcher-keys span { padding: 1px 6px; border: 1px solid var(--rm-divider); border-radius: 4px; font-family: inherit; }
.rm-sidebar-foot { display: flex; gap: 4px; padding: 8px; border-top: 1px solid var(--rm-divider); }
.rm-foot-btn {
flex: 1;
diff --git a/web/src/app/commands.ts b/web/src/app/commands.ts
new file mode 100644
index 0000000..754cf50
--- /dev/null
+++ b/web/src/app/commands.ts
@@ -0,0 +1,62 @@
+// 命令注册表:命令面板列出的操作与全局快捷键都来自这里;插件也通过 registerCommand 添加命令
+import { shallowReactive } from 'vue'
+
+export interface Command {
+ id: string
+ title: string
+ /** 快捷键,如 'Mod+Shift+F'、'F1';Mod 在 Mac 上为 Cmd,其他平台为 Ctrl。第一个用于显示 */
+ keys?: string[]
+ /** 返回 false 时命令不可用:不在面板中列出,快捷键也不触发 */
+ when?: () => boolean
+ run: () => void
+}
+
+const commands = shallowReactive(new Map())
+
+/** 注册命令,返回注销函数;id 重复时后注册的覆盖先注册的 */
+export function registerCommand(cmd: Command): () => void {
+ commands.set(cmd.id, cmd)
+ return () => {
+ if (commands.get(cmd.id) === cmd) commands.delete(cmd.id)
+ }
+}
+
+export function availableCommands(): Command[] {
+ return [...commands.values()].filter((c) => c.when?.() ?? true)
+}
+
+export function runCommand(id: string) {
+ const cmd = commands.get(id)
+ 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 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('+')
+}
+
+/** 全局快捷键分发;编辑器等已处理的按键(defaultPrevented)和输入法组字中的按键不处理 */
+export function handleShortcut(e: KeyboardEvent) {
+ if (e.defaultPrevented || e.isComposing) return
+ for (const cmd of availableCommands()) {
+ if (cmd.keys?.some((k) => matchKeys(e, k))) {
+ e.preventDefault()
+ cmd.run()
+ return
+ }
+ }
+}
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index 97440f1..b62ddde 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -4,6 +4,7 @@ import { Annotation, Transaction, type EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { nextTick, reactive, watch } from 'vue'
import { createEditorState, pasteImages } from '../editor'
+import { handleShortcut, registerCommand } from './commands'
import { api, ApiError, type Entry } from './api'
import { drafts } from './drafts'
import { connectEvents, type VaultEvent } from './events'
@@ -42,8 +43,9 @@ export const ws = reactive({
sidebarOpen: false,
// 侧栏显示搜索而非目录树
searchOpen: false,
- // 快速切换对话框
- switcherOpen: false,
+ // 快速切换/命令面板的初始输入,null 为关闭;以 > 开头时列出命令
+ switcher: null as string | null,
+ trashOpen: false,
// 最近打开的笔记,最新的在前
recent: JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[],
toasts: [] as Toast[],
@@ -732,21 +734,40 @@ export async function startWorkspace() {
if (document.visibilityState === 'hidden') void flushAll(true)
})
window.addEventListener('pagehide', () => void flushAll(true))
- window.addEventListener('keydown', (e) => {
- if (!(e.ctrlKey || e.metaKey)) return
- const key = e.key.toLowerCase()
- if (key === 's' && !e.shiftKey) {
- e.preventDefault()
- if (ws.active) void flush(ws.active)
- } else if (key === 'f' && e.shiftKey) {
- e.preventDefault()
- openSearch()
- } else if (key === 'p' && !e.shiftKey) {
- // 覆盖浏览器的打印快捷键
- e.preventDefault()
- ws.switcherOpen = !ws.switcherOpen
- }
- })
+ window.addEventListener('keydown', handleShortcut)
+}
+
+// 内置命令不依赖工作区状态,模块载入时注册,排在组件注册的命令(如退出登录)之前
+registerBuiltinCommands()
+
+function registerBuiltinCommands() {
+ const hasNote = () => ws.active !== null
+ const toggleSwitcher = (initial: string) => {
+ ws.switcher = ws.switcher === null ? initial : null
+ }
+ for (const cmd of [
+ // Mod+P 覆盖浏览器的打印;Firefox 不允许网页覆盖 Ctrl+Shift+P(隐私窗口),F1 作为通用的替代
+ { id: 'app.quickOpen', title: '快速打开笔记', keys: ['Mod+P'], run: () => toggleSwitcher('') },
+ { id: 'app.commandPalette', title: '命令面板', keys: ['Mod+Shift+P', 'F1'], run: () => toggleSwitcher('>') },
+ { id: 'app.search', title: '全文搜索', keys: ['Mod+Shift+F'], run: openSearch },
+ { id: 'note.new', title: '新建笔记', run: () => withSidebar(() => createNote('')) },
+ { id: 'folder.new', title: '新建文件夹', run: () => withSidebar(() => createFolder('')) },
+ { id: 'note.save', title: '保存当前笔记', keys: ['Mod+S'], when: hasNote, run: () => void flush(ws.active!) },
+ { id: 'note.rename', title: '重命名当前笔记', when: hasNote, run: () => withSidebar(() => (ws.renaming = ws.active)) },
+ { id: 'note.delete', title: '删除当前笔记', when: hasNote, run: () => void remove(ws.active!) },
+ // 浏览器不允许网页覆盖 Ctrl+W
+ { id: 'tab.close', title: '关闭当前标签页', when: hasNote, run: () => void closeTab(ws.active!) },
+ { id: 'app.trash', title: '打开回收站', run: () => (ws.trashOpen = true) },
+ ]) {
+ registerCommand(cmd)
+ }
+}
+
+// 需要在目录树中操作的命令:切回目录树,移动端打开侧栏
+function withSidebar(fn: () => unknown) {
+ ws.searchOpen = false
+ ws.sidebarOpen = true
+ void fn()
}
export function openSearch() {