From 48582e46ec48d6b43a0b737ff75d934466787d60 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 10:03:01 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=AB=ED=82=A4=203=EB=8C=80=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95:=20Alt=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20+=20=EC=A1=B0=ED=95=A9=ED=82=A4=20?= =?UTF-8?q?=EB=85=B9=ED=99=94=20+=20aria-hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WindowManager: Menu.setApplicationMenu(null)로 Alt 키 메뉴 완전 제거 - HotkeyRecordModal: 조합키 로직 재설계 — modifier 누른 채로 일반키 입력 시 조합 확정 - HotkeyRecordModal/SettingsModal: disableEnforceFocus로 aria-hidden 에러 방지 --- src/main/windows/WindowManager.ts | 6 +- src/renderer/components/HotkeyRecordModal.tsx | 296 ++++++++---------- src/renderer/components/SettingsModal.tsx | 2 +- 3 files changed, 135 insertions(+), 169 deletions(-) diff --git a/src/main/windows/WindowManager.ts b/src/main/windows/WindowManager.ts index 75c5a06..c91d7a3 100644 --- a/src/main/windows/WindowManager.ts +++ b/src/main/windows/WindowManager.ts @@ -1,7 +1,7 @@ // src/main/windows/WindowManager.ts // 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈 -import { BrowserWindow, shell, screen, ipcMain } from 'electron' +import { BrowserWindow, shell, screen, ipcMain, Menu } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import { WINDOW_SIZE } from '@shared/constants' @@ -40,6 +40,10 @@ export function createMainWindow(): BrowserWindow { } }) + // Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거 + mainWindow.setMenu(null) + Menu.setApplicationMenu(null) + mainWindow.on('ready-to-show', () => { mainWindow?.show() if (is.dev) { diff --git a/src/renderer/components/HotkeyRecordModal.tsx b/src/renderer/components/HotkeyRecordModal.tsx index c10fdef..0533e88 100644 --- a/src/renderer/components/HotkeyRecordModal.tsx +++ b/src/renderer/components/HotkeyRecordModal.tsx @@ -1,5 +1,7 @@ // src/renderer/components/HotkeyRecordModal.tsx // Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소 +// 조합키: modifier(Ctrl/Alt/Shift) 누른 상태에서 일반키 입력 → 조합 완성 +// 단일키: modifier만 눌렀다 놓으면 해당 modifier가 단독 핫키로 등록 import { useState, useEffect, useCallback, useRef } from 'react' import { @@ -16,71 +18,48 @@ import { import { d3roPalette } from '../theme' import type { HotkeyBinding } from '@shared/types' -// ── 키 이름 매핑 (Windows 전용) ────────────────────────── +// ── 키 이름 매핑 (Windows) ────────────────────────────── const KEY_DISPLAY_MAP: Record = { // 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', + 16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win', + 160: 'Left Shift', 161: 'Right Shift', + 162: 'Left Ctrl', 163: 'Right Ctrl', + 164: 'Left Alt', 165: 'Right Alt', // 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', + 8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock', + 27: 'Esc', 32: 'Space', + 33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home', + 37: '←', 38: '↑', 39: '→', 40: '↓', + 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', + // Numpad + 96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4', + 101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9', + 106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/', + // Special + 186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`', + 219: '[', 220: '\\', 221: ']', 222: "'", } -// 시스템 예약 단축키 블랙리스트 (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 +// 시스템 예약 조합 +const RESERVED_COMBOS = [ + 'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W', + 'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', ] -function getKeyDisplayName(keyCode: number, key: string): string { +const MODIFIER_KEYCODES = new Set([16, 17, 18, 91, 92, 160, 161, 162, 163, 164, 165]) + +function getKeyName(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 - ) +function isModifier(keyCode: number): boolean { + return MODIFIER_KEYCODES.has(keyCode) } interface HotkeyRecordModalProps { @@ -91,12 +70,6 @@ interface HotkeyRecordModalProps { title?: string } -interface RecordedKey { - keyCode: number - displayName: string - isModifier: boolean -} - export function HotkeyRecordModal({ open, onClose, @@ -104,67 +77,57 @@ export function HotkeyRecordModal({ currentBinding, title = '단축키 설정', }: HotkeyRecordModalProps): React.ReactElement { - const [recordedKeys, setRecordedKeys] = useState([]) - const [isRecording, setIsRecording] = useState(false) - const [validationError, setValidationError] = useState(null) - const keysRef = useRef>(new Set()) + // 현재 눌려있는 키들을 실시간 추적 + const [pressedKeys, setPressedKeys] = useState>([]) + // 확정된 조합 (녹화 완료 후) + const [captured, setCaptured] = useState | null>(null) + const [error, setError] = useState(null) + const pressedRef = useRef>(new Map()) - // Modal 열릴 때 녹화 시작 + // Modal 열릴 때 리셋 useEffect(() => { if (open) { - setRecordedKeys([]) - setValidationError(null) - setIsRecording(true) - keysRef.current.clear() - } else { - setIsRecording(false) + setPressedKeys([]) + setCaptured(null) + setError(null) + pressedRef.current.clear() } }, [open]) - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!isRecording) return - e.preventDefault() - e.stopPropagation() + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (captured) return // 이미 확정됨 + e.preventDefault() + e.stopPropagation() - const keyCode = e.keyCode || e.which - if (keysRef.current.has(keyCode)) return - keysRef.current.add(keyCode) + const keyCode = e.keyCode || e.which + if (pressedRef.current.has(keyCode)) return - const displayName = getKeyDisplayName(keyCode, e.key) - const isMod = isModifierKey(keyCode) + const name = getKeyName(keyCode, e.key) + pressedRef.current.set(keyCode, name) - setRecordedKeys((prev) => { - const next = [...prev, { keyCode, displayName, isModifier: isMod }] - // modifier가 아닌 키가 들어오면 녹화 완료 (modifier + key 조합) - if (!isMod) { - setIsRecording(false) - } - return next - }) - setValidationError(null) - }, - [isRecording] - ) + const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) + setPressedKeys(keys) - const handleKeyUp = useCallback( - (e: KeyboardEvent) => { - const keyCode = e.keyCode || e.which - keysRef.current.delete(keyCode) + // modifier가 아닌 키가 눌리면 → 조합 확정 + if (!isModifier(keyCode)) { + setCaptured(keys) + } + }, [captured]) - // 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] - ) + const handleKeyUp = useCallback((e: KeyboardEvent) => { + if (captured) return // 이미 확정됨 + + const keyCode = e.keyCode || e.which + pressedRef.current.delete(keyCode) + + // 모든 키를 놓았고 modifier만 눌렀었다면 → 단일 modifier 확정 + if (pressedRef.current.size === 0 && pressedKeys.length > 0 && pressedKeys.every(k => isModifier(k.keyCode))) { + setCaptured(pressedKeys) + } + + const keys = Array.from(pressedRef.current.entries()).map(([kc, n]) => ({ keyCode: kc, name: n })) + setPressedKeys(keys) + }, [captured, pressedKeys]) useEffect(() => { if (!open) return @@ -176,81 +139,75 @@ export function HotkeyRecordModal({ } }, [open, handleKeyDown, handleKeyUp]) + const displayKeys = captured ?? pressedKeys + const isReady = captured !== null && captured.length > 0 + const handleSave = () => { - if (recordedKeys.length === 0) { - setValidationError('키를 입력해주세요') + if (!captured || captured.length === 0) { + setError('키를 입력해주세요') 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 - } + // 예약 단축키 체크 + const label = captured.map(k => k.name).join('+') + if (RESERVED_COMBOS.includes(label)) { + setError(`${label}은 시스템 예약 단축키입니다`) + handleReset() + return } // HotkeyBinding 생성 - const displayLabel = recordedKeys.map((k) => k.displayName).join(' + ') - const primaryKeyCode = mainKey?.keyCode ?? recordedKeys[0].keyCode + const mainKey = captured.find(k => !isModifier(k.keyCode)) + const primaryKeyCode = mainKey?.keyCode ?? captured[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 + ctrl: captured.some(k => k.keyCode === 17 || k.keyCode === 162 || k.keyCode === 163), + alt: captured.some(k => k.keyCode === 18 || k.keyCode === 164 || k.keyCode === 165), + shift: captured.some(k => k.keyCode === 16 || k.keyCode === 160 || k.keyCode === 161), + meta: captured.some(k => k.keyCode === 91 || k.keyCode === 92), + displayLabel: label, } onSave(binding) onClose() } - const resetRecording = () => { - setRecordedKeys([]) - setIsRecording(true) - keysRef.current.clear() + const handleReset = () => { + setPressedKeys([]) + setCaptured(null) + pressedRef.current.clear() } const handleCancel = () => { - setRecordedKeys([]) - setIsRecording(false) - keysRef.current.clear() + handleReset() onClose() } return ( - + {title} {/* 녹화 영역 */} 0 + ? d3roPalette.accent.amber + : d3roPalette.border.strong }`, borderRadius: '12px', p: 3, @@ -262,20 +219,21 @@ export function HotkeyRecordModal({ transition: 'border-color 0.2s ease', }} > - {recordedKeys.length > 0 ? ( + {displayKeys.length > 0 ? ( - {recordedKeys.map((key, i) => ( + {displayKeys.map((key, i) => ( ))} @@ -283,48 +241,52 @@ export function HotkeyRecordModal({ ) : ( - {isRecording ? '키 조합을 눌러주세요...' : '키를 입력하세요'} + 키 조합을 눌러주세요... )} - {/* 검증 오류 */} - {validationError && ( - - {validationError} + {/* 상태 표시 */} + {isReady && !error && ( + + ✓ {captured?.map(k => k.name).join(' + ')} — 저장을 눌러주세요 + + )} + + {error && ( + + {error} )} - {/* 현재 설정 표시 */} {currentBinding && ( 현재: {currentBinding.displayLabel} )} - {/* 안내 */} - 단일 키(예: Right Alt) 또는 조합키(예: Ctrl+Shift+Q)를 입력하세요 + 조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요 - {!isRecording && recordedKeys.length > 0 && ( - )} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index c78113c..88e202e 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -260,7 +260,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac return ( <> - + 설정