194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
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<boolean>[]
|
|
prevent?: boolean
|
|
}
|
|
|
|
export interface ShortcutsConfig {
|
|
[key: string]: ShortcutConfig | Function
|
|
}
|
|
|
|
export interface ShortcutsOptions {
|
|
chainDelay?: number
|
|
}
|
|
|
|
interface Shortcut {
|
|
handler: Function
|
|
condition: ComputedRef<boolean>
|
|
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<string[]>([])
|
|
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<Shortcut>
|
|
|
|
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<boolean>[] = []
|
|
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
|
|
}
|
|
} |