668 lines
21 KiB
TypeScript
668 lines
21 KiB
TypeScript
// src/main/windows/WindowManager.ts
|
|
// 설계서 01 WindowManagerService: 메인 윈도우 + 팝업 프리로딩 + 2-phase 리사이즈
|
|
|
|
import { BrowserWindow, shell, screen, ipcMain, Menu, clipboard } from 'electron'
|
|
import { join } from 'path'
|
|
import { is } from '@electron-toolkit/utils'
|
|
import { WINDOW_SIZE } from '@d3ro/core/constants'
|
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|
import { getLogger } from '../services/LoggerService'
|
|
import { getIsQuitting } from '../lifecycle'
|
|
import { configGet } from '../services/ConfigService'
|
|
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
|
|
import { getI18n } from '@d3ro/i18n'
|
|
|
|
const logger = getLogger('WindowManager')
|
|
|
|
// ── 팝업 테마 주입 ────────────────────────────────────────
|
|
/**
|
|
* 팝업 BrowserWindow에 현재 설정 테마의 CSS 변수를 insertCSS로 주입.
|
|
* did-finish-load 이후 호출해야 한다.
|
|
*/
|
|
function injectPopupTheme(win: BrowserWindow): void {
|
|
const theme = configGet('theme') as string
|
|
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
|
|
const resolvedTheme = theme === 'system' ? 'dark' : theme
|
|
const css = buildPopupThemeCss(resolvedTheme)
|
|
win.webContents.insertCSS(css).catch((err: unknown) => {
|
|
logger.warn('팝업 테마 CSS 주입 실패', err)
|
|
})
|
|
}
|
|
|
|
// ── 팝업 i18n 문자열 가져오기 ─────────────────────────────
|
|
/**
|
|
* 팝업에 주입할 i18n 문자열 맵을 반환.
|
|
*/
|
|
function getPopupI18nStrings(): Record<string, string> {
|
|
const locale = (configGet('language') as string) || 'ko'
|
|
const { t } = getI18n(locale as 'ko' | 'en' | 'ja' | 'zh' | 'zh-TW' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' | 'th')
|
|
return {
|
|
// command-popup
|
|
commandSelect: t('popup.commandSelect'),
|
|
hintsSelect: t('popup.hints.select'),
|
|
hintsApply: t('popup.hints.apply'),
|
|
hintsClear: t('popup.hints.clear'),
|
|
hintsClose: t('popup.hints.close'),
|
|
noCommands: t('popup.noCommands'),
|
|
noCommand: t('popup.noCommand'),
|
|
// history-popup
|
|
hintsSelectEn: t('popup.hints.selectEn'),
|
|
hintsPaste: t('popup.hints.paste'),
|
|
noHistory: t('popup.noHistory'),
|
|
timeJustNow: t('popup.time.justNow'),
|
|
timeMinutesAgo: t('popup.time.minutesAgo'),
|
|
timeHoursAgo: t('popup.time.hoursAgo'),
|
|
timeDaysAgo: t('popup.time.daysAgo'),
|
|
// result-popup
|
|
copy: t('popup.copy'),
|
|
// recording-tip
|
|
errorDefault: t('popup.error.default'),
|
|
// caption-overlay
|
|
captionLoading: t('popup.caption.loading'),
|
|
}
|
|
}
|
|
|
|
// ── 윈도우 참조 ───────────────────────────────────────
|
|
|
|
let mainWindow: BrowserWindow | null = null
|
|
let recordingTipWindow: BrowserWindow | null = null
|
|
let resultPopupWindow: BrowserWindow | null = null
|
|
let historyPopupWindow: BrowserWindow | null = null
|
|
let commandPopupWindow: BrowserWindow | null = null
|
|
let captionOverlayWindow: BrowserWindow | null = null
|
|
|
|
// ── 메인 윈도우 ───────────────────────────────────────
|
|
|
|
export function getMainWindow(): BrowserWindow | null {
|
|
return mainWindow
|
|
}
|
|
|
|
export function createMainWindow(): BrowserWindow {
|
|
const primaryDisplay = screen.getPrimaryDisplay()
|
|
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
|
|
|
const initWidth = Math.min(WINDOW_SIZE.MAIN.width, screenWidth)
|
|
const initHeight = Math.min(WINDOW_SIZE.MAIN.height, screenHeight)
|
|
|
|
mainWindow = new BrowserWindow({
|
|
title: 'D3RO Voice',
|
|
width: initWidth,
|
|
height: initHeight,
|
|
x: Math.max(0, Math.round((screenWidth - initWidth) / 2)),
|
|
y: Math.max(0, Math.round((screenHeight - initHeight) / 2)),
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
show: true,
|
|
autoHideMenuBar: true,
|
|
// 보더리스: 렌더러의 커스텀 TitleBar(AppLayout 최상단)가 크롬을 대체한다.
|
|
// macOS는 hiddenInset으로 네이티브 교통신호(traffic lights)를 콘텐츠 위에
|
|
// 인셋 유지 — 이 플랫폼에서는 커스텀 최대/최소/닫기 버튼을 렌더러에서 숨긴다.
|
|
...(process.platform === 'darwin'
|
|
? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 13 } }
|
|
: { frame: false }),
|
|
roundedCorners: true,
|
|
backgroundColor: '#0a0e1c',
|
|
webPreferences: {
|
|
preload: join(__dirname, '../preload/index.js'),
|
|
sandbox: false,
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
})
|
|
|
|
mainWindow.setSkipTaskbar(false)
|
|
|
|
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
|
|
mainWindow.setMenu(null)
|
|
Menu.setApplicationMenu(null)
|
|
|
|
mainWindow.on('ready-to-show', () => {
|
|
mainWindow?.show()
|
|
mainWindow?.restore()
|
|
mainWindow?.focus()
|
|
mainWindow?.flashFrame(true)
|
|
if (is.dev && process.env['ELECTRON_OPEN_DEVTOOLS'] === '1') {
|
|
mainWindow?.webContents.openDevTools({ mode: 'right' })
|
|
}
|
|
logger.info('Main window shown')
|
|
})
|
|
|
|
mainWindow.on('close', (event) => {
|
|
if (!getIsQuitting() && configGet('closeToTray')) {
|
|
event.preventDefault()
|
|
mainWindow?.hide()
|
|
logger.info('Main window hidden to tray')
|
|
}
|
|
})
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null
|
|
})
|
|
|
|
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
|
|
logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`)
|
|
})
|
|
|
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
|
shell.openExternal(details.url)
|
|
return { action: 'deny' }
|
|
})
|
|
|
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
|
} else {
|
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
|
}
|
|
|
|
mainWindow.center()
|
|
mainWindow.restore()
|
|
mainWindow.show()
|
|
mainWindow.focus()
|
|
mainWindow.setAlwaysOnTop(true)
|
|
setTimeout(() => {
|
|
try {
|
|
mainWindow?.setAlwaysOnTop(false)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 1000)
|
|
|
|
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,
|
|
backgroundThrottling: 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.webContents.on('did-finish-load', () => {
|
|
injectPopupTheme(win)
|
|
})
|
|
|
|
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
|
|
*/
|
|
// RecordingTip 기본 크기 (고정 — 2-phase 복잡도 제거)
|
|
// v2: 실시간 부분 전사 라인 표시를 위해 상향 (280x80 → 320x110)
|
|
const TIP_WIDTH = 320
|
|
const TIP_HEIGHT = 110
|
|
|
|
export function showRecordingTip(
|
|
state: string,
|
|
params?: { text?: string; errorMessage?: string }
|
|
): void {
|
|
const win = getRecordingTipWindow()
|
|
|
|
// 커서 위치에 배치
|
|
const cursorPos = screen.getCursorScreenPoint()
|
|
const display = screen.getDisplayNearestPoint(cursorPos)
|
|
|
|
let x = cursorPos.x - Math.round(TIP_WIDTH / 2)
|
|
let y = cursorPos.y - TIP_HEIGHT - 20
|
|
|
|
// 화면 밖 보정
|
|
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - TIP_WIDTH))
|
|
if (y < display.workArea.y) {
|
|
y = cursorPos.y + 20
|
|
}
|
|
if (y + TIP_HEIGHT > display.workArea.y + display.workArea.height) {
|
|
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - TIP_HEIGHT)
|
|
}
|
|
|
|
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
|
|
|
|
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
|
|
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
|
if (!win.isVisible()) {
|
|
win.showInactive()
|
|
}
|
|
}
|
|
|
|
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(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
|
}
|
|
}
|
|
|
|
export function sendAudioLevelToTip(level: number): void {
|
|
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
|
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE.AUDIO_LEVEL, { level })
|
|
}
|
|
}
|
|
|
|
/** 실시간 부분 전사 텍스트를 RecordingTip에 전달 */
|
|
export function sendPartialTranscriptToTip(text: string): void {
|
|
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
|
recordingTipWindow.webContents.send(IPC_CHANNELS.VOICE_PARTIAL.PARTIAL_TRANSCRIPT, { text })
|
|
}
|
|
}
|
|
|
|
// ── 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.webContents.on('did-finish-load', () => {
|
|
injectPopupTheme(win)
|
|
})
|
|
|
|
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(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
|
|
|
|
ipcMain.once(IPC_CHANNELS.POPUP_RESULT.MEASURED, (_event, data: { width: number; height: number }) => {
|
|
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
|
|
}
|
|
if (y + data.height > display.workArea.y + display.workArea.height) {
|
|
y = Math.max(display.workArea.y, display.workArea.y + display.workArea.height - data.height)
|
|
}
|
|
|
|
win.setBounds({ x, y, width: data.width, height: data.height })
|
|
|
|
if (!win.isVisible()) {
|
|
win.showInactive()
|
|
}
|
|
|
|
// Phase 2: show
|
|
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.SHOW, { autoHideMs })
|
|
})
|
|
}
|
|
|
|
export function hideResultPopup(): void {
|
|
if (resultPopupWindow && !resultPopupWindow.isDestroyed()) {
|
|
resultPopupWindow.hide()
|
|
}
|
|
}
|
|
|
|
// ── HistoryPopup 팝업 ─────────────────────────────────
|
|
|
|
function createHistoryPopupWindow(): BrowserWindow {
|
|
const win = new BrowserWindow({
|
|
width: 360,
|
|
height: 400,
|
|
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/history-popup/index.html`)
|
|
} else {
|
|
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
|
|
}
|
|
|
|
win.webContents.on('did-finish-load', () => {
|
|
injectPopupTheme(win)
|
|
})
|
|
|
|
win.on('closed', () => {
|
|
historyPopupWindow = null
|
|
})
|
|
|
|
return win
|
|
}
|
|
|
|
export function getHistoryPopupWindow(): BrowserWindow {
|
|
if (!historyPopupWindow || historyPopupWindow.isDestroyed()) {
|
|
historyPopupWindow = createHistoryPopupWindow()
|
|
logger.info('HistoryPopup window created')
|
|
}
|
|
return historyPopupWindow
|
|
}
|
|
|
|
export function showHistoryPopup(entries: Array<Record<string, unknown>>): void {
|
|
const win = getHistoryPopupWindow()
|
|
|
|
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
|
|
const cursorPos = screen.getCursorScreenPoint()
|
|
const display = screen.getDisplayNearestPoint(cursorPos)
|
|
|
|
const popupWidth = 380
|
|
const popupHeight = Math.min(60 + entries.length * 44, 500)
|
|
|
|
let x = cursorPos.x - Math.round(popupWidth / 2)
|
|
let y = cursorPos.y - popupHeight - 20
|
|
|
|
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
|
|
if (y < display.workArea.y) {
|
|
y = cursorPos.y + 20
|
|
}
|
|
|
|
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
|
|
|
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
|
|
|
|
if (!win.isVisible()) {
|
|
win.showInactive()
|
|
}
|
|
|
|
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW, {})
|
|
}
|
|
|
|
export function hideHistoryPopup(): void {
|
|
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
|
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.HIDE, {})
|
|
setTimeout(() => {
|
|
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
|
historyPopupWindow.hide()
|
|
}
|
|
}, 150)
|
|
}
|
|
}
|
|
|
|
export function sendKeyToHistoryPopup(key: string): void {
|
|
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
|
|
historyPopupWindow.webContents.send(IPC_CHANNELS.POPUP_HISTORY.KEY_EVENT, { key })
|
|
}
|
|
}
|
|
|
|
export function isHistoryPopupVisible(): boolean {
|
|
return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible())
|
|
}
|
|
|
|
// ── CommandPopup 팝업 ─────────────────────────────────
|
|
|
|
function createCommandPopupWindow(): BrowserWindow {
|
|
const win = new BrowserWindow({
|
|
width: 340,
|
|
height: 300,
|
|
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/command-popup/index.html`)
|
|
} else {
|
|
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
|
|
}
|
|
|
|
win.webContents.on('did-finish-load', () => {
|
|
injectPopupTheme(win)
|
|
})
|
|
|
|
win.on('closed', () => { commandPopupWindow = null })
|
|
return win
|
|
}
|
|
|
|
export function getCommandPopupWindow(): BrowserWindow {
|
|
if (!commandPopupWindow || commandPopupWindow.isDestroyed()) {
|
|
commandPopupWindow = createCommandPopupWindow()
|
|
}
|
|
return commandPopupWindow
|
|
}
|
|
|
|
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
|
|
const win = getCommandPopupWindow()
|
|
const cursorPos = screen.getCursorScreenPoint()
|
|
const display = screen.getDisplayNearestPoint(cursorPos)
|
|
|
|
const popupWidth = 340
|
|
const popupHeight = Math.min(80 + commands.length * 48, 400)
|
|
|
|
let x = cursorPos.x - Math.round(popupWidth / 2)
|
|
let y = cursorPos.y - popupHeight - 20
|
|
|
|
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
|
|
if (y < display.workArea.y) { y = cursorPos.y + 20 }
|
|
|
|
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
|
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
|
|
|
|
if (!win.isVisible()) { win.showInactive() }
|
|
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {})
|
|
}
|
|
|
|
export function hideCommandPopup(): void {
|
|
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
|
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.HIDE, {})
|
|
setTimeout(() => {
|
|
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
|
|
commandPopupWindow.hide()
|
|
}
|
|
}, 150)
|
|
}
|
|
}
|
|
|
|
export function sendKeyToCommandPopup(key: string): void {
|
|
if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) {
|
|
commandPopupWindow.webContents.send(IPC_CHANNELS.POPUP_COMMAND.KEY_EVENT, { key })
|
|
}
|
|
}
|
|
|
|
export function isCommandPopupVisible(): boolean {
|
|
return !!(commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible())
|
|
}
|
|
|
|
// ── CaptionOverlay 팝업 (Phase 10.1) ─────────────────
|
|
|
|
function createCaptionOverlayWindow(): BrowserWindow {
|
|
const primaryDisplay = screen.getPrimaryDisplay()
|
|
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
|
const overlayWidth = Math.round(screenWidth * 0.8)
|
|
const overlayHeight = 120
|
|
|
|
const win = new BrowserWindow({
|
|
width: overlayWidth,
|
|
height: overlayHeight,
|
|
x: Math.round((screenWidth - overlayWidth) / 2),
|
|
y: screenHeight - overlayHeight - 40,
|
|
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
|
|
}
|
|
})
|
|
|
|
// 클릭 통과: 마우스 이벤트를 무시하되, CSS hover 등을 위해 forward 활성화
|
|
win.setIgnoreMouseEvents(true, { forward: true })
|
|
|
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
|
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/caption-overlay/index.html`)
|
|
} else {
|
|
win.loadFile(join(__dirname, '../renderer/popups/caption-overlay/index.html'))
|
|
}
|
|
|
|
win.webContents.on('did-finish-load', () => {
|
|
injectPopupTheme(win)
|
|
})
|
|
|
|
win.on('closed', () => {
|
|
captionOverlayWindow = null
|
|
})
|
|
|
|
return win
|
|
}
|
|
|
|
export function getCaptionOverlayWindow(): BrowserWindow {
|
|
if (!captionOverlayWindow || captionOverlayWindow.isDestroyed()) {
|
|
captionOverlayWindow = createCaptionOverlayWindow()
|
|
logger.info('CaptionOverlay window created')
|
|
}
|
|
return captionOverlayWindow
|
|
}
|
|
|
|
export function showCaptionOverlay(): void {
|
|
const win = getCaptionOverlayWindow()
|
|
if (!win.isVisible()) {
|
|
win.showInactive()
|
|
}
|
|
}
|
|
|
|
export function hideCaptionOverlay(): void {
|
|
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
|
captionOverlayWindow.webContents.send(IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
|
|
captionOverlayWindow.hide()
|
|
}
|
|
}
|
|
|
|
export function sendToCaptionOverlay(channel: string, data: unknown): void {
|
|
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
|
|
captionOverlayWindow.webContents.send(channel, data)
|
|
}
|
|
}
|
|
|
|
// ── 프리로딩 ──────────────────────────────────────────
|
|
|
|
export function preloadPopupWindows(): void {
|
|
getRecordingTipWindow()
|
|
getResultPopupWindow()
|
|
getHistoryPopupWindow()
|
|
getCommandPopupWindow()
|
|
logger.info('Popup windows preloaded')
|
|
}
|
|
|
|
// ── 테마 재주입 (설정에서 테마 변경 시 호출) ──────────
|
|
|
|
/**
|
|
* 현재 살아있는 팝업 윈도우에 테마 CSS를 재주입.
|
|
* config SET_THEME 핸들러에서 호출한다.
|
|
*/
|
|
export function reapplyThemeToAllPopups(): void {
|
|
const popupWindows = [
|
|
recordingTipWindow,
|
|
resultPopupWindow,
|
|
historyPopupWindow,
|
|
commandPopupWindow,
|
|
captionOverlayWindow,
|
|
]
|
|
for (const win of popupWindows) {
|
|
if (win && !win.isDestroyed()) {
|
|
injectPopupTheme(win)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── clipboard:copy IPC (ResultPopup에서 사용) ─────────
|
|
|
|
ipcMain.on(IPC_CHANNELS.CLIPBOARD.COPY, (_event, text: string) => {
|
|
clipboard.writeText(text)
|
|
})
|