From 63f3b999cf3711c21df749d4ebc051118d5bc7bc Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Tue, 9 Dec 2025 15:14:51 +0100 Subject: [PATCH] Add hotkey feature, #122 --- resources/js/lib/utils.ts | 185 ++++++++++++++++++++++++++++++++ resources/js/pages/Invoices.vue | 90 +++++++++++----- 2 files changed, 249 insertions(+), 26 deletions(-) diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts index 3881141..302a6b1 100644 --- a/resources/js/lib/utils.ts +++ b/resources/js/lib/utils.ts @@ -134,4 +134,189 @@ export function randomDate(): Date { let d = new Date() d.setDate(d.getDate() - Math.random() * 20) return d +} + + +// ----------------------------------------------------------------------------- +// Hotkeys +// ----------------------------------------------------------------------------- + +// Füge diese Typdefinitionen am Anfang der Datei hinzu +type HotkeyCallback = (params?: any) => void; +type HotkeyEnabledCallback = () => boolean; + +// Füge diese Variablen am Anfang der Datei hinzu +const registeredHotkeys = new Map(); + + + +/** + * Registriert einen globalen Tastatur-Shortcut + * @param keyCombination Tastaturkombination (z.B. 'Mod+S' für automatische Erkennung von Cmd/Ctrl) + * @param callback Funktion, die aufgerufen werden soll + * @param callbackParameters Optionale Parameter für den Callback + * @param enabled Optionaler Callback, der angibt, ob der Shortcut aktiv ist + * @returns Funktion zum Entfernen des Shortcuts + * + * @example + * // Beispiel 1: Shortcut mit Plus-Taste + * const removeZoomInShortcut = hotkey('Mod+plus', () => { + * console.log('Zoom in') + * }); + * + * @example + * // Beispiel 2: Shortcut mit Pfeiltasten + * const removeMoveLeftShortcut = hotkey('leftarrow', () => { + * console.log('Move left') + * }); + * + * @example + * // Beispiel 3: Shortcut mit Enter-Taste + * const removeSubmitShortcut = hotkey('enter', () => { + * console.log('Submit form') + * }); + * + * @example + * // Beispiel 4: Shortcut mit Escape-Taste + * const removeCancelShortcut = hotkey('esc', () => { + * console.log('Cancel action') + * }); + * + * @example + * // Beispiel 5: Shortcut, der nur ausgelöst wird, wenn kein Eingabefeld fokussiert ist + * const removeGlobalSearchShortcut = hotkey('Mod+k', () => { + * console.log('Open global search') + * }); + */ +export function hotkey( + keyCombination: string, + callback: HotkeyCallback, + callbackParameters?: any, + enabled?: HotkeyEnabledCallback +): () => void { + // Ersetzen von 'Mod' durch den passenden Modifier + const modifier = getPlatformModifier() + + // Normalisieren der Tastaturkombination + const normalizedCombination = keyCombination + .toLowerCase() + .replace('mod', modifier) + .split('+') + .map(key => normalizeKeyName(key)) + .join('+') + + // Überprüfen, ob der Shortcut bereits registriert ist + if (registeredHotkeys.has(normalizedCombination)) { + console.warn(`Hotkey '${keyCombination}' is already registered. Overwriting existing hotkey.`) + } + + // Speichern der Hotkey-Informationen + registeredHotkeys.set(normalizedCombination, { + callback, + params: callbackParameters, + enabled + }); + + // Rückgabe einer Funktion zum Entfernen des Shortcuts + return () => { + registeredHotkeys.delete(normalizedCombination) + }; +} + +function getPlatformModifier(): string { + // Überprüfen, ob wir auf macOS/iOS sind + if (navigator.userAgent.includes('Macintosh') || + navigator.userAgent.includes('iPhone') || + navigator.userAgent.includes('iPad')) { + return 'cmd' + } + return 'ctrl' +} + +export function getPlatformModifierSymbol(): string { + if (navigator.userAgent.includes('Macintosh') || + navigator.userAgent.includes('iPhone') || + navigator.userAgent.includes('iPad')) { + return '⌘' + } + return 'Ctrl' +} + +function normalizeKeyName(key: string): string { + const keyMap: Record = { + '+': 'plus', + '-': 'minus', + 'arrowleft': 'leftarrow', + 'arrowright': 'rightarrow', + 'arrowup': 'uparrow', + 'arrowdown': 'downarrow', + 'return': 'enter', + 'esc': 'escape' + }; + + // Konvertiere den Tastennamen in Kleinbuchstaben + const lowerKey = key.toLowerCase() + + // Ersetze den Tastennamen, falls in der Map vorhanden + return keyMap[lowerKey] || lowerKey +} + +function handleKeyboardEvent(event: KeyboardEvent) { + // Überprüfen, ob das Event-Element ein Eingabefeld, Textarea oder ähnliches ist + const target = event.target as HTMLElement; + if (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable) { + // Wenn das Event-Element ein Eingabefeld ist, den Shortcut nicht auslösen + return; + } + + // Erstellen einer normalisierten Tastaturkombination + let combination = '' + + // Bestimmen des passenden Modifiers + const modifier = getPlatformModifier() + + // Hinzufügen von Modifier-Tasten + if ((modifier === 'ctrl' && event.ctrlKey) || + (modifier === 'cmd' && event.metaKey)) { + combination += `${modifier}+` + } + if (event.shiftKey) { + combination += 'shift+' + } + if (event.altKey) { + combination += 'alt+' + } + + // Hinzufügen der Haupttaste + combination += normalizeKeyName(event.key) + + // Überprüfen, ob der Shortcut registriert ist + const hotkeyInfo = registeredHotkeys.get(combination); + if (hotkeyInfo) { + // Überprüfen, ob der Shortcut aktiv ist + if (!hotkeyInfo.enabled || hotkeyInfo.enabled()) { + // Ausführen des Callbacks mit den Parametern + hotkeyInfo.callback(hotkeyInfo.params) + + // Verhindern der Standardaktion, falls gewünscht + event.preventDefault() + event.stopPropagation() + return 0 + } + } +} + +// Registrieren des globalen Event-Listeners +window.addEventListener('keydown', handleKeyboardEvent); + +// Füge die Funktion zum Entfernen aller Hotkeys hinzu +export function removeAllHotkeys() { + registeredHotkeys.clear() } \ No newline at end of file diff --git a/resources/js/pages/Invoices.vue b/resources/js/pages/Invoices.vue index ab938e5..8f4b10e 100644 --- a/resources/js/pages/Invoices.vue +++ b/resources/js/pages/Invoices.vue @@ -17,6 +17,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd' import { statusBadgeLabels } from '@/components/ui/status-badge' import AppHeader from '@/components/AppHeader.vue' import InvoiceDialog from '@/components/documents/InvoiceDialog.vue' +import { hotkey, getPlatformModifierSymbol } from '@/lib/utils' // initial invoice data from inertia interface Props { @@ -47,6 +48,21 @@ onMounted(async () => { if (params.get('action') == 'new') createInvoice() searchField.value = document.getElementById('search') + + // register hotkeys + hotkey('n', createInvoice) + hotkey('mod+leftarrow', () => { + if (selectedYearIndex.value < (years.value.length - 1)) { + selectedYearIndex.value++ + } + }) + hotkey('mod+rightarrow', () => { + if (selectedYearIndex.value > 0) { + selectedYearIndex.value-- + } + }) + hotkey('a', () => { selectedYearIndex.value = -1 }) + hotkey('mod+f', () => { searchField.value.focus() }) }) const years = computed((): number[] => { @@ -140,30 +156,54 @@ const onDeleteInvoice = async (id: number) => { @@ -193,8 +233,6 @@ const onDeleteInvoice = async (id: number) => { Neue Rechnung anlegen - - Ctrl N