feat(快捷键): 实现全局快捷键 Provider
- 实现全局键盘事件监听器 - 支持快捷键注册和注销机制 - 处理 IME 输入法状态检测 - 支持输入框内快捷键过滤 - 内置 ? 键显示帮助面板 - 内置 Esc 键关闭弹窗
This commit is contained in:
parent
9080c3af21
commit
14e3185664
270
src/providers/HotkeysProvider.tsx
Normal file
270
src/providers/HotkeysProvider.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user