/** * useKeyboardShortcuts - 全局快捷键管理 Hook (v1.4.0 自定义快捷键) * * 在 App.tsx 挂载,处理全局键盘快捷键。 * 从 keybindingStore 读取用户自定义的键绑定,实现可配置的快捷键。 * * 默认快捷键: * - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签 * - Ctrl+1~9: 跳转到指定索引标签 * - Ctrl+W: 关闭当前标签 * - F11: 全屏切换 * - Ctrl+Shift+N: 新建标签 * - Ctrl+Shift+P: 打开命令面板 * - Ctrl+N: 新建会话 * - Ctrl+K: 快速连接 */ import { useEffect } from 'react' import { useTabStore } from '../stores/tabStore' import { useCommandPaletteStore } from '../stores/commandPaletteStore' import { useKeybindingStore } from '../stores/keybindingStore' import { KeybindingAction } from '../constants/keybindings' import { createLogger } from '../utils/logger' const logger = createLogger('KeyboardShortcuts') /** 检查事件目标是否为输入元素 */ function isInputElement(e: KeyboardEvent): boolean { const target = e.target as HTMLElement if (!target) return false const tagName = target.tagName.toLowerCase() return tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target.isContentEditable } /** Native text editing commands are reserved inside editable controls. */ function isNativeEditingShortcut(e: KeyboardEvent): boolean { if (!(e.ctrlKey || e.metaKey) || e.altKey) return false const key = e.key.toLowerCase() return ['a', 'c', 'v', 'x', 'z', 'y'].includes(key) } /** * 将键序列字符串 (如 "Ctrl+Shift+P") 匹配到 KeyboardEvent * 支持跨平台: Ctrl 和 Meta (Cmd) 均可触发 */ function matchesKeySequence(e: KeyboardEvent, keySequence: string): boolean { if (!keySequence) return false const parts = keySequence .split('+') .map((p) => p.trim()) .filter(Boolean) const modifiers: string[] = [] let mainKey = '' for (const part of parts) { const lower = part.toLowerCase() if (lower === 'ctrl' || lower === 'control' || lower === 'meta' || lower === 'cmd') { modifiers.push('ctrl') } else if (lower === 'shift') { modifiers.push('shift') } else if (lower === 'alt' || lower === 'option') { modifiers.push('alt') } else { mainKey = part } } const needsCtrl = modifiers.includes('ctrl') const needsShift = modifiers.includes('shift') const needsAlt = modifiers.includes('alt') // 跨平台: Ctrl (Win/Linux) 或 Meta (macOS Cmd) const ctrlOrMeta = e.ctrlKey || e.metaKey const eventHasCtrl = ctrlOrMeta const eventHasShift = e.shiftKey const eventHasAlt = e.altKey if (needsCtrl !== eventHasCtrl) return false if (needsShift !== eventHasShift) return false if (needsAlt !== eventHasAlt) return false // 匹配主键 if (!mainKey) return false // 特殊键映射 const keyLower = mainKey.toLowerCase() const eventKey = e.key switch (keyLower) { case 'tab': return eventKey === 'Tab' case 'enter': return eventKey === 'Enter' case 'escape': return eventKey === 'Escape' case 'space': return eventKey === ' ' case 'backspace': return eventKey === 'Backspace' case 'delete': return eventKey === 'Delete' default: // F-keys if (keyLower.startsWith('f') && keyLower.length <= 3) { return eventKey === mainKey } // 普通字母/数字 return eventKey.toLowerCase() === keyLower } } /** * 判断快捷键是否应该在输入框中触发 * 通过 keybindingStore 的 fireInInput 属性决定 */ function shouldFireInInput(e: KeyboardEvent): boolean { const store = useKeybindingStore.getState() const allBindings = store.getAllEffective() // 使用动态 import 避免的 KEYBINDING_DEFINITIONS // 直接检查已知的 fireInInput 操作 const fireAlways = [ KeybindingAction.TAB_NEXT, KeybindingAction.TAB_PREV, KeybindingAction.TAB_GOTO_N, KeybindingAction.VIEW_FULLSCREEN, KeybindingAction.TOOLS_COMMAND_PALETTE, ] for (const actionId of fireAlways) { if (matchesKeySequence(e, allBindings[actionId])) { return true } } return false } export function useKeyboardShortcuts(): void { useEffect(() => { // 加载自定义快捷键 useKeybindingStore.getState().loadFromSettings() const handleKeyDown = (e: KeyboardEvent) => { const ctrlOrMeta = e.ctrlKey || e.metaKey const store = useKeybindingStore.getState() const keys = store.getAllEffective() // Never let configurable application shortcuts shadow the platform's // select/copy/cut/paste/undo/redo behavior in text controls. if (isInputElement(e) && isNativeEditingShortcut(e)) return // ---- 命令面板 ---- if (matchesKeySequence(e, keys[KeybindingAction.TOOLS_COMMAND_PALETTE])) { e.preventDefault() useCommandPaletteStore.getState().toggle() return } // ---- Ctrl+Tab: 下一个标签 ---- if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEXT])) { e.preventDefault() useTabStore.getState().switchToNextTab() return } // ---- Ctrl+Shift+Tab: 上一个标签 ---- if (matchesKeySequence(e, keys[KeybindingAction.TAB_PREV])) { e.preventDefault() useTabStore.getState().switchToPrevTab() return } // ---- Ctrl+1~9: 跳转指定标签 ---- if (ctrlOrMeta && !e.shiftKey && !e.altKey) { const num = parseInt(e.key, 10) if (num >= 1 && num <= 9) { // 特殊处理: Ctrl+1~9 是范围键,不匹配单个键序列 const gotoKeys = keys[KeybindingAction.TAB_GOTO_N] if (gotoKeys.includes('…') || gotoKeys.includes('...')) { e.preventDefault() useTabStore.getState().activateTabByIndex(num - 1) return } } } // ---- F11: 全屏 ---- if (matchesKeySequence(e, keys[KeybindingAction.VIEW_FULLSCREEN])) { e.preventDefault() if (window.opsxApi?.settingsSet) { window.opsxApi .settingsGet('fullscreen') .then((res: { success: boolean; value?: unknown }) => { const isFullscreen = res?.value === true window.opsxApi?.settingsSet('fullscreen', !isFullscreen) }) .catch(() => { logger.warn('Failed to toggle fullscreen') }) } return } // ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ---- if (isInputElement(e) && !shouldFireInInput(e)) { return } // ---- Ctrl+W: 关闭标签 ---- if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) { e.preventDefault() const { tabs, activeTabId, closeTab } = useTabStore.getState() if (activeTabId && tabs.length > 1) { logger.debug('Ctrl+W: closing tab', activeTabId) closeTab(activeTabId) } return } // ---- Ctrl+Shift+N: 新建标签 ---- if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEW])) { e.preventDefault() useTabStore.getState().addTab({ title: '新标签', type: 'welcome' }) return } // ---- Ctrl+N: 新建会话 (在 App.tsx 通过 onMenuNewSession 处理) ---- if (matchesKeySequence(e, keys[KeybindingAction.SESSION_NEW])) { e.preventDefault() // 触发新建会话对话框 — 通过 CustomEvent 与 App 通信 window.dispatchEvent(new CustomEvent('opsxshell:new-session')) return } // ---- Ctrl+K: 快速连接 ---- if (matchesKeySequence(e, keys[KeybindingAction.SESSION_QUICK_CONNECT])) { e.preventDefault() window.dispatchEvent(new CustomEvent('opsxshell:quick-connect')) return } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, []) } // 导出匹配函数供测试使用 export { isNativeEditingShortcut, matchesKeySequence } export default useKeyboardShortcuts