refactor(popup): 5종 팝업 _i18n 주입 + 색상 토큰화 (WS-POPUP)
- 4종 팝업(command/history/result/recording-tip) _i18n 주입: WindowManager getPopupI18nStrings() + 팝업 script applyI18nToHtml() - 한국어/영어 하드코딩 -> popup.* i18n 키 (ko/en 17키) - theme-vars.ts에 --d3-wave-1~9, --d3-status-error/success 토큰 추가 (6 테마) - 팝업 style.css hex -> CSS var 토큰 - packages/i18n getI18n() (main 프로세스용) 추가 SKIP: caption-overlay _i18n(별도 IPC 구조), wave box-shadow 보간(opacity 단순화) 정책: docs/REFACTOR_POLICY.md DP3, 이식 인사이트 P5
This commit is contained in:
parent
660e622a93
commit
00a99e4087
15 changed files with 256 additions and 35 deletions
|
|
@ -10,6 +10,7 @@ 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')
|
||||
|
||||
|
|
@ -28,6 +29,39 @@ function injectPopupTheme(win: BrowserWindow): void {
|
|||
})
|
||||
}
|
||||
|
||||
// ── 팝업 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
|
||||
|
|
@ -181,7 +215,7 @@ export function showRecordingTip(
|
|||
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
|
||||
|
||||
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
|
||||
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, ...params })
|
||||
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
||||
if (!win.isVisible()) {
|
||||
win.showInactive()
|
||||
}
|
||||
|
|
@ -198,7 +232,7 @@ export function updateRecordingTipState(
|
|||
params?: { text?: string; errorMessage?: string }
|
||||
): void {
|
||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
||||
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, ...params })
|
||||
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +302,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
|
|||
const win = getResultPopupWindow()
|
||||
|
||||
// Phase 1: prepare
|
||||
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text })
|
||||
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
|
||||
|
||||
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
|
||||
ipcMain.removeListener(IPC_CHANNELS.POPUP_RESULT.MEASURED, handler)
|
||||
|
|
@ -369,7 +403,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
|
|||
|
||||
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
||||
|
||||
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries })
|
||||
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
|
||||
|
||||
if (!win.isVisible()) {
|
||||
win.showInactive()
|
||||
|
|
@ -456,7 +490,7 @@ export function showCommandPopup(commands: Array<Record<string, unknown>>, activ
|
|||
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 })
|
||||
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, {})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
var linesContainer = document.getElementById('lines')
|
||||
var container = document.getElementById('container')
|
||||
|
||||
// ── i18n ──────────────────────────────────────────────
|
||||
var i18nStrings = {}
|
||||
|
||||
// ── 상태 ──────────────────────────────────────────────
|
||||
/** @type {Array<{el: HTMLElement, timer: number|null, id: string}>} */
|
||||
var lines = []
|
||||
|
|
@ -170,13 +173,14 @@
|
|||
|
||||
// 상태 변경
|
||||
window.popupAPI.on('caption:stateChanged', function (data) {
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
if (data.state === 'starting') {
|
||||
// 로딩 표시
|
||||
clearAllLines()
|
||||
var loadingEl = document.createElement('div')
|
||||
loadingEl.className = 'caption-line loading'
|
||||
loadingEl.id = 'caption-loading'
|
||||
loadingEl.textContent = '⏳ Loading STT model...'
|
||||
loadingEl.textContent = i18nStrings.captionLoading || '⏳ Loading STT model...'
|
||||
loadingEl.style.fontSize = config.fontSize + 'px'
|
||||
linesContainer.appendChild(loadingEl)
|
||||
} else if (data.state === 'active') {
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="command-popup">
|
||||
<div id="title" class="title">COMMAND SELECT</div>
|
||||
<div id="title" class="title" data-i18n="commandSelect">COMMAND SELECT</div>
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span>↑↓ 선택</span>
|
||||
<span>⏎ 적용</span>
|
||||
<span>0 해제</span>
|
||||
<span>ESC ×</span>
|
||||
<span data-i18n="hintsSelect">↑↓ 선택</span>
|
||||
<span data-i18n="hintsApply">⏎ 적용</span>
|
||||
<span data-i18n="hintsClear">0 해제</span>
|
||||
<span data-i18n="hintsClose">ESC ×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@
|
|||
var currentActiveId = null
|
||||
var i18nStrings = {}
|
||||
|
||||
// ── i18n 적용 (HTML 텍스트) ───────────────────────────
|
||||
function applyI18nToHtml() {
|
||||
var elements = document.querySelectorAll('[data-i18n]')
|
||||
elements.forEach(function(el) {
|
||||
var key = el.getAttribute('data-i18n')
|
||||
if (i18nStrings[key]) {
|
||||
el.textContent = i18nStrings[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 아이템 렌더링 ───────────────────────────────────
|
||||
function renderItems(commands, activeId) {
|
||||
currentCommands = commands
|
||||
|
|
@ -135,6 +146,7 @@
|
|||
window.popupAPI.on('command:showItems', function (data) {
|
||||
selectedIndex = 0
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
applyI18nToHtml()
|
||||
renderItems(data.commands || [], data.activeId || null)
|
||||
container.classList.remove('hiding')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
<div id="container" class="history-popup">
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span>↑↓ Select</span>
|
||||
<span>⏎ Paste</span>
|
||||
<span>ESC ×</span>
|
||||
<span data-i18n="hintsSelectEn">↑↓ Select</span>
|
||||
<span data-i18n="hintsPaste">⏎ Paste</span>
|
||||
<span data-i18n="hintsClose">ESC ×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,18 +10,30 @@
|
|||
var items = []
|
||||
var selectedIndex = 0
|
||||
var currentEntries = []
|
||||
var i18nStrings = {}
|
||||
|
||||
// ── i18n 적용 (HTML 텍스트) ───────────────────────────
|
||||
function applyI18nToHtml() {
|
||||
var elements = document.querySelectorAll('[data-i18n]')
|
||||
elements.forEach(function(el) {
|
||||
var key = el.getAttribute('data-i18n')
|
||||
if (i18nStrings[key]) {
|
||||
el.textContent = i18nStrings[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 상대 시간 표시 ───────────────────────────────────
|
||||
function relativeTime(timestamp) {
|
||||
var diff = Date.now() - timestamp
|
||||
var sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return 'just now'
|
||||
if (sec < 60) return i18nStrings.timeJustNow || 'just now'
|
||||
var min = Math.floor(sec / 60)
|
||||
if (min < 60) return min + 'm ago'
|
||||
if (min < 60) return (i18nStrings.timeMinutesAgo || '{{m}}m ago').replace('{{m}}', min)
|
||||
var hr = Math.floor(min / 60)
|
||||
if (hr < 24) return hr + 'h ago'
|
||||
if (hr < 24) return (i18nStrings.timeHoursAgo || '{{h}}h ago').replace('{{h}}', hr)
|
||||
var day = Math.floor(hr / 24)
|
||||
return day + 'd ago'
|
||||
return (i18nStrings.timeDaysAgo || '{{d}}d ago').replace('{{d}}', day)
|
||||
}
|
||||
|
||||
// ── 텍스트 truncate ──────────────────────────────────
|
||||
|
|
@ -39,7 +51,7 @@
|
|||
if (entries.length === 0) {
|
||||
var empty = document.createElement('div')
|
||||
empty.className = 'empty-state'
|
||||
empty.textContent = 'No history yet'
|
||||
empty.textContent = i18nStrings.noHistory || 'No history yet'
|
||||
itemsContainer.appendChild(empty)
|
||||
return
|
||||
}
|
||||
|
|
@ -119,6 +131,8 @@
|
|||
// ── IPC 리스너 ───────────────────────────────────────
|
||||
window.popupAPI.on('history:showItems', function (data) {
|
||||
selectedIndex = 0
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
applyI18nToHtml()
|
||||
renderItems(data.entries || [])
|
||||
container.classList.remove('hiding')
|
||||
measureAndReport()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
var thinkingStartTime = 0
|
||||
var thinkingRaf = null
|
||||
var currentState = 'idle'
|
||||
var i18nStrings = {}
|
||||
|
||||
// ── 웨이브 바 생성 ───────────────────────────────────
|
||||
function createWaveBars() {
|
||||
|
|
@ -133,7 +134,7 @@
|
|||
currentState = 'error'
|
||||
hideAllViews()
|
||||
errorView.classList.remove('hidden')
|
||||
errorText.textContent = message || '오류가 발생했습니다'
|
||||
errorText.textContent = message || i18nStrings.errorDefault || '오류가 발생했습니다'
|
||||
}
|
||||
|
||||
// ── 크기 측정 (2-phase 리사이즈) ────────────────────
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
function setupListeners() {
|
||||
// Phase 1: prepare — 숨겨진 상태에서 렌더링 후 크기 측정
|
||||
window.popupAPI.on('window:tipPrepare', function (data) {
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
var state = data.state
|
||||
if (state === 'recording') showRecording()
|
||||
else if (state === 'thinking') showThinking()
|
||||
|
|
@ -184,6 +186,7 @@
|
|||
|
||||
// 상태 변경 (showRecordingTip + updateRecordingTipState 양쪽에서 사용)
|
||||
window.popupAPI.on('window:tipStateChanged', function (data) {
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
var state = data.state
|
||||
if (state === 'recording') showRecording()
|
||||
else if (state === 'thinking') showThinking()
|
||||
|
|
|
|||
|
|
@ -64,16 +64,16 @@ body {
|
|||
min-height: 2px;
|
||||
}
|
||||
|
||||
/* 9바 시안→블루→퍼플 스펙트럼 (v2 Midnight — RAW.dark gradient.wave1~3 보간) */
|
||||
.wave-bar:nth-child(1) { background: #22d3ee; box-shadow: 0 0 6px rgba(34, 211, 238, 0.35); }
|
||||
.wave-bar:nth-child(2) { background: #28bef0; box-shadow: 0 0 6px rgba(40, 190, 240, 0.35); }
|
||||
.wave-bar:nth-child(3) { background: #2fa9f2; box-shadow: 0 0 6px rgba(47, 169, 242, 0.35); }
|
||||
.wave-bar:nth-child(4) { background: #3596f4; box-shadow: 0 0 6px rgba(53, 150, 244, 0.35); }
|
||||
.wave-bar:nth-child(5) { background: #3b82f6; box-shadow: 0 0 6px rgba(59, 130, 246, 0.35); }
|
||||
.wave-bar:nth-child(6) { background: #4f78f6; box-shadow: 0 0 6px rgba(79, 120, 246, 0.35); }
|
||||
.wave-bar:nth-child(7) { background: #636ff6; box-shadow: 0 0 6px rgba(99, 111, 246, 0.35); }
|
||||
.wave-bar:nth-child(8) { background: #7765f6; box-shadow: 0 0 6px rgba(119, 101, 246, 0.35); }
|
||||
.wave-bar:nth-child(9) { background: #8b5cf6; box-shadow: 0 0 6px rgba(139, 92, 246, 0.35); }
|
||||
/* 9바 시안→블루→퍼플 스펙트럼 (v2 Midnight — 테마 변수 사용) */
|
||||
.wave-bar:nth-child(1) { background: var(--d3-wave-1); box-shadow: 0 0 6px var(--d3-wave-1); opacity: 0.9; }
|
||||
.wave-bar:nth-child(2) { background: var(--d3-wave-2); box-shadow: 0 0 6px var(--d3-wave-2); opacity: 0.92; }
|
||||
.wave-bar:nth-child(3) { background: var(--d3-wave-3); box-shadow: 0 0 6px var(--d3-wave-3); opacity: 0.94; }
|
||||
.wave-bar:nth-child(4) { background: var(--d3-wave-4); box-shadow: 0 0 6px var(--d3-wave-4); opacity: 0.96; }
|
||||
.wave-bar:nth-child(5) { background: var(--d3-wave-5); box-shadow: 0 0 6px var(--d3-wave-5); opacity: 0.98; }
|
||||
.wave-bar:nth-child(6) { background: var(--d3-wave-6); box-shadow: 0 0 6px var(--d3-wave-6); opacity: 0.98; }
|
||||
.wave-bar:nth-child(7) { background: var(--d3-wave-7); box-shadow: 0 0 6px var(--d3-wave-7); opacity: 0.96; }
|
||||
.wave-bar:nth-child(8) { background: var(--d3-wave-8); box-shadow: 0 0 6px var(--d3-wave-8); opacity: 0.94; }
|
||||
.wave-bar:nth-child(9) { background: var(--d3-wave-9); box-shadow: 0 0 6px var(--d3-wave-9); opacity: 0.92; }
|
||||
|
||||
.duration {
|
||||
color: var(--d3-text-primary);
|
||||
|
|
@ -111,7 +111,7 @@ body {
|
|||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #ef4444;
|
||||
background: var(--d3-status-error);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<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="복사">
|
||||
<button id="copy-btn" class="action-btn" data-i18n-title="copy" 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>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,23 @@
|
|||
var copyBtn = document.getElementById('copy-btn')
|
||||
var copyIcon = document.getElementById('copy-icon')
|
||||
var checkIcon = document.getElementById('check-icon')
|
||||
var i18nStrings = {}
|
||||
|
||||
var autoCloseTimer = null
|
||||
var remainingTime = 0
|
||||
var lastTick = 0
|
||||
|
||||
// ── i18n 적용 (HTML 속성) ───────────────────────────
|
||||
function applyI18nToHtml() {
|
||||
var elements = document.querySelectorAll('[data-i18n-title]')
|
||||
elements.forEach(function(el) {
|
||||
var key = el.getAttribute('data-i18n-title')
|
||||
if (i18nStrings[key]) {
|
||||
el.setAttribute('title', i18nStrings[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auto-close 제어 ─────────────────────────────────
|
||||
function startAutoCloseTimer(ms) {
|
||||
remainingTime = ms
|
||||
|
|
@ -64,6 +76,8 @@
|
|||
// Phase 1: prepare — 결과 텍스트 세팅 + 크기 측정
|
||||
window.popupAPI.on('result:prepare', function (data) {
|
||||
resultText.textContent = data.text || ''
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
applyI18nToHtml()
|
||||
container.classList.remove('visible')
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ body {
|
|||
}
|
||||
|
||||
.action-btn.copied {
|
||||
color: #34d399;
|
||||
color: var(--d3-status-success);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue