초기 프로젝트 설정: 하네스 시스템 + 설계서 + 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개 문서
This commit is contained in:
commit
e24bb8378c
35 changed files with 11452 additions and 0 deletions
334
docs/design/09-history-popup.md
Normal file
334
docs/design/09-history-popup.md
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
# 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 필터
|
||||
- **핀 고정**: 자주 쓰는 항목 상단 고정
|
||||
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
|
||||
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기
|
||||
Loading…
Add table
Add a link
Reference in a new issue