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