diff --git a/CLAUDE.md b/CLAUDE.md index 07ada22..e4f2e92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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일), 세션 완료 시 자동 이력 저장 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c99b1d0..7fd460e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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' ) } } diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index f349b64..3078770 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -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 { async function initPopupWindows(): Promise { 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>) + } + }) } async function initVoiceMode(): Promise { @@ -149,3 +164,33 @@ async function initLLMPolling(): Promise { 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>) + }) + + // 아이템 선택 → 텍스트 삽입 + 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() + }) +} diff --git a/src/main/windows/WindowManager.ts b/src/main/windows/WindowManager.ts index f36b164..5babf4a 100644 --- a/src/main/windows/WindowManager.ts +++ b/src/main/windows/WindowManager.ts @@ -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>): 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') } diff --git a/src/renderer/popups/history-popup/index.html b/src/renderer/popups/history-popup/index.html new file mode 100644 index 0000000..029efb5 --- /dev/null +++ b/src/renderer/popups/history-popup/index.html @@ -0,0 +1,22 @@ + + + + + + + History Popup + + +
+
+
+
+ ↑↓ Select + ⏎ Paste + ESC × +
+
+
+ + + diff --git a/src/renderer/popups/history-popup/script.js b/src/renderer/popups/history-popup/script.js new file mode 100644 index 0000000..68469d9 --- /dev/null +++ b/src/renderer/popups/history-popup/script.js @@ -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) + } + } + }) +})() diff --git a/src/renderer/popups/history-popup/style.css b/src/renderer/popups/history-popup/style.css new file mode 100644 index 0000000..f66c052 --- /dev/null +++ b/src/renderer/popups/history-popup/style.css @@ -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; +}