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

9.4 KiB
Raw Permalink Blame History

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 기반)

.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. 윈도우 설정

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. 키보드 인터랙션 (글로벌 후킹)

// 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. 위치 계산

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. 타입 정의

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 준수)

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