d3ro-voice/docs/design/09-history-popup.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵
- .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입)
- .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug
- .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist
- docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널),
  DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석,
  VoiceMode패턴, 디자인시스템, 히스토리팝업
- docs/phases/1-7+3.5: 전체 구현 페이즈 문서
- docs/re-findings/: Speakly RE 노하우 5개 문서
2026-04-05 01:03:03 +09:00

334 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 필터
- **핀 고정**: 자주 쓰는 항목 상단 고정
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기