315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
// 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<Array<{ keyCode: number; name: string }>>([])
|
|
// 확정된 조합 (녹화 완료 후)
|
|
const [captured, setCaptured] = useState<Array<{ keyCode: number; name: string }> | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const pressedRef = useRef<Map<number, string>>(new Map())
|
|
// modifier-only 확정을 위한 타이머 (Alt만 눌렀을 때 바로 확정하지 않고 잠시 대기)
|
|
const modifierTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
// 가장 최근 pressedKeys 스냅샷 (타이머 콜백에서 stale closure 방지)
|
|
const lastPressedRef = useRef<Array<{ keyCode: number; name: string }>>([])
|
|
|
|
// 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 (
|
|
<Dialog
|
|
open={open}
|
|
onClose={handleCancel}
|
|
maxWidth="xs"
|
|
fullWidth
|
|
// aria-hidden 에러 방지: disableEnforceFocus + disableAutoFocus
|
|
disableEnforceFocus
|
|
disableAutoFocus
|
|
disableRestoreFocus
|
|
>
|
|
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
|
|
<DialogContent>
|
|
{/* 녹화 영역 */}
|
|
<Box
|
|
sx={{
|
|
border: `2px solid ${
|
|
error
|
|
? d3roPalette.tag.red
|
|
: isReady
|
|
? d3roPalette.tag.green
|
|
: displayKeys.length > 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 ? (
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
|
|
{displayKeys.map((key, i) => (
|
|
<Chip
|
|
key={`${key.keyCode}-${i}`}
|
|
label={key.name}
|
|
sx={{
|
|
fontWeight: 500,
|
|
fontSize: '14px',
|
|
bgcolor: d3roPalette.bg.chassis,
|
|
color: d3roPalette.text.primary,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
borderRadius: '8px',
|
|
height: 40,
|
|
px: 1,
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<Typography
|
|
sx={{
|
|
color: d3roPalette.accent.main,
|
|
fontSize: '13px',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{t('hotkey.prompt')}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
{/* 상태 표시 */}
|
|
{isReady && !error && (
|
|
<Typography sx={{ color: d3roPalette.tag.green, fontSize: '12px', mt: 1, fontWeight: 600 }}>
|
|
{t('hotkey.ready', {
|
|
keys:
|
|
captured
|
|
?.map((k) => k.name)
|
|
.join(getPlatform() === 'darwin' ? '' : ' + ') ?? ''
|
|
})}
|
|
</Typography>
|
|
)}
|
|
|
|
{error && (
|
|
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
|
|
{error}
|
|
</Typography>
|
|
)}
|
|
|
|
{currentBinding && (
|
|
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
|
|
{t('hotkey.current', { keys: formatHotkeyLabel(currentBinding) })}
|
|
</Typography>
|
|
)}
|
|
|
|
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
|
|
{t('hotkey.hint')}
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
|
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
{isReady && (
|
|
<Button onClick={handleReset} sx={{ color: d3roPalette.text.inactive }}>
|
|
{t('hotkey.reset')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleSave}
|
|
disabled={!isReady}
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|