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
|
|
@ -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