From 80b413a724d0702d66df9488b9a974f0d7ba0d41 Mon Sep 17 00:00:00 2001 From: Sylvain Marroufin Date: Thu, 18 Apr 2024 15:51:43 +0200 Subject: [PATCH] feat(defineShortcuts): migrate with reactivity (#72) --- playground/app.vue | 1 + playground/pages/shortcuts.vue | 76 +++++++++ src/runtime/composables/defineShortcuts.ts | 175 +++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 playground/pages/shortcuts.vue create mode 100644 src/runtime/composables/defineShortcuts.ts diff --git a/playground/app.vue b/playground/app.vue index aedbd599..8da7677b 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -33,6 +33,7 @@ const components = [ 'popover', 'radio-group', 'separator', + 'shortcuts', 'skeleton', 'slideover', 'slider', diff --git a/playground/pages/shortcuts.vue b/playground/pages/shortcuts.vue new file mode 100644 index 00000000..7dee620b --- /dev/null +++ b/playground/pages/shortcuts.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/runtime/composables/defineShortcuts.ts b/src/runtime/composables/defineShortcuts.ts new file mode 100644 index 00000000..b4766009 --- /dev/null +++ b/src/runtime/composables/defineShortcuts.ts @@ -0,0 +1,175 @@ +import { ref, computed, toValue } from 'vue' +import type { MaybeRef } from 'vue' +import { useEventListener, useDebounceFn } from '@vueuse/core' +import { useShortcuts } from './useShortcuts' + +type Handler = () => void + +export interface ShortcutConfig { + handler: Handler + usingInput?: string | boolean +} + +export interface ShortcutsConfig { + [key: string]: ShortcutConfig | Handler | false | null | undefined +} + +export interface ShortcutsOptions { + chainDelay?: number +} + +interface Shortcut { + handler: Handler + enabled: boolean + chained: boolean + // KeyboardEvent attributes + key: string + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean + // code?: string + // keyCode?: number +} + +const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/ +const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/ + +export const defineShortcuts = (config: MaybeRef, options: ShortcutsOptions = {}) => { + const { macOS, usingInput } = useShortcuts() + + 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.value.filter(s => s.chained)) { + if (shortcut.key !== chainedKey) { + continue + } + + if (shortcut.enabled) { + e.preventDefault() + shortcut.handler() + } + clearChainedInput() + return + } + } + + // try matching a standard shortcut + for (const shortcut of shortcuts.value.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.enabled) { + e.preventDefault() + shortcut.handler() + } + clearChainedInput() + return + } + + debouncedClearChainedInput() + } + + // Map config to full detailled shortcuts + const shortcuts = computed(() => { + return Object.entries(toValue(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 } + } + + if (!shortcut.handler) { + console.trace('[Shortcut] Invalid value') + return null + } + + let enabled = true + if (!(shortcutConfig as ShortcutConfig).usingInput) { + enabled = !usingInput.value + } else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') { + enabled = usingInput.value === (shortcutConfig as ShortcutConfig).usingInput + } + shortcut.enabled = enabled + + return shortcut + }).filter(Boolean) as Shortcut[] + }) + + useEventListener('keydown', onKeyDown) +}