93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
import { useEffect, useLayoutEffect } from 'react'
|
|
import { useEventCallback } from 'usehooks-ts'
|
|
import type { InputEvent, Key } from '../events/input-event.js'
|
|
import useStdin from './use-stdin.js'
|
|
|
|
type Handler = (input: string, key: Key, event: InputEvent) => void
|
|
|
|
type Options = {
|
|
/**
|
|
* Enable or disable capturing of user input.
|
|
* Useful when there are multiple useInput hooks used at once to avoid handling the same input several times.
|
|
*
|
|
* @default true
|
|
*/
|
|
isActive?: boolean
|
|
}
|
|
|
|
/**
|
|
* This hook is used for handling user input.
|
|
* It's a more convenient alternative to using `StdinContext` and listening to `data` events.
|
|
* The callback you pass to `useInput` is called for each character when user enters any input.
|
|
* However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`.
|
|
*
|
|
* ```
|
|
* import {useInput} from 'ink';
|
|
*
|
|
* const UserInput = () => {
|
|
* useInput((input, key) => {
|
|
* if (input === 'q') {
|
|
* // Exit program
|
|
* }
|
|
*
|
|
* if (key.leftArrow) {
|
|
* // Left arrow key pressed
|
|
* }
|
|
* });
|
|
*
|
|
* return …
|
|
* };
|
|
* ```
|
|
*/
|
|
const useInput = (inputHandler: Handler, options: Options = {}) => {
|
|
const { setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin()
|
|
|
|
// useLayoutEffect (not useEffect) so that raw mode is enabled synchronously
|
|
// during React's commit phase, before render() returns. With useEffect, raw
|
|
// mode setup is deferred to the next event loop tick via React's scheduler,
|
|
// leaving the terminal in cooked mode — keystrokes echo and the cursor is
|
|
// visible until the effect fires.
|
|
useLayoutEffect(() => {
|
|
if (options.isActive === false) {
|
|
return
|
|
}
|
|
|
|
setRawMode(true)
|
|
|
|
return () => {
|
|
setRawMode(false)
|
|
}
|
|
}, [options.isActive, setRawMode])
|
|
|
|
// Register the listener once on mount so its slot in the EventEmitter's
|
|
// listener array is stable. If isActive were in the effect's deps, the
|
|
// listener would re-append on false→true, moving it behind listeners
|
|
// that registered while it was inactive — breaking
|
|
// stopImmediatePropagation() ordering. useEventCallback keeps the
|
|
// reference stable while reading latest isActive/inputHandler from
|
|
// closure (it syncs via useLayoutEffect, so it's compiler-safe).
|
|
const handleData = useEventCallback((event: InputEvent) => {
|
|
if (options.isActive === false) {
|
|
return
|
|
}
|
|
const { input, key } = event
|
|
|
|
// If app is not supposed to exit on Ctrl+C, then let input listener handle it
|
|
// Note: discreteUpdates is called at the App level when emitting events,
|
|
// so all listeners are already within a high-priority update context.
|
|
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
|
|
inputHandler(input, key, event)
|
|
}
|
|
})
|
|
|
|
useEffect(() => {
|
|
internal_eventEmitter?.on('input', handleData)
|
|
|
|
return () => {
|
|
internal_eventEmitter?.removeListener('input', handleData)
|
|
}
|
|
}, [internal_eventEmitter, handleData])
|
|
}
|
|
|
|
export default useInput
|