import { ref, computed } from 'vue' import type { ComputedRef, WatchSource } from 'vue' import { logicAnd, logicNot } from '@vueuse/math' import { useEventListener, useDebounceFn, createSharedComposable, useActiveElement } from '@vueuse/core' export interface ShortcutConfig { handler: Function usingInput?: string | boolean whenever?: WatchSource[] prevent?: boolean } export interface ShortcutsConfig { [key: string]: ShortcutConfig | Function } export interface ShortcutsOptions { chainDelay?: number } interface Shortcut { handler: Function condition: ComputedRef chained: boolean // KeyboardEvent attributes key: string ctrlKey: boolean metaKey: boolean shiftKey: boolean altKey: boolean // code?: string // keyCode?: number prevent?: boolean } const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/ const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/ export const useShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => { const { macOS, usingInput } = _useShortcuts() let shortcuts: Shortcut[] = [] const chainedInputs = ref([]) const clearChainedInput = () => { chainedInputs.value.splice(0, chainedInputs.value.length) } const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800) const onKeyDown = (e: KeyboardEvent) => { // Input autocomplete triggers a keydown event if (!e.key) { return } const alphabeticalKey = /^[a-z]{1}$/i.test(e.key) let chainedKey chainedInputs.value.push(e.key) // try matching a chained shortcut if (chainedInputs.value.length >= 2) { chainedKey = chainedInputs.value.slice(-2).join('-') for (const shortcut of shortcuts.filter(s => s.chained)) { if (shortcut.key !== chainedKey) { continue } if (shortcut.condition.value) { e.stopPropagation shortcut.prevent && e.preventDefault() shortcut.handler() } clearChainedInput() return } } // try matching a standard shortcut for (const shortcut of shortcuts.filter(s => !s.chained)) { if (e.key.toLowerCase() !== shortcut.key) { continue } if (e.metaKey !== shortcut.metaKey) { continue } if (e.ctrlKey !== shortcut.ctrlKey) { continue } // shift modifier is only checked in combination with alphabetical keys // (shift with non-alphabetical keys would change the key) if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue } // alt modifier changes the combined key anyways // if (e.altKey !== shortcut.altKey) { continue } if (shortcut.condition.value) { e.preventDefault() shortcut.handler() } clearChainedInput() return } debouncedClearChainedInput() } // Map config to full detailled shortcuts shortcuts = Object.entries(config).map(([key, shortcutConfig]) => { if (!shortcutConfig) { return null } // Parse key and modifiers let shortcut: Partial if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) { console.trace(`[Shortcut] Invalid key: "${key}"`) } if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) { console.trace(`[Shortcut] Invalid key: "${key}"`) } const chained = key.includes('-') && key !== '-' if (chained) { shortcut = { key: key.toLowerCase(), metaKey: false, ctrlKey: false, shiftKey: false, altKey: false } } else { const keySplit = key.toLowerCase().split('_').map(k => k) shortcut = { key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'), metaKey: keySplit.includes('meta'), ctrlKey: keySplit.includes('ctrl'), shiftKey: keySplit.includes('shift'), altKey: keySplit.includes('alt') } } shortcut.chained = chained // Convert Meta to Ctrl for non-MacOS if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) { shortcut.metaKey = false shortcut.ctrlKey = true } // Retrieve handler function if (typeof shortcutConfig === 'function') { shortcut.handler = shortcutConfig } else if (typeof shortcutConfig === 'object') { shortcut = { ...shortcut, handler: shortcutConfig.handler, prevent: shortcutConfig.prevent } } if (!shortcut.handler) { console.trace('[Shortcut] Invalid value') return null } // Create shortcut computed const conditions: ComputedRef[] = [] if (!(shortcutConfig as ShortcutConfig).usingInput) { conditions.push(logicNot(usingInput)) } else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') { conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput)) } shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || [])) return shortcut as Shortcut }).filter(Boolean) as Shortcut[] useEventListener('keydown', onKeyDown) } export const _useShortcuts = () => { const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/)) const metaSymbol = ref(' ') const activeElement = useActiveElement() const usingInput = computed(() => { const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true') if (usingInput) { return ((activeElement.value as any)?.name as string) || true } return false }) onMounted(() => { metaSymbol.value = macOS.value ? '⌘' : 'Ctrl' }) return { macOS, metaSymbol, activeElement, usingInput } }