# 09. 커서 위치 히스토리 팝업 설계 > 핫키로 커서 근처에 최근 전사 히스토리를 띄우고, Arrow 키로 선택하여 즉시 붙여넣는 기능. > Speakly에 없는 D3RO-VOICE 고유 기능. --- ## 1. 개요 ``` 사용자: Ctrl+Shift+V 누름 ↓ 1. mouse.getPosition()으로 현재 커서 좌표 획득 2. HistoryService에서 최근 10건 조회 3. 커서 위에 HistoryPopupWindow 표시 (focusable: false) 4. uiohook-napi로 Arrow↑↓/Enter/Escape 글로벌 인터셉트 5. Enter → 선택된 항목의 텍스트를 TextInsertService로 삽입 6. 팝업 닫기 ``` --- ## 2. 비주얼 디자인 (08-design-system 준수) ``` ┌──────────────────────────────┐ │ RECENT TRANSCRIPTS × │ ← label-uppercase, 앰버(#f25b29) ├──────────────────────────────┤ │ ▸ 주식회사 트렌티원스라는... │ ← 선택됨: bg #2a2a2d, left-bar 앰버 │ 이거 다 영어로 번역해 │ ← text-secondary #8e8e93 │ 바보라고 다시 고쳐줘 │ │ 시뮬레이션 다시 돌려보니.. │ │ UI 팀 다시 소집해 │ ├──────────────────────────────┤ │ ↑↓ SELECT ⏎ PASTE ESC × │ ← 하단 힌트, label 스타일 └──────────────────────────────┘ ``` ### CSS 변수 (08-design-system 기반) ```css .history-popup { background: var(--bg-card); /* #242427 */ border-radius: var(--radius-card); /* 22px */ border-top: 1px solid rgba(255, 255, 255, 0.04); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px); width: 340px; max-height: 320px; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .history-popup-header { padding: 12px 16px 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent-amber); /* #f25b29 */ display: flex; justify-content: space-between; align-items: center; } .history-item { padding: 10px 16px; cursor: default; transition: background-color 0.1s; border-left: 3px solid transparent; display: flex; flex-direction: column; gap: 2px; } .history-item.selected { background: var(--bg-card-hover); /* #2a2a2d */ border-left-color: var(--accent-amber); } .history-item-text { font-size: 13px; color: var(--text-primary); /* #ffffff */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 300px; } .history-item-meta { font-size: 10px; color: var(--text-label); /* #7c7c82 */ font-family: ui-monospace, SFMono-Regular, monospace; } .history-popup-footer { padding: 8px 16px; border-top: 1px solid rgba(255, 255, 255, 0.04); font-size: 10px; color: var(--text-label); font-family: ui-monospace, monospace; display: flex; gap: 12px; } .history-popup-footer kbd { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; font-size: 10px; } ``` --- ## 3. 윈도우 설정 ```typescript const historyPopup = new BrowserWindow({ width: 340, height: 320, // max, 실제는 콘텐츠에 맞춤 frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, focusable: false, // 핵심: 활성 앱 포커스 유지 resizable: false, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, '../preload/popup.js'), }, }); ``` **focusable: false가 핵심** — 팝업이 포커스를 뺏지 않으므로: - 원래 앱의 커서 위치가 유지됨 - 텍스트 삽입 시 원래 앱으로 전환할 필요 없음 - 키 입력은 uiohook-napi 글로벌 후킹으로 캡처 --- ## 4. 키보드 인터랙션 (글로벌 후킹) ```typescript // HotkeyService에 historyPopup 전용 모드 추가 interface HistoryPopupKeyHandler { // 팝업 열림 중에만 활성화 onArrowUp(): void; // 이전 항목 선택 onArrowDown(): void; // 다음 항목 선택 onEnter(): void; // 선택된 항목 삽입 + 팝업 닫기 onEscape(): void; // 팝업 닫기 (삽입 안 함) onNumberKey(n: number): void; // 1-9 직접 선택 + 삽입 } // uiohook-napi에서 키 인터셉트 // 팝업 열림 중: Arrow, Enter, Escape, 1-9 키를 소비 (앱에 전달 안 함) // 팝업 닫힘: 키 인터셉트 해제 ``` ### 숫자 키 단축 선택 ``` 1 주식회사 트렌티원스라는... ← 숫자 1 누르면 바로 삽입 2 이거 다 영어로 번역해 3 바보라고 다시 고쳐줘 ... ``` --- ## 5. 위치 계산 ```typescript async function calculatePopupPosition(): Promise<{ x: number; y: number }> { const { x: mouseX, y: mouseY } = await mouse.getPosition(); const display = screen.getDisplayNearestPoint({ x: mouseX, y: mouseY }); const { width: dw, height: dh } = display.workArea; const popupW = 340; const popupH = 320; const margin = 8; // 기본: 커서 위에 표시 let x = mouseX - popupW / 2; let y = mouseY - popupH - margin; // 화면 밖 보정 if (x < display.workArea.x) x = display.workArea.x + margin; if (x + popupW > display.workArea.x + dw) x = display.workArea.x + dw - popupW - margin; if (y < display.workArea.y) { // 위에 공간 없으면 아래에 표시 y = mouseY + margin; } return { x: Math.round(x), y: Math.round(y) }; } ``` --- ## 6. 데이터 흐름 ``` 1. 핫키 Ctrl+Shift+V → HotkeyService.emit('history-popup-trigger') 2. VoiceModeService / Main index.js에서 수신 → HistoryService.getRecent(10) // 최근 10건 → mouse.getPosition() → calculatePopupPosition() → historyPopup.setBounds({ x, y, width, height }) → historyPopup.webContents.send('history:showItems', items) → historyPopup.show() → HotkeyService.enterHistoryPopupMode() // 키 인터셉트 시작 3. Arrow↑↓ → HotkeyService가 인터셉트 → historyPopup.webContents.send('history:selectItem', direction) 4. Enter (또는 숫자 1-9) → HotkeyService가 인터셉트 → historyPopup.hide() → HotkeyService.exitHistoryPopupMode() // 키 인터셉트 종료 → TextInsertService.insertText(selectedText) 5. Escape → historyPopup.hide() → HotkeyService.exitHistoryPopupMode() ``` --- ## 7. IPC 채널 추가 | 채널명 | 방향 | 타입 | 설명 | |--------|------|------|------| | `history:showPopup` | handle | `void → void` | 히스토리 팝업 표시 | | `history:hidePopup` | handle | `void → void` | 히스토리 팝업 숨김 | | `history:showItems` | send | `HistoryPopupItem[]` | 팝업에 항목 전달 | | `history:selectItem` | send | `{ direction: 'up' \| 'down' } \| { index: number }` | 항목 선택 | | `history:itemSelected` | on | `{ id: string; text: string }` | 선택 확정 (Enter) | | `history:popupDismissed` | on | `void` | 팝업 닫힘 (Escape) | --- ## 8. 타입 정의 ```typescript interface HistoryPopupItem { id: string; text: string; // 전사 텍스트 (truncate 50자) fullText: string; // 전체 텍스트 (삽입용) mode: 'dictation' | 'translate' | 'command'; timestamp: number; // 상대 시간 표시용 ("2분 전", "어제") duration: number; // 녹음 시간 } interface HistoryPopupConfig { maxItems: number; // 기본 10 hotkey: HotkeyBinding; // 기본 Ctrl+Shift+V (Settings > 핫키 탭에서 변경 가능) showDuration: boolean; // 녹음 시간 표시 여부 autoClose: boolean; // 포커스 잃으면 자동 닫기 autoCloseMs: number; // 자동 닫기 타임아웃 (기본 10초) } // ConfigService의 hotkey 섹션에 통합 관리됨: // config.hotkey.dictation — 받아쓰기 (기본: Right Alt) // config.hotkey.handsFree — 핸즈프리 토글 // config.hotkey.command — 명령 모드 // config.hotkey.historyPopup — 히스토리 팝업 (기본: Ctrl+Shift+V) // → Settings > 핫키 탭에서 모두 한곳에서 설정 ``` --- ## 9. 애니메이션 (08-design-system 준수) ```css /* 팝업 등장 */ .history-popup { animation: popup-enter 0.15s ease-out; } @keyframes popup-enter { from { opacity: 0; transform: translateY(8px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } } /* 항목 선택 전환 */ .history-item { transition: background-color 0.08s ease, border-left-color 0.08s ease; } /* 팝업 퇴장 */ .history-popup.hiding { animation: popup-exit 0.1s ease-in forwards; } @keyframes popup-exit { to { opacity: 0; transform: translateY(4px) scale(0.98); } } ``` --- ## 10. 구현 페이즈 **Phase 3.5** (텍스트 삽입 완료 후, LLM 연동 전): - Phase 3에서 TextInsertService + HistoryService + HotkeyService 완성 - Phase 3.5에서 이 팝업만 추가 (1-2일 규모) - Phase 4 (LLM)와 독립적이므로 순서 유연 --- ## 11. 향후 확장 - **검색**: 팝업 상단에 인라인 검색 필드 (타이핑 시 필터) - **카테고리 탭**: dictation / translate / command 필터 - **핀 고정**: 자주 쓰는 항목 상단 고정 - **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시 - **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기