From 14e3185664ad79537690965a6cd2f2140e1f8b44 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sat, 27 Dec 2025 23:56:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BF=AB=E6=8D=B7=E9=94=AE):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=85=A8=E5=B1=80=E5=BF=AB=E6=8D=B7=E9=94=AE=20Provid?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现全局键盘事件监听器 - 支持快捷键注册和注销机制 - 处理 IME 输入法状态检测 - 支持输入框内快捷键过滤 - 内置 ? 键显示帮助面板 - 内置 Esc 键关闭弹窗 --- src/providers/HotkeysProvider.tsx | 270 ++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/providers/HotkeysProvider.tsx diff --git a/src/providers/HotkeysProvider.tsx b/src/providers/HotkeysProvider.tsx new file mode 100644 index 0000000..0ffc38e --- /dev/null +++ b/src/providers/HotkeysProvider.tsx @@ -0,0 +1,270 @@ +'use client'; + +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + useRef, +} from 'react'; +import type { + HotkeyConfig, + HotkeysContextType, + RegisteredHotkey, + ModifierKey, +} from '@/types/hotkeys'; +import { HotkeysHelper } from '@/components/ui/HotkeysHelper'; + +// 创建 Context +const HotkeysContext = createContext(null); + +/** + * 检查当前焦点是否在输入元素中 + */ +function isInputElement(target: EventTarget | null): boolean { + if (!target || !(target instanceof HTMLElement)) return false; + + const tagName = target.tagName.toLowerCase(); + + // 检查是否是输入类型元素 + if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { + return true; + } + + // 检查是否是 contentEditable 元素 + if (target.isContentEditable) { + return true; + } + + // 检查父元素是否是 contentEditable + const closestEditable = target.closest('[contenteditable="true"]'); + if (closestEditable) { + return true; + } + + return false; +} + +/** + * 检查修饰键是否匹配 + */ +function checkModifiers( + event: KeyboardEvent, + modifiers: ModifierKey[] = [] +): boolean { + const hasCtrl = modifiers.includes('ctrl'); + const hasMeta = modifiers.includes('meta'); + const hasAlt = modifiers.includes('alt'); + const hasShift = modifiers.includes('shift'); + + // 对于 ctrl 和 meta,我们允许任一匹配(跨平台兼容) + const ctrlOrMetaRequired = hasCtrl || hasMeta; + const ctrlOrMetaPressed = event.ctrlKey || event.metaKey; + + if (ctrlOrMetaRequired && !ctrlOrMetaPressed) return false; + if (!ctrlOrMetaRequired && ctrlOrMetaPressed) return false; + + if (hasAlt !== event.altKey) return false; + if (hasShift !== event.shiftKey) return false; + + return true; +} + +/** + * 检查快捷键是否匹配 + */ +function matchHotkey( + event: KeyboardEvent, + config: HotkeyConfig, + isInInput: boolean +): boolean { + // 检查是否启用 + if (config.enabled !== undefined) { + const isEnabled = + typeof config.enabled === 'function' ? config.enabled() : config.enabled; + if (!isEnabled) return false; + } + + // 检查作用域 + const hasModifiers = config.modifiers && config.modifiers.length > 0; + + // 如果在输入框中 + if (isInInput) { + // 单键快捷键(无修饰键)在输入框中不触发 + if (!hasModifiers && !config.allowInInput) { + return false; + } + // 有修饰键的快捷键默认也不在输入框触发,除非明确设置 allowInInput + if (hasModifiers && !config.allowInInput) { + return false; + } + } + + // 检查按键是否匹配(不区分大小写) + const eventKey = event.key.toLowerCase(); + const configKey = config.key.toLowerCase(); + + if (eventKey !== configKey) return false; + + // 检查修饰键 + if (!checkModifiers(event, config.modifiers)) return false; + + return true; +} + +/** + * 快捷键 Provider 组件 + */ +export function HotkeysProvider({ children }: { children: React.ReactNode }) { + // 使用 ref 存储快捷键,避免重新渲染 + const hotkeysRef = useRef>(new Map()); + + // 帮助面板状态 + const [isHelperOpen, setIsHelperOpen] = useState(false); + + // 全局禁用状态 + const [isDisabled, setIsDisabled] = useState(false); + + // 强制更新(用于 getAll) + const [, forceUpdate] = useState({}); + + /** + * 注册快捷键 + */ + const register = useCallback((id: string, config: HotkeyConfig) => { + hotkeysRef.current.set(id, config); + forceUpdate({}); + }, []); + + /** + * 注销快捷键 + */ + const unregister = useCallback((id: string) => { + hotkeysRef.current.delete(id); + forceUpdate({}); + }, []); + + /** + * 设置快捷键启用状态 + */ + const setEnabled = useCallback((id: string, enabled: boolean) => { + const config = hotkeysRef.current.get(id); + if (config) { + hotkeysRef.current.set(id, { ...config, enabled }); + } + }, []); + + /** + * 获取所有已注册的快捷键 + */ + const getAll = useCallback((): RegisteredHotkey[] => { + const result: RegisteredHotkey[] = []; + hotkeysRef.current.forEach((config, id) => { + result.push({ id, config }); + }); + return result; + }, []); + + /** + * 切换帮助面板 + */ + const toggleHelper = useCallback(() => { + setIsHelperOpen((prev) => !prev); + }, []); + + /** + * 关闭帮助面板 + */ + const closeHelper = useCallback(() => { + setIsHelperOpen(false); + }, []); + + /** + * 打开帮助面板 + */ + const openHelper = useCallback(() => { + setIsHelperOpen(true); + }, []); + + /** + * 全局键盘事件监听 + */ + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // 检查 IME 输入状态 + if (event.isComposing) return; + + // 全局禁用时只允许 Escape 关闭帮助面板 + if (isDisabled && event.key !== 'Escape') return; + + // 检查是否在输入框中 + const isInInput = isInputElement(event.target); + + // 特殊处理:? 键显示帮助面板 + if (event.key === '?' && !isInInput && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + toggleHelper(); + return; + } + + // 特殊处理:Escape 关闭帮助面板 + if (event.key === 'Escape' && isHelperOpen) { + event.preventDefault(); + closeHelper(); + return; + } + + // 遍历所有注册的快捷键 + for (const [, config] of hotkeysRef.current) { + if (matchHotkey(event, config, isInInput)) { + // 阻止默认行为 + if (config.preventDefault !== false) { + event.preventDefault(); + } + // 执行回调 + config.action(); + return; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isDisabled, isHelperOpen, toggleHelper, closeHelper]); + + // Context 值 + const contextValue: HotkeysContextType = { + register, + unregister, + setEnabled, + getAll, + isHelperOpen, + toggleHelper, + closeHelper, + openHelper, + isDisabled, + setDisabled: setIsDisabled, + }; + + return ( + + {children} + {/* 快捷键帮助面板 */} + + + ); +} + +/** + * 使用快捷键 Context 的 Hook + */ +export function useHotkeysContext(): HotkeysContextType { + const context = useContext(HotkeysContext); + if (!context) { + throw new Error('useHotkeysContext must be used within a HotkeysProvider'); + } + return context; +}