Phase 3 구현: 텍스트 삽입 + RecordingTip/ResultPopup + Settings

- 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 빌드
This commit is contained in:
Yun Chan 2026-04-05 02:11:58 +09:00
parent 1d152d01a1
commit 517210af2f
16 changed files with 1366 additions and 10 deletions

View file

@ -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<string, never>) => void
'clipboard-restored': (payload: Record<string, never>) => void
}
// ============================================================
// TextInsertService
// ============================================================
class TextInsertService extends EventEmitter {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoaded = false
private _nutLoadPromise: Promise<void> | null = null
/**
* nut-js는 ESM + lazy dynamic import .
*/
private async _ensureNut(): Promise<NutKeyboard> {
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<InsertResult> {
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<void> {
// 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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof TextInsertEvents>(
event: K,
listener: TextInsertEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof TextInsertEvents>(
event: K,
listener: TextInsertEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof TextInsertEvents>(
event: K,
...args: Parameters<TextInsertEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ── nut-js 타입 (lazy import용) ──────────────────────────
interface NutKeyboard {
pressKey: (...keys: number[]) => Promise<void>
releaseKey: (...keys: number[]) => Promise<void>
type: (text: string) => Promise<void>
Key: Record<string, number>
}
// ── 싱글톤 ─────────────────────────────────────────────
let instance: TextInsertService | null = null
export function getTextInsertService(): TextInsertService {
if (!instance) {
instance = new TextInsertService()
}
return instance
}

View file

@ -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<void> {
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로 복귀

View file

@ -24,3 +24,4 @@ export type {
export { getHotkeyService } from './HotkeyService'
export { getVoiceModeService } from './VoiceModeService'
export { getAudioCaptureService } from './AudioCaptureService'
export { getTextInsertService } from './TextInsertService'