Add hotkey feature, #122
This commit is contained in:
@@ -134,4 +134,189 @@ export function randomDate(): Date {
|
|||||||
let d = new Date()
|
let d = new Date()
|
||||||
d.setDate(d.getDate() - Math.random() * 20)
|
d.setDate(d.getDate() - Math.random() * 20)
|
||||||
return d
|
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()
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|||||||
import { statusBadgeLabels } from '@/components/ui/status-badge'
|
import { statusBadgeLabels } from '@/components/ui/status-badge'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import InvoiceDialog from '@/components/documents/InvoiceDialog.vue'
|
import InvoiceDialog from '@/components/documents/InvoiceDialog.vue'
|
||||||
|
import { hotkey, getPlatformModifierSymbol } from '@/lib/utils'
|
||||||
|
|
||||||
// initial invoice data from inertia
|
// initial invoice data from inertia
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -47,6 +48,21 @@ onMounted(async () => {
|
|||||||
if (params.get('action') == 'new') createInvoice()
|
if (params.get('action') == 'new') createInvoice()
|
||||||
|
|
||||||
searchField.value = document.getElementById('search')
|
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[] => {
|
const years = computed((): number[] => {
|
||||||
@@ -140,30 +156,54 @@ const onDeleteInvoice = async (id: number) => {
|
|||||||
<AppHeader>
|
<AppHeader>
|
||||||
<!-- Year select -->
|
<!-- Year select -->
|
||||||
<template #left>
|
<template #left>
|
||||||
<Button variant="ghost" :disabled="selectedYearIndex >= (years.length - 1)"
|
<TooltipProvider>
|
||||||
@click="selectedYearIndex++">
|
<Tooltip>
|
||||||
<ChevronLeft />
|
<TooltipTrigger>
|
||||||
</Button>
|
<Button variant="ghost" :disabled="selectedYearIndex >= (years.length - 1)"
|
||||||
<Select size="sm" v-model="selectedYearIndex">
|
@click="selectedYearIndex++">
|
||||||
<SelectTrigger class="hover:bg-accent">
|
<ChevronLeft />
|
||||||
<SelectValue :placeholder="(new Date()).getFullYear().toString()"
|
</Button>
|
||||||
:disabled="years.length < 1" />
|
</TooltipTrigger>
|
||||||
</SelectTrigger>
|
<TooltipContent>
|
||||||
<SelectContent>
|
<span>Ein Jahr zurück</span>
|
||||||
<SelectGroup>
|
<KbdGroup class="ml-2">
|
||||||
<SelectItem :value="null">
|
<Kbd>{{ getPlatformModifierSymbol() }}</Kbd>
|
||||||
<SelectLabel>Alle</SelectLabel>
|
<Kbd>←</Kbd>
|
||||||
</SelectItem>
|
</KbdGroup>
|
||||||
<SelectSeparator />
|
</TooltipContent>
|
||||||
<SelectItem v-for="(year, index) in years" :value="index">
|
</Tooltip>
|
||||||
<SelectLabel>{{ year }}</SelectLabel>
|
<Select size="sm" v-model="selectedYearIndex">
|
||||||
</SelectItem>
|
<SelectTrigger class="hover:bg-accent">
|
||||||
</SelectGroup>
|
<SelectValue :placeholder="(new Date()).getFullYear().toString()"
|
||||||
</SelectContent>
|
:disabled="years.length < 1" />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
<Button variant="ghost" @click="selectedYearIndex--" :disabled="selectedYearIndex <= 0">
|
<SelectContent>
|
||||||
<ChevronRight />
|
<SelectGroup>
|
||||||
</Button>
|
<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>→</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Search field -->
|
<!-- Search field -->
|
||||||
@@ -193,8 +233,6 @@ const onDeleteInvoice = async (id: number) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<span>Neue Rechnung anlegen</span>
|
<span>Neue Rechnung anlegen</span>
|
||||||
<KbdGroup class="ml-2">
|
<KbdGroup class="ml-2">
|
||||||
<Kbd class="visible-mac">⌘</Kbd>
|
|
||||||
<Kbd class="visible-pc">Ctrl</Kbd>
|
|
||||||
<Kbd>N</Kbd>
|
<Kbd>N</Kbd>
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user