185 lines
5.0 KiB
TypeScript
185 lines
5.0 KiB
TypeScript
import { useEffect, useCallback, useReducer, useMemo } from 'react'
|
|
|
|
const disabledTargets = ['INPUT', 'TEXTAREA']
|
|
|
|
/* Usage:
|
|
commands: KeyboardCommand[] = [{
|
|
shortcutKeys: ['shift|alt', 'r'],
|
|
callback: () => {}
|
|
}
|
|
]
|
|
*/
|
|
|
|
const KBAR_KEYS = ['control', 'meta', 'v', 'i', 's', 'n', '[', ']']
|
|
|
|
type KeyPressed = {
|
|
[name: string]: boolean
|
|
}
|
|
|
|
export type KeyboardCommand = {
|
|
shortcutKeys: string[]
|
|
callback: () => void
|
|
actionDescription: string
|
|
shortcutKeyDescription: string
|
|
ignoreDisabledTargets?: boolean
|
|
}
|
|
|
|
enum ActionType {
|
|
SET_KEY_DOWN,
|
|
SET_KEY_UP,
|
|
}
|
|
|
|
const keysReducer = (
|
|
state: KeyPressed,
|
|
action: { type: ActionType; key: string }
|
|
): KeyPressed => {
|
|
switch (action.type) {
|
|
case ActionType.SET_KEY_DOWN:
|
|
return { ...state, [action.key]: true }
|
|
case ActionType.SET_KEY_UP:
|
|
return { ...state, [action.key]: false }
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
export const useKeyboardShortcuts = (commands: KeyboardCommand[]): void => {
|
|
const initalKeyMapping = useMemo(() => {
|
|
const currentKeys: KeyPressed = {}
|
|
commands.forEach((command) => {
|
|
command.shortcutKeys.forEach((key) => {
|
|
const aliases = key.split('|')
|
|
aliases.forEach((k) => {
|
|
currentKeys[k.toLowerCase()] = false
|
|
})
|
|
})
|
|
})
|
|
|
|
KBAR_KEYS.map((key) => currentKeys[key.toLowerCase()] = false)
|
|
return currentKeys
|
|
}, [commands])
|
|
|
|
const [keys, setKeys] = useReducer(keysReducer, initalKeyMapping)
|
|
|
|
const applyCommands = useCallback(
|
|
(updatedKeys: KeyPressed, disabled?: boolean) => {
|
|
let commandApplied = false
|
|
commands.forEach((command) => {
|
|
if (disabled && !command.ignoreDisabledTargets) {
|
|
return
|
|
}
|
|
const tempState = { ...updatedKeys }
|
|
const arePressed = command.shortcutKeys.every((key) => {
|
|
const aliases = key.split('|')
|
|
for (const k of aliases) {
|
|
if (tempState[k]) {
|
|
delete tempState[k]
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
// apply callback if 1)corresponding buttons are pressed 2) no other buttons are pressed
|
|
if (arePressed && !Object.values(tempState).some((k) => k)) {
|
|
command.callback()
|
|
commandApplied = true
|
|
// remove applied keys from state to prevent Key Lifetime smashes
|
|
command.shortcutKeys.forEach((key, index) => {
|
|
// don't remove a base key to allow non-stop actions
|
|
if (index === 0 && command.shortcutKeys.length > 1) return
|
|
const aliases = key.split('|')
|
|
for (const l of aliases) {
|
|
setKeys({ type: ActionType.SET_KEY_UP, key: l })
|
|
}
|
|
})
|
|
}
|
|
})
|
|
return commandApplied
|
|
},
|
|
[commands]
|
|
)
|
|
|
|
const keydownListener = useCallback(
|
|
(keydownEvent) => {
|
|
const { target } = keydownEvent
|
|
if (!keydownEvent.key) return
|
|
const key = keydownEvent.key.toLowerCase()
|
|
|
|
if (keys[key] === undefined) return
|
|
if (keys[key] === false) {
|
|
setKeys({ type: ActionType.SET_KEY_DOWN, key })
|
|
}
|
|
const commandApplied = applyCommands(
|
|
{ ...keys, [key]: true },
|
|
disabledTargets.includes(target.tagName)
|
|
)
|
|
if (commandApplied) {
|
|
keydownEvent.preventDefault()
|
|
}
|
|
},
|
|
[applyCommands, keys]
|
|
)
|
|
|
|
const keyupListener = useCallback(
|
|
(keyupEvent) => {
|
|
if (!keyupEvent.key) return
|
|
const key = keyupEvent.key.toLowerCase()
|
|
if (keys[key] === undefined) return
|
|
|
|
if (keys[key] === true) {
|
|
setTimeout(() => setKeys({ type: ActionType.SET_KEY_UP, key }), 800)
|
|
}
|
|
},
|
|
[keys]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
window.addEventListener('keydown', keydownListener, true)
|
|
return () => window.removeEventListener('keydown', keydownListener, true)
|
|
}, [keydownListener])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
window.addEventListener('keyup', keyupListener, true)
|
|
return () => window.removeEventListener('keyup', keyupListener, true)
|
|
}, [keyupListener])
|
|
}
|
|
|
|
export const useConfirmListener = (
|
|
onConfirm?: () => void,
|
|
onCancel?: () => void,
|
|
ctrlOnConfirm = true
|
|
): void => {
|
|
const keyHandleConfirm = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
const key = e.key.toLowerCase()
|
|
if (onConfirm && key === 'enter') {
|
|
// if control key is required we make sure CTRL on
|
|
// meta is pressed when pressing enter.
|
|
if (ctrlOnConfirm && !e.metaKey && !e.ctrlKey) {
|
|
return
|
|
}
|
|
onConfirm()
|
|
} else if (onCancel && key === 'escape') {
|
|
onCancel()
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[onCancel, onConfirm]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
window.addEventListener('keydown', keyHandleConfirm)
|
|
return () => {
|
|
window.removeEventListener('keydown', keyHandleConfirm)
|
|
}
|
|
}, [keyHandleConfirm])
|
|
}
|