feat(keybindings): 快捷键自定义系统 — store+冲突检测+交互式重新绑定UI | 13 files +1032/-137 | 72 tests passing tsc零错误
This commit is contained in:
1 parent
514d62644e
commit
3f636b1b0e
13 files changed
+1033
-138
No files matched your search
@@ -1,18 +1,25 @@
|
||||
/**
|
||||
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.3.0)
|
||||
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.4.0 自定义快捷键)
|
||||
*
|
||||
* 在 App.tsx 挂载,处理全局键盘快捷键:
|
||||
* 在 App.tsx 挂载,处理全局键盘快捷键。
|
||||
* 从 keybindingStore 读取用户自定义的键绑定,实现可配置的快捷键。
|
||||
*
|
||||
* 默认快捷键:
|
||||
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
|
||||
* - Ctrl+1~9: 跳转到指定索引标签
|
||||
* - Ctrl+W: 关闭当前标签
|
||||
* - F11: 全屏切换
|
||||
* - Ctrl+Shift+N: 新建标签
|
||||
* - Ctrl+Shift+P: 打开命令面板 (通过 commandPaletteStore)
|
||||
* - 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')
|
||||
@@ -31,63 +38,141 @@ function isInputElement(e: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断快捷键是否应该在输入框中触发
|
||||
* Ctrl+Tab/Ctrl+Shift+Tab 等导航类快捷键始终生效
|
||||
* 将键序列字符串 (如 "Ctrl+Shift+P") 匹配到 KeyboardEvent
|
||||
* 支持跨平台: Ctrl 和 Meta (Cmd) 均可触发
|
||||
*/
|
||||
function shouldSkipInInput(e: KeyboardEvent): boolean {
|
||||
// Ctrl+Tab 系列:始终生效(浏览器级别标签切换)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') return false
|
||||
// F11:始终生效
|
||||
if ( e.key === 'F11') return false
|
||||
// Ctrl+Shift+P:命令面板始终生效
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'p' || e.key === 'P')) return false
|
||||
// 其他快捷键在输入框中不触发
|
||||
return true
|
||||
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()
|
||||
|
||||
// ---- Ctrl+Shift+P: 切换命令面板 (通过 commandPaletteStore) ----
|
||||
if (ctrlOrMeta && e.shiftKey && (e.key === 'p' || e.key === 'P')) {
|
||||
// ---- 命令面板 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TOOLS_COMMAND_PALETTE])) {
|
||||
e.preventDefault()
|
||||
useCommandPaletteStore.getState().toggle()
|
||||
return
|
||||
}
|
||||
|
||||
// ---- Ctrl+Tab: 切换到下一个标签 ----
|
||||
if (ctrlOrMeta && e.key === 'Tab' && !e.shiftKey) {
|
||||
// ---- Ctrl+Tab: 下一个标签 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEXT])) {
|
||||
e.preventDefault()
|
||||
const { switchToNextTab } = useTabStore.getState()
|
||||
switchToNextTab()
|
||||
useTabStore.getState().switchToNextTab()
|
||||
return
|
||||
}
|
||||
|
||||
// ---- Ctrl+Shift+Tab: 切换到上一个标签 ----
|
||||
if (ctrlOrMeta && e.key === 'Tab' && e.shiftKey) {
|
||||
// ---- Ctrl+Shift+Tab: 上一个标签 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_PREV])) {
|
||||
e.preventDefault()
|
||||
const { switchToPrevTab } = useTabStore.getState()
|
||||
switchToPrevTab()
|
||||
useTabStore.getState().switchToPrevTab()
|
||||
return
|
||||
}
|
||||
|
||||
// ---- Ctrl+1~9: 跳转到指定索引标签 ----
|
||||
// ---- Ctrl+1~9: 跳转指定标签 ----
|
||||
if (ctrlOrMeta && !e.shiftKey && !e.altKey) {
|
||||
const num = parseInt(e.key, 10)
|
||||
if (num >= 1 && num <= 9) {
|
||||
e.preventDefault()
|
||||
const { activateTabByIndex } = useTabStore.getState()
|
||||
activateTabByIndex(num - 1) // 0-indexed
|
||||
return
|
||||
// 特殊处理: 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 (e.key === 'F11') {
|
||||
// ---- F11: 全屏 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_FULLSCREEN])) {
|
||||
e.preventDefault()
|
||||
// Electron 渲染进程通过 IPC 请求全屏切换
|
||||
if (window.electronAPI?.settingsSet) {
|
||||
window.electronAPI.settingsGet('fullscreen').then((res: { success: boolean; value?: unknown }) => {
|
||||
const isFullscreen = res?.value === true
|
||||
@@ -99,13 +184,13 @@ export function useKeyboardShortcuts(): void {
|
||||
return
|
||||
}
|
||||
|
||||
// ---- 以下快捷键在输入框中不触发 ----
|
||||
if (isInputElement(e) && shouldSkipInInput(e)) {
|
||||
// ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ----
|
||||
if (isInputElement(e) && !shouldFireInInput(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
// ---- Ctrl+W: 关闭当前标签 ----
|
||||
if (ctrlOrMeta && e.key === 'w') {
|
||||
// ---- Ctrl+W: 关闭标签 ----
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
|
||||
e.preventDefault()
|
||||
const { tabs, activeTabId, closeTab } = useTabStore.getState()
|
||||
if (activeTabId && tabs.length > 1) {
|
||||
@@ -116,10 +201,24 @@ export function useKeyboardShortcuts(): void {
|
||||
}
|
||||
|
||||
// ---- Ctrl+Shift+N: 新建标签 ----
|
||||
if (ctrlOrMeta && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
|
||||
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEW])) {
|
||||
e.preventDefault()
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: '新标签', type: 'welcome' })
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -129,4 +228,7 @@ export function useKeyboardShortcuts(): void {
|
||||
}, [])
|
||||
}
|
||||
|
||||
// 导出匹配函数供测试使用
|
||||
export { matchesKeySequence }
|
||||
|
||||
export default useKeyboardShortcuts
|
||||
Reference in new issue
Block a user