import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react'; import type { Key } from '../core/events/input-event.js'; import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js'; import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; /** Handler registration for action callbacks */ type HandlerRegistration = { action: string; context: KeybindingContextName; handler: () => void; }; type KeybindingContextValue = { /** Resolve a key input to an action name (with chord support) */ resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult; /** Update the pending chord state */ setPendingChord: (pending: ParsedKeystroke[] | null) => void; /** Get display text for an action (e.g., "ctrl+t") */ getDisplayText: (action: string, context: KeybindingContextName) => string | undefined; /** All parsed bindings (for help display) */ bindings: ParsedBinding[]; /** Current pending chord keystrokes (null if not in a chord) */ pendingChord: ParsedKeystroke[] | null; /** Currently active keybinding contexts (for priority resolution) */ activeContexts: Set; /** Register a context as active (call on mount) */ registerActiveContext: (context: KeybindingContextName) => void; /** Unregister a context (call on unmount) */ unregisterActiveContext: (context: KeybindingContextName) => void; /** Register a handler for an action (used by useKeybinding) */ registerHandler: (registration: HandlerRegistration) => () => void; /** Invoke all handlers for an action (used by ChordInterceptor) */ invokeAction: (action: string) => boolean; }; const KeybindingContext = createContext(null); type ProviderProps = { bindings: ParsedBinding[]; /** Ref for immediate access to pending chord (avoids React state delay) */ pendingChordRef: RefObject; /** State value for re-renders (UI updates) */ pendingChord: ParsedKeystroke[] | null; setPendingChord: (pending: ParsedKeystroke[] | null) => void; activeContexts: Set; registerActiveContext: (context: KeybindingContextName) => void; unregisterActiveContext: (context: KeybindingContextName) => void; /** Ref to handler registry (used by ChordInterceptor) */ handlerRegistryRef: RefObject>>; children: React.ReactNode; }; export function KeybindingProvider({ bindings, pendingChordRef, pendingChord, setPendingChord, activeContexts, registerActiveContext, unregisterActiveContext, handlerRegistryRef, children, }: ProviderProps): React.ReactNode { const value = useMemo(() => { const getDisplay = (action: string, context: KeybindingContextName) => getBindingDisplayText(action, context, bindings); // Register a handler for an action const registerHandler = (registration: HandlerRegistration) => { const registry = handlerRegistryRef.current; if (!registry) return () => {}; if (!registry.has(registration.action)) { registry.set(registration.action, new Set()); } registry.get(registration.action)!.add(registration); // Return unregister function return () => { const handlers = registry.get(registration.action); if (handlers) { handlers.delete(registration); if (handlers.size === 0) { registry.delete(registration.action); } } }; }; // Invoke all handlers for an action const invokeAction = (action: string): boolean => { const registry = handlerRegistryRef.current; if (!registry) return false; const handlers = registry.get(action); if (!handlers || handlers.size === 0) return false; // Find handlers whose context is active for (const registration of handlers) { if (activeContexts.has(registration.context)) { registration.handler(); return true; } } return false; }; return { // Use ref for immediate access to pending chord, avoiding React state delay // This is critical for chord sequences where the second key might be pressed // before React re-renders with the updated pendingChord state resolve: (input, key, contexts) => resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current), setPendingChord, getDisplayText: getDisplay, bindings, pendingChord, activeContexts, registerActiveContext, unregisterActiveContext, registerHandler, invokeAction, }; }, [ bindings, pendingChordRef, pendingChord, setPendingChord, activeContexts, registerActiveContext, unregisterActiveContext, handlerRegistryRef, ]); return {children}; } export function useKeybindingContext(): KeybindingContextValue { const ctx = useContext(KeybindingContext); if (!ctx) { throw new Error('useKeybindingContext must be used within KeybindingProvider'); } return ctx; } /** * Optional hook that returns undefined outside of KeybindingProvider. * Useful for components that may render before provider is available. */ export function useOptionalKeybindingContext(): KeybindingContextValue | null { return useContext(KeybindingContext); } /** * Hook to register a keybinding context as active while the component is mounted. * * When a context is registered, its keybindings take precedence over Global bindings. * This allows context-specific bindings (like ThemePicker's ctrl+t) to override * global bindings (like the todo toggle) when the context is active. * * @example * ```tsx * function ThemePicker() { * useRegisterKeybindingContext('ThemePicker') * // Now ThemePicker's ctrl+t binding takes precedence over Global * } * ``` */ export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void { const keybindingContext = useOptionalKeybindingContext(); useLayoutEffect(() => { if (!keybindingContext || !isActive) return; keybindingContext.registerActiveContext(context); return () => { keybindingContext.unregisterActiveContext(context); }; }, [context, keybindingContext, isActive]); }