Compare commits
5 Commits
da65cedf28
...
c48d05885a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c48d05885a | ||
|
|
f859ffe4cd | ||
|
|
827a033d41 | ||
|
|
14e3185664 | ||
|
|
9080c3af21 |
@ -3,6 +3,7 @@ import "./globals.css";
|
|||||||
import { SettingsProvider } from "@/components/providers/SettingsProvider";
|
import { SettingsProvider } from "@/components/providers/SettingsProvider";
|
||||||
import { AuthProvider } from "@/providers/AuthProvider";
|
import { AuthProvider } from "@/providers/AuthProvider";
|
||||||
import { PromptOptimizerProvider } from "@/providers/PromptOptimizerProvider";
|
import { PromptOptimizerProvider } from "@/providers/PromptOptimizerProvider";
|
||||||
|
import { HotkeysProvider } from "@/providers/HotkeysProvider";
|
||||||
import { Toaster } from "@/components/ui/Toast";
|
import { Toaster } from "@/components/ui/Toast";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -23,10 +24,12 @@ export default function RootLayout({
|
|||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<PromptOptimizerProvider>
|
<HotkeysProvider>
|
||||||
{children}
|
<PromptOptimizerProvider>
|
||||||
<Toaster />
|
{children}
|
||||||
</PromptOptimizerProvider>
|
<Toaster />
|
||||||
|
</PromptOptimizerProvider>
|
||||||
|
</HotkeysProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useConversations } from '@/hooks/useConversations';
|
|||||||
import { useAuth } from '@/providers/AuthProvider';
|
import { useAuth } from '@/providers/AuthProvider';
|
||||||
import type { Conversation } from '@/drizzle/schema';
|
import type { Conversation } from '@/drizzle/schema';
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useHotkeys } from '@/hooks/useHotkeys';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@ -54,21 +55,45 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
|
|||||||
};
|
};
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
// 全局键盘快捷键:⌘K / Ctrl+K 打开搜索
|
// 快捷键:N - 新建对话(单键,非输入框时生效)
|
||||||
useEffect(() => {
|
useHotkeys(
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
'new-chat',
|
||||||
// ⌘K (Mac) 或 Ctrl+K (Windows/Linux)
|
{
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
key: 'n',
|
||||||
e.preventDefault();
|
description: '新建对话',
|
||||||
setShowSearchModal(true);
|
category: '导航',
|
||||||
}
|
action: useCallback(() => setShowNewChatModal(true), []),
|
||||||
};
|
},
|
||||||
|
[setShowNewChatModal]
|
||||||
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
// 快捷键:Cmd/Ctrl + K - 打开搜索
|
||||||
return () => {
|
useHotkeys(
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
'open-search-k',
|
||||||
};
|
{
|
||||||
}, []);
|
key: 'k',
|
||||||
|
modifiers: ['ctrl', 'meta'],
|
||||||
|
description: '打开搜索',
|
||||||
|
category: '导航',
|
||||||
|
action: useCallback(() => setShowSearchModal(true), []),
|
||||||
|
allowInInput: true,
|
||||||
|
},
|
||||||
|
[setShowSearchModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 快捷键:Cmd/Ctrl + / - 打开搜索(备用)
|
||||||
|
useHotkeys(
|
||||||
|
'open-search-slash',
|
||||||
|
{
|
||||||
|
key: '/',
|
||||||
|
modifiers: ['ctrl', 'meta'],
|
||||||
|
description: '打开搜索',
|
||||||
|
category: '导航',
|
||||||
|
action: useCallback(() => setShowSearchModal(true), []),
|
||||||
|
allowInInput: true,
|
||||||
|
},
|
||||||
|
[setShowSearchModal]
|
||||||
|
);
|
||||||
|
|
||||||
// 创建新对话 - 显示选择助手弹框
|
// 创建新对话 - 显示选择助手弹框
|
||||||
const handleNewChat = () => {
|
const handleNewChat = () => {
|
||||||
|
|||||||
207
src/components/ui/HotkeysHelper.tsx
Normal file
207
src/components/ui/HotkeysHelper.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { X, Keyboard } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { RegisteredHotkey, HotkeyCategory } from '@/types/hotkeys';
|
||||||
|
import { formatHotkey } from '@/types/hotkeys';
|
||||||
|
|
||||||
|
interface HotkeysHelperProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
registeredHotkeys?: RegisteredHotkey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预定义的快捷键列表(包括系统内置的)
|
||||||
|
const BUILTIN_HOTKEYS: RegisteredHotkey[] = [
|
||||||
|
{
|
||||||
|
id: 'new-chat',
|
||||||
|
config: {
|
||||||
|
key: 'n',
|
||||||
|
description: '新建对话',
|
||||||
|
category: '导航',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-search',
|
||||||
|
config: {
|
||||||
|
key: 'k',
|
||||||
|
modifiers: ['meta'],
|
||||||
|
description: '打开搜索',
|
||||||
|
category: '导航',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'help',
|
||||||
|
config: {
|
||||||
|
key: '?',
|
||||||
|
description: '显示快捷键帮助',
|
||||||
|
category: '通用',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'close-modal',
|
||||||
|
config: {
|
||||||
|
key: 'Escape',
|
||||||
|
description: '关闭弹窗',
|
||||||
|
category: '通用',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷键帮助面板
|
||||||
|
*/
|
||||||
|
export function HotkeysHelper({ isOpen, onClose, registeredHotkeys = [] }: HotkeysHelperProps) {
|
||||||
|
// 检测是否为 Mac 系统
|
||||||
|
const [isMac, setIsMac] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMac(navigator.platform.toLowerCase().includes('mac'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 合并内置和动态快捷键,按分类分组
|
||||||
|
const groupedHotkeys = useMemo(() => {
|
||||||
|
const allHotkeys = [...BUILTIN_HOTKEYS, ...registeredHotkeys];
|
||||||
|
|
||||||
|
// 去重(以 id 为准,优先保留 BUILTIN)
|
||||||
|
const uniqueHotkeys = allHotkeys.reduce((acc, hotkey) => {
|
||||||
|
if (!acc.find((h) => h.id === hotkey.id)) {
|
||||||
|
acc.push(hotkey);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as RegisteredHotkey[]);
|
||||||
|
|
||||||
|
// 按分类分组
|
||||||
|
const groups: Record<HotkeyCategory, RegisteredHotkey[]> = {
|
||||||
|
导航: [],
|
||||||
|
编辑: [],
|
||||||
|
通用: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
uniqueHotkeys.forEach((hotkey) => {
|
||||||
|
const category = hotkey.config.category || '通用';
|
||||||
|
if (groups[category]) {
|
||||||
|
groups[category].push(hotkey);
|
||||||
|
} else {
|
||||||
|
groups['通用'].push(hotkey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 过滤空分类
|
||||||
|
return Object.entries(groups).filter(([, items]) => items.length > 0);
|
||||||
|
}, [registeredHotkeys]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 遮罩层 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[200] animate-fade-in-fast"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 帮助面板 */}
|
||||||
|
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[201] w-full max-w-[480px] px-4">
|
||||||
|
<div className="bg-[var(--color-bg-primary)] rounded shadow-2xl overflow-hidden animate-scale-in-fast">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border-light)]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center bg-[var(--color-primary-alpha)] rounded-lg">
|
||||||
|
<Keyboard size={20} className="text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
键盘快捷键
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
使用快捷键提高效率
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷键列表 */}
|
||||||
|
<div className="px-6 py-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{groupedHotkeys.map(([category, hotkeys], groupIndex) => (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
className={cn(groupIndex > 0 && 'mt-6')}
|
||||||
|
>
|
||||||
|
{/* 分类标题 */}
|
||||||
|
<h3 className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 快捷键项 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hotkeys.map((hotkey) => (
|
||||||
|
<div
|
||||||
|
key={hotkey.id}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-[var(--color-text-primary)]">
|
||||||
|
{hotkey.config.description}
|
||||||
|
</span>
|
||||||
|
<HotkeyBadge config={hotkey.config} isMac={isMac} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
<div className="px-6 py-3 bg-[var(--color-bg-secondary)] border-t border-[var(--color-border-light)]">
|
||||||
|
<div className="flex items-center justify-between text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
<span>按 <kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded text-[10px] mx-1">Esc</kbd> 关闭</span>
|
||||||
|
<span>{isMac ? 'macOS' : 'Windows/Linux'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷键徽章组件
|
||||||
|
*/
|
||||||
|
function HotkeyBadge({
|
||||||
|
config,
|
||||||
|
isMac,
|
||||||
|
}: {
|
||||||
|
config: RegisteredHotkey['config'];
|
||||||
|
isMac: boolean;
|
||||||
|
}) {
|
||||||
|
const keys = formatHotkey(config, isMac).split(isMac ? ' ' : '+');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{keys.map((key, index) => (
|
||||||
|
<kbd
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center min-w-[24px] h-6 px-2',
|
||||||
|
'bg-[var(--color-bg-tertiary)] border border-[var(--color-border)]',
|
||||||
|
'rounded text-xs font-medium text-[var(--color-text-secondary)]',
|
||||||
|
'shadow-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/hooks/useHotkeys.ts
Normal file
208
src/hooks/useHotkeys.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, DependencyList, useMemo } from 'react';
|
||||||
|
import { useHotkeysContext } from '@/providers/HotkeysProvider';
|
||||||
|
import type { HotkeyConfig, ModifierKey, HotkeyCategory } from '@/types/hotkeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useHotkeys Hook 配置选项
|
||||||
|
*/
|
||||||
|
export interface UseHotkeysOptions {
|
||||||
|
/** 主键 */
|
||||||
|
key: string;
|
||||||
|
/** 修饰键 */
|
||||||
|
modifiers?: ModifierKey[];
|
||||||
|
/** 快捷键描述 */
|
||||||
|
description: string;
|
||||||
|
/** 分类 */
|
||||||
|
category?: HotkeyCategory;
|
||||||
|
/** 触发回调 */
|
||||||
|
action: () => void;
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled?: boolean | (() => boolean);
|
||||||
|
/** 是否阻止默认行为 */
|
||||||
|
preventDefault?: boolean;
|
||||||
|
/** 是否允许在输入框中触发 */
|
||||||
|
allowInInput?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册快捷键的 Hook
|
||||||
|
*
|
||||||
|
* @param id 快捷键唯一标识符
|
||||||
|
* @param options 快捷键配置
|
||||||
|
* @param deps 依赖数组(当依赖变化时重新注册)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 单键快捷键(非输入框时触发)
|
||||||
|
* useHotkeys('new-chat', {
|
||||||
|
* key: 'n',
|
||||||
|
* description: '新建对话',
|
||||||
|
* category: '导航',
|
||||||
|
* action: () => setShowNewChatModal(true),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 组合键快捷键
|
||||||
|
* useHotkeys('open-search', {
|
||||||
|
* key: 'k',
|
||||||
|
* modifiers: ['ctrl', 'meta'], // Ctrl+K 或 Cmd+K
|
||||||
|
* description: '打开搜索',
|
||||||
|
* category: '导航',
|
||||||
|
* action: () => setShowSearchModal(true),
|
||||||
|
* allowInInput: true, // 在输入框中也生效
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useHotkeys(
|
||||||
|
id: string,
|
||||||
|
options: UseHotkeysOptions,
|
||||||
|
deps: DependencyList = []
|
||||||
|
): void {
|
||||||
|
const { register, unregister } = useHotkeysContext();
|
||||||
|
|
||||||
|
// 将 options 转换为 HotkeyConfig
|
||||||
|
const config: HotkeyConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
key: options.key,
|
||||||
|
modifiers: options.modifiers,
|
||||||
|
description: options.description,
|
||||||
|
category: options.category,
|
||||||
|
action: options.action,
|
||||||
|
enabled: options.enabled,
|
||||||
|
preventDefault: options.preventDefault,
|
||||||
|
allowInInput: options.allowInInput,
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
options.key,
|
||||||
|
options.description,
|
||||||
|
options.category,
|
||||||
|
options.enabled,
|
||||||
|
options.preventDefault,
|
||||||
|
options.allowInInput,
|
||||||
|
// modifiers 需要特殊处理(数组比较)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
JSON.stringify(options.modifiers),
|
||||||
|
// action 放在 deps 中由用户控制
|
||||||
|
...deps,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 注册快捷键
|
||||||
|
register(id, {
|
||||||
|
...config,
|
||||||
|
action: options.action, // 使用最新的 action
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理:注销快捷键
|
||||||
|
return () => {
|
||||||
|
unregister(id);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id, config, register, unregister]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量注册快捷键的 Hook
|
||||||
|
*
|
||||||
|
* @param configs 快捷键配置数组
|
||||||
|
* @param deps 依赖数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* useHotkeysGroup([
|
||||||
|
* {
|
||||||
|
* id: 'new-chat',
|
||||||
|
* key: 'n',
|
||||||
|
* description: '新建对话',
|
||||||
|
* action: () => setShowNewChatModal(true),
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* id: 'open-search',
|
||||||
|
* key: 'k',
|
||||||
|
* modifiers: ['ctrl', 'meta'],
|
||||||
|
* description: '打开搜索',
|
||||||
|
* action: () => setShowSearchModal(true),
|
||||||
|
* },
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useHotkeysGroup(
|
||||||
|
configs: (UseHotkeysOptions & { id: string })[],
|
||||||
|
deps: DependencyList = []
|
||||||
|
): void {
|
||||||
|
const { register, unregister } = useHotkeysContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 批量注册
|
||||||
|
configs.forEach(({ id, ...options }) => {
|
||||||
|
register(id, {
|
||||||
|
key: options.key,
|
||||||
|
modifiers: options.modifiers,
|
||||||
|
description: options.description,
|
||||||
|
category: options.category,
|
||||||
|
action: options.action,
|
||||||
|
enabled: options.enabled,
|
||||||
|
preventDefault: options.preventDefault,
|
||||||
|
allowInInput: options.allowInInput,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理:批量注销
|
||||||
|
return () => {
|
||||||
|
configs.forEach(({ id }) => {
|
||||||
|
unregister(id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [register, unregister, JSON.stringify(configs.map((c) => c.id)), ...deps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取快捷键帮助面板控制的 Hook
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { isOpen, toggle, open, close } = useHotkeysHelper();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <button onClick={toggle}>
|
||||||
|
* {isOpen ? '关闭帮助' : '快捷键帮助'}
|
||||||
|
* </button>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useHotkeysHelper() {
|
||||||
|
const { isHelperOpen, toggleHelper, openHelper, closeHelper } =
|
||||||
|
useHotkeysContext();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: isHelperOpen,
|
||||||
|
toggle: toggleHelper,
|
||||||
|
open: openHelper,
|
||||||
|
close: closeHelper,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已注册快捷键的 Hook
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const hotkeys = useRegisteredHotkeys();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <ul>
|
||||||
|
* {hotkeys.map(({ id, config }) => (
|
||||||
|
* <li key={id}>{config.description}</li>
|
||||||
|
* ))}
|
||||||
|
* </ul>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useRegisteredHotkeys() {
|
||||||
|
const { getAll } = useHotkeysContext();
|
||||||
|
return getAll();
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
120
src/types/hotkeys.ts
Normal file
120
src/types/hotkeys.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 快捷键系统类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 修饰键类型 */
|
||||||
|
export type ModifierKey = 'ctrl' | 'meta' | 'alt' | 'shift';
|
||||||
|
|
||||||
|
/** 快捷键作用域 */
|
||||||
|
export type HotkeyScope = 'global' | 'modal' | 'input';
|
||||||
|
|
||||||
|
/** 快捷键分类 */
|
||||||
|
export type HotkeyCategory = '导航' | '编辑' | '通用';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷键配置
|
||||||
|
*/
|
||||||
|
export interface HotkeyConfig {
|
||||||
|
/** 主键(如 'n', '/', 'k', 'Escape') */
|
||||||
|
key: string;
|
||||||
|
/** 修饰键组合 */
|
||||||
|
modifiers?: ModifierKey[];
|
||||||
|
/** 快捷键描述 */
|
||||||
|
description: string;
|
||||||
|
/** 分类 */
|
||||||
|
category?: HotkeyCategory;
|
||||||
|
/** 触发回调 */
|
||||||
|
action: () => void;
|
||||||
|
/** 是否启用(可以是布尔值或函数) */
|
||||||
|
enabled?: boolean | (() => boolean);
|
||||||
|
/** 是否阻止默认行为(默认 true) */
|
||||||
|
preventDefault?: boolean;
|
||||||
|
/** 作用域:global-全局, modal-仅弹窗, input-包括输入框 */
|
||||||
|
scope?: HotkeyScope;
|
||||||
|
/** 是否允许在输入框中触发(默认 false,仅对有修饰键的快捷键生效) */
|
||||||
|
allowInInput?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已注册的快捷键信息(用于显示帮助面板)
|
||||||
|
*/
|
||||||
|
export interface RegisteredHotkey {
|
||||||
|
id: string;
|
||||||
|
config: HotkeyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷键 Context 类型
|
||||||
|
*/
|
||||||
|
export interface HotkeysContextType {
|
||||||
|
/** 注册快捷键 */
|
||||||
|
register: (id: string, config: HotkeyConfig) => void;
|
||||||
|
/** 注销快捷键 */
|
||||||
|
unregister: (id: string) => void;
|
||||||
|
/** 设置快捷键启用状态 */
|
||||||
|
setEnabled: (id: string, enabled: boolean) => void;
|
||||||
|
/** 获取所有已注册的快捷键 */
|
||||||
|
getAll: () => RegisteredHotkey[];
|
||||||
|
/** 帮助面板是否打开 */
|
||||||
|
isHelperOpen: boolean;
|
||||||
|
/** 切换帮助面板 */
|
||||||
|
toggleHelper: () => void;
|
||||||
|
/** 关闭帮助面板 */
|
||||||
|
closeHelper: () => void;
|
||||||
|
/** 打开帮助面板 */
|
||||||
|
openHelper: () => void;
|
||||||
|
/** 全局禁用状态(用于弹窗打开时暂停快捷键) */
|
||||||
|
isDisabled: boolean;
|
||||||
|
/** 设置全局禁用状态 */
|
||||||
|
setDisabled: (disabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化快捷键显示
|
||||||
|
* @param config 快捷键配置
|
||||||
|
* @param isMac 是否为 Mac 系统
|
||||||
|
* @returns 格式化的快捷键字符串
|
||||||
|
*/
|
||||||
|
export function formatHotkey(config: HotkeyConfig, isMac: boolean = true): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (config.modifiers?.includes('ctrl')) {
|
||||||
|
parts.push(isMac ? '⌃' : 'Ctrl');
|
||||||
|
}
|
||||||
|
if (config.modifiers?.includes('meta')) {
|
||||||
|
parts.push(isMac ? '⌘' : 'Ctrl');
|
||||||
|
}
|
||||||
|
if (config.modifiers?.includes('alt')) {
|
||||||
|
parts.push(isMac ? '⌥' : 'Alt');
|
||||||
|
}
|
||||||
|
if (config.modifiers?.includes('shift')) {
|
||||||
|
parts.push(isMac ? '⇧' : 'Shift');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化主键显示
|
||||||
|
const keyDisplay = formatKeyDisplay(config.key);
|
||||||
|
parts.push(keyDisplay);
|
||||||
|
|
||||||
|
return isMac ? parts.join(' ') : parts.join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化单个按键显示
|
||||||
|
*/
|
||||||
|
function formatKeyDisplay(key: string): string {
|
||||||
|
const keyMap: Record<string, string> = {
|
||||||
|
'Escape': 'Esc',
|
||||||
|
'ArrowUp': '↑',
|
||||||
|
'ArrowDown': '↓',
|
||||||
|
'ArrowLeft': '←',
|
||||||
|
'ArrowRight': '→',
|
||||||
|
'Enter': '↵',
|
||||||
|
' ': 'Space',
|
||||||
|
'/': '/',
|
||||||
|
',': ',',
|
||||||
|
'.': '.',
|
||||||
|
'?': '?',
|
||||||
|
};
|
||||||
|
|
||||||
|
return keyMap[key] || key.toUpperCase();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user