/** * COMPOSABLE: useKeyboardShortcuts * * Provides keyboard shortcut functionality with support for * modifier keys (Ctrl/Cmd, Shift, Alt) and custom key combinations. * * Usage: * ```js * import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' * * useKeyboardShortcuts({ * 'ctrl+s': saveForm, * 'ctrl+shift+p': addNewItem, * 'escape': closeDialog, * 'alt+n': () => console.log('Alt+N pressed') * }) * ``` */ import { onMounted, onUnmounted, ref } from 'vue' export function useKeyboardShortcuts(shortcuts, options = {}) { const { enabled = true, preventDefault = true, target = null // null means window, or pass a ref to an element } = options const isEnabled = ref(enabled) const registeredShortcuts = ref(shortcuts) /** * Normalizes a key string to lowercase and handles platform differences * @param {string} key - Key string * @returns {string} */ const normalizeKey = (key) => { return key.toLowerCase() .replace('meta', 'ctrl') // Treat Meta (Cmd) as Ctrl for consistency .replace('command', 'ctrl') } /** * Builds a shortcut string from a KeyboardEvent * @param {KeyboardEvent} event - Keyboard event * @returns {string} */ const buildShortcutString = (event) => { const parts = [] // Add modifiers in consistent order if (event.ctrlKey || event.metaKey) parts.push('ctrl') if (event.shiftKey) parts.push('shift') if (event.altKey) parts.push('alt') // Add the actual key (normalized) const key = event.key.toLowerCase() // Handle special keys const specialKeys = { ' ': 'space', 'arrowup': 'up', 'arrowdown': 'down', 'arrowleft': 'left', 'arrowright': 'right', 'enter': 'enter', 'escape': 'escape', 'tab': 'tab', 'backspace': 'backspace', 'delete': 'delete', 'insert': 'insert', 'home': 'home', 'end': 'end', 'pageup': 'pageup', 'pagedown': 'pagedown' } const normalizedKey = specialKeys[key] || key parts.push(normalizedKey) return parts.join('+') } /** * Checks if the event should be ignored (e.g., inside an input field) * @param {KeyboardEvent} event - Keyboard event * @returns {boolean} */ const shouldIgnoreEvent = (event) => { // Don't trigger shortcuts when typing in input fields (unless specified) const target = event.target const tagName = target.tagName.toLowerCase() // Check if target is an editable element if ( tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target.isContentEditable ) { // Allow Escape key even in input fields if (event.key === 'Escape') return false // Allow Ctrl+S (save) even in input fields if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { return false } return true } return false } /** * Handles keyboard events * @param {KeyboardEvent} event - Keyboard event */ const handleKeydown = (event) => { // Check if shortcuts are enabled if (!isEnabled.value) return // Check if we should ignore this event if (shouldIgnoreEvent(event)) return // Build the shortcut string const shortcutString = buildShortcutString(event) // Find matching shortcut const matchingShortcut = Object.keys(registeredShortcuts.value).find( shortcut => normalizeKey(shortcut) === shortcutString ) if (matchingShortcut) { const action = registeredShortcuts.value[matchingShortcut] // Prevent default browser behavior if configured if (preventDefault) { event.preventDefault() } // Execute the shortcut action if (typeof action === 'function') { action(event) } } } /** * Registers a new shortcut * @param {string} shortcut - Shortcut string (e.g., 'ctrl+s') * @param {Function} action - Action to execute */ const registerShortcut = (shortcut, action) => { registeredShortcuts.value[shortcut] = action } /** * Unregisters a shortcut * @param {string} shortcut - Shortcut string */ const unregisterShortcut = (shortcut) => { delete registeredShortcuts.value[shortcut] } /** * Enables keyboard shortcuts */ const enable = () => { isEnabled.value = true } /** * Disables keyboard shortcuts */ const disable = () => { isEnabled.value = false } /** * Gets all registered shortcuts * @returns {object} */ const getShortcuts = () => { return { ...registeredShortcuts.value } } // Setup and cleanup onMounted(() => { const element = target?.value || window element.addEventListener('keydown', handleKeydown) }) onUnmounted(() => { const element = target?.value || window element.removeEventListener('keydown', handleKeydown) }) return { registerShortcut, unregisterShortcut, enable, disable, getShortcuts, isEnabled } } /** * Composable for displaying keyboard shortcut hints */ export function useKeyboardShortcutHints(shortcuts) { /** * Formats a shortcut string for display * @param {string} shortcut - Shortcut string (e.g., 'ctrl+s') * @returns {string[]} Array of key labels */ const formatShortcut = (shortcut) => { const parts = shortcut.toLowerCase().split('+') const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 return parts.map(part => { // Platform-specific labels if (part === 'ctrl') { return isMac ? '⌘' : 'Ctrl' } if (part === 'shift') { return isMac ? '⇧' : 'Shift' } if (part === 'alt') { return isMac ? '⌥' : 'Alt' } // Special key labels const specialKeys = { space: 'Space', enter: 'Enter', escape: 'Esc', tab: 'Tab', backspace: 'Backspace', delete: 'Del', up: '↑', down: '↓', left: '←', right: '→', pageup: 'PgUp', pagedown: 'PgDn' } return specialKeys[part] || part.toUpperCase() }) } /** * Gets all shortcuts with formatted labels * @returns {Array} Array of {shortcut, keys, description} */ const getFormattedShortcuts = (descriptions = {}) => { return Object.keys(shortcuts).map(shortcut => ({ shortcut, keys: formatShortcut(shortcut), description: descriptions[shortcut] || '' })) } return { formatShortcut, getFormattedShortcuts } } /** * Common keyboard shortcuts presets */ export const commonShortcuts = { // Form actions save: 'ctrl+s', cancel: 'escape', submit: 'ctrl+enter', // Navigation nextField: 'tab', prevField: 'shift+tab', firstField: 'ctrl+home', lastField: 'ctrl+end', // Editing undo: 'ctrl+z', redo: 'ctrl+shift+z', copy: 'ctrl+c', cut: 'ctrl+x', paste: 'ctrl+v', selectAll: 'ctrl+a', // Item management addItem: 'ctrl+shift+p', deleteItem: 'ctrl+d', duplicateItem: 'ctrl+shift+d', // Search search: 'ctrl+f', searchNext: 'ctrl+g', searchPrev: 'ctrl+shift+g', // View toggleFullscreen: 'f11', zoomIn: 'ctrl+plus', zoomOut: 'ctrl+minus', resetZoom: 'ctrl+0' } /** * Keyboard shortcut descriptions for invoice form */ export const invoiceFormShortcuts = { 'ctrl+s': 'Rechnung speichern', 'ctrl+shift+p': 'Position hinzufügen', 'escape': 'Formular schließen', 'ctrl+shift+d': 'Als Entwurf speichern', 'alt+c': 'Kunde auswählen', 'alt+d': 'Datum ändern' } /** * Hook for managing shortcut conflicts */ export function useShortcutConflicts() { const conflicts = ref([]) /** * Checks for conflicting shortcuts * @param {object} shortcuts - Shortcuts object * @returns {Array} Array of conflicts */ const checkConflicts = (shortcuts) => { const normalized = {} const conflictsList = [] Object.keys(shortcuts).forEach(key => { const normalizedKey = key.toLowerCase() if (normalized[normalizedKey]) { conflictsList.push({ shortcut: key, conflictsWith: normalized[normalizedKey] }) } else { normalized[normalizedKey] = key } }) conflicts.value = conflictsList return conflictsList } /** * Checks if a shortcut conflicts with browser defaults * @param {string} shortcut - Shortcut string * @returns {boolean} */ const conflictsWithBrowser = (shortcut) => { const browserShortcuts = [ 'ctrl+t', // New tab 'ctrl+w', // Close tab 'ctrl+n', // New window 'ctrl+r', // Reload 'ctrl+p', // Print 'ctrl+o', // Open file 'ctrl+l', // Address bar 'ctrl+k', // Search bar 'ctrl+d', // Bookmark 'ctrl+h', // History 'ctrl+j', // Downloads 'ctrl+shift+t', // Reopen closed tab 'ctrl+shift+n', // New incognito window 'ctrl+shift+delete' // Clear browsing data ] return browserShortcuts.includes(shortcut.toLowerCase()) } return { conflicts, checkConflicts, conflictsWithBrowser } }