From 919575f3e776cd5c71ade36bc2b4bc5d1c23a15a Mon Sep 17 00:00:00 2001 From: eezd Date: Sat, 17 Oct 2026 12:14:11 +0800 Subject: [PATCH] Add command palette and command registry - Commands (with optional shortcuts and availability checks) are registered in one place; global shortcuts are dispatched from it, and plugins can use registerCommand later - The quick switcher lists commands when the input starts with '>' (VS Code style), opened with Ctrl+Shift+P or F1 (Firefox reserves Ctrl+Shift+P), plus buttons in the sidebar footer and the mobile top bar; recently used commands come first - Built-in commands: quick open, search, new note/folder, save, rename, delete, close tab, trash, log out - Mount the trash dialog at the workspace level so it is not positioned inside the transformed mobile drawer --- web/src/app/Icon.vue | 1 + web/src/app/QuickSwitcher.vue | 134 +++++++++++++++++++++------------- web/src/app/Sidebar.vue | 6 +- web/src/app/Workspace.vue | 15 +++- web/src/app/app.css | 1 + web/src/app/commands.ts | 62 ++++++++++++++++ web/src/app/workspace.ts | 55 +++++++++----- 7 files changed, 201 insertions(+), 73 deletions(-) create mode 100644 web/src/app/commands.ts 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 @@ 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() {