197 lines
6.7 KiB
TypeScript
197 lines
6.7 KiB
TypeScript
import { useCallback, useEffect } from 'react'
|
|
import type { InputEvent } from '../ink/events/input-event.js'
|
|
import { type Key, useInput } from '../ink.js'
|
|
import { useOptionalKeybindingContext } from './KeybindingContext.js'
|
|
import type { KeybindingContextName } from './types.js'
|
|
|
|
type Options = {
|
|
/** Which context this binding belongs to (default: 'Global') */
|
|
context?: KeybindingContextName
|
|
/** Only handle when active (like useInput's isActive) */
|
|
isActive?: boolean
|
|
}
|
|
|
|
/**
|
|
* Ink-native hook for handling a keybinding.
|
|
*
|
|
* The handler stays in the component (React way).
|
|
* The binding (keystroke → action) comes from config.
|
|
*
|
|
* Supports chord sequences (e.g., "ctrl+k ctrl+s"). When a chord is started,
|
|
* the hook will manage the pending state automatically.
|
|
*
|
|
* Uses stopImmediatePropagation() to prevent other handlers from firing
|
|
* once this binding is handled.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* useKeybinding('app:toggleTodos', () => {
|
|
* setShowTodos(prev => !prev)
|
|
* }, { context: 'Global' })
|
|
* ```
|
|
*/
|
|
export function useKeybinding(
|
|
action: string,
|
|
handler: () => void | false | Promise<void>,
|
|
options: Options = {},
|
|
): void {
|
|
const { context = 'Global', isActive = true } = options
|
|
const keybindingContext = useOptionalKeybindingContext()
|
|
|
|
// Register handler with the context for ChordInterceptor to invoke
|
|
useEffect(() => {
|
|
if (!keybindingContext || !isActive) return
|
|
return keybindingContext.registerHandler({ action, context, handler })
|
|
}, [action, context, handler, keybindingContext, isActive])
|
|
|
|
const handleInput = useCallback(
|
|
(input: string, key: Key, event: InputEvent) => {
|
|
// If no keybinding context available, skip resolution
|
|
if (!keybindingContext) return
|
|
|
|
// Build context list: registered active contexts + this context + Global
|
|
// More specific contexts (registered ones) take precedence over Global
|
|
const contextsToCheck: KeybindingContextName[] = [
|
|
...keybindingContext.activeContexts,
|
|
context,
|
|
'Global',
|
|
]
|
|
// Deduplicate while preserving order (first occurrence wins for priority)
|
|
const uniqueContexts = [...new Set(contextsToCheck)]
|
|
|
|
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
|
|
|
switch (result.type) {
|
|
case 'match':
|
|
// Chord completed (if any) - clear pending state
|
|
keybindingContext.setPendingChord(null)
|
|
if (result.action === action) {
|
|
if (handler() !== false) {
|
|
event.stopImmediatePropagation()
|
|
}
|
|
}
|
|
break
|
|
case 'chord_started':
|
|
// User started a chord sequence - update pending state
|
|
keybindingContext.setPendingChord(result.pending)
|
|
event.stopImmediatePropagation()
|
|
break
|
|
case 'chord_cancelled':
|
|
// Chord was cancelled (escape or invalid key)
|
|
keybindingContext.setPendingChord(null)
|
|
break
|
|
case 'unbound':
|
|
// Explicitly unbound - clear any pending chord
|
|
keybindingContext.setPendingChord(null)
|
|
event.stopImmediatePropagation()
|
|
break
|
|
case 'none':
|
|
// No match - let other handlers try
|
|
break
|
|
}
|
|
},
|
|
[action, context, handler, keybindingContext],
|
|
)
|
|
|
|
useInput(handleInput, { isActive })
|
|
}
|
|
|
|
/**
|
|
* Handle multiple keybindings in one hook (reduces useInput calls).
|
|
*
|
|
* Supports chord sequences. When a chord is started, the hook will
|
|
* manage the pending state automatically.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* useKeybindings({
|
|
* 'chat:submit': () => handleSubmit(),
|
|
* 'chat:cancel': () => handleCancel(),
|
|
* }, { context: 'Chat' })
|
|
* ```
|
|
*/
|
|
export function useKeybindings(
|
|
// Handler returning `false` means "not consumed" — the event propagates
|
|
// to later useInput/useKeybindings handlers. Useful for fall-through:
|
|
// e.g. ScrollKeybindingHandler's scroll:line* returns false when the
|
|
// ScrollBox content fits (scroll is a no-op), letting a child component's
|
|
// handler take the wheel event for list navigation instead. Promise<void>
|
|
// is allowed for fire-and-forget async handlers (the `!== false` check
|
|
// only skips propagation for a sync `false`, not a pending Promise).
|
|
handlers: Record<string, () => void | false | Promise<void>>,
|
|
options: Options = {},
|
|
): void {
|
|
const { context = 'Global', isActive = true } = options
|
|
const keybindingContext = useOptionalKeybindingContext()
|
|
|
|
// Register all handlers with the context for ChordInterceptor to invoke
|
|
useEffect(() => {
|
|
if (!keybindingContext || !isActive) return
|
|
|
|
const unregisterFns: Array<() => void> = []
|
|
for (const [action, handler] of Object.entries(handlers)) {
|
|
unregisterFns.push(
|
|
keybindingContext.registerHandler({ action, context, handler }),
|
|
)
|
|
}
|
|
|
|
return () => {
|
|
for (const unregister of unregisterFns) {
|
|
unregister()
|
|
}
|
|
}
|
|
}, [context, handlers, keybindingContext, isActive])
|
|
|
|
const handleInput = useCallback(
|
|
(input: string, key: Key, event: InputEvent) => {
|
|
// If no keybinding context available, skip resolution
|
|
if (!keybindingContext) return
|
|
|
|
// Build context list: registered active contexts + this context + Global
|
|
// More specific contexts (registered ones) take precedence over Global
|
|
const contextsToCheck: KeybindingContextName[] = [
|
|
...keybindingContext.activeContexts,
|
|
context,
|
|
'Global',
|
|
]
|
|
// Deduplicate while preserving order (first occurrence wins for priority)
|
|
const uniqueContexts = [...new Set(contextsToCheck)]
|
|
|
|
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
|
|
|
switch (result.type) {
|
|
case 'match':
|
|
// Chord completed (if any) - clear pending state
|
|
keybindingContext.setPendingChord(null)
|
|
if (result.action in handlers) {
|
|
const handler = handlers[result.action]
|
|
if (handler && handler() !== false) {
|
|
event.stopImmediatePropagation()
|
|
}
|
|
}
|
|
break
|
|
case 'chord_started':
|
|
// User started a chord sequence - update pending state
|
|
keybindingContext.setPendingChord(result.pending)
|
|
event.stopImmediatePropagation()
|
|
break
|
|
case 'chord_cancelled':
|
|
// Chord was cancelled (escape or invalid key)
|
|
keybindingContext.setPendingChord(null)
|
|
break
|
|
case 'unbound':
|
|
// Explicitly unbound - clear any pending chord
|
|
keybindingContext.setPendingChord(null)
|
|
event.stopImmediatePropagation()
|
|
break
|
|
case 'none':
|
|
// No match - let other handlers try
|
|
break
|
|
}
|
|
},
|
|
[context, handlers, keybindingContext],
|
|
)
|
|
|
|
useInput(handleInput, { isActive })
|
|
}
|