import { useCallback, useEffect, useRef, useState } from 'react'; import { isMac } from '@renderer/lib/platform'; /** Modifier keys we recognise, in canonical display order. */ const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']); /** Keys that should never be accepted as the primary (non-modifier) key. */ const IGNORED_KEYS = new Set([ 'Dead', 'Unidentified', 'Process', 'CapsLock', 'NumLock', 'ScrollLock', 'Fn', 'FnLock', 'Hyper', 'Super', 'OS', ]); /** * Convert a portable hotkey string (e.g. `"Alt+Shift+C"`) into * platform-aware display tokens. */ export function hotkeyToDisplayTokens(hotkey: string): string[] { return hotkey .replace(/\bSuper\b/g, isMac ? '⌘' : 'Win') .replace(/\bMeta\b/g, isMac ? '⌘' : 'Win') .replace(/\bCtrl\b/g, isMac ? '⌃' : 'Ctrl') .replace(/\bControl\b/g, isMac ? '⌃' : 'Ctrl') .replace(/\bAlt\b/g, isMac ? '⌥' : 'Alt') .replace(/\bShift\b/g, isMac ? '⇧' : 'Shift') .split('+') .map((k) => k.trim()); } /** * Normalise a `KeyboardEvent` key name into the portable format used in * settings (Electron accelerator-compatible). */ function normaliseKeyName(key: string): string | undefined { if (MODIFIER_KEYS.has(key) || IGNORED_KEYS.has(key)) return undefined; // Letters → uppercase if (/^[a-zA-Z]$/.test(key)) return key.toUpperCase(); // Digits if (/^[0-9]$/.test(key)) return key; // F-keys if (/^F\d{1,2}$/.test(key)) return key; // Named keys const named: Record = { ' ': 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', '+': 'Plus', '-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '/': 'Slash', ';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period', '`': 'Backquote', }; return named[key] ?? key; } /** Build the portable hotkey string from modifier flags and a key name. */ function buildHotkeyString(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }, key: string): string { const parts: string[] = []; if (mods.ctrl) parts.push('Ctrl'); if (mods.alt) parts.push('Alt'); if (mods.shift) parts.push('Shift'); if (mods.meta) parts.push('Super'); parts.push(key); return parts.join('+'); } interface HotkeyRecorderProps { value: string; onChange: (hotkey: string) => void; disabled?: boolean; } export function HotkeyRecorder({ value, onChange, disabled }: HotkeyRecorderProps) { const [recording, setRecording] = useState(false); const [liveKeys, setLiveKeys] = useState(null); const recorderRef = useRef(null); const displayTokens = hotkeyToDisplayTokens(liveKeys ?? value); const stopRecording = useCallback(() => { setRecording(false); setLiveKeys(null); }, []); // Escape cancels recording // Valid combo (modifier + key) commits immediately useEffect(() => { if (!recording) return; function handleKeyDown(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); // Escape cancels if (e.key === 'Escape') { stopRecording(); return; } const mods = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }; const hasModifier = mods.ctrl || mods.alt || mods.shift || mods.meta; const key = normaliseKeyName(e.key); if (key && hasModifier) { const hotkey = buildHotkeyString(mods, key); onChange(hotkey); setRecording(false); setLiveKeys(null); } else if (!key) { // Only modifiers held — show live preview const preview = buildModifierPreview(mods); if (preview) setLiveKeys(preview); } } function handleKeyUp(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); } window.addEventListener('keydown', handleKeyDown, true); window.addEventListener('keyup', handleKeyUp, true); return () => { window.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('keyup', handleKeyUp, true); }; }, [recording, onChange, stopRecording]); // Click outside cancels recording useEffect(() => { if (!recording) return; function handleClick(e: MouseEvent) { if (recorderRef.current && !recorderRef.current.contains(e.target as Node)) { stopRecording(); } } document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, [recording, stopRecording]); return ( ); } function buildModifierPreview(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }): string | null { const parts: string[] = []; if (mods.ctrl) parts.push('Ctrl'); if (mods.alt) parts.push('Alt'); if (mods.shift) parts.push('Shift'); if (mods.meta) parts.push('Super'); return parts.length > 0 ? parts.join('+') + '+…' : null; }