Phase 3.5 구현: 커서 위치 히스토리 팝업 (D3RO 고유 기능)

- HistoryPopup: Vanilla JS, 다크 카드(#242427), 앰버 악센트(#f25b29)
- Ctrl+Shift+V → 커서 위치에 최근 10건 히스토리 팝업
- Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
- focusable: false → 활성 앱 포커스 유지
- 2-phase 리사이즈, 등장/퇴장 애니메이션
- WindowManager: HistoryPopup 관리 + 프리로딩
- Bootstrap: globalShortcut 등록 + IPC 연동
This commit is contained in:
Yun Chan 2026-04-05 02:37:35 +09:00
parent 291e2a29d0
commit f131b581a9
7 changed files with 459 additions and 5 deletions

View file

@ -77,11 +77,18 @@ npm run typecheck # tsc --noEmit
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
## 현재 상태
Phase: 5 완료
마지막 완료: Phase 5 — SQLite DB + HistoryService + DictionaryService + UI 완성
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 6 — 커스텀 명령어
Phase: 5 + 3.5 완료
마지막 완료: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능)
다음 작업: Phase 6 — 커스텀 명령어 + 설정 UI 고도화 + i18n
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
### Phase 3.5 구현 내용
- HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29)
- Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업
- Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
- focusable: false → 활성 앱 포커스 유지
- 2-phase 리사이즈, 등장/퇴장 애니메이션 (0.15s/0.1s)
### Phase 5 구현 내용
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats 테이블, WAL 모드)
- HistoryService: CRUD + 검색 + 통계 + 보존 정책(30일), 세션 완료 시 자동 이력 저장

View file

@ -39,6 +39,10 @@ export default defineConfig({
'popups/result-popup': resolve(
__dirname,
'src/renderer/popups/result-popup/index.html'
),
'popups/history-popup': resolve(
__dirname,
'src/renderer/popups/history-popup/index.html'
)
}
}

View file

@ -1,6 +1,6 @@
// src/main/bootstrap.ts — 초기화 시퀀스
import { app, dialog } from 'electron'
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
@ -15,7 +15,11 @@ import {
hideRecordingTip,
updateRecordingTipState,
sendAudioLevelToTip,
showResultPopup
showResultPopup,
showHistoryPopup,
hideHistoryPopup,
sendKeyToHistoryPopup,
isHistoryPopupVisible
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { registerAllIpcHandlers } from './ipc'
@ -92,6 +96,17 @@ async function initHotkey(): Promise<void> {
async function initPopupWindows(): Promise<void> {
preloadPopupWindows()
setupHistoryPopupIPC()
// Ctrl+Shift+V → 히스토리 팝업 토글
globalShortcut.register('Ctrl+Shift+V', () => {
if (isHistoryPopupVisible()) {
hideHistoryPopup()
} else {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
}
})
}
async function initVoiceMode(): Promise<void> {
@ -149,3 +164,33 @@ async function initLLMPolling(): Promise<void> {
const llm = getLocalLLMService()
llm.startPolling()
}
// ── HistoryPopup IPC 연동 ────────────────────────────
function setupHistoryPopupIPC(): void {
const { ipcMain } = require('electron')
const { getTextInsertService } = require('./services/TextInsertService')
// 히스토리 팝업 열기 (핫키에서 호출)
ipcMain.on('history:showPopup', () => {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
})
// 아이템 선택 → 텍스트 삽입
ipcMain.on('history:itemSelected', (_event: Electron.IpcMainEvent, data: { text: string }) => {
hideHistoryPopup()
setTimeout(async () => {
try {
await getTextInsertService().insertText(data.text)
} catch (error) {
logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
}
}, 200)
})
// 팝업 닫기
ipcMain.on('history:popupDismissed', () => {
hideHistoryPopup()
})
}

View file

@ -16,6 +16,7 @@ const logger = getLogger('WindowManager')
let mainWindow: BrowserWindow | null = null
let recordingTipWindow: BrowserWindow | null = null
let resultPopupWindow: BrowserWindow | null = null
let historyPopupWindow: BrowserWindow | null = null
// ── 메인 윈도우 ───────────────────────────────────────
@ -262,11 +263,106 @@ export function hideResultPopup(): void {
}
}
// ── HistoryPopup 팝업 ─────────────────────────────────
function createHistoryPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 360,
height: 400,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
}
win.on('closed', () => {
historyPopupWindow = null
})
return win
}
export function getHistoryPopupWindow(): BrowserWindow {
if (!historyPopupWindow || historyPopupWindow.isDestroyed()) {
historyPopupWindow = createHistoryPopupWindow()
logger.info('HistoryPopup window created')
}
return historyPopupWindow
}
export function showHistoryPopup(entries: Array<Record<string, unknown>>): void {
const win = getHistoryPopupWindow()
win.webContents.send('history:showItems', { entries })
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
ipcMain.removeListener('history:popupMeasured', handler)
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
let x = cursorPos.x - Math.round(data.width / 2)
let y = cursorPos.y - data.height - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
if (y < display.workArea.y) {
y = cursorPos.y + 20
}
win.setBounds({ x, y, width: data.width, height: data.height })
if (!win.isVisible()) {
win.showInactive()
}
win.webContents.send('history:show', {})
}
ipcMain.on('history:popupMeasured', handler)
}
export function hideHistoryPopup(): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.webContents.send('history:hide', {})
setTimeout(() => {
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
historyPopupWindow.hide()
}
}, 150)
}
}
export function sendKeyToHistoryPopup(key: string): void {
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
historyPopupWindow.webContents.send('history:keyEvent', { key })
}
}
export function isHistoryPopupVisible(): boolean {
return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible())
}
// ── 프리로딩 ──────────────────────────────────────────
export function preloadPopupWindows(): void {
getRecordingTipWindow()
getResultPopupWindow()
getHistoryPopupWindow()
logger.info('Popup windows preloaded')
}

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./style.css">
<title>History Popup</title>
</head>
<body>
<div id="root">
<div id="container" class="history-popup">
<div id="items" class="items"></div>
<div class="hints">
<span>↑↓ Select</span>
<span>⏎ Paste</span>
<span>ESC ×</span>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>

