Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성
- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등) - DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여) - HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달 - SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경 - DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑 - recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버) - 설계 문서: phase-8.md, speakly-settings-ui.md
This commit is contained in:
parent
f41fc277e3
commit
28371a9d1a
19 changed files with 1879 additions and 403 deletions
334
src/renderer/components/HotkeyRecordModal.tsx
Normal file
334
src/renderer/components/HotkeyRecordModal.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// src/renderer/components/HotkeyRecordModal.tsx
|
||||
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { d3roPalette } from '../theme'
|
||||
import type { HotkeyBinding } from '@shared/types'
|
||||
|
||||
// ── 키 이름 매핑 (Windows 전용) ──────────────────────────
|
||||
const KEY_DISPLAY_MAP: Record<number, string> = {
|
||||
// Modifier keys
|
||||
160: 'Left Shift',
|
||||
161: 'Right Shift',
|
||||
162: 'Left Ctrl',
|
||||
163: 'Right Ctrl',
|
||||
164: 'Left Alt',
|
||||
165: 'Right Alt',
|
||||
91: 'Left Win',
|
||||
92: 'Right Win',
|
||||
// Common keys
|
||||
8: 'Backspace',
|
||||
9: 'Tab',
|
||||
13: 'Enter',
|
||||
19: 'Pause',
|
||||
20: 'Caps Lock',
|
||||
27: 'Escape',
|
||||
32: 'Space',
|
||||
33: 'Page Up',
|
||||
34: 'Page Down',
|
||||
35: 'End',
|
||||
36: 'Home',
|
||||
37: 'Left',
|
||||
38: 'Up',
|
||||
39: 'Right',
|
||||
40: 'Down',
|
||||
45: 'Insert',
|
||||
46: 'Delete',
|
||||
// F-keys
|
||||
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
|
||||
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
|
||||
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
|
||||
// Number keys
|
||||
48: '0', 49: '1', 50: '2', 51: '3', 52: '4',
|
||||
53: '5', 54: '6', 55: '7', 56: '8', 57: '9',
|
||||
}
|
||||
|
||||
// 시스템 예약 단축키 블랙리스트 (Windows)
|
||||
const RESERVED_COMBOS: Array<{ ctrl: boolean; alt: boolean; shift: boolean; key: string }> = [
|
||||
{ ctrl: true, alt: false, shift: false, key: 'c' }, // Copy
|
||||
{ ctrl: true, alt: false, shift: false, key: 'v' }, // Paste
|
||||
{ ctrl: true, alt: false, shift: false, key: 'x' }, // Cut
|
||||
{ ctrl: true, alt: false, shift: false, key: 'z' }, // Undo
|
||||
{ ctrl: true, alt: false, shift: false, key: 'a' }, // Select All
|
||||
{ ctrl: true, alt: false, shift: false, key: 's' }, // Save
|
||||
{ ctrl: true, alt: false, shift: false, key: 'w' }, // Close tab
|
||||
{ ctrl: false, alt: true, shift: false, key: 'F4' }, // Close window
|
||||
{ ctrl: false, alt: false, shift: false, key: 'F5' }, // Refresh
|
||||
]
|
||||
|
||||
function getKeyDisplayName(keyCode: number, key: string): string {
|
||||
if (KEY_DISPLAY_MAP[keyCode]) return KEY_DISPLAY_MAP[keyCode]
|
||||
if (key.length === 1) return key.toUpperCase()
|
||||
return key
|
||||
}
|
||||
|
||||
function isModifierKey(keyCode: number): boolean {
|
||||
return (keyCode >= 160 && keyCode <= 165) || keyCode === 91 || keyCode === 92
|
||||
}
|
||||
|
||||
function isReservedCombo(ctrl: boolean, alt: boolean, shift: boolean, key: string): boolean {
|
||||
return RESERVED_COMBOS.some(
|
||||
(c) => c.ctrl === ctrl && c.alt === alt && c.shift === shift && c.key === key
|
||||
)
|
||||
}
|
||||
|
||||
interface HotkeyRecordModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSave: (binding: HotkeyBinding) => void
|
||||
currentBinding?: HotkeyBinding | null
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface RecordedKey {
|
||||
keyCode: number
|
||||
displayName: string
|
||||
isModifier: boolean
|
||||
}
|
||||
|
||||
export function HotkeyRecordModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
currentBinding,
|
||||
title = '단축키 설정',
|
||||
}: HotkeyRecordModalProps): React.ReactElement {
|
||||
const [recordedKeys, setRecordedKeys] = useState<RecordedKey[]>([])
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const keysRef = useRef<Set<number>>(new Set())
|
||||
|
||||
// Modal 열릴 때 녹화 시작
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRecordedKeys([])
|
||||
setValidationError(null)
|
||||
setIsRecording(true)
|
||||
keysRef.current.clear()
|
||||
} else {
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!isRecording) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const keyCode = e.keyCode || e.which
|
||||
if (keysRef.current.has(keyCode)) return
|
||||
keysRef.current.add(keyCode)
|
||||
|
||||
const displayName = getKeyDisplayName(keyCode, e.key)
|
||||
const isMod = isModifierKey(keyCode)
|
||||
|
||||
setRecordedKeys((prev) => {
|
||||
const next = [...prev, { keyCode, displayName, isModifier: isMod }]
|
||||
// modifier가 아닌 키가 들어오면 녹화 완료 (modifier + key 조합)
|
||||
if (!isMod) {
|
||||
setIsRecording(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setValidationError(null)
|
||||
},
|
||||
[isRecording]
|
||||
)
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const keyCode = e.keyCode || e.which
|
||||
keysRef.current.delete(keyCode)
|
||||
|
||||
// modifier만 눌렀다 놓은 경우: 단일 modifier 키 등록
|
||||
if (isRecording && isModifierKey(keyCode) && keysRef.current.size === 0) {
|
||||
setRecordedKeys((prev) => {
|
||||
if (prev.length > 0 && prev.every((k) => k.isModifier)) {
|
||||
setIsRecording(false)
|
||||
return prev
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
},
|
||||
[isRecording]
|
||||
)
|
||||
|
||||
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 handleSave = () => {
|
||||
if (recordedKeys.length === 0) {
|
||||
setValidationError('키를 입력해주세요')
|
||||
return
|
||||
}
|
||||
|
||||
const hasModifier = recordedKeys.some((k) => k.isModifier)
|
||||
const mainKey = recordedKeys.find((k) => !k.isModifier)
|
||||
|
||||
// 시스템 예약 단축키 체크
|
||||
if (mainKey) {
|
||||
const ctrl = recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163)
|
||||
const alt = recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165)
|
||||
const shift = recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161)
|
||||
|
||||
if (isReservedCombo(ctrl, alt, shift, mainKey.displayName)) {
|
||||
setValidationError('시스템 예약 단축키입니다')
|
||||
resetRecording()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HotkeyBinding 생성
|
||||
const displayLabel = recordedKeys.map((k) => k.displayName).join(' + ')
|
||||
const primaryKeyCode = mainKey?.keyCode ?? recordedKeys[0].keyCode
|
||||
|
||||
const binding: HotkeyBinding = {
|
||||
keyCode: primaryKeyCode,
|
||||
ctrl: recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163),
|
||||
alt: recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165),
|
||||
shift: recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161),
|
||||
meta: recordedKeys.some((k) => k.keyCode === 91 || k.keyCode === 92),
|
||||
displayLabel,
|
||||
}
|
||||
|
||||
// 단일 modifier만 입력한 경우 (예: Right Alt만)
|
||||
if (!mainKey && hasModifier && recordedKeys.length === 1) {
|
||||
const modKey = recordedKeys[0]
|
||||
binding.keyCode = modKey.keyCode
|
||||
binding.ctrl = modKey.keyCode === 162 || modKey.keyCode === 163
|
||||
binding.alt = modKey.keyCode === 164 || modKey.keyCode === 165
|
||||
binding.shift = modKey.keyCode === 160 || modKey.keyCode === 161
|
||||
binding.meta = modKey.keyCode === 91 || modKey.keyCode === 92
|
||||
}
|
||||
|
||||
onSave(binding)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const resetRecording = () => {
|
||||
setRecordedKeys([])
|
||||
setIsRecording(true)
|
||||
keysRef.current.clear()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setRecordedKeys([])
|
||||
setIsRecording(false)
|
||||
keysRef.current.clear()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleCancel} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* 녹화 영역 */}
|
||||
<Box
|
||||
sx={{
|
||||
border: `2px solid ${
|
||||
validationError
|
||||
? d3roPalette.tag.red
|
||||
: isRecording
|
||||
? d3roPalette.accent.amber
|
||||
: d3roPalette.border.strong
|
||||
}`,
|
||||
borderRadius: '12px',
|
||||
p: 3,
|
||||
minHeight: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{recordedKeys.length > 0 ? (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
|
||||
{recordedKeys.map((key, i) => (
|
||||
<Chip
|
||||
key={`${key.keyCode}-${i}`}
|
||||
label={key.displayName}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '13px',
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.primary,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: '8px',
|
||||
height: 36,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: isRecording ? d3roPalette.accent.amber : d3roPalette.text.inactive,
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{isRecording ? '키 조합을 눌러주세요...' : '키를 입력하세요'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 검증 오류 */}
|
||||
{validationError && (
|
||||
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
|
||||
{validationError}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 현재 설정 표시 */}
|
||||
{currentBinding && (
|
||||
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
|
||||
현재: {currentBinding.displayLabel}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 안내 */}
|
||||
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
|
||||
단일 키(예: Right Alt) 또는 조합키(예: Ctrl+Shift+Q)를 입력하세요
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
|
||||
취소
|
||||
</Button>
|
||||
{!isRecording && recordedKeys.length > 0 && (
|
||||
<Button onClick={resetRecording} sx={{ color: d3roPalette.text.inactive }}>
|
||||
다시 입력
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={isRecording || recordedKeys.length === 0}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue