Add hotkey feature, #122

This commit is contained in:
2025-12-09 15:14:51 +01:00
parent 8173dacfb8
commit 63f3b999cf
2 changed files with 249 additions and 26 deletions
+185
View File
@@ -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<string, {
callback: HotkeyCallback;
params: any;
enabled: HotkeyEnabledCallback | undefined;
}>();
/**
* 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<string, string> = {
'+': '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()
}
+64 -26
View File
@@ -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) => {
<AppHeader>
<!-- Year select -->
<template #left>
<Button variant="ghost" :disabled="selectedYearIndex >= (years.length - 1)"
@click="selectedYearIndex++">
<ChevronLeft />
</Button>
<Select size="sm" v-model="selectedYearIndex">
<SelectTrigger class="hover:bg-accent">
<SelectValue :placeholder="(new Date()).getFullYear().toString()"
:disabled="years.length < 1" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value="null">
<SelectLabel>Alle</SelectLabel>
</SelectItem>
<SelectSeparator />
<SelectItem v-for="(year, index) in years" :value="index">
<SelectLabel>{{ year }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button variant="ghost" @click="selectedYearIndex--" :disabled="selectedYearIndex <= 0">
<ChevronRight />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" :disabled="selectedYearIndex >= (years.length - 1)"
@click="selectedYearIndex++">
<ChevronLeft />
</Button>
</TooltipTrigger>
<TooltipContent>
<span>Ein Jahr zurück</span>
<KbdGroup class="ml-2">
<Kbd>{{ getPlatformModifierSymbol() }}</Kbd>
<Kbd>&larr;</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
<Select size="sm" v-model="selectedYearIndex">
<SelectTrigger class="hover:bg-accent">
<SelectValue :placeholder="(new Date()).getFullYear().toString()"
:disabled="years.length < 1" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem :value="-1">
<SelectLabel>Alle</SelectLabel>
</SelectItem>
<SelectSeparator />
<SelectItem v-for="(year, index) in years" :value="index">
<SelectLabel>{{ year }}</SelectLabel>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" @click="selectedYearIndex--" :disabled="selectedYearIndex <= 0">
<ChevronRight />
</Button>
</TooltipTrigger>
<TooltipContent>
<span>Ein Jahr vorwärts</span>
<KbdGroup class="ml-2">
<Kbd>{{ getPlatformModifierSymbol() }}</Kbd>
<Kbd>&rarr;</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
<!-- Search field -->
@@ -193,8 +233,6 @@ const onDeleteInvoice = async (id: number) => {
<TooltipContent>
<span>Neue Rechnung anlegen</span>
<KbdGroup class="ml-2">
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>N</Kbd>
</KbdGroup>
</TooltipContent>