Keyboard cleanup (#1071)
* Delete keyboard-shortcuts-context and useKeyboardShortcut * Fix handler re-registration * fix
This commit is contained in:
committed by
GitHub
parent
ad854895fc
commit
3a3d066071
@@ -20,9 +20,7 @@ import {
|
|||||||
} from '@/components/config-provider';
|
} from '@/components/config-provider';
|
||||||
import { ThemeProvider } from '@/components/theme-provider';
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
import { SearchProvider } from '@/contexts/search-context';
|
import { SearchProvider } from '@/contexts/search-context';
|
||||||
import { KeyboardShortcutsProvider } from '@/contexts/keyboard-shortcuts-context';
|
|
||||||
|
|
||||||
import { ShortcutsHelp } from '@/components/shortcuts-help';
|
|
||||||
import { HotkeysProvider } from 'react-hotkeys-hook';
|
import { HotkeysProvider } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import { ProjectProvider } from '@/contexts/project-context';
|
import { ProjectProvider } from '@/contexts/project-context';
|
||||||
@@ -194,7 +192,6 @@ function AppContent() {
|
|||||||
</Route>
|
</Route>
|
||||||
</SentryRoutes>
|
</SentryRoutes>
|
||||||
</div>
|
</div>
|
||||||
<ShortcutsHelp />
|
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
@@ -208,11 +205,9 @@ function App() {
|
|||||||
<ClickedElementsProvider>
|
<ClickedElementsProvider>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<HotkeysProvider initiallyActiveScopes={['*', 'global', 'kanban']}>
|
<HotkeysProvider initiallyActiveScopes={['*', 'global', 'kanban']}>
|
||||||
<KeyboardShortcutsProvider>
|
<NiceModal.Provider>
|
||||||
<NiceModal.Provider>
|
<AppContent />
|
||||||
<AppContent />
|
</NiceModal.Provider>
|
||||||
</NiceModal.Provider>
|
|
||||||
</KeyboardShortcutsProvider>
|
|
||||||
</HotkeysProvider>
|
</HotkeysProvider>
|
||||||
</ProjectProvider>
|
</ProjectProvider>
|
||||||
</ClickedElementsProvider>
|
</ClickedElementsProvider>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useKeyboardShortcutsRegistry } from '@/contexts/keyboard-shortcuts-context';
|
|
||||||
import { useKeyShowHelp, Scope } from '@/keyboard';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
|
|
||||||
export function ShortcutsHelp() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { shortcuts } = useKeyboardShortcutsRegistry();
|
|
||||||
|
|
||||||
// Global shortcut to open help using semantic hook
|
|
||||||
useKeyShowHelp(() => setIsOpen(true), { scope: Scope.GLOBAL });
|
|
||||||
|
|
||||||
const groupedShortcuts = shortcuts.reduce(
|
|
||||||
(acc, shortcut) => {
|
|
||||||
const group = shortcut.group || 'Other';
|
|
||||||
if (!acc[group]) acc[group] = [];
|
|
||||||
acc[group].push(shortcut);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof shortcuts>
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatKeys = (keys: string | string[]) => {
|
|
||||||
if (Array.isArray(keys)) {
|
|
||||||
return keys.join(' or ');
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.entries(groupedShortcuts).map(([group, shortcuts]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<h3 className="text-lg font-medium mb-3">{group}</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{shortcuts.map((shortcut) => (
|
|
||||||
<div
|
|
||||||
key={shortcut.id}
|
|
||||||
className="flex justify-between items-center py-1"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{shortcut.description}</span>
|
|
||||||
<kbd className="px-2 py-1 bg-muted rounded text-xs font-mono">
|
|
||||||
{formatKeys(shortcut.keys)}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground mt-4 pt-4 border-t">
|
|
||||||
Press <kbd className="px-1 py-0.5 bg-muted rounded text-xs">?</kbd> to
|
|
||||||
open this help dialog
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export interface ShortcutConfig {
|
|
||||||
keys: string | string[]; // 'c' or ['cmd+k', 'ctrl+k']
|
|
||||||
callback: (e: KeyboardEvent) => void;
|
|
||||||
description: string; // For help documentation
|
|
||||||
group?: string; // 'Dialog', 'Kanban', 'Global'
|
|
||||||
scope?: string; // 'global', 'kanban', 'dialog'
|
|
||||||
when?: boolean | (() => boolean); // Dynamic enabling
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisteredShortcut extends ShortcutConfig {
|
|
||||||
id: string; // Auto-generated unique ID
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyboardShortcutsState {
|
|
||||||
shortcuts: RegisteredShortcut[];
|
|
||||||
register: (config: ShortcutConfig) => () => void; // Returns unregister function
|
|
||||||
getShortcutsByScope: (scope?: string) => RegisteredShortcut[];
|
|
||||||
getShortcutsByGroup: (group?: string) => RegisteredShortcut[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsState | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
interface KeyboardShortcutsProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyboardShortcutsProvider({
|
|
||||||
children,
|
|
||||||
}: KeyboardShortcutsProviderProps) {
|
|
||||||
const [shortcuts, setShortcuts] = useState<RegisteredShortcut[]>([]);
|
|
||||||
const idCounter = useRef(0);
|
|
||||||
const shortcutsRef = useRef<RegisteredShortcut[]>([]);
|
|
||||||
|
|
||||||
// Keep ref in sync with state
|
|
||||||
useEffect(() => {
|
|
||||||
shortcutsRef.current = shortcuts;
|
|
||||||
}, [shortcuts]);
|
|
||||||
|
|
||||||
const register = useCallback(
|
|
||||||
(config: ShortcutConfig) => {
|
|
||||||
const id = `shortcut-${idCounter.current++}`;
|
|
||||||
const registeredShortcut: RegisteredShortcut = { ...config, id };
|
|
||||||
|
|
||||||
// Development-only conflict detection using ref to avoid dependency cycle
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const conflictingShortcut = shortcutsRef.current.find((existing) => {
|
|
||||||
const sameScope =
|
|
||||||
(existing.scope || 'global') === (config.scope || 'global');
|
|
||||||
const sameKeys =
|
|
||||||
JSON.stringify(existing.keys) === JSON.stringify(config.keys);
|
|
||||||
return sameScope && sameKeys;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (conflictingShortcut) {
|
|
||||||
console.warn(
|
|
||||||
`Keyboard shortcut conflict detected!`,
|
|
||||||
`\nExisting: ${conflictingShortcut.description} (${conflictingShortcut.keys})`,
|
|
||||||
`\nNew: ${config.description} (${config.keys})`,
|
|
||||||
`\nScope: ${config.scope || 'global'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setShortcuts((prev) => [...prev, registeredShortcut]);
|
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
|
||||||
setShortcuts((prev) => prev.filter((shortcut) => shortcut.id !== id));
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[] // Empty dependencies - function stays stable
|
|
||||||
);
|
|
||||||
|
|
||||||
const getShortcutsByScope = useCallback(
|
|
||||||
(scope?: string) => {
|
|
||||||
const targetScope = scope || 'global';
|
|
||||||
return shortcuts.filter(
|
|
||||||
(shortcut) => (shortcut.scope || 'global') === targetScope
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[shortcuts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getShortcutsByGroup = useCallback(
|
|
||||||
(group?: string) => {
|
|
||||||
if (!group) return shortcuts;
|
|
||||||
return shortcuts.filter((shortcut) => shortcut.group === group);
|
|
||||||
},
|
|
||||||
[shortcuts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const value: KeyboardShortcutsState = {
|
|
||||||
shortcuts,
|
|
||||||
register,
|
|
||||||
getShortcutsByScope,
|
|
||||||
getShortcutsByGroup,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardShortcutsContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</KeyboardShortcutsContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcutsRegistry(): KeyboardShortcutsState {
|
|
||||||
const context = useContext(KeyboardShortcutsContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
'useKeyboardShortcutsRegistry must be used within a KeyboardShortcutsProvider'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,5 @@ export { useRebase } from './useRebase';
|
|||||||
export { useChangeTargetBranch } from './useChangeTargetBranch';
|
export { useChangeTargetBranch } from './useChangeTargetBranch';
|
||||||
export { useMerge } from './useMerge';
|
export { useMerge } from './useMerge';
|
||||||
export { usePush } from './usePush';
|
export { usePush } from './usePush';
|
||||||
export { useKeyboardShortcut } from './useKeyboardShortcut';
|
|
||||||
export { useAttemptConflicts } from './useAttemptConflicts';
|
export { useAttemptConflicts } from './useAttemptConflicts';
|
||||||
export { useNavigateWithSearch } from './useNavigateWithSearch';
|
export { useNavigateWithSearch } from './useNavigateWithSearch';
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
import {
|
|
||||||
useKeyboardShortcutsRegistry,
|
|
||||||
type ShortcutConfig,
|
|
||||||
} from '@/contexts/keyboard-shortcuts-context';
|
|
||||||
import type { EnableOnFormTags } from '@/keyboard/types';
|
|
||||||
|
|
||||||
export interface KeyboardShortcutOptions {
|
|
||||||
enableOnContentEditable?: boolean;
|
|
||||||
enableOnFormTags?: EnableOnFormTags;
|
|
||||||
preventDefault?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcut(
|
|
||||||
config: ShortcutConfig,
|
|
||||||
options: KeyboardShortcutOptions = {}
|
|
||||||
): void {
|
|
||||||
const { register } = useKeyboardShortcutsRegistry();
|
|
||||||
const unregisterRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
const { keys, callback, when = true, description, group, scope } = config;
|
|
||||||
const {
|
|
||||||
enableOnContentEditable = false,
|
|
||||||
enableOnFormTags,
|
|
||||||
preventDefault = false,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Keep latest callback/when without forcing re-register
|
|
||||||
const callbackRef = useRef(callback);
|
|
||||||
useEffect(() => {
|
|
||||||
callbackRef.current = callback;
|
|
||||||
}, [callback]);
|
|
||||||
|
|
||||||
const whenRef = useRef(when);
|
|
||||||
useEffect(() => {
|
|
||||||
whenRef.current = when;
|
|
||||||
}, [when]);
|
|
||||||
|
|
||||||
// Register once per identity fields (no direct 'config' usage here)
|
|
||||||
useEffect(() => {
|
|
||||||
const unregister = register({
|
|
||||||
keys,
|
|
||||||
description,
|
|
||||||
group,
|
|
||||||
scope,
|
|
||||||
// delegate to latest refs
|
|
||||||
callback: (e: KeyboardEvent) => callbackRef.current?.(e as KeyboardEvent),
|
|
||||||
when: () => {
|
|
||||||
const w = whenRef.current;
|
|
||||||
return typeof w === 'function' ? !!w() : !!w;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
unregisterRef.current = unregister;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unregisterRef.current?.();
|
|
||||||
unregisterRef.current = null;
|
|
||||||
};
|
|
||||||
}, [register, keys, description, group, scope]);
|
|
||||||
|
|
||||||
// Bind the actual keyboard handling
|
|
||||||
useHotkeys(
|
|
||||||
keys,
|
|
||||||
(event) => {
|
|
||||||
// Skip if IME composition is in progress (e.g., Japanese, Chinese, Korean input)
|
|
||||||
// This prevents shortcuts from firing when user is converting text with Enter
|
|
||||||
if (event.isComposing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = whenRef.current;
|
|
||||||
const enabled = typeof w === 'function' ? !!w() : !!w;
|
|
||||||
if (enabled) callbackRef.current?.(event as KeyboardEvent);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: true, // we gate inside handler via whenRef
|
|
||||||
enableOnContentEditable,
|
|
||||||
enableOnFormTags,
|
|
||||||
preventDefault,
|
|
||||||
scopes: scope ? [scope] : ['*'],
|
|
||||||
},
|
|
||||||
[keys, scope, enableOnContentEditable, enableOnFormTags, preventDefault] // handler uses refs; only rebinding when identity changes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
// Export all semantic keyboard hooks
|
// Export all semantic keyboard hooks
|
||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './registry';
|
export * from './registry';
|
||||||
|
|
||||||
// Re-export the raw hook for edge cases
|
|
||||||
export { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut';
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import {
|
|
||||||
useKeyboardShortcut,
|
|
||||||
type KeyboardShortcutOptions,
|
|
||||||
} from '@/hooks/useKeyboardShortcut';
|
|
||||||
import type { EnableOnFormTags } from './types';
|
import type { EnableOnFormTags } from './types';
|
||||||
import { Action, Scope, getKeysFor, getBindingFor } from './registry';
|
import { Action, Scope, getKeysFor } from './registry';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
export interface SemanticKeyOptions {
|
export interface SemanticKeyOptions {
|
||||||
scope?: Scope;
|
scope?: Scope;
|
||||||
@@ -40,29 +37,35 @@ export function createSemanticHook<A extends Action>(action: A) {
|
|||||||
// Memoize to get stable array references and prevent unnecessary re-registrations
|
// Memoize to get stable array references and prevent unnecessary re-registrations
|
||||||
const keys = useMemo(() => getKeysFor(action, scope), [action, scope]);
|
const keys = useMemo(() => getKeysFor(action, scope), [action, scope]);
|
||||||
|
|
||||||
const binding = useMemo(
|
useHotkeys(
|
||||||
() => getBindingFor(action, scope),
|
keys,
|
||||||
[action, scope]
|
(event) => {
|
||||||
);
|
// Skip if IME composition is in progress (e.g., Japanese, Chinese, Korean input)
|
||||||
|
// This prevents shortcuts from firing when user is converting text with Enter
|
||||||
|
if (event.isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcutOptions: KeyboardShortcutOptions = {};
|
if (isEnabled) {
|
||||||
if (enableOnContentEditable !== undefined)
|
handler(event);
|
||||||
keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable;
|
}
|
||||||
if (enableOnFormTags !== undefined)
|
|
||||||
keyboardShortcutOptions.enableOnFormTags = enableOnFormTags;
|
|
||||||
if (preventDefault !== undefined)
|
|
||||||
keyboardShortcutOptions.preventDefault = preventDefault;
|
|
||||||
|
|
||||||
useKeyboardShortcut(
|
|
||||||
{
|
|
||||||
keys: keys.length === 1 ? keys[0] : keys,
|
|
||||||
callback: keys.length === 0 ? () => {} : handler,
|
|
||||||
description: binding?.description || `${action} action`,
|
|
||||||
group: binding?.group || 'Actions',
|
|
||||||
scope: scope || Scope.GLOBAL,
|
|
||||||
when: keys.length > 0 && isEnabled,
|
|
||||||
},
|
},
|
||||||
keyboardShortcutOptions
|
{
|
||||||
|
enabled,
|
||||||
|
enableOnContentEditable,
|
||||||
|
enableOnFormTags,
|
||||||
|
preventDefault,
|
||||||
|
scopes: scope ? [scope] : ['*'],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
keys,
|
||||||
|
scope,
|
||||||
|
enableOnContentEditable,
|
||||||
|
enableOnFormTags,
|
||||||
|
preventDefault,
|
||||||
|
handler,
|
||||||
|
isEnabled,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user