Improve shortcut capture with key combinations

This commit is contained in:
Pandipipas
2026-02-25 14:15:11 +01:00
parent 8a3b3ebcaf
commit 5fd5224f0c
4 changed files with 182 additions and 29 deletions
+3
View File
@@ -23,6 +23,7 @@ type Translations = {
settingsShortcutRightDecrementLabel: string; settingsShortcutRightDecrementLabel: string;
settingsShortcutRightDecrementHint: string; settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string; settingsShortcutReset: string;
settingsShortcutRecordingHint: string;
languageEnglish: string; languageEnglish: string;
languageSpanish: string; languageSpanish: string;
scoreboardUnassigned: string; scoreboardUnassigned: string;
@@ -109,6 +110,7 @@ const messages: Record<Locale, Translations> = {
settingsShortcutRightDecrementLabel: 'P2 score -1', settingsShortcutRightDecrementLabel: 'P2 score -1',
settingsShortcutRightDecrementHint: 'Decreases right player score by one.', settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
settingsShortcutReset: 'Reset shortcuts', settingsShortcutReset: 'Reset shortcuts',
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
languageEnglish: 'English', languageEnglish: 'English',
languageSpanish: 'Spanish', languageSpanish: 'Spanish',
scoreboardUnassigned: '(Unassigned)', scoreboardUnassigned: '(Unassigned)',
@@ -191,6 +193,7 @@ const messages: Record<Locale, Translations> = {
settingsShortcutRightDecrementLabel: 'Score P2 -1', settingsShortcutRightDecrementLabel: 'Score P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.', settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos', settingsShortcutReset: 'Restablecer atajos',
settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).',
languageEnglish: 'Inglés', languageEnglish: 'Inglés',
languageSpanish: 'Castellano', languageSpanish: 'Castellano',
scoreboardUnassigned: '(Sin asignar)', scoreboardUnassigned: '(Sin asignar)',
+6 -7
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, onMounted, onUnmounted } from 'vue';
import { t } from './i18n'; import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard'; import { useScoreboardStore } from './stores/scoreboard';
import { useShortcutSettingsStore } from './stores/shortcut-settings'; import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
const menuItems = computed(() => [ const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' }, { label: t('menuDashboard'), to: '/', icon: 'dashboard' },
@@ -27,31 +27,30 @@ const isEditableTarget = (target: EventTarget | null): boolean => {
}; };
const onShortcutPress = (event: KeyboardEvent) => { const onShortcutPress = (event: KeyboardEvent) => {
if (event.ctrlKey || event.altKey || event.metaKey || isEditableTarget(event.target)) { if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') {
return; return;
} }
const key = event.key.toLowerCase();
const { shortcuts } = shortcutSettingsStore; const { shortcuts } = shortcutSettingsStore;
if (key === shortcuts.leftIncrement) { if (isShortcutMatch(event, shortcuts.leftIncrement)) {
scoreboardStore.leftScore += 1; scoreboardStore.leftScore += 1;
event.preventDefault(); event.preventDefault();
return; return;
} }
if (key === shortcuts.leftDecrement) { if (isShortcutMatch(event, shortcuts.leftDecrement)) {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
event.preventDefault(); event.preventDefault();
return; return;
} }
if (key === shortcuts.rightIncrement) { if (isShortcutMatch(event, shortcuts.rightIncrement)) {
scoreboardStore.rightScore += 1; scoreboardStore.rightScore += 1;
event.preventDefault(); event.preventDefault();
return; return;
} }
if (key === shortcuts.rightDecrement) { if (isShortcutMatch(event, shortcuts.rightDecrement)) {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
event.preventDefault(); event.preventDefault();
} }
@@ -2,35 +2,88 @@ import { defineStore } from 'pinia';
import { reactive } from 'vue'; import { reactive } from 'vue';
export type ShortcutAction = 'leftIncrement' | 'leftDecrement' | 'rightIncrement' | 'rightDecrement'; export type ShortcutAction = 'leftIncrement' | 'leftDecrement' | 'rightIncrement' | 'rightDecrement';
export type ShortcutSettings = Record<ShortcutAction, string>; export type ShortcutSettings = Record<ShortcutAction, string>;
type ShortcutParts = {
key: string;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
};
const STORAGE_KEY = 'scoreko-dev.shortcut-settings'; const STORAGE_KEY = 'scoreko-dev.shortcut-settings';
const defaultShortcuts: ShortcutSettings = { const defaultShortcuts: ShortcutSettings = {
leftIncrement: 'q', leftIncrement: 'Q',
leftDecrement: 'a', leftDecrement: 'A',
rightIncrement: 'p', rightIncrement: 'P',
rightDecrement: 'l', rightDecrement: 'L',
}; };
const normalizeShortcutKey = (value: unknown): string => { const normalizeKey = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
const normalized = value.trim();
return normalized.length === 1 ? normalized.toUpperCase() : '';
};
const normalizeShortcut = (value: unknown): string => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return ''; return '';
} }
const normalized = value.trim().toLowerCase(); const parts = value.split('+').map((part) => part.trim()).filter(Boolean);
return normalized.slice(0, 1); if (parts.length === 0) {
return '';
}
const modifiers = {
altKey: false,
ctrlKey: false,
shiftKey: false,
metaKey: false,
};
let key = '';
for (const part of parts) {
const lowered = part.toLowerCase();
if (lowered === 'alt') {
modifiers.altKey = true;
continue;
}
if (lowered === 'ctrl' || lowered === 'control') {
modifiers.ctrlKey = true;
continue;
}
if (lowered === 'shift') {
modifiers.shiftKey = true;
continue;
}
if (lowered === 'meta' || lowered === 'cmd' || lowered === 'command') {
modifiers.metaKey = true;
continue;
}
key = normalizeKey(part);
}
if (!key) {
return '';
}
return formatShortcut({ key, ...modifiers });
}; };
const normalizeSettings = (input: unknown): ShortcutSettings => { const normalizeSettings = (input: unknown): ShortcutSettings => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}; const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return { return {
leftIncrement: normalizeShortcutKey(candidate.leftIncrement) || defaultShortcuts.leftIncrement, leftIncrement: normalizeShortcut(candidate.leftIncrement) || defaultShortcuts.leftIncrement,
leftDecrement: normalizeShortcutKey(candidate.leftDecrement) || defaultShortcuts.leftDecrement, leftDecrement: normalizeShortcut(candidate.leftDecrement) || defaultShortcuts.leftDecrement,
rightIncrement: normalizeShortcutKey(candidate.rightIncrement) || defaultShortcuts.rightIncrement, rightIncrement: normalizeShortcut(candidate.rightIncrement) || defaultShortcuts.rightIncrement,
rightDecrement: normalizeShortcutKey(candidate.rightDecrement) || defaultShortcuts.rightDecrement, rightDecrement: normalizeShortcut(candidate.rightDecrement) || defaultShortcuts.rightDecrement,
}; };
}; };
@@ -44,6 +97,7 @@ const readStoredSettings = (): ShortcutSettings => {
if (!raw) { if (!raw) {
return { ...defaultShortcuts }; return { ...defaultShortcuts };
} }
return normalizeSettings(JSON.parse(raw)); return normalizeSettings(JSON.parse(raw));
} catch { } catch {
return { ...defaultShortcuts }; return { ...defaultShortcuts };
@@ -58,14 +112,57 @@ const persistSettings = (settings: ShortcutSettings) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}; };
export const formatShortcut = ({ key, altKey, ctrlKey, shiftKey, metaKey }: ShortcutParts): string => {
const parts: string[] = [];
if (ctrlKey) {
parts.push('Ctrl');
}
if (altKey) {
parts.push('Alt');
}
if (shiftKey) {
parts.push('Shift');
}
if (metaKey) {
parts.push('Meta');
}
parts.push(key);
return parts.join('+');
};
export const eventToShortcut = (event: KeyboardEvent): string => {
const key = normalizeKey(event.key);
if (!key) {
return '';
}
return formatShortcut({
key,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
});
};
export const isShortcutMatch = (event: KeyboardEvent, shortcut: string): boolean => {
const normalizedShortcut = normalizeShortcut(shortcut);
if (!normalizedShortcut) {
return false;
}
return eventToShortcut(event) === normalizedShortcut;
};
export const useShortcutSettingsStore = defineStore('shortcut-settings', () => { export const useShortcutSettingsStore = defineStore('shortcut-settings', () => {
const shortcuts = reactive<ShortcutSettings>(readStoredSettings()); const shortcuts = reactive<ShortcutSettings>(readStoredSettings());
const setShortcut = (action: ShortcutAction, value: string) => { const setShortcut = (action: ShortcutAction, value: string) => {
const normalized = normalizeShortcutKey(value); const normalized = normalizeShortcut(value);
if (!normalized) { if (!normalized) {
return; return;
} }
shortcuts[action] = normalized; shortcuts[action] = normalized;
persistSettings(shortcuts); persistSettings(shortcuts);
}; };
+63 -9
View File
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import type { ShortcutAction } from '../stores/shortcut-settings'; import {
import { useShortcutSettingsStore } from '../stores/shortcut-settings'; eventToShortcut,
type ShortcutAction,
useShortcutSettingsStore,
} from '../stores/shortcut-settings';
defineOptions({ name: 'SettingsView' }); defineOptions({ name: 'SettingsView' });
@@ -21,6 +24,7 @@ const selectedLanguage = computed<Locale>({
}); });
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
{ action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') }, { action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') },
@@ -29,10 +33,51 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') }, { action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]); ]);
const updateShortcut = (action: ShortcutAction, value: string | number | null) => { const stopRecording = () => {
shortcutSettingsStore.setShortcut(action, String(value ?? '')); recordingAction.value = null;
if (typeof document !== 'undefined') {
delete document.body.dataset.shortcutRecording;
}
}; };
const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) {
return;
}
const shortcut = eventToShortcut(event);
if (!shortcut) {
return;
}
event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording();
};
const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) {
stopRecording();
return;
}
recordingAction.value = action;
if (typeof document !== 'undefined') {
document.body.dataset.shortcutRecording = 'true';
}
};
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onRecordKeydown);
}
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onRecordKeydown);
}
stopRecording();
});
useHead(() => ({ title: t('settingsTitle') })); useHead(() => ({ title: t('settingsTitle') }));
</script> </script>
@@ -70,7 +115,7 @@ useHead(() => ({ title: t('settingsTitle') }));
flat flat
bordered bordered
class="q-pa-md" class="q-pa-md"
style="max-width: 420px;" style="max-width: 500px;"
> >
<QCardSection class="q-pa-none"> <QCardSection class="q-pa-none">
<div class="text-subtitle1 q-mb-md"> <div class="text-subtitle1 q-mb-md">
@@ -87,12 +132,21 @@ useHead(() => ({ title: t('settingsTitle') }));
:key="field.action" :key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]" :model-value="shortcutSettingsStore.shortcuts[field.action]"
outlined outlined
maxlength="1" readonly
:label="field.label" :label="field.label"
@update:model-value="(value) => updateShortcut(field.action, value)"
> >
<template #append>
<QBtn
flat
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
@click="startRecording(field.action)"
/>
</template>
<template #hint> <template #hint>
{{ field.hint }} {{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
</template> </template>
</QInput> </QInput>
</div> </div>