import type { MaybeRefOrGetter } from 'vue' import { ref, toValue, watch } from 'vue' import type { KeyFilter, KeyPredicate } from '@vueuse/core' import { onKeyStroke, useEventListener } from '@vueuse/core' import { useWindow } from './useWindow' export interface UseKeyPressOptions { actInsideInputWithModifier?: MaybeRefOrGetter } export function isInputDOMNode(event: KeyboardEvent): boolean { const target = (event.composedPath?.()?.[0] || event.target) as HTMLElement const hasAttribute = typeof target?.hasAttribute === 'function' ? target.hasAttribute('contenteditable') : false const closest = typeof target?.closest === 'function' ? target.closest('.nokey') : null // when an input field is focused we don't want to trigger deletion or movement of nodes return ['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.nodeName) || hasAttribute || !!closest } // we want to be able to do a multi selection event if we are in an input field function wasModifierPressed(event: KeyboardEvent) { return event.ctrlKey || event.metaKey || event.shiftKey } function isKeyMatch(pressedKey: string, keyToMatch: string, pressedKeys: Set, isKeyUp: boolean) { const keyCombination = keyToMatch.split('+').map((k) => k.trim().toLowerCase()) if (keyCombination.length === 1) { return pressedKey.toLowerCase() === keyToMatch.toLowerCase() } if (isKeyUp) { pressedKeys.delete(pressedKey.toLowerCase()) } else { pressedKeys.add(pressedKey.toLowerCase()) } return keyCombination.every( (key, index) => pressedKeys.has(key) && Array.from(pressedKeys.values())[index] === keyCombination[index], ) } function createKeyPredicate(keyFilter: string | string[], pressedKeys: Set): KeyPredicate { return (event: KeyboardEvent) => { if (!event.code && !event.key) { return false } const keyOrCode = useKeyOrCode(event.code, keyFilter) // if the keyFilter is an array of multiple keys, we need to check each possible key combination if (Array.isArray(keyFilter)) { return keyFilter.some((key) => isKeyMatch(event[keyOrCode], key, pressedKeys, event.type === 'keyup')) } // if the keyFilter is a string, we need to check if the key matches the string return isKeyMatch(event[keyOrCode], keyFilter, pressedKeys, event.type === 'keyup') } } function useKeyOrCode(code: string, keysToWatch: string | string[]) { if (typeof keysToWatch === 'string') { return code === keysToWatch ? 'code' : 'key' } return keysToWatch.includes(code) ? 'code' : 'key' } /** * Reactive key press state * * todo: make this public? * @internal * @param keyFilter - Can be a boolean, a string or an array of strings. If it's a boolean, it will always return that value. If it's a string, it will return true if the key is pressed. If it's an array of strings, it will return true if any of the keys are pressed, or a combination is pressed (e.g. ['ctrl+a', 'ctrl+b']) * @param onChange - Callback function that will be called when the key state changes */ export function useKeyPress( keyFilter: MaybeRefOrGetter, onChange?: (keyPressed: boolean) => void, options: UseKeyPressOptions = { actInsideInputWithModifier: true }, ) { const window = useWindow() const isPressed = ref(toValue(keyFilter) === true) let modifierPressed = false const pressedKeys = new Set() let currentFilter = createKeyFilterFn(toValue(keyFilter)) watch(isPressed, (isKeyPressed, wasPressed) => { if (isKeyPressed !== wasPressed) { onChange?.(isKeyPressed) } }) watch( () => toValue(keyFilter), (nextKeyFilter, previousKeyFilter) => { // if the previous keyFilter was a boolean but is now something else, we need to reset the isPressed value if (typeof previousKeyFilter === 'boolean' && typeof nextKeyFilter !== 'boolean') { reset() } currentFilter = createKeyFilterFn(nextKeyFilter) }, { immediate: true, }, ) useEventListener(window, 'blur', () => { if (toValue(keyFilter) !== true) { isPressed.value = false } }) onKeyStroke( (...args) => currentFilter(...args), (e) => { modifierPressed = wasModifierPressed(e) const preventAction = (!modifierPressed || (modifierPressed && !options.actInsideInputWithModifier)) && isInputDOMNode(e) if (preventAction) { return } e.preventDefault() isPressed.value = true }, { eventName: 'keydown' }, ) onKeyStroke( (...args) => currentFilter(...args), (e) => { if (isPressed.value) { const preventAction = (!modifierPressed || (modifierPressed && !options.actInsideInputWithModifier)) && isInputDOMNode(e) if (preventAction) { return } reset() } }, { eventName: 'keyup' }, ) return isPressed function reset() { modifierPressed = false pressedKeys.clear() isPressed.value = false } function createKeyFilterFn(keyFilter: KeyFilter | null) { // if the keyFilter is null, we just set the isPressed value to false if (keyFilter === null) { reset() return () => false } // if the keyFilter is a boolean, we just set the isPressed value to that boolean if (typeof keyFilter === 'boolean') { reset() isPressed.value = keyFilter return () => false } if (Array.isArray(keyFilter) || typeof keyFilter === 'string') { return createKeyPredicate(keyFilter, pressedKeys) } return keyFilter } }