refactor(desktop): IME composition 가드 helper 추출 + 8곳 Enter 핸들러 통합 (빅뱅 Phase 5 Part 5)

Bug 10 fix(MeetingModePage 인라인 가드)를 isImeComposingEvent helper로 추출하고,
한글 위험도 있는 나머지 7개 Enter 핸들러에 일괄 적용. 총 8곳이 이제 동일 helper 경유.

신규:
- apps/desktop/src/renderer/utils/keyboard.ts — isImeComposingEvent(e)
  JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함

적용 8곳:
- pages/MeetingModePage.tsx:164 회의 메모 (기존 인라인 가드 4줄 교체)
- pages/KnowledgeBasePage.tsx:84 RAG 쿼리
- pages/VoiceConversationPage.tsx:113 텍스트 채팅
- pages/CommandsPage.tsx:336 키워드 추가 (Enter+Esc)
- components/meeting/MeetingChatPanel.tsx:111 미팅 챗
- components/meeting/EditableSegment.tsx:70 전사 세그먼트 편집 (Enter+Esc)
- components/meeting/MeetingDetailTabs.tsx:242 미팅 타이틀 (인라인 arrow → 블록)
- components/shared/HistoryEntryCard.tsx:64 태그 추가 (Enter+Esc)

/simplify 패스 품질 리뷰:
- Phase 3.3 CloudSyncService.pushOne 훅 8곳은 이미 fire-and-forget 1줄로 일관.
  내부 try-catch가 에러 삼켜 로컬 write 차단 금지 철학 준수 → 수정 없음, 현 상태가 최적.

검증:
- desktop tsc --noEmit EXIT=0
- Vite HMR로 dev 프로세스 자동 반영 (재기동 없음)
- 한글 Enter 시연은 사용자 실측 대기 (VoiceConversationPage / MeetingDetailTabs 대표 2곳)
This commit is contained in:
윤찬 2026-04-11 22:06:16 +09:00
parent c0c6206cbe
commit 3b77be01bc
10 changed files with 86 additions and 4 deletions

View file

@ -5,6 +5,7 @@ import { useState, useRef, useCallback } from 'react'
import { Box, TextField, Tooltip, Chip } from '@mui/material'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { isImeComposingEvent } from '../../utils/keyboard'
// Phase 15.5: 화자별 색상 매핑 (d3roPalette SSOT)
const SPEAKER_COLORS = [
@ -69,6 +70,7 @@ export function EditableSegment({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleCommit()

View file

@ -9,6 +9,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { MeetingChatMessage } from '@d3ro/core/types'
@ -110,6 +111,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()

View file

@ -19,6 +19,7 @@ import { DocumentTab } from './DocumentTab'
import { AddDocumentDialog } from './AddDocumentDialog'
import { MeetingChatPanel } from './MeetingChatPanel'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
@ -239,7 +240,10 @@ export function MeetingDetailTabs({
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
onKeyDown={(e) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') handleSaveTitle()
}}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },

View file

@ -14,6 +14,7 @@ import { MetalCard, Led } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatDuration } from '../../utils/formatters'
import { isImeComposingEvent } from '../../utils/keyboard'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
interface HistoryEntryCardProps {
@ -62,6 +63,7 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
}, [entry.id])
const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') { e.preventDefault(); handleAddTag() }
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
}, [handleAddTag])

View file

@ -12,6 +12,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import LinkIcon from '@mui/icons-material/Link'
import { MetalCard, PhosphorText, Led, ScreenPanel } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { TemplateSection } from '../components/TemplateSection'
@ -334,6 +335,7 @@ function VoiceKeywordsSection({ instructions }: { instructions: CustomInstructio
value={keywordInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setKeywordInput(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) }
if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') }
}}

View file

@ -12,6 +12,7 @@ import SendIcon from '@mui/icons-material/Send'
import DescriptionIcon from '@mui/icons-material/Description'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'
@ -82,6 +83,7 @@ export function KnowledgeBasePage(): React.ReactElement {
}, [query])
const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()

View file

@ -16,6 +16,7 @@ import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
@ -163,9 +164,7 @@ export function MeetingModePage(): React.ReactElement {
const handleMemoKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// 한글 IME 조합 중 Enter는 keydown을 2번 발화(조합 확정 + 실제 Enter)하므로
// isComposing=true인 이벤트는 무시해 addMemo 중복 호출 방지
if (e.nativeEvent.isComposing || e.keyCode === 229) return
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddMemo()

View file

@ -11,6 +11,7 @@ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import CancelIcon from '@mui/icons-material/Cancel'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '@d3ro/ui/components/ds'
import { PageHeader } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
@ -111,6 +112,7 @@ export function VoiceConversationPage(): React.ReactElement {
}, [])
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendText()

View file

@ -0,0 +1,25 @@
import type { KeyboardEvent } from 'react'
/**
* // IME .
*
* IME가 Enter를 Chromium이 `keydown`
* :
* 1) IME `nativeEvent.isComposing === true`
* ( `isComposing` false로 `keyCode === 229`)
* 2) Enter `isComposing === false`
*
* Enter `true` return
* IPC/DB (Bug 10 ).
*
* :
* ```tsx
* const handleKeyDown = (e: KeyboardEvent) => {
* if (isImeComposingEvent(e)) return
* if (e.key === 'Enter' && !e.shiftKey) { ... }
* }
* ```
*/
export function isImeComposingEvent(e: KeyboardEvent): boolean {
return e.nativeEvent.isComposing || e.keyCode === 229
}