feat(快捷键): 实现全局快捷键 Provider

- 实现全局键盘事件监听器
- 支持快捷键注册和注销机制
- 处理 IME 输入法状态检测
- 支持输入框内快捷键过滤
- 内置 ? 键显示帮助面板
- 内置 Esc 键关闭弹窗
This commit is contained in:
gaoziman 2025-12-27 23:56:05 +08:00
parent 9080c3af21
commit 14e3185664

View File

@ -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<HotkeysContextType | null>(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<Map<string, HotkeyConfig>>(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 (
<HotkeysContext.Provider value={contextValue}>
{children}
{/* 快捷键帮助面板 */}
<HotkeysHelper isOpen={isHelperOpen} onClose={closeHelper} />
</HotkeysContext.Provider>
);
}
/**
* 使 Context Hook
*/
export function useHotkeysContext(): HotkeysContextType {
const context = useContext(HotkeysContext);
if (!context) {
throw new Error('useHotkeysContext must be used within a HotkeysProvider');
}
return context;
}