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
|
|
@ -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<void> {
|
|||
{ 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<void> {
|
|||
hotkey.start()
|
||||
}
|
||||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
}
|
||||
|
||||
async function initVoiceMode(): Promise<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
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 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로 복귀
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export type {
|
|||
export { getHotkeyService } from './HotkeyService'
|
||||
export { getVoiceModeService } from './VoiceModeService'
|
||||
export { getAudioCaptureService } from './AudioCaptureService'
|
||||
export { getTextInsertService } from './TextInsertService'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue