diff --git a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx index 7018f90d..d0bfd394 100644 --- a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx +++ b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx @@ -23,6 +23,7 @@ import { useRef } from 'react' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { SettingsDialogExtendedKey } from '@/features/settings/type' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import ShortcutTab from './tabs/ShortcutTab' import AccessibilityTab from './tabs/AccessibilityTab' const tabsStyle = css({ @@ -107,6 +108,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { {isWideScreen && t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)} + + keyboard + {isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)} + {isAdminOrOwner && ( @@ -130,6 +135,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { + {/* Transcription tab won't be accessible if the tab is not active in the tab list */} diff --git a/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx b/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx new file mode 100644 index 00000000..84bf975e --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx @@ -0,0 +1,217 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Shortcut } from '@/features/shortcuts/types' +import { shortcutCatalog } from '@/features/shortcuts/catalog' +import { + formatLongPressLabel, + formatShortcutLabel, + formatShortcutLabelForSR, + getKeyLabelFromCode, +} from '@/features/shortcuts/formatLabels' +import { css } from '@/styled-system/css' +import { useTranslation } from 'react-i18next' +import { text } from '@/primitives/Text' +import { buttonRecipe } from '@/primitives/buttonRecipe' +import { TabPanel, type TabPanelProps } from '@/primitives/Tabs' + +type ShortcutOverrides = Record + +const STORAGE_KEY = 'shortcuts:overrides' + +const loadOverrides = (): ShortcutOverrides => { + if (typeof window === 'undefined') return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return {} + return JSON.parse(raw) as ShortcutOverrides + } catch (e) { + console.warn('Failed to parse shortcut overrides', e) + return {} + } +} + +const saveOverrides = (overrides: ShortcutOverrides) => { + if (typeof window === 'undefined') return + localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides)) +} + +const rowStyle = css({ + display: 'grid', + gridTemplateColumns: '1.25fr auto auto', + alignItems: 'center', + gap: '0.75rem', + padding: '0.65rem 0', + borderBottom: '1px solid rgba(255,255,255,0.08)', +}) + +const badgeStyle = css({ + fontFamily: 'monospace', + backgroundColor: 'rgba(255,255,255,0.12)', + paddingInline: '0.4rem', + paddingBlock: '0.2rem', + borderRadius: '6px', + whiteSpace: 'nowrap', + minWidth: '5.5rem', + textAlign: 'center', +}) + +const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' }) + +const ShortcutTab = ({ id }: Pick) => { + const { t } = useTranslation(['settings', 'rooms']) + const tRooms = useCallback( + (key: string, options?: Record) => + t(key, { ns: 'rooms', ...options }), + [t] + ) + const [overrides, setOverrides] = useState({}) + const [editingId, setEditingId] = useState(null) + + useEffect(() => { + setOverrides(loadOverrides()) + }, []) + + const handleStartEdit = useCallback((shortcutId: string) => { + setEditingId(shortcutId) + }, []) + + const handleReset = useCallback( + (shortcutId: string) => { + const next = { ...overrides } + delete next[shortcutId] + setOverrides(next) + saveOverrides(next) + }, + [overrides] + ) + + const handleKeyCapture = useCallback( + (e: React.KeyboardEvent, shortcutId: string) => { + e.preventDefault() + const { key, ctrlKey } = e + // Ignore modifier-only keys + if (!key || key === 'Control' || key === 'Meta' || key === 'Shift') return + const normalized: Shortcut = { + key, + ctrlKey, + } + const next = { ...overrides, [shortcutId]: normalized } + setOverrides(next) + saveOverrides(next) + setEditingId(null) + }, + [overrides] + ) + + const rows = useMemo(() => { + return shortcutCatalog.map((item) => { + const override = overrides[item.id] + const effectiveShortcut = override ?? item.shortcut + const visualShortcut = + item.kind === 'longPress' + ? formatLongPressLabel( + getKeyLabelFromCode(item.code), + tRooms('shortcutsPanel.visual.hold', { key: '{{key}}' }) + ) + : formatShortcutLabel(effectiveShortcut) + const srShortcut = + item.kind === 'longPress' + ? formatLongPressLabel( + getKeyLabelFromCode(item.code), + tRooms('shortcutsPanel.sr.hold', { key: '{{key}}' }) + ) + : formatShortcutLabelForSR(effectiveShortcut, { + controlLabel: tRooms('shortcutsPanel.sr.control'), + commandLabel: tRooms('shortcutsPanel.sr.command'), + plusLabel: tRooms('shortcutsPanel.sr.plus'), + noShortcutLabel: tRooms('shortcutsPanel.sr.noShortcut'), + }) + return { + item, + override, + visualShortcut, + srShortcut, + } + }) + }, [overrides, tRooms]) + + return ( + +
{t('tabs.shortcuts')}
+
+ {t('shortcutsEditor.description')} +
+
+ {rows.map(({ item, override, visualShortcut, srShortcut }) => ( +
+
+
+ {tRooms(`shortcutsPanel.actions.${item.id}`)} +
+
+
+ {visualShortcut} + {override && ( + + ({t('shortcutsEditor.custom')}) + + )} +
+
+ + +
+
+ ))} +
+
+ {t('shortcutsEditor.limitations')} +
+
+ ) +} + +export default ShortcutTab diff --git a/src/frontend/src/features/settings/type.ts b/src/frontend/src/features/settings/type.ts index 93428717..95fcc1e0 100644 --- a/src/frontend/src/features/settings/type.ts +++ b/src/frontend/src/features/settings/type.ts @@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey { GENERAL = 'general', NOTIFICATIONS = 'notifications', TRANSCRIPTION = 'transcription', + SHORTCUTS = 'shortcuts', ACCESSIBILITY = 'accessibility', } diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 124dcb9c..3ea9b914 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -114,6 +114,17 @@ "video": "Video", "general": "Allgemein", "notifications": "Benachrichtigungen", - "transcription": "Transkription" + "transcription": "Transkription", + "shortcuts": "Tastenkürzel" + }, + "shortcutsEditor": { + "description": "Tastenkürzel anpassen (lokal auf diesem Gerät).", + "pressHint": "Drücken Sie die gewünschten Tasten, um ein Kürzel zu setzen.", + "longPressHint": "Halten Sie eine Taste gedrückt, um ein Long-Press-Kürzel zu setzen.", + "edit": "Bearbeiten", + "capture": "Tasten drücken …", + "reset": "Zurücksetzen", + "custom": "benutzerdefiniert", + "limitations": "Die Kürzel werden lokal gespeichert und gelten nicht zwingend für alle Aktionen." } } diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 0c1865dc..1e21526b 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -120,6 +120,17 @@ "general": "General", "notifications": "Notifications", "accessibility": "Accessibility", - "transcription": "Transcription" + "transcription": "Transcription", + "shortcuts": "Shortcuts" + }, + "shortcutsEditor": { + "description": "Customize keyboard shortcuts (local to this device).", + "pressHint": "Press the desired keys to set a new shortcut.", + "longPressHint": "Hold a key to set a long-press shortcut.", + "edit": "Edit", + "capture": "Press keys…", + "reset": "Reset", + "custom": "custom", + "limitations": "Shortcuts are saved locally and may not apply to all actions yet." } } diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index c5d96efa..7e295c7b 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -120,6 +120,17 @@ "general": "Général", "notifications": "Notifications", "accessibility": "Accessibilité", - "transcription": "Transcription" + "transcription": "Transcription", + "shortcuts": "Raccourcis" + }, + "shortcutsEditor": { + "description": "Personnaliser les raccourcis clavier (local à cet appareil).", + "pressHint": "Appuyez sur les touches souhaitées pour définir un raccourci.", + "longPressHint": "Maintenez une touche pour définir un raccourci long.", + "edit": "Modifier", + "capture": "Appuyez sur les touches…", + "reset": "Réinitialiser", + "custom": "personnalisé", + "limitations": "Les raccourcis sont enregistrés localement et peuvent ne pas s’appliquer à toutes les actions." } } diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index 31edf1c5..7e37d7b3 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -114,6 +114,17 @@ "video": "Video", "general": "Algemeen", "notifications": "Meldingen", - "transcription": "Transcriptie" + "transcription": "Transcriptie", + "shortcuts": "Sneltoetsen" + }, + "shortcutsEditor": { + "description": "Sneltoetsen aanpassen (lokaal op dit apparaat).", + "pressHint": "Druk op de gewenste toetsen om een sneltoets in te stellen.", + "longPressHint": "Houd een toets ingedrukt voor een long-press sneltoets.", + "edit": "Bewerken", + "capture": "Toetsen indrukken…", + "reset": "Resetten", + "custom": "aangepast", + "limitations": "Sneltoetsen worden lokaal opgeslagen en gelden mogelijk niet voor alle acties." } }