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:
parent
1d152d01a1
commit
517210af2f
16 changed files with 1366 additions and 10 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -77,10 +77,20 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 2 완료
|
Phase: 3 완료
|
||||||
마지막 완료: Phase 2 — 로컬 STT (faster-whisper sidecar) + 핫키 (uiohook-napi) + VoiceModeService 오케스트레이터
|
마지막 완료: Phase 3 — TextInsertService + RecordingTip/ResultPopup 팝업 + Settings 모달
|
||||||
다음 작업: Phase 3 — 텍스트 삽입 + 기본 UI (RecordingTip, ResultPopup)
|
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능)
|
||||||
차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가 (수동 설치 필요)
|
차단 이슈: 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 구현 내용
|
### Phase 2 구현 내용
|
||||||
- AudioCaptureService: node-record-lpcm16 + SoX 실제 마이크 캡처 (PCM16 16kHz mono, 60ms 프레임, RMS 레벨)
|
- AudioCaptureService: node-record-lpcm16 + SoX 실제 마이크 캡처 (PCM16 16kHz mono, 60ms 프레임, RMS 레벨)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||||
|
popup: resolve(__dirname, 'src/preload/popup.ts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@shared': resolve('src/shared')
|
'@shared': resolve('src/shared')
|
||||||
|
|
@ -20,6 +28,21 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@shared': resolve('src/shared')
|
'@shared': resolve('src/shared')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,15 @@ import { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
import { initConfigService } from './services/ConfigService'
|
import { initConfigService } from './services/ConfigService'
|
||||||
import { getHotkeyService } from './services/HotkeyService'
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
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 { createTray } from './windows/TrayManager'
|
||||||
import { registerAllIpcHandlers } from './ipc'
|
import { registerAllIpcHandlers } from './ipc'
|
||||||
|
|
||||||
|
|
@ -24,6 +32,7 @@ export async function bootstrap(): Promise<void> {
|
||||||
{ name: 'create-windows', critical: true, fn: createWindows },
|
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||||
{ name: 'tray', critical: false, fn: initTray },
|
{ name: 'tray', critical: false, fn: initTray },
|
||||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
|
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode }
|
{ name: 'voice-mode', critical: false, fn: initVoiceMode }
|
||||||
]
|
]
|
||||||
|
|
@ -72,7 +81,42 @@ async function initHotkey(): Promise<void> {
|
||||||
hotkey.start()
|
hotkey.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initPopupWindows(): Promise<void> {
|
||||||
|
preloadPopupWindows()
|
||||||
|
}
|
||||||
|
|
||||||
async function initVoiceMode(): Promise<void> {
|
async function initVoiceMode(): Promise<void> {
|
||||||
const voiceMode = getVoiceModeService()
|
const voiceMode = getVoiceModeService()
|
||||||
voiceMode.connectHotkey()
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
258
src/main/services/TextInsertService.ts
Normal file
258
src/main/services/TextInsertService.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import type { TranscriptionResult } from './LocalSTTService'
|
||||||
import { getHotkeyService } from './HotkeyService'
|
import { getHotkeyService } from './HotkeyService'
|
||||||
import type { HotkeyConfig } from './HotkeyService'
|
import type { HotkeyConfig } from './HotkeyService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
|
import { getTextInsertService } from './TextInsertService'
|
||||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
import { TIMING } from '@shared/constants'
|
import { TIMING } from '@shared/constants'
|
||||||
import { RecognitionState, AudioState } from '@shared/types'
|
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
|
if (!this._session) return
|
||||||
|
|
||||||
this._setRecognitionState(RecognitionState.COMPLETED)
|
this._setRecognitionState(RecognitionState.COMPLETED)
|
||||||
|
|
@ -430,6 +431,16 @@ class VoiceModeService extends EventEmitter {
|
||||||
const session = { ...this._session }
|
const session = { ...this._session }
|
||||||
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
|
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 })
|
this.emit('session-completed', { session, finalText })
|
||||||
|
|
||||||
// IDLE로 복귀
|
// IDLE로 복귀
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,4 @@ export type {
|
||||||
export { getHotkeyService } from './HotkeyService'
|
export { getHotkeyService } from './HotkeyService'
|
||||||
export { getVoiceModeService } from './VoiceModeService'
|
export { getVoiceModeService } from './VoiceModeService'
|
||||||
export { getAudioCaptureService } from './AudioCaptureService'
|
export { getAudioCaptureService } from './AudioCaptureService'
|
||||||
|
export { getTextInsertService } from './TextInsertService'
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
// src/main/windows/WindowManager.ts
|
// 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 { join } from 'path'
|
||||||
import { is } from '@electron-toolkit/utils'
|
import { is } from '@electron-toolkit/utils'
|
||||||
import { WINDOW_SIZE } from '@shared/constants'
|
import { WINDOW_SIZE } from '@shared/constants'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
import { getIsQuitting, setIsQuitting } from '../lifecycle'
|
import { getIsQuitting } from '../lifecycle'
|
||||||
import { configGet } from '../services/ConfigService'
|
import { configGet } from '../services/ConfigService'
|
||||||
|
|
||||||
const logger = getLogger('WindowManager')
|
const logger = getLogger('WindowManager')
|
||||||
|
|
||||||
|
// ── 윈도우 참조 ───────────────────────────────────────
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
let recordingTipWindow: BrowserWindow | null = null
|
||||||
|
let resultPopupWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
// ── 메인 윈도우 ───────────────────────────────────────
|
||||||
|
|
||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow
|
return mainWindow
|
||||||
|
|
@ -54,7 +61,6 @@ export function createMainWindow(): BrowserWindow {
|
||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 개발/프로덕션 URL 로드
|
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -64,3 +70,209 @@ export function createMainWindow(): BrowserWindow {
|
||||||
logger.info('Main window created')
|
logger.info('Main window created')
|
||||||
return mainWindow
|
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)
|
||||||
|
})
|
||||||
|
|
|
||||||
21
src/preload/popup.ts
Normal file
21
src/preload/popup.ts
Normal file
|
|
@ -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
|
||||||
|
|
@ -17,6 +17,7 @@ import HistoryIcon from '@mui/icons-material/History'
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import { DashboardPage } from '../pages/DashboardPage'
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
|
import { SettingsModal } from './SettingsModal'
|
||||||
|
|
||||||
type Route = 'dashboard' | 'history' | 'dictionary'
|
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 {
|
export function AppLayout(): React.ReactElement {
|
||||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||||
|
|
@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement {
|
||||||
|
|
||||||
{/* Bottom */}
|
{/* Bottom */}
|
||||||
<List>
|
<List>
|
||||||
<ListItemButton sx={{ my: 0.5 }}>
|
<ListItemButton sx={{ my: 0.5 }} onClick={() => setSettingsOpen(true)}>
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
|
|
@ -105,6 +107,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
src/renderer/components/SettingsModal.tsx
Normal file
221
src/renderer/components/SettingsModal.tsx
Normal file
|
|
@ -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 <Box sx={{ pt: 2 }}>{children}</Box>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
|
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||||
|
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 <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
Settings
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<Divider />
|
||||||
|
<DialogContent>
|
||||||
|
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||||
|
<Tab label="General" />
|
||||||
|
<Tab label="Audio" />
|
||||||
|
<Tab label="STT" />
|
||||||
|
<Tab label="LLM" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* General */}
|
||||||
|
<TabPanel value={activeTab} index={0}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Theme</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Theme"
|
||||||
|
value={config.theme ?? 'auto'}
|
||||||
|
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
|
||||||
|
>
|
||||||
|
<MenuItem value="auto">System</MenuItem>
|
||||||
|
<MenuItem value="light">Light</MenuItem>
|
||||||
|
<MenuItem value="dark">Dark</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Language</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Language"
|
||||||
|
value={config.language ?? 'ko'}
|
||||||
|
onChange={(e) => updateConfig('language', e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="ko">한국어</MenuItem>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={config.closeToTray ?? true}
|
||||||
|
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Close to tray"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={config.autoInsert ?? true}
|
||||||
|
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Auto-insert text after transcription"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={config.soundEnabled ?? true}
|
||||||
|
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Sound effects"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Audio */}
|
||||||
|
<TabPanel value={activeTab} index={1}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Microphone device selection will be available in a future update.
|
||||||
|
Currently using the system default microphone.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Insert Method</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Insert Method"
|
||||||
|
value={config.insertMethod ?? 'clipboard'}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value="clipboard">Clipboard (Ctrl+V)</MenuItem>
|
||||||
|
<MenuItem value="keyboard">Keyboard typing</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* STT */}
|
||||||
|
<TabPanel value={activeTab} index={2}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Whisper Model</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Whisper Model"
|
||||||
|
value={config.sttModelId ?? 'base'}
|
||||||
|
onChange={(e) => updateConfig('sttModelId', e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="tiny">tiny (39 MB, fastest)</MenuItem>
|
||||||
|
<MenuItem value="base">base (74 MB, balanced)</MenuItem>
|
||||||
|
<MenuItem value="small">small (244 MB, better)</MenuItem>
|
||||||
|
<MenuItem value="medium">medium (769 MB, good)</MenuItem>
|
||||||
|
<MenuItem value="large-v3">large-v3 (1.5 GB, best)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Language</InputLabel>
|
||||||
|
<Select
|
||||||
|
label="Language"
|
||||||
|
value={config.sttLanguage ?? 'auto'}
|
||||||
|
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="auto">Auto-detect</MenuItem>
|
||||||
|
<MenuItem value="ko">한국어</MenuItem>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="ja">日本語</MenuItem>
|
||||||
|
<MenuItem value="zh">中文</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* LLM */}
|
||||||
|
<TabPanel value={activeTab} index={3}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Ollama Server URL"
|
||||||
|
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||||
|
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
LLM model selection will be available after Ollama integration (Phase 4).
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/renderer/popups/recording-tip/index.html
Normal file
35
src/renderer/popups/recording-tip/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<!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>Recording Tip</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div id="container" class="recording-tip">
|
||||||
|
<!-- recording 상태 -->
|
||||||
|
<div id="recording-view" class="view">
|
||||||
|
<div id="wave-bars" class="wave-bars"></div>
|
||||||
|
<span id="duration-text" class="duration">0:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- thinking 상태 -->
|
||||||
|
<div id="thinking-view" class="view hidden">
|
||||||
|
<div class="progress-container">
|
||||||
|
<div id="progress-bar" class="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span id="thinking-text" class="thinking-label">처리 중...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- error 상태 -->
|
||||||
|
<div id="error-view" class="view hidden">
|
||||||
|
<span class="error-icon">!</span>
|
||||||
|
<span id="error-text" class="error-label"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
185
src/renderer/popups/recording-tip/script.js
Normal file
185
src/renderer/popups/recording-tip/script.js
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})()
|
||||||
106
src/renderer/popups/recording-tip/style.css
Normal file
106
src/renderer/popups/recording-tip/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
28
src/renderer/popups/result-popup/index.html
Normal file
28
src/renderer/popups/result-popup/index.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!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>Result</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root">
|
||||||
|
<div id="container" class="result-popup">
|
||||||
|
<div id="result-text" class="result-text"></div>
|
||||||
|
<div id="actions" class="actions">
|
||||||
|
<button id="copy-btn" class="action-btn" title="복사">
|
||||||
|
<svg id="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
<svg id="check-icon" class="hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="./script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
95
src/renderer/popups/result-popup/script.js
Normal file
95
src/renderer/popups/result-popup/script.js
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
})()
|
||||||
102
src/renderer/popups/result-popup/style.css
Normal file
102
src/renderer/popups/result-popup/style.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue