// src/renderer/components/HotkeyRecordModal.tsx // Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소 // 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성 // 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록 import { useState, useEffect, useCallback, useRef } from 'react' import { Dialog, DialogTitle, DialogContent, DialogActions, Box, Button, Chip, Stack, Typography, } from '@mui/material' import { d3roPalette, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { HotkeyBinding } from '@d3ro/core/types' import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey' // 시스템 예약 조합 const RESERVED_COMBOS = [ 'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W', 'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', ] const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165]) function getKeyName(keyCode: number, key: string): string { // 플랫폼별 라벨 (macOS는 ⌘/⇧/⌃/⌥). format-hotkey.ts의 단일 매핑을 사용. const platform = getPlatform() const mapped = keyCodeToName(keyCode, platform) if (mapped && !mapped.startsWith('Key')) return mapped if (key.length === 1) return key.toUpperCase() return key } function isModifier(keyCode: number): boolean { return MODIFIER_KEYCODES.has(keyCode) } interface HotkeyRecordModalProps { open: boolean onClose: () => void onSave: (binding: HotkeyBinding) => void currentBinding?: HotkeyBinding | null title?: string } export function HotkeyRecordModal({ open, onClose, onSave, currentBinding, title, }: HotkeyRecordModalProps): React.ReactElement { const { t } = useI18n() // 현재 눌려있는 키들을 실시간 추적 const [pressedKeys, setPressedKeys] = useState>([]) // 확정된 조합 (녹화 완료 후) const [captured, setCaptured] = useState | null>(null) const [error, setError] = useState(null) const pressedRef = useRef>(new Map()) // modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기) const modifierTimerRef = useRef | null>(null) // 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지) const lastPressedRef = useRef>([]) // Modal 열릴 때 리셋 useEffect(() => { if (open) { setPressedKeys([]) setCaptured(null) setError(null) pressedRef.current.clear() lastPressedRef.current = [] if (modifierTimerRef.current) { clearTimeout(modifierTimerRef.current) modifierTimerRef.current = null } } }, [open]) const handleKeyDown = useCallback((e: KeyboardEvent) => { if (captured) return // 이미 확정됨 e.preventDefault() e.stopPropagation() // 새 키가 눌렸으므로 modifier-only 타이머 취소 if (modifierTimerRef.current) { clearTimeout(modifierTimerRef.current) modifierTimerRef.current = null } const keyCode = e.keyCode || e.which if (pressedRef.current.has(keyCode)) return const name = getKeyName(keyCode, e.key) pressedRef.current.set(keyCode, name) const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) setPressedKeys(keys) lastPressedRef.current = keys // modifier가 아닌 키가 눌리면 → 조합 즉시 확정 (Alt+1, Ctrl+F5 등) if (!isModifier(keyCode)) { setCaptured(keys) } }, [captured]) const handleKeyUp = useCallback((e: KeyboardEvent) => { if (captured) return // 이미 확정됨 const keyCode = e.keyCode || e.which pressedRef.current.delete(keyCode) // 모든 키를 놓았고 modifier만 눌렀었다면 → 500ms 대기 후 확정 // 이 대기 시간 동안 추가 키를 누르면 타이머가 취소되어 조합키로 확장 가능 if (pressedRef.current.size === 0 && lastPressedRef.current.length > 0 && lastPressedRef.current.every(k => isModifier(k.keyCode))) { if (modifierTimerRef.current) clearTimeout(modifierTimerRef.current) modifierTimerRef.current = setTimeout(() => { // 타이머 만료: 여전히 confirmed 안됐고 추가 키 입력 없으면 단일 modifier로 확정 setCaptured(lastPressedRef.current) modifierTimerRef.current = null }, 500) } const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) setPressedKeys(keys) }, [captured]) useEffect(() => { if (!open) return window.addEventListener('keydown', handleKeyDown, true) window.addEventListener('keyup', handleKeyUp, true) return () => { window.removeEventListener('keydown', handleKeyDown, true) window.removeEventListener('keyup', handleKeyUp, true) } }, [open, handleKeyDown, handleKeyUp]) const displayKeys = captured ?? pressedKeys const isReady = captured !== null && captured.length > 0 const handleSave = () => { if (!captured || captured.length === 0) { setError(t('hotkey.noKey')) return } // 예약 단축키 체크 const label = captured.map(k => k.name).join('+') if (RESERVED_COMBOS.includes(label)) { setError(t('hotkey.reserved', { keys: label })) handleReset() return } // HotkeyBinding 생성 // 주 키(main key) = modifier가 아닌 키가 있으면 그것, 없으면 첫 번째 modifier const mainKey = captured.find(k => !isModifier(k.keyCode)) const primaryKeyCode = mainKey?.keyCode ?? captured[0].keyCode // modifier 플래그: 주 키 자체가 해당 modifier인 경우 false 유지 // 예: Right Alt 단독 → keyCode=165, alt=false (주 키가 Alt 자체이므로) // 예: Ctrl+A → keyCode=A, ctrl=true (Ctrl은 modifier로 사용) const isCtrlKey = (kc: number) => kc === 17 || kc === 162 || kc === 163 const isAltKey = (kc: number) => kc === 18 || kc === 164 || kc === 165 const isShiftKey = (kc: number) => kc === 16 || kc === 160 || kc === 161 const isMetaKey = (kc: number) => kc === 91 || kc === 92 // 주 키를 제외한 나머지 키들만 modifier로 취급 const modifierKeys = captured.filter(k => k.keyCode !== primaryKeyCode) const binding: HotkeyBinding = { keyCode: primaryKeyCode, ctrl: modifierKeys.some(k => isCtrlKey(k.keyCode)), alt: modifierKeys.some(k => isAltKey(k.keyCode)), shift: modifierKeys.some(k => isShiftKey(k.keyCode)), meta: modifierKeys.some(k => isMetaKey(k.keyCode)), displayLabel: label, } onSave(binding) onClose() } const handleReset = () => { setPressedKeys([]) setCaptured(null) pressedRef.current.clear() } const handleCancel = () => { handleReset() onClose() } return ( {title ?? t('hotkey.title')} {/* 녹화 영역 */} 0 ? d3roPalette.accent.main : d3roPalette.border.strong }`, borderRadius: d3roRadius.inner, p: 3, minHeight: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: d3roPalette.bg.inset, transition: 'border-color 0.2s ease', }} > {displayKeys.length > 0 ? ( {displayKeys.map((key, i) => ( ))} ) : ( {t('hotkey.prompt')} )} {/* 상태 표시 */} {isReady && !error && ( {t('hotkey.ready', { keys: captured ?.map((k) => k.name) .join(getPlatform() === 'darwin' ? '' : ' + ') ?? '' })} )} {error && ( {error} )} {currentBinding && ( {t('hotkey.current', { keys: formatHotkeyLabel(currentBinding) })} )} {t('hotkey.hint')} {isReady && ( )} ) }