View file

@ -0,0 +1,156 @@
// HistoryPopup 팝업 스크립트
// Phase 3.5: 커서 위치에 최근 전사 히스토리 팝업
// Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
;(function () {
'use strict'
var container = document.getElementById('container')
var itemsContainer = document.getElementById('items')
var items = []
var selectedIndex = 0
var currentEntries = []
// ── 상대 시간 표시 ───────────────────────────────────
function relativeTime(timestamp) {
var diff = Date.now() - timestamp
var sec = Math.floor(diff / 1000)
if (sec < 60) return 'just now'
var min = Math.floor(sec / 60)
if (min < 60) return min + 'm ago'
var hr = Math.floor(min / 60)
if (hr < 24) return hr + 'h ago'
var day = Math.floor(hr / 24)
return day + 'd ago'
}
// ── 텍스트 truncate ──────────────────────────────────
function truncate(text, maxLen) {
if (text.length <= maxLen) return text
return text.substring(0, maxLen) + '...'
}
// ── 아이템 렌더링 ───────────────────────────────────
function renderItems(entries) {
currentEntries = entries
itemsContainer.innerHTML = ''
items = []
if (entries.length === 0) {
var empty = document.createElement('div')
empty.className = 'empty-state'
empty.textContent = 'No history yet'
itemsContainer.appendChild(empty)
return
}
entries.forEach(function (entry, index) {
var div = document.createElement('div')
div.className = 'item' + (index === selectedIndex ? ' selected' : '')
div.dataset.index = String(index)
var num = document.createElement('span')
num.className = 'item-number'
num.textContent = String(index + 1)
var text = document.createElement('span')
text.className = 'item-text'
text.textContent = truncate(entry.polishedText || entry.originalText, 50)
var time = document.createElement('span')
time.className = 'item-time'
time.textContent = relativeTime(entry.createdAt)
div.appendChild(num)
div.appendChild(text)
div.appendChild(time)
itemsContainer.appendChild(div)
items.push(div)
// 클릭으로 선택+삽입
div.addEventListener('click', function () {
selectAndInsert(index)
})
})
}
// ── 선택 업데이트 ───────────────────────────────────
function updateSelection(newIndex) {
if (currentEntries.length === 0) return
if (newIndex < 0) newIndex = currentEntries.length - 1
if (newIndex >= currentEntries.length) newIndex = 0
items.forEach(function (item, i) {
if (i === newIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
})
selectedIndex = newIndex
// 스크롤 보장
if (items[selectedIndex]) {
items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
}
// ── 선택 후 삽입 ───────────────────────────────────
function selectAndInsert(index) {
if (index < 0 || index >= currentEntries.length) return
var entry = currentEntries[index]
var text = entry.polishedText || entry.originalText
window.popupAPI.send('history:itemSelected', { id: entry.id, text: text })
}
// ── 크기 측정 ───────────────────────────────────────
function measureAndReport() {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var rect = container.getBoundingClientRect()
window.popupAPI.send('history:popupMeasured', {
width: Math.ceil(rect.width) + 4,
height: Math.ceil(rect.height) + 4
})
})
})
}
// ── IPC 리스너 ───────────────────────────────────────
window.popupAPI.on('history:showItems', function (data) {
selectedIndex = 0
renderItems(data.entries || [])
container.classList.remove('hiding')
measureAndReport()
})
window.popupAPI.on('history:show', function () {
container.classList.add('visible')
container.classList.remove('hiding')
})
window.popupAPI.on('history:hide', function () {
container.classList.add('hiding')
container.classList.remove('visible')
})
// 키보드 이벤트 (main에서 전달)
window.popupAPI.on('history:keyEvent', function (data) {
var key = data.key
if (key === 'ArrowUp') {
updateSelection(selectedIndex - 1)
} else if (key === 'ArrowDown') {
updateSelection(selectedIndex + 1)
} else if (key === 'Enter') {
selectAndInsert(selectedIndex)
} else if (key === 'Escape') {
window.popupAPI.send('history:popupDismissed', {})
} else if (key >= '1' && key <= '9') {
var idx = parseInt(key, 10) - 1
if (idx < currentEntries.length) {
selectAndInsert(idx)
}
}
})
})()

View file

@ -0,0 +1,124 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
}
#root {
width: 100%;
height: 100%;
}
.history-popup {
background: #242427;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px) scale(0.98);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
min-width: 300px;
max-width: 420px;
}
.history-popup.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.history-popup.hiding {
opacity: 0;
transform: translateY(-4px) scale(0.98);
transition-duration: 0.1s;
transition-timing-function: ease-in;
}
.items {
max-height: 360px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 100ms;
gap: 8px;
position: relative;
}
.item:hover,
.item.selected {
background: rgba(255, 255, 255, 0.06);
}
.item.selected::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
background: #f25b29;
border-radius: 1.5px;
}
.item-number {
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace;
min-width: 16px;
text-align: center;
}
.item-text {
flex: 1;
color: rgba(255, 255, 255, 0.87);
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-time {
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
white-space: nowrap;
flex-shrink: 0;
}
.hints {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 0 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.hints span {
color: rgba(255, 255, 255, 0.25);
font-size: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.empty-state {
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
text-align: center;
padding: 24px 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}