From 517210af2fdf3a12a2b2050caf49a70a1959367c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 02:11:58 +0900 Subject: [PATCH] =?UTF-8?q?Phase=203=20=EA=B5=AC=ED=98=84:=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=82=BD=EC=9E=85=20+=20RecordingTip/Resu?= =?UTF-8?q?ltPopup=20+=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js) - RecordingTip 팝업: 9개 웨이브바 cos분포, thinking 점근수렴, 2-phase 리사이즈 - ResultPopup 팝업: 복사 버튼, auto-close, 마우스 호버 유지, 다크모드 - WindowManager: 팝업 프리로딩, 커서 위치 표시, 멀티모니터 보정 - Settings 모달: General/Audio/STT/LLM 탭 - VoiceModeService: 전사 완료 시 자동 텍스트 삽입 + 팝업 연동 - electron-vite: 팝업 HTML 멀티 엔트리 + popup preload 빌드 --- CLAUDE.md | 18 +- electron.vite.config.ts | 23 ++ src/main/bootstrap.ts | 46 +++- src/main/services/TextInsertService.ts | 258 +++++++++++++++++++ src/main/services/VoiceModeService.ts | 13 +- src/main/services/index.ts | 1 + src/main/windows/WindowManager.ts | 218 +++++++++++++++- src/preload/popup.ts | 21 ++ src/renderer/components/AppLayout.tsx | 6 +- src/renderer/components/SettingsModal.tsx | 221 ++++++++++++++++ src/renderer/popups/recording-tip/index.html | 35 +++ src/renderer/popups/recording-tip/script.js | 185 +++++++++++++ src/renderer/popups/recording-tip/style.css | 106 ++++++++ src/renderer/popups/result-popup/index.html | 28 ++ src/renderer/popups/result-popup/script.js | 95 +++++++ src/renderer/popups/result-popup/style.css | 102 ++++++++ 16 files changed, 1366 insertions(+), 10 deletions(-) create mode 100644 src/main/services/TextInsertService.ts create mode 100644 src/preload/popup.ts create mode 100644 src/renderer/components/SettingsModal.tsx create mode 100644 src/renderer/popups/recording-tip/index.html create mode 100644 src/renderer/popups/recording-tip/script.js create mode 100644 src/renderer/popups/recording-tip/style.css create mode 100644 src/renderer/popups/result-popup/index.html create mode 100644 src/renderer/popups/result-popup/script.js create mode 100644 src/renderer/popups/result-popup/style.css diff --git a/CLAUDE.md b/CLAUDE.md index 10efbd3..1f4424a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,10 +77,20 @@ npm run typecheck # tsc --noEmit 6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션 ## 현재 상태 -Phase: 2 완료 -마지막 완료: Phase 2 — 로컬 STT (faster-whisper sidecar) + 핫키 (uiohook-napi) + VoiceModeService 오케스트레이터 -다음 작업: Phase 3 — 텍스트 삽입 + 기본 UI (RecordingTip, ResultPopup) -차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가 (수동 설치 필요) +Phase: 3 완료 +마지막 완료: Phase 3 — TextInsertService + RecordingTip/ResultPopup 팝업 + Settings 모달 +다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능) +차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가, @nut-tree/nut-js → @nut-tree-fork/nut-js 포크 사용 + +### Phase 3 구현 내용 +- TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js, lazy dynamic import) +- RecordingTip 팝업: Vanilla JS, 9개 웨이브바 cos분포 가중치, thinking 점근수렴, 2-phase 리사이즈 +- ResultPopup 팝업: Vanilla JS, 복사 버튼, auto-close(5초), 마우스 호버 유지, 다크모드 +- WindowManager 리팩토링: 팝업 프리로딩, 커서 위치 표시, 멀티모니터 보정 +- Settings 모달: General/Audio/STT/LLM 탭, 실시간 설정 변경 +- VoiceModeService 연동: 전사 완료 시 자동 텍스트 삽입 + RecordingTip→ResultPopup 전환 +- Bootstrap: 9단계 초기화 (popup-preload 추가) +- electron-vite: 팝업 HTML 멀티 엔트리 + popup preload 빌드 ### Phase 2 구현 내용 - AudioCaptureService: node-record-lpcm16 + SoX 실제 마이크 캡처 (PCM16 16kHz mono, 60ms 프레임, RMS 레벨) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d2703ed..c99b1d0 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,6 +13,14 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/preload/index.ts'), + popup: resolve(__dirname, 'src/preload/popup.ts') + } + } + }, resolve: { alias: { '@shared': resolve('src/shared') @@ -20,6 +28,21 @@ export default defineConfig({ } }, renderer: { + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html'), + 'popups/recording-tip': resolve( + __dirname, + 'src/renderer/popups/recording-tip/index.html' + ), + 'popups/result-popup': resolve( + __dirname, + 'src/renderer/popups/result-popup/index.html' + ) + } + } + }, resolve: { alias: { '@shared': resolve('src/shared') diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 88d556e..8d50c79 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -5,7 +5,15 @@ import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService } from './services/ConfigService' import { getHotkeyService } from './services/HotkeyService' import { getVoiceModeService } from './services/VoiceModeService' -import { createMainWindow } from './windows/WindowManager' +import { + createMainWindow, + preloadPopupWindows, + showRecordingTip, + hideRecordingTip, + updateRecordingTipState, + sendAudioLevelToTip, + showResultPopup +} from './windows/WindowManager' import { createTray } from './windows/TrayManager' import { registerAllIpcHandlers } from './ipc' @@ -24,6 +32,7 @@ export async function bootstrap(): Promise { { name: 'create-windows', critical: true, fn: createWindows }, { name: 'tray', critical: false, fn: initTray }, { name: 'ipc-handlers', critical: true, fn: initIpcHandlers }, + { name: 'popup-preload', critical: false, fn: initPopupWindows }, { name: 'hotkey', critical: false, fn: initHotkey }, { name: 'voice-mode', critical: false, fn: initVoiceMode } ] @@ -72,7 +81,42 @@ async function initHotkey(): Promise { hotkey.start() } +async function initPopupWindows(): Promise { + preloadPopupWindows() +} + async function initVoiceMode(): Promise { const voiceMode = getVoiceModeService() voiceMode.connectHotkey() + + // RecordingTip 연동: 세션 시작/종료 시 팝업 표시/숨김 + voiceMode.on('session-started', () => { + showRecordingTip('recording') + }) + + voiceMode.on('audio-level', ({ level }) => { + sendAudioLevelToTip(level) + }) + + voiceMode.on('recognition-state-changed', ({ current }) => { + if (current === 'recognizing') { + updateRecordingTipState('thinking') + } + }) + + voiceMode.on('session-completed', ({ finalText }) => { + hideRecordingTip() + if (finalText.length > 0) { + showResultPopup(finalText) + } + }) + + voiceMode.on('session-cancelled', () => { + hideRecordingTip() + }) + + voiceMode.on('error', ({ error }) => { + updateRecordingTipState('error', { errorMessage: error.message }) + setTimeout(() => hideRecordingTip(), 3000) + }) } diff --git a/src/main/services/TextInsertService.ts b/src/main/services/TextInsertService.ts new file mode 100644 index 0000000..c2e1777 --- /dev/null +++ b/src/main/services/TextInsertService.ts @@ -0,0 +1,258 @@ +// src/main/services/TextInsertService.ts +// 전사된 텍스트를 현재 활성 앱에 삽입한다. +// 설계서 01의 ITextInsertService 구현. Speakly ClipboardPaste 패턴. +// @nut-tree-fork/nut-js + electron clipboard API 사용. + +import { EventEmitter } from 'events' +import { clipboard } from 'electron' +import { getLogger } from './LoggerService' +import { D3ROError, ErrorCode } from '@shared/errors' + +const logger = getLogger('TextInsertService') + +// ============================================================ +// 타입 +// ============================================================ + +type InsertMethod = 'clipboard' | 'keyboard' + +interface ClipboardSnapshot { + text: string | null + html: string | null + image: Electron.NativeImage | null + rtf: string | null + hasContent: boolean +} + +interface InsertResult { + success: boolean + method: InsertMethod + textLength: number + durationMs: number +} + +interface TextInsertEvents { + 'insert-started': (payload: { text: string; method: InsertMethod }) => void + 'insert-completed': (payload: { result: InsertResult }) => void + 'insert-failed': (payload: { error: D3ROError; method: InsertMethod }) => void + 'clipboard-saved': (payload: Record) => void + 'clipboard-restored': (payload: Record) => void +} + +// ============================================================ +// TextInsertService +// ============================================================ + +class TextInsertService extends EventEmitter { + private _nutKeyboard: NutKeyboard | null = null + private _nutLoaded = false + private _nutLoadPromise: Promise | null = null + + /** + * nut-js는 ESM + 네이티브 모듈이므로 lazy dynamic import 한다. + */ + private async _ensureNut(): Promise { + if (this._nutKeyboard) return this._nutKeyboard + + if (!this._nutLoadPromise) { + this._nutLoadPromise = (async () => { + try { + const nut = await import('@nut-tree-fork/nut-js') + this._nutKeyboard = { + pressKey: nut.keyboard.pressKey.bind(nut.keyboard), + releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard), + type: nut.keyboard.type.bind(nut.keyboard), + Key: nut.Key + } + this._nutLoaded = true + logger.info('nut-js loaded successfully') + } catch (error) { + logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`) + throw new D3ROError( + ErrorCode.TextInsertKeySimulationFailed, + 'nut-js 로드 실패. 키보드 시뮬레이션을 사용할 수 없습니다.' + ) + } + })() + } + + await this._nutLoadPromise + if (!this._nutKeyboard) { + throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available') + } + return this._nutKeyboard + } + + /** + * 텍스트를 현재 활성 앱에 삽입한다. + * 기본 전략: clipboard save → set → Ctrl+V → restore + */ + async insertText(text: string, method: InsertMethod = 'clipboard'): Promise { + const start = performance.now() + this.emit('insert-started', { text, method }) + + try { + if (method === 'clipboard') { + await this._insertViaClipboard(text) + } else { + await this._insertViaKeyboard(text) + } + + const result: InsertResult = { + success: true, + method, + textLength: text.length, + durationMs: performance.now() - start + } + + this.emit('insert-completed', { result }) + logger.info(`Text inserted (${text.length} chars, ${Math.round(result.durationMs)}ms)`) + return result + } catch (error) { + const d3roError = + error instanceof D3ROError + ? error + : new D3ROError( + ErrorCode.TextInsertFailed, + `Text insert failed: ${error instanceof Error ? error.message : String(error)}` + ) + + this.emit('insert-failed', { error: d3roError, method }) + throw d3roError + } + } + + /** + * 클립보드 방식: save → set → Ctrl+V → restore (Speakly 패턴) + */ + private async _insertViaClipboard(text: string): Promise { + // 1. 기존 클립보드 저장 + const snapshot = this.saveClipboard() + this.emit('clipboard-saved', {}) + + try { + // 2. 클립보드에 텍스트 설정 + clipboard.writeText(text) + + // 3. Ctrl+V 시뮬레이션 + const nut = await this._ensureNut() + await nut.pressKey(nut.Key.LeftControl, nut.Key.V) + await nut.releaseKey(nut.Key.LeftControl, nut.Key.V) + + // 4. 붙여넣기 완료 대기 + await this._sleep(150) + + // 5. 클립보드 복원 + this.restoreClipboard(snapshot) + this.emit('clipboard-restored', {}) + } catch (error) { + // 실패 시에도 클립보드 복원 시도 + try { + this.restoreClipboard(snapshot) + } catch { + logger.warn('Failed to restore clipboard after insert error') + } + throw error + } + } + + /** + * 키보드 방식: 한 글자씩 타이핑 (느리지만 클립보드 비파괴) + */ + private async _insertViaKeyboard(text: string): Promise { + const nut = await this._ensureNut() + await nut.type(text) + } + + /** + * 현재 클립보드 상태를 캡처한다. + */ + saveClipboard(): ClipboardSnapshot { + const text = clipboard.readText() || null + const html = clipboard.readHTML() || null + const rtf = clipboard.readRTF() || null + const image = clipboard.readImage() + const hasImage = image && !image.isEmpty() + + return { + text, + html, + image: hasImage ? image : null, + rtf, + hasContent: !!(text || html || rtf || hasImage) + } + } + + /** + * 저장된 클립보드 상태를 복원한다. + */ + restoreClipboard(snapshot: ClipboardSnapshot): void { + if (!snapshot.hasContent) { + clipboard.clear() + return + } + + // 텍스트가 있으면 텍스트 우선 복원 + if (snapshot.text) { + clipboard.writeText(snapshot.text) + } else if (snapshot.html) { + clipboard.writeHTML(snapshot.html) + } else if (snapshot.rtf) { + clipboard.writeRTF(snapshot.rtf) + } else if (snapshot.image) { + clipboard.writeImage(snapshot.image) + } + } + + dispose(): void { + this.removeAllListeners() + logger.info('TextInsertService disposed') + } + + private _sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + + // ── EventEmitter 타입 오버라이드 ─────────────────────── + + override on( + event: K, + listener: TextInsertEvents[K] + ): this { + return super.on(event, listener) + } + + override off( + event: K, + listener: TextInsertEvents[K] + ): this { + return super.off(event, listener) + } + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } +} + +// ── nut-js 타입 (lazy import용) ────────────────────────── + +interface NutKeyboard { + pressKey: (...keys: number[]) => Promise + releaseKey: (...keys: number[]) => Promise + type: (text: string) => Promise + Key: Record +} + +// ── 싱글톤 ───────────────────────────────────────────── + +let instance: TextInsertService | null = null + +export function getTextInsertService(): TextInsertService { + if (!instance) { + instance = new TextInsertService() + } + return instance +} diff --git a/src/main/services/VoiceModeService.ts b/src/main/services/VoiceModeService.ts index 99a68ff..5ecc1c2 100644 --- a/src/main/services/VoiceModeService.ts +++ b/src/main/services/VoiceModeService.ts @@ -12,6 +12,7 @@ import type { TranscriptionResult } from './LocalSTTService' import { getHotkeyService } from './HotkeyService' import type { HotkeyConfig } from './HotkeyService' import { configGet } from './ConfigService' +import { getTextInsertService } from './TextInsertService' import { D3ROError, ErrorCode } from '@shared/errors' import { TIMING } from '@shared/constants' import { RecognitionState, AudioState } from '@shared/types' @@ -421,7 +422,7 @@ class VoiceModeService extends EventEmitter { // ── 세션 완료/취소 ───────────────────────────────────── - private _completeSession(finalText: string): void { + private async _completeSession(finalText: string): Promise { if (!this._session) return this._setRecognitionState(RecognitionState.COMPLETED) @@ -430,6 +431,16 @@ class VoiceModeService extends EventEmitter { const session = { ...this._session } logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`) + // 텍스트 삽입 (autoInsert 설정 확인) + if (configGet('autoInsert') && finalText.length > 0) { + try { + const insertMethod = configGet('insertMethod') + await getTextInsertService().insertText(finalText, insertMethod) + } catch (error) { + logger.warn(`Text insert failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + this.emit('session-completed', { session, finalText }) // IDLE로 복귀 diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 4665a7f..3fc70de 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -24,3 +24,4 @@ export type { export { getHotkeyService } from './HotkeyService' export { getVoiceModeService } from './VoiceModeService' export { getAudioCaptureService } from './AudioCaptureService' +export { getTextInsertService } from './TextInsertService' diff --git a/src/main/windows/WindowManager.ts b/src/main/windows/WindowManager.ts index 828dbc3..f36b164 100644 --- a/src/main/windows/WindowManager.ts +++ b/src/main/windows/WindowManager.ts @@ -1,16 +1,23 @@ // src/main/windows/WindowManager.ts +// 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈 -import { BrowserWindow, shell } from 'electron' +import { BrowserWindow, shell, screen, ipcMain } from 'electron' import { join } from 'path' import { is } from '@electron-toolkit/utils' import { WINDOW_SIZE } from '@shared/constants' import { getLogger } from '../services/LoggerService' -import { getIsQuitting, setIsQuitting } from '../lifecycle' +import { getIsQuitting } from '../lifecycle' import { configGet } from '../services/ConfigService' const logger = getLogger('WindowManager') +// ── 윈도우 참조 ─────────────────────────────────────── + let mainWindow: BrowserWindow | null = null +let recordingTipWindow: BrowserWindow | null = null +let resultPopupWindow: BrowserWindow | null = null + +// ── 메인 윈도우 ─────────────────────────────────────── export function getMainWindow(): BrowserWindow | null { return mainWindow @@ -54,7 +61,6 @@ export function createMainWindow(): BrowserWindow { return { action: 'deny' } }) - // 개발/프로덕션 URL 로드 if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -64,3 +70,209 @@ export function createMainWindow(): BrowserWindow { logger.info('Main window created') return mainWindow } + +// ── RecordingTip 팝업 ───────────────────────────────── + +function createRecordingTipWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: WINDOW_SIZE.RECORDING_TIP.width, + height: WINDOW_SIZE.RECORDING_TIP.height, + 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/recording-tip/index.html`) + } else { + win.loadFile(join(__dirname, '../renderer/popups/recording-tip/index.html')) + } + + win.on('closed', () => { + recordingTipWindow = null + }) + + return win +} + +export function getRecordingTipWindow(): BrowserWindow { + if (!recordingTipWindow || recordingTipWindow.isDestroyed()) { + recordingTipWindow = createRecordingTipWindow() + logger.info('RecordingTip window created (preloaded)') + } + return recordingTipWindow +} + +/** + * RecordingTip 표시 (2-phase 리사이즈) + * Phase 1: prepare → 측정 + * Phase 2: resize → show + */ +export function showRecordingTip( + state: string, + params?: { text?: string; errorMessage?: string } +): void { + const win = getRecordingTipWindow() + + // Phase 1: prepare (크기 측정) + win.webContents.send('window:tipPrepare', { state, ...params }) + + // tipMeasured 이벤트를 한번만 처리 + const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => { + ipcMain.removeListener('window:tipMeasured', 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() + } + + // Phase 2: show + win.webContents.send('window:tipShow', { state }) + } + + ipcMain.on('window:tipMeasured', handler) +} + +export function hideRecordingTip(): void { + if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { + recordingTipWindow.hide() + } +} + +export function updateRecordingTipState( + state: string, + params?: { text?: string; errorMessage?: string } +): void { + if (recordingTipWindow && !recordingTipWindow.isDestroyed()) { + recordingTipWindow.webContents.send('window:tipStateChanged', { state, ...params }) + } +} + +export function sendAudioLevelToTip(level: number): void { + if (recordingTipWindow && !recordingTipWindow.isDestroyed() && recordingTipWindow.isVisible()) { + recordingTipWindow.webContents.send('voice:audioLevel', { level }) + } +} + +// ── ResultPopup 팝업 ────────────────────────────────── + +function createResultPopupWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: WINDOW_SIZE.RESULT_POPUP.width, + height: WINDOW_SIZE.RESULT_POPUP.height, + 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/result-popup/index.html`) + } else { + win.loadFile(join(__dirname, '../renderer/popups/result-popup/index.html')) + } + + win.on('closed', () => { + resultPopupWindow = null + }) + + return win +} + +export function getResultPopupWindow(): BrowserWindow { + if (!resultPopupWindow || resultPopupWindow.isDestroyed()) { + resultPopupWindow = createResultPopupWindow() + logger.info('ResultPopup window created (preloaded)') + } + return resultPopupWindow +} + +/** + * ResultPopup 표시 (2-phase 리사이즈) + */ +export function showResultPopup(text: string, autoHideMs = 5000): void { + const win = getResultPopupWindow() + + // Phase 1: prepare + win.webContents.send('result:prepare', { text }) + + const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => { + ipcMain.removeListener('result:measured', 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() + } + + // Phase 2: show + win.webContents.send('result:show', { autoHideMs }) + } + + ipcMain.on('result:measured', handler) +} + +export function hideResultPopup(): void { + if (resultPopupWindow && !resultPopupWindow.isDestroyed()) { + resultPopupWindow.hide() + } +} + +// ── 프리로딩 ────────────────────────────────────────── + +export function preloadPopupWindows(): void { + getRecordingTipWindow() + getResultPopupWindow() + logger.info('Popup windows preloaded') +} + +// ── clipboard:copy IPC (ResultPopup에서 사용) ───────── + +ipcMain.on('clipboard:copy', (_event, text: string) => { + const { clipboard } = require('electron') + clipboard.writeText(text) +}) diff --git a/src/preload/popup.ts b/src/preload/popup.ts new file mode 100644 index 0000000..3ced993 --- /dev/null +++ b/src/preload/popup.ts @@ -0,0 +1,21 @@ +// src/preload/popup.ts +// 팝업 윈도우(RecordingTip, ResultPopup)용 최소 preload + +import { contextBridge, ipcRenderer } from 'electron' + +type Unsubscribe = () => void + +const popupAPI = { + send: (channel: string, ...args: unknown[]): void => { + ipcRenderer.send(channel, ...args) + }, + on: (channel: string, callback: (...args: unknown[]) => void): Unsubscribe => { + const handler = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args) + ipcRenderer.on(channel, handler) + return () => ipcRenderer.removeListener(channel, handler) + } +} as const + +contextBridge.exposeInMainWorld('popupAPI', popupAPI) + +export type PopupAPI = typeof popupAPI diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 4695730..4be6386 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -17,6 +17,7 @@ import HistoryIcon from '@mui/icons-material/History' import MenuBookIcon from '@mui/icons-material/MenuBook' import SettingsIcon from '@mui/icons-material/Settings' import { DashboardPage } from '../pages/DashboardPage' +import { SettingsModal } from './SettingsModal' type Route = 'dashboard' | 'history' | 'dictionary' @@ -30,6 +31,7 @@ const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement } export function AppLayout(): React.ReactElement { const [currentRoute, setCurrentRoute] = useState('dashboard') + const [settingsOpen, setSettingsOpen] = useState(false) return ( @@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement { {/* Bottom */} - + setSettingsOpen(true)}> @@ -105,6 +107,8 @@ export function AppLayout(): React.ReactElement { )} + + setSettingsOpen(false)} /> ) } diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx new file mode 100644 index 0000000..50bb77e --- /dev/null +++ b/src/renderer/components/SettingsModal.tsx @@ -0,0 +1,221 @@ +// src/renderer/components/SettingsModal.tsx +// 설계서 03: Settings React Modal (일반/오디오/핫키 탭) + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + Tabs, + Tab, + Box, + TextField, + Select, + MenuItem, + Switch, + FormControlLabel, + Typography, + IconButton, + Divider, + InputLabel, + FormControl +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import type { ThemeMode, AppConfig } from '@shared/types' + +interface SettingsModalProps { + open: boolean + onClose: () => void +} + +interface TabPanelProps { + children: React.ReactNode + value: number + index: number +} + +function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null { + if (value !== index) return null + return {children} +} + +export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement { + const [activeTab, setActiveTab] = useState(0) + const [config, setConfig] = useState>({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!open) return + setLoading(true) + window.electronAPI.config + .getAll() + .then((result) => { + if (result.success) { + setConfig(result.data) + } + }) + .finally(() => setLoading(false)) + }, [open]) + + const updateConfig = (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => { + setConfig((prev) => ({ ...prev, [key]: value })) + window.electronAPI.config.set({ key, value }) + } + + if (loading) return + + return ( + + + Settings + + + + + + + setActiveTab(v)}> + + + + + + + {/* General */} + + + + Theme + + + + + Language + + + + updateConfig('closeToTray', e.target.checked)} + /> + } + label="Close to tray" + /> + + updateConfig('autoInsert', e.target.checked)} + /> + } + label="Auto-insert text after transcription" + /> + + updateConfig('soundEnabled', e.target.checked)} + /> + } + label="Sound effects" + /> + + + + {/* Audio */} + + + + Microphone device selection will be available in a future update. + Currently using the system default microphone. + + + + Insert Method + + + + + + {/* STT */} + + + + Whisper Model + + + + + Language + + + + + + {/* LLM */} + + + updateConfig('ollamaServerUrl', e.target.value)} + fullWidth + /> + + + LLM model selection will be available after Ollama integration (Phase 4). + + + + + + ) +} diff --git a/src/renderer/popups/recording-tip/index.html b/src/renderer/popups/recording-tip/index.html new file mode 100644 index 0000000..dae416d --- /dev/null +++ b/src/renderer/popups/recording-tip/index.html @@ -0,0 +1,35 @@ + + + + + + + Recording Tip + + +
+
+ +
+
+ 0:00 +
+ + + + + + +
+
+ + + diff --git a/src/renderer/popups/recording-tip/script.js b/src/renderer/popups/recording-tip/script.js new file mode 100644 index 0000000..c9e776e --- /dev/null +++ b/src/renderer/popups/recording-tip/script.js @@ -0,0 +1,185 @@ +// RecordingTip 팝업 스크립트 +// 설계서 03: 9개 웨이브바, cos 분포 가중치, 100ms 애니메이션 +// Speakly 패턴 준수 + +;(function () { + 'use strict' + + // ── 상수 ───────────────────────────────────────────── + const BAR_COUNT = 9 + const UPDATE_INTERVAL = 100 + const MIN_HEIGHT = 2 + const MAX_HEIGHT = 28 + const SMOOTHING = 0.5 + const RANDOM_FACTOR = 0.35 + + // 코사인 분포 가중치 (중앙이 가장 높음) + // 설계서 03: cos((n - 4) * PI / 9) + const weights = Array.from({ length: BAR_COUNT }, function (_, i) { + var center = (BAR_COUNT - 1) / 2 + var normalized = (i - center) / center + return Math.cos(normalized * Math.PI / 2) + }) + + // ── DOM 참조 ───────────────────────────────────────── + var container = document.getElementById('container') + var recordingView = document.getElementById('recording-view') + var thinkingView = document.getElementById('thinking-view') + var errorView = document.getElementById('error-view') + var waveBarsContainer = document.getElementById('wave-bars') + var durationText = document.getElementById('duration-text') + var progressBar = document.getElementById('progress-bar') + var errorText = document.getElementById('error-text') + + // ── 상태 ───────────────────────────────────────────── + var bars = [] + var currentHeights = new Array(BAR_COUNT).fill(MIN_HEIGHT) + var audioLevel = 0 + var animInterval = null + var durationInterval = null + var recordingStartTime = 0 + var thinkingStartTime = 0 + var thinkingRaf = null + var currentState = 'idle' + + // ── 웨이브 바 생성 ─────────────────────────────────── + function createWaveBars() { + for (var i = 0; i < BAR_COUNT; i++) { + var bar = document.createElement('div') + bar.className = 'wave-bar' + bar.style.height = MIN_HEIGHT + 'px' + waveBarsContainer.appendChild(bar) + bars.push(bar) + } + } + + // ── 웨이브 바 애니메이션 ───────────────────────────── + function updateBars() { + for (var i = 0; i < BAR_COUNT; i++) { + var baseTarget = audioLevel * MAX_HEIGHT * weights[i] + var randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) + var target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized)) + + // 스무딩 보간 + currentHeights[i] += (target - currentHeights[i]) * SMOOTHING + bars[i].style.height = Math.round(currentHeights[i]) + 'px' + } + } + + // ── 녹음 시간 표시 ─────────────────────────────────── + function updateDuration() { + var elapsed = Math.floor((Date.now() - recordingStartTime) / 1000) + var minutes = Math.floor(elapsed / 60) + var seconds = elapsed % 60 + durationText.textContent = minutes + ':' + (seconds < 10 ? '0' : '') + seconds + } + + // ── Thinking 프로그레스 바 (점근 수렴 패턴) ────────── + // 설계서 03: min(95, (1 - 1/(1 + 1.5*t)) * 100)% + function updateThinkingProgress() { + var elapsed = (performance.now() - thinkingStartTime) / 1000 + var progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100) + progressBar.style.width = progress + '%' + + if (progress < 95 && currentState === 'thinking') { + thinkingRaf = requestAnimationFrame(updateThinkingProgress) + } + } + + // ── 뷰 전환 ───────────────────────────────────────── + function hideAllViews() { + recordingView.classList.add('hidden') + thinkingView.classList.add('hidden') + errorView.classList.add('hidden') + clearInterval(animInterval) + clearInterval(durationInterval) + if (thinkingRaf) cancelAnimationFrame(thinkingRaf) + animInterval = null + durationInterval = null + thinkingRaf = null + } + + function showRecording() { + currentState = 'recording' + hideAllViews() + recordingView.classList.remove('hidden') + recordingStartTime = Date.now() + durationText.textContent = '0:00' + currentHeights.fill(MIN_HEIGHT) + + animInterval = setInterval(updateBars, UPDATE_INTERVAL) + durationInterval = setInterval(updateDuration, 1000) + } + + function showThinking() { + currentState = 'thinking' + hideAllViews() + thinkingView.classList.remove('hidden') + progressBar.style.width = '0%' + progressBar.style.transition = 'width 100ms linear' + thinkingStartTime = performance.now() + thinkingRaf = requestAnimationFrame(updateThinkingProgress) + } + + function showError(message) { + currentState = 'error' + hideAllViews() + errorView.classList.remove('hidden') + errorText.textContent = message || '오류가 발생했습니다' + } + + // ── 크기 측정 (2-phase 리사이즈) ──────────────────── + function measureAndReport() { + requestAnimationFrame(function () { + requestAnimationFrame(function () { + var rect = container.getBoundingClientRect() + window.popupAPI.send('window:tipMeasured', { + width: Math.ceil(rect.width) + 4, + height: Math.ceil(rect.height) + 4 + }) + }) + }) + } + + // ── IPC 리스너 ─────────────────────────────────────── + function setupListeners() { + // Phase 1: prepare — 숨겨진 상태에서 렌더링 후 크기 측정 + window.popupAPI.on('window:tipPrepare', function (data) { + var state = data.state + if (state === 'recording') showRecording() + else if (state === 'thinking') showThinking() + else if (state === 'error') showError(data.errorMessage) + + measureAndReport() + }) + + // Phase 2: show — 리사이즈 완료 후 표시 + window.popupAPI.on('window:tipShow', function () { + container.style.opacity = '1' + }) + + // 오디오 레벨 + window.popupAPI.on('voice:audioLevel', function (data) { + audioLevel = data.level || 0 + }) + + // 상태 변경 + window.popupAPI.on('window:tipStateChanged', function (data) { + var state = data.state + if (state === 'recording') showRecording() + else if (state === 'thinking') showThinking() + else if (state === 'error') showError(data.errorMessage) + }) + + // 클릭 시 녹음 취소 + container.addEventListener('click', function () { + window.popupAPI.send('voice:cancelRecording', {}) + }) + } + + // ── 초기화 ─────────────────────────────────────────── + document.addEventListener('DOMContentLoaded', function () { + createWaveBars() + setupListeners() + }) +})() diff --git a/src/renderer/popups/recording-tip/style.css b/src/renderer/popups/recording-tip/style.css new file mode 100644 index 0000000..0237445 --- /dev/null +++ b/src/renderer/popups/recording-tip/style.css @@ -0,0 +1,106 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: transparent; + overflow: hidden; + -webkit-app-region: no-drag; + user-select: none; +} + +#root { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} + +.recording-tip { + background: rgba(0, 0, 0, 0.85); + border-radius: 8px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + backdrop-filter: blur(10px); + transition: opacity 150ms ease-in-out; + cursor: pointer; +} + +.wave-bars { + display: flex; + align-items: center; + gap: 2px; + height: 32px; +} + +.wave-bar { + width: 3px; + background: #1F5DF2; + border-radius: 1.5px; + transition: height 100ms ease-out; + min-height: 2px; +} + +.duration { + color: rgba(255, 255, 255, 0.87); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-variant-numeric: tabular-nums; + min-width: 32px; +} + +.progress-container { + width: 120px; + height: 3px; + background: rgba(255, 255, 255, 0.15); + border-radius: 1.5px; + overflow: hidden; +} + +.progress-bar { + height: 3px; + background: #1F5DF2; + border-radius: 1.5px; + width: 0%; + transition: width 100ms linear; +} + +.thinking-label { + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.error-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: #D32F2F; + color: white; + border-radius: 50%; + font-size: 12px; + font-weight: 700; +} + +.error-label { + color: rgba(255, 255, 255, 0.87); + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.view { + display: flex; + align-items: center; + gap: 8px; +} + +.view.hidden { + display: none; +} diff --git a/src/renderer/popups/result-popup/index.html b/src/renderer/popups/result-popup/index.html new file mode 100644 index 0000000..9055f55 --- /dev/null +++ b/src/renderer/popups/result-popup/index.html @@ -0,0 +1,28 @@ + + + + + + + Result + + +
+
+
+
+ +
+
+
+ + + diff --git a/src/renderer/popups/result-popup/script.js b/src/renderer/popups/result-popup/script.js new file mode 100644 index 0000000..5ee4579 --- /dev/null +++ b/src/renderer/popups/result-popup/script.js @@ -0,0 +1,95 @@ +// ResultPopup 팝업 스크립트 +// 설계서 03: 2-phase 리사이즈, auto-close, 마우스 호버 시 유지 + +;(function () { + 'use strict' + + var container = document.getElementById('container') + var resultText = document.getElementById('result-text') + var copyBtn = document.getElementById('copy-btn') + var copyIcon = document.getElementById('copy-icon') + var checkIcon = document.getElementById('check-icon') + + var autoCloseTimer = null + var remainingTime = 0 + var lastTick = 0 + + // ── Auto-close 제어 ───────────────────────────────── + function startAutoCloseTimer(ms) { + remainingTime = ms + lastTick = Date.now() + clearInterval(autoCloseTimer) + autoCloseTimer = setInterval(function () { + remainingTime -= (Date.now() - lastTick) + lastTick = Date.now() + if (remainingTime <= 0) { + clearInterval(autoCloseTimer) + autoCloseTimer = null + window.popupAPI.send('window:hideResultPopup') + } + }, 100) + } + + function pauseAutoClose() { + clearInterval(autoCloseTimer) + autoCloseTimer = null + } + + function resumeAutoClose() { + startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000) + } + + // ── 복사 버튼 ─────────────────────────────────────── + copyBtn.addEventListener('click', function () { + // navigator.clipboard는 팝업에서 작동 안 할 수 있으므로 IPC 사용 + window.popupAPI.send('clipboard:copy', resultText.textContent) + + copyBtn.classList.add('copied') + copyIcon.classList.add('hidden') + checkIcon.classList.remove('hidden') + + setTimeout(function () { + copyBtn.classList.remove('copied') + copyIcon.classList.remove('hidden') + checkIcon.classList.add('hidden') + }, 2000) + }) + + // ── 마우스 호버 시 auto-close 일시정지 ────────────── + container.addEventListener('mouseenter', pauseAutoClose) + container.addEventListener('mouseleave', resumeAutoClose) + + // ── IPC 리스너 ─────────────────────────────────────── + + // Phase 1: prepare — 결과 텍스트 세팅 + 크기 측정 + window.popupAPI.on('result:prepare', function (data) { + resultText.textContent = data.text || '' + container.classList.remove('visible') + + requestAnimationFrame(function () { + requestAnimationFrame(function () { + var rect = container.getBoundingClientRect() + window.popupAPI.send('result:measured', { + width: Math.ceil(rect.width) + 4, + height: Math.ceil(rect.height) + 4 + }) + }) + }) + }) + + // Phase 2: show — 리사이즈 완료 후 표시 + window.popupAPI.on('result:show', function (data) { + container.classList.add('visible') + var autoHideMs = (data && data.autoHideMs) || 5000 + if (autoHideMs > 0) { + startAutoCloseTimer(autoHideMs) + } + }) + + // hide + window.popupAPI.on('result:hide', function () { + container.classList.remove('visible') + clearInterval(autoCloseTimer) + autoCloseTimer = null + }) +})() diff --git a/src/renderer/popups/result-popup/style.css b/src/renderer/popups/result-popup/style.css new file mode 100644 index 0000000..b106264 --- /dev/null +++ b/src/renderer/popups/result-popup/style.css @@ -0,0 +1,102 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: transparent; + overflow: hidden; + -webkit-app-region: no-drag; + user-select: none; +} + +#root { + display: flex; + width: 100%; + height: 100%; +} + +.result-popup { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + padding: 12px 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + opacity: 0; + transform: translateY(4px); + transition: opacity 200ms ease-out, transform 200ms ease-out; + max-width: 400px; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.result-popup.visible { + opacity: 1; + transform: translateY(0); +} + +.result-text { + flex: 1; + color: rgba(0, 0, 0, 0.87); + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + word-break: break-word; +} + +.actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + border-radius: 6px; + color: rgba(0, 0, 0, 0.4); + cursor: pointer; + transition: background 150ms, color 150ms; +} + +.action-btn:hover { + background: rgba(0, 0, 0, 0.06); + color: rgba(0, 0, 0, 0.7); +} + +.action-btn.copied { + color: #4CAF50; +} + +.hidden { + display: none; +} + +/* 다크모드 */ +@media (prefers-color-scheme: dark) { + .result-popup { + background: #1E1E1E; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } + + .result-text { + color: rgba(255, 255, 255, 0.87); + } + + .action-btn { + color: rgba(255, 255, 255, 0.4); + } + + .action-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + } +}