diff --git a/src/renderer/components/Shortcut.tsx b/src/renderer/components/Shortcut.tsx index b83410304..704b455ff 100644 --- a/src/renderer/components/Shortcut.tsx +++ b/src/renderer/components/Shortcut.tsx @@ -1,5 +1,6 @@ -import { Box, Combobox, Flex, Input, InputBase, Kbd, Select, Table, Text, useCombobox } from '@mantine/core' +import { Box, Button, Combobox, Flex, Input, InputBase, Kbd, Select, Table, Text, useCombobox } from '@mantine/core' import { IconAlertHexagon } from '@tabler/icons-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { type Settings, @@ -13,6 +14,26 @@ import { ScalableIcon } from './ScalableIcon' const os = getOS() +// Constants for keyboard shortcut recording +const MODIFIER_KEYS = ['Control', 'Alt', 'Shift', 'Meta', 'OS', 'Command'] +const MODIFIER_KEY_NAMES = ['Ctrl', 'Alt', 'Shift', 'Command', 'Control', 'Super'] +const FUNCTION_KEY_PATTERN = /^F([1-9]|1[0-9]|2[0-4])$/ + +// Mapping for special key normalization +const KEY_NORMALIZATION_MAP: Record = { + ' ': 'Space', + Escape: 'Escape', + Enter: 'Enter', + Tab: 'Tab', + Backspace: 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', +} + function formatKey(key: string) { const COMMON_KEY_MAPS: Record = { ctrl: 'Ctrl', @@ -106,7 +127,6 @@ export function ShortcutConfig(props: { label: t('Show/Hide the Application Window'), name: 'quickToggle', keys: shortcuts.quickToggle, - options: shortcutToggleWindowValues, }, { label: t('Focus on the Input Box'), @@ -209,7 +229,21 @@ export function ShortcutConfig(props: { {label} - {options ? ( + {name === 'quickToggle' ? ( + { + if (name && setShortcuts) { + setShortcuts({ + ...shortcuts, + [name]: val, + }) + } + }} + isConflict={name ? isConflict(name, keys) : false} + suggestedValues={shortcutToggleWindowValues} + /> + ) : options ? ( ([]) + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isRecording) return + + e.preventDefault() + e.stopPropagation() + + const keys: string[] = [] + + // Capture modifier keys in the correct order + // For cross-platform compatibility, prefer Command on Mac and Ctrl on other platforms + // The main.ts normalizer will convert these to proper Electron format + if (os === 'Mac') { + // On Mac, prefer Command over Control + if (e.metaKey) { + keys.push('Command') + } else if (e.ctrlKey) { + keys.push('Ctrl') + } + } else { + // On Windows/Linux, handle Ctrl and Super/Meta separately + if (e.ctrlKey) { + keys.push('Ctrl') + } + if (e.metaKey) { + // metaKey is the Super key on Linux and Windows key on Windows + keys.push('Super') + } + } + if (e.altKey) { + keys.push('Alt') + } + if (e.shiftKey) { + keys.push('Shift') + } + + // Capture the main key (not a modifier) + const pressedKey = e.key + const isModifier = MODIFIER_KEYS.includes(pressedKey) + + if (!isModifier && pressedKey) { + // Normalize special keys to match Electron's accelerator format + let normalizedKey = pressedKey + + // Check if key is in normalization map + if (KEY_NORMALIZATION_MAP[pressedKey]) { + normalizedKey = KEY_NORMALIZATION_MAP[pressedKey] + } else if (pressedKey === '`') { + // Backtick is kept as-is + normalizedKey = '`' + } else if (pressedKey.startsWith('Arrow')) { + // ArrowUp -> Up, ArrowDown -> Down, etc. + normalizedKey = pressedKey.replace('Arrow', '') + } else if (FUNCTION_KEY_PATTERN.test(pressedKey)) { + // Function keys F1-F24 + normalizedKey = pressedKey + } else if (pressedKey.length === 1) { + // Single character keys - keep uppercase for consistency + normalizedKey = pressedKey.toUpperCase() + } + // Other keys - keep as is (default value) + + keys.push(normalizedKey) + } + + setRecordedKeys(keys) + } + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (!isRecording) return + + e.preventDefault() + e.stopPropagation() + + // Only finalize if we have a non-modifier key + if (recordedKeys.length > 0) { + const lastKey = recordedKeys[recordedKeys.length - 1] + const isModifier = MODIFIER_KEY_NAMES.includes(lastKey) + + if (!isModifier) { + const shortcut = recordedKeys.join('+') + onSelect?.(shortcut) + setIsRecording(false) + setRecordedKeys([]) + } + } + } + + const handleStartRecording = () => { + setIsRecording(true) + setRecordedKeys([]) + } + + const handleCancelRecording = () => { + setIsRecording(false) + setRecordedKeys([]) + } + + const handleClearShortcut = () => { + onSelect?.('') + } + + const displayValue = isRecording ? (recordedKeys.length > 0 ? recordedKeys.join('+') : t('Press keys...')) : value + + return ( + + + + + + {isRecording && ( + + )} + {!isRecording && value && ( + + )} + {!isRecording && suggestedValues && suggestedValues.length > 0 && ( + { + onSelect?.(val) + combobox.closeDropdown() + }} + > + + + + + + + {suggestedValues.map((o) => ( + + + + ))} + + + + )} + + + ) +} + function ShortcutSelect({ options, value, diff --git a/src/shared/types/settings.ts b/src/shared/types/settings.ts index 6280cfb24..bc639aef8 100644 --- a/src/shared/types/settings.ts +++ b/src/shared/types/settings.ts @@ -131,7 +131,7 @@ export const shortcutSendValues = [ const ShortcutSendValueSchema = z.enum(shortcutSendValues as [string, ...string[]]) export const shortcutToggleWindowValues = ['', 'Alt+`', 'Alt+Space', 'Ctrl+Alt+Space', 'Ctrl+Space'] -const ShortcutToggleWindowValueSchema = z.enum(shortcutToggleWindowValues as [string, ...string[]]) +const ShortcutToggleWindowValueSchema = z.string() const ShortcutSettingSchema = z.object({ quickToggle: ShortcutToggleWindowValueSchema,