feat(desktop): honest, unified dictation popups with Esc to cancel
Wave 3 of the 2026-09-28 design overhaul. - Esc (no modifiers) cancels an in-progress dictation, recording or processing: VoiceModeService.requestEscape() queues the dead 'escape' action. Clicking the capsule cancels only while recording, so a stray click during processing no longer throws the text away. - Errors show in the user's language: bootstrap now passes error.code to the tip (the translated branch never ran), and one core classifier (voice-error-message) feeds both the tip and the main-window alert. - Capsule: bars stay flat without input (no fake "alive" wobble before the mic opens), an indeterminate bar replaces the made-up percentage, the partial transcript stays visible while processing, an Esc hint, and a brief "Inserted" state so success is visible. - All six popups share popups/_shared (Pretendard, one panel radius, selection, kbd hints, reduced motion). History/command popups get titles, listbox roles and window sizes from popup-list-geometry (empty states were clipped); the command popup's "0" key is registered; result popup explains why it appeared and can be closed; captions keep a theme-independent dark backing on purpose. - Popup themes follow the OS when set to system and the document lang follows the app language. - Site hero demo mirrors the new capsule (done state, indeterminate bar, Esc hint; 10 locales).
This commit is contained in:
parent
f064c93197
commit
7b1210b5d1
41 changed files with 1565 additions and 1143 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||
|
||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef, nativeTheme } from 'electron'
|
||||
import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi'
|
||||
import { VK } from '@d3ro/core/keybinding'
|
||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||
|
|
@ -21,7 +21,7 @@ import { getTextInsertService } from './services/TextInsertService'
|
|||
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||
import { getVoiceCommandService } from './services/VoiceCommandService'
|
||||
import { getInputTelemetryService } from './services/InputTelemetryService'
|
||||
import { getSuggestionService } from './services/SuggestionService'
|
||||
import { getSuggestionService, hasInputContextConsent } from './services/SuggestionService'
|
||||
import { getSoundEffectService } from './services/SoundEffectService'
|
||||
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||
import { getAudioCaptureService } from './services/AudioCaptureService'
|
||||
|
|
@ -161,6 +161,8 @@ async function initConfigEffects(): Promise<void> {
|
|||
effects.register('autoLaunch', (value) => getAutoLaunchService().setEnabled(value === true))
|
||||
// 미리 로드된 팝업(녹음 팁·기록·명령·제안·자막)의 테마 CSS
|
||||
effects.register('theme', () => reapplyThemeToAllPopups())
|
||||
// 팝업 문서 lang 도 같은 재주입에서 맞춘다
|
||||
effects.register('language', () => reapplyThemeToAllPopups())
|
||||
effects.start()
|
||||
getShutdownRegistry().register({ name: 'config-effects', dispose: () => effects.dispose() })
|
||||
}
|
||||
|
|
@ -260,11 +262,12 @@ async function initInputIntelligence(): Promise<void> {
|
|||
|
||||
// 제안이 켜져 있으면 켜진 시점부터 모델을 미리 올려 둔다 — 토글 시에만 워밍업하면
|
||||
// 앱을 새로 켤 때마다 첫 제안이 콜드 로딩으로 타임아웃된다.
|
||||
if (configGet('suggestionEnabled')) void suggestion.warmUp()
|
||||
// 입력 수집 동의가 없으면 입력 맥락이 오지 않아 제안이 뜰 수 없다 — 모델을 데우지 않는다.
|
||||
if (configGet('suggestionEnabled') && hasInputContextConsent()) void suggestion.warmUp()
|
||||
|
||||
// Ollama 서버가 나중에 뜨는 경우(느린 부팅 등) 가용해지는 시점에도 워밍업한다.
|
||||
getLocalLLMService().on('availability-changed', ({ available }) => {
|
||||
if (available && configGet('suggestionEnabled')) void suggestion.warmUp()
|
||||
if (available && configGet('suggestionEnabled') && hasInputContextConsent()) void suggestion.warmUp()
|
||||
})
|
||||
|
||||
telemetry.start()
|
||||
|
|
@ -311,6 +314,19 @@ async function initAutoUpdate(): Promise<void> {
|
|||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
|
||||
// 시스템 테마를 따르는 설정이면 OS 다크/라이트 전환 때 팝업도 다시 칠한다(언어가 바뀌면 lang 도 함께)
|
||||
const onNativeThemeUpdated = (): void => {
|
||||
const theme = configGet('theme')
|
||||
if (theme === 'auto') reapplyThemeToAllPopups()
|
||||
}
|
||||
nativeTheme.on('updated', onNativeThemeUpdated)
|
||||
getShutdownRegistry().register({
|
||||
name: 'popup-native-theme',
|
||||
dispose: () => {
|
||||
nativeTheme.removeListener('updated', onNativeThemeUpdated)
|
||||
},
|
||||
})
|
||||
|
||||
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
|
||||
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
|
||||
|
||||
|
|
@ -395,22 +411,35 @@ async function initVoiceMode(): Promise<void> {
|
|||
voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text))
|
||||
voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text))
|
||||
|
||||
// 수정자 없는 Esc 로 진행 중인 받아쓰기(녹음·처리)를 취소한다. 녹음 팁은 포커스를 받지 않는 창이라
|
||||
// 전역 keydown 을 직접 듣는다(제안 오버레이의 Esc 와 같은 방식). 세션이 없으면 아무 일도 하지 않는다.
|
||||
// uiohook 은 수동 감청이라 Esc 는 앞에 있는 앱에도 그대로 전달된다.
|
||||
const releaseEscapeHook = acquireGlobalInputHook()
|
||||
getShutdownRegistry().register({ name: 'voice-escape-hook', dispose: () => releaseEscapeHook() })
|
||||
uIOhook.on('keydown', (event: UiohookKeyboardEvent) => {
|
||||
if (uiohookCodeToVk(event.keycode) !== VK.Escape) return
|
||||
if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
|
||||
voiceMode.requestEscape()
|
||||
})
|
||||
|
||||
// 효과음 연동
|
||||
voiceMode.on('session-started', () => {
|
||||
tip.onSessionStarted()
|
||||
soundEffect.play('recording-start')
|
||||
})
|
||||
|
||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||
tip.onSessionEnded()
|
||||
voiceMode.on('session-completed', ({ session, finalText, audioPath }) => {
|
||||
tip.onSessionCompleted()
|
||||
soundEffect.play('recording-stop')
|
||||
|
||||
// 녹음은 완료 이벤트 전에 저장됐다 — 실제 저장 경로(없으면 null)만 히스토리에 남긴다.
|
||||
persistCompletedVoiceSessionSafe(
|
||||
(error) => {
|
||||
voiceMode.emit('error', { error, session })
|
||||
},
|
||||
session,
|
||||
finalText,
|
||||
audioPath,
|
||||
)
|
||||
|
||||
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'session-completed' })
|
||||
|
|
@ -426,7 +455,8 @@ async function initVoiceMode(): Promise<void> {
|
|||
voiceMode.on('error', ({ error, severity }) => {
|
||||
// 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
|
||||
if (severity !== 'warning') soundEffect.play('error')
|
||||
tip.onError(error.message, severity)
|
||||
// 코드를 넘겨야 팁이 사용자 언어 문구를 고른다 — 빠져 있어 영문 로그 문구가 그대로 보였다(2026-09-28)
|
||||
tip.onError(error.message, severity, error.code)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,8 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { createFsRecordingStore, type RecordingStore } from './recording/recording-store'
|
||||
import { getAudioCaptureService } from './AudioCaptureService'
|
||||
import { getSTTManager } from './stt/STTManager'
|
||||
import type { TranscriptionResult } from './stt/types'
|
||||
|
|
@ -85,7 +83,8 @@ export interface VoiceModeEvents {
|
|||
current: AudioState
|
||||
}) => void
|
||||
'transcription-update': (payload: { text: string; isFinal: boolean }) => void
|
||||
'session-completed': (payload: { session: VoiceSession; finalText: string }) => void
|
||||
/** audioPath: 저장된 녹음 WAV 경로(완료 이벤트 전에 저장됨). 오디오가 없거나 저장 실패면 null */
|
||||
'session-completed': (payload: { session: VoiceSession; finalText: string; audioPath: string | null }) => void
|
||||
'session-cancelled': (payload: {
|
||||
session: VoiceSession
|
||||
reason: 'user' | 'timeout' | 'too-short'
|
||||
|
|
@ -196,6 +195,11 @@ const NOTHING_PENDING: StopOutcome = { finished: Promise.resolve() }
|
|||
// ============================================================
|
||||
|
||||
class VoiceModeService extends EventEmitter {
|
||||
/** 녹음 저장 포트 — 경로·fs·WAV 인코딩은 어댑터가 맡는다 (DIP) */
|
||||
constructor(private readonly _recordings: RecordingStore = createFsRecordingStore()) {
|
||||
super()
|
||||
}
|
||||
|
||||
private _session: VoiceSession | null = null
|
||||
private _run: SessionRun | null = null
|
||||
private _recognitionState = RecognitionState.IDLE
|
||||
|
|
@ -472,6 +476,22 @@ class VoiceModeService extends EventEmitter {
|
|||
this._cancelSession('user')
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정자 없는 Esc — 녹음·처리 중인 세션을 취소한다(2026-09-28: 녹음 팁의 "Esc 취소").
|
||||
* 액션 큐로 넣어 누름/뗌과 순서를 지킨다. 진행 중인 세션이 없으면 아무것도 하지 않고 false.
|
||||
*/
|
||||
requestEscape(): boolean {
|
||||
if (!this.isActive) return false
|
||||
this._enqueueAction({
|
||||
type: 'escape',
|
||||
timestamp: Date.now(),
|
||||
mode: 'dictation',
|
||||
actionId: 'dictation',
|
||||
holdMode: false,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// ── 상태 머신 ──────────────────────────────────────────
|
||||
|
||||
private _isInTerminalState(): boolean {
|
||||
|
|
@ -916,7 +936,7 @@ class VoiceModeService extends EventEmitter {
|
|||
severity: 'warning',
|
||||
})
|
||||
}
|
||||
void this._completeSession(run, effectiveText)
|
||||
await this._completeSession(run, effectiveText)
|
||||
} else {
|
||||
await this._processWithLLM(run, effectiveText, route)
|
||||
}
|
||||
|
|
@ -985,7 +1005,7 @@ class VoiceModeService extends EventEmitter {
|
|||
const chainResult = await getChainService().execute(route.chainId, userText)
|
||||
if (!this._isCurrent(run)) return
|
||||
if (this._session) this._session.processedText = chainResult.finalText
|
||||
void this._completeSession(run, chainResult.finalText)
|
||||
await this._completeSession(run, chainResult.finalText)
|
||||
} catch (error) {
|
||||
if (!this._isCurrent(run)) return
|
||||
logger.warn(`Chain execution failed: ${describe(error)}`)
|
||||
|
|
@ -1010,7 +1030,7 @@ class VoiceModeService extends EventEmitter {
|
|||
this._session.processedText = processedText
|
||||
}
|
||||
|
||||
void this._completeSession(run, processedText)
|
||||
await this._completeSession(run, processedText)
|
||||
} catch (error) {
|
||||
if (!this._isCurrent(run)) return
|
||||
this._handleError(
|
||||
|
|
@ -1066,71 +1086,48 @@ class VoiceModeService extends EventEmitter {
|
|||
|
||||
// ── 세션 완료/취소 ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 세션 완료 — 녹음을 먼저 저장하고(실제 경로를 이벤트에 싣는다) 완료를 알린 뒤, 삽입은 따로 돈다.
|
||||
*
|
||||
* 예전엔 완료 이벤트(히스토리 생성 + history_audio 동기화 예약)가 먼저 나가고 WAV 는 텍스트 삽입이
|
||||
* 끝난 뒤에 써졌다 — 그사이 flush 가 돌면 "파일 없음"을 성공으로 처리해 녹음이 영영 올라가지 않았다.
|
||||
*/
|
||||
private async _completeSession(run: SessionRun, finalText: string): Promise<void> {
|
||||
if (!this._isCurrent(run) || !this._session) return
|
||||
|
||||
const audioPath = await this._recordings.save(this._session.id, run.lastAudio)
|
||||
// 저장하는 사이 취소·새 세션이 시작됐으면 완료를 알리지 않는다.
|
||||
if (!this._isCurrent(run) || !this._session) return
|
||||
|
||||
const session = { ...this._session }
|
||||
const lastAudio = run.lastAudio
|
||||
const selectionCapture = run.selectionCapture
|
||||
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
|
||||
|
||||
// Speakly 패턴: 먼저 IDLE 복귀 (다음 핫키 즉시 사용 가능). 표시 계층이 완료 이벤트로 팁을 숨긴다.
|
||||
this._setRecognitionState(RecognitionState.COMPLETED)
|
||||
this._setAudioState(AudioState.STOPPED)
|
||||
this.emit('session-completed', { session, finalText })
|
||||
this.emit('session-completed', { session, finalText, audioPath })
|
||||
this._endRun(run)
|
||||
|
||||
// 텍스트 삽입은 세션과 무관하게 비동기 실행 (세션 블로킹 방지)
|
||||
if (configGet('autoInsert') && finalText.length > 0) {
|
||||
try {
|
||||
// 선택 텍스트 캡처가 클립보드를 쓰는 중이면 끝날 때까지 기다린다 (클립보드 경합 방지)
|
||||
if (selectionCapture) await selectionCapture
|
||||
const insertMethod = configGet('insertMethod')
|
||||
await getTextInsertService().insertText(finalText, insertMethod)
|
||||
} catch (error) {
|
||||
logger.warn(`Text insert failed: ${describe(error)}`)
|
||||
// 삽입 실패 시 표시 계층이 ResultPopup에 텍스트를 보여 준다
|
||||
this.emit('insert-failed', { text: finalText, sessionId: session.id })
|
||||
}
|
||||
}
|
||||
|
||||
// 녹음 오디오 WAV 파일 저장 (비동기, 실패해도 무시)
|
||||
void this._saveAudioFile(session.id, lastAudio)
|
||||
void this._insertCompletedText(session.id, finalText, selectionCapture)
|
||||
}
|
||||
|
||||
/** PCM 버퍼를 WAV 파일로 저장 */
|
||||
private async _saveAudioFile(sessionId: string, pcmData: Buffer | null): Promise<void> {
|
||||
if (!pcmData || pcmData.length === 0) return
|
||||
|
||||
private async _insertCompletedText(
|
||||
sessionId: string,
|
||||
finalText: string,
|
||||
selectionCapture: Promise<unknown> | null,
|
||||
): Promise<void> {
|
||||
if (!configGet('autoInsert') || finalText.length === 0) return
|
||||
try {
|
||||
const recordingsDir = join(app.getPath('userData'), 'recordings')
|
||||
await mkdir(recordingsDir, { recursive: true })
|
||||
|
||||
const wavPath = join(recordingsDir, `${sessionId}.wav`)
|
||||
|
||||
// WAV 헤더 생성 (16kHz, 16bit, mono)
|
||||
const header = Buffer.alloc(44)
|
||||
const dataSize = pcmData.length
|
||||
const fileSize = dataSize + 36
|
||||
|
||||
header.write('RIFF', 0)
|
||||
header.writeUInt32LE(fileSize, 4)
|
||||
header.write('WAVE', 8)
|
||||
header.write('fmt ', 12)
|
||||
header.writeUInt32LE(16, 16) // fmt chunk size
|
||||
header.writeUInt16LE(1, 20) // PCM format
|
||||
header.writeUInt16LE(1, 22) // mono
|
||||
header.writeUInt32LE(16000, 24) // sample rate
|
||||
header.writeUInt32LE(32000, 28) // byte rate (16000 * 2)
|
||||
header.writeUInt16LE(2, 32) // block align
|
||||
header.writeUInt16LE(16, 34) // bits per sample
|
||||
header.write('data', 36)
|
||||
header.writeUInt32LE(dataSize, 40)
|
||||
|
||||
await writeFile(wavPath, Buffer.concat([header, pcmData]))
|
||||
logger.info(`Audio saved: ${wavPath} (${Math.round(dataSize / 1024)}KB)`)
|
||||
// 선택 텍스트 캡처가 클립보드를 쓰는 중이면 끝날 때까지 기다린다 (클립보드 경합 방지)
|
||||
if (selectionCapture) await selectionCapture
|
||||
const insertMethod = configGet('insertMethod')
|
||||
await getTextInsertService().insertText(finalText, insertMethod)
|
||||
} catch (error) {
|
||||
logger.warn(`Audio save failed: ${describe(error)}`)
|
||||
logger.warn(`Text insert failed: ${describe(error)}`)
|
||||
// 삽입 실패 시 표시 계층이 ResultPopup에 텍스트를 보여 준다
|
||||
this.emit('insert-failed', { text: finalText, sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1353,6 +1350,13 @@ export function getVoiceModeService(): VoiceModeService {
|
|||
return instance
|
||||
}
|
||||
|
||||
/** 테스트 전용: 가짜 녹음 저장소를 주입한 싱글톤을 만든다. */
|
||||
export function createVoiceModeServiceForTests(recordings: RecordingStore): VoiceModeService {
|
||||
resetVoiceModeServiceForTests()
|
||||
instance = new VoiceModeService(recordings)
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetVoiceModeServiceForTests(): void {
|
||||
if (instance) {
|
||||
instance.removeAllListeners()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
import { getLogger } from '../services/LoggerService'
|
||||
import { getIsQuitting, setIsQuitting } from '../lifecycle'
|
||||
import { hardenWebContents } from './web-contents-hardening'
|
||||
import { commandPopupSize, historyPopupSize } from './popup-list-geometry'
|
||||
import { configGet, configSet } from '../services/ConfigService'
|
||||
import { getI18n } from '@d3ro/i18n'
|
||||
import {
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
sendToPopupWindow
|
||||
} from './popup-window-factory'
|
||||
import { createElectronScreenGeometry } from './screen-geometry'
|
||||
import { installApplicationMenu } from './app-menu'
|
||||
import {
|
||||
SUGGESTION_OVERLAY_MAX_HEIGHT,
|
||||
SUGGESTION_OVERLAY_WIDTH,
|
||||
|
|
@ -48,30 +50,42 @@ const logger = getLogger('WindowManager')
|
|||
/**
|
||||
* 팝업에 주입할 i18n 문자열 맵을 반환.
|
||||
*/
|
||||
function getPopupI18nStrings(): Record<string, string> {
|
||||
/** 현재 설정 언어의 번역 함수 — 팝업(Vanilla JS)에 넘길 문구를 main 에서 만들 때 쓴다 */
|
||||
export function getPopupTranslator(): ReturnType<typeof getI18n>['t'] {
|
||||
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 getI18n(locale as 'ko' | 'en' | 'ja' | 'zh' | 'zh-TW' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' | 'th').t
|
||||
}
|
||||
|
||||
function getPopupI18nStrings(): Record<string, string> {
|
||||
const t = getPopupTranslator()
|
||||
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'),
|
||||
// history-popup 상대 시간
|
||||
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'),
|
||||
copied: t('popup.copied'),
|
||||
resultTitle: t('popup.result.title'),
|
||||
// 목록 팝업 공통 (v4: 키는 kbd 칩, 설명은 동사)
|
||||
hintMove: t('popup.hints.move'),
|
||||
hintPaste: t('popup.hints.paste2'),
|
||||
hintApply: t('popup.hints.apply2'),
|
||||
hintClose: t('popup.hints.close2'),
|
||||
hintPick: t('popup.hints.pick'),
|
||||
historyTitle: t('popup.history.title'),
|
||||
historyEmpty: t('popup.history.empty'),
|
||||
commandTitle: t('popup.command.title'),
|
||||
commandEmpty: t('popup.command.empty'),
|
||||
commandNone: t('popup.command.none'),
|
||||
// recording-tip
|
||||
errorDefault: t('popup.error.default'),
|
||||
tipProcessing: t('popup.tip.processing'),
|
||||
tipInserted: t('popup.tip.inserted'),
|
||||
tipCancelHint: t('popup.tip.cancelHint'),
|
||||
tipRecording: t('popup.tip.recordingLabel'),
|
||||
close: t('popup.close'),
|
||||
// caption-overlay
|
||||
captionLoading: t('popup.caption.loading'),
|
||||
captionWaiting: t('popup.caption.waiting'),
|
||||
|
|
@ -83,6 +97,8 @@ function getPopupI18nStrings(): Record<string, string> {
|
|||
suggestionWarming: t('popup.suggestion.warming'),
|
||||
suggestionHintGenerating: t('popup.suggestion.hintGenerating'),
|
||||
suggestionLoading: t('popup.suggestion.loading'),
|
||||
// 경과 초가 붙는 "생성 중 {{s}}초" — 빠져 있어 script.js 가 loading 으로 떨어졌다(2026-09-28)
|
||||
suggestionGenerating: t('popup.suggestion.generating'),
|
||||
suggestionSourceModel: t('popup.suggestion.sourceModel'),
|
||||
suggestionSourceMemory: t('popup.suggestion.sourceMemory'),
|
||||
suggestionContinuations: t('popup.suggestion.continuations'),
|
||||
|
|
@ -259,9 +275,10 @@ export function createMainWindow(): BrowserWindow {
|
|||
mainWindowCreated = true
|
||||
mainWindow.setSkipTaskbar(false)
|
||||
|
||||
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
|
||||
// Win/Linux: Alt 키로 메뉴 활성화 방지(메뉴 완전 제거). macOS: 편집 단축키(Cmd+C/V/X/A/Z)와
|
||||
// Cmd+Q 는 애플리케이션 메뉴의 role 항목으로만 동작하므로 최소 메뉴를 유지한다.
|
||||
mainWindow.setMenu(null)
|
||||
Menu.setApplicationMenu(null)
|
||||
installApplicationMenu(Menu, process.platform)
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
|
|
@ -368,6 +385,20 @@ export function showRecordingTip(
|
|||
logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류를 RecordingTip 에 그리고 팁을 띄운다.
|
||||
* 쿼터 초과·자막 사용 중·STT 모델 미설치 오류는 'recording' phase 전에 세션을 끝내므로 팁이 숨겨진
|
||||
* 상태다 — 상태 IPC 만 보내면 보이지 않는 창에 그려지고 오류음만 남는다. 이미 떠 있는 팁(녹음 도중
|
||||
* 난 오류)은 자리를 옮기지 않고 상태만 바꾼다.
|
||||
*/
|
||||
export function showRecordingTipError(errorMessage: string): void {
|
||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed() && recordingTipWindow.isVisible()) {
|
||||
updateRecordingTipState('error', { errorMessage })
|
||||
return
|
||||
}
|
||||
showRecordingTip('error', { errorMessage })
|
||||
}
|
||||
|
||||
export function hideRecordingTip(): void {
|
||||
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
|
||||
recordingTipWindow.hide()
|
||||
|
|
@ -537,7 +568,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
|
|||
const win = getHistoryPopupWindow()
|
||||
|
||||
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
|
||||
placePopupNearCursor(win, { width: 380, height: Math.min(60 + entries.length * 44, 500) })
|
||||
placePopupNearCursor(win, historyPopupSize(entries.length))
|
||||
|
||||
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
|
||||
|
||||
|
|
@ -572,7 +603,7 @@ export function isHistoryPopupVisible(): boolean {
|
|||
function createCommandPopupWindow(): BrowserWindow {
|
||||
return createPopupWindow({
|
||||
name: 'command-popup',
|
||||
bounds: { width: 340, height: 300 },
|
||||
bounds: commandPopupSize(0),
|
||||
onClosed: () => {
|
||||
commandPopupWindow = null
|
||||
}
|
||||
|
|
@ -588,7 +619,7 @@ export function getCommandPopupWindow(): BrowserWindow {
|
|||
|
||||
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
|
||||
const win = getCommandPopupWindow()
|
||||
placePopupNearCursor(win, { width: 340, height: Math.min(80 + commands.length * 48, 400) })
|
||||
placePopupNearCursor(win, commandPopupSize(commands.length))
|
||||
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
|
||||
|
||||
presentPopup(win)
|
||||
|
|
|
|||
34
apps/desktop/src/main/windows/popup-list-geometry.ts
Normal file
34
apps/desktop/src/main/windows/popup-list-geometry.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// src/main/windows/popup-list-geometry.ts
|
||||
// 기록·명령 팝업(목록형) 창 크기 — CSS 치수와 같은 상수로 계산한다(순수 함수).
|
||||
//
|
||||
// 이전 공식(기록 60+44n, 명령 80+48n)은 CSS 와 따로 놀아 빈 상태에서 창이 내용보다 작아 잘렸고
|
||||
// (기록 60px ← 필요 약 116px), 항목이 많으면 남는 투명 영역이 클릭을 삼켰다(2026-09-28 감사).
|
||||
// 아래 값은 popups/_shared/popup-base.css · history-popup/style.css · command-popup/style.css 와 짝이다 —
|
||||
// 한쪽을 바꾸면 다른 쪽도 바꾼다.
|
||||
|
||||
/** 목록 한 줄 높이(px) — .d3-list-item height */
|
||||
export const POPUP_LIST_ROW = 40
|
||||
/** 제목 줄(34) + 힌트 줄(30) + 판 위아래 패딩(6+6) + 테두리(1+1) */
|
||||
export const POPUP_LIST_CHROME = 34 + 30 + 12 + 2
|
||||
/** 빈 상태 한 줄 높이 */
|
||||
export const POPUP_LIST_EMPTY = 56
|
||||
export const HISTORY_POPUP_WIDTH = 380
|
||||
export const HISTORY_POPUP_MAX_HEIGHT = 500
|
||||
export const COMMAND_POPUP_WIDTH = 340
|
||||
export const COMMAND_POPUP_MAX_HEIGHT = 420
|
||||
|
||||
export interface PopupSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function historyPopupSize(entryCount: number): PopupSize {
|
||||
const body = entryCount === 0 ? POPUP_LIST_EMPTY : entryCount * POPUP_LIST_ROW
|
||||
return { width: HISTORY_POPUP_WIDTH, height: Math.min(POPUP_LIST_CHROME + body, HISTORY_POPUP_MAX_HEIGHT) }
|
||||
}
|
||||
|
||||
/** 명령 팝업은 맨 위에 "명령 없이 원문 넣기" 줄이 항상 있다 */
|
||||
export function commandPopupSize(commandCount: number): PopupSize {
|
||||
const body = POPUP_LIST_ROW + (commandCount === 0 ? POPUP_LIST_EMPTY : commandCount * POPUP_LIST_ROW)
|
||||
return { width: COMMAND_POPUP_WIDTH, height: Math.min(POPUP_LIST_CHROME + body, COMMAND_POPUP_MAX_HEIGHT) }
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ export const POPUP_NAV_KEYS: ReadonlyArray<{ accel: string; key: string }> = [
|
|||
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
|
||||
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
|
||||
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
|
||||
// 0 = 명령 팝업의 "명령 없이 원문 넣기"(힌트만 있고 등록이 빠져 있었다 — 2026-09-28). 기록 팝업은 무시한다.
|
||||
{ accel: '0', key: '0' },
|
||||
]
|
||||
|
||||
/** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// 처리를 각자 복붙하던 시절 옵션이 조용히 갈라졌다 — ResultPopup 에만 backgroundThrottling
|
||||
// 이 빠져 숨김 상태에서 2-phase 측정이 멈췄다. 창 종류는 이제 PopupWindowSpec 선언만 다르다.
|
||||
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
|
||||
|
|
@ -87,12 +87,16 @@ export function createPopupWindow(spec: PopupWindowSpec): BrowserWindow {
|
|||
*/
|
||||
export function injectPopupTheme(win: BrowserWindow): void {
|
||||
const theme = configGet('theme') as string
|
||||
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
|
||||
const resolvedTheme = theme === 'system' ? 'dark' : theme
|
||||
const css = buildPopupThemeCss(resolvedTheme)
|
||||
// 시스템/자동 테마는 main 이 아는 OS 다크 모드로 푼다(이전: 늘 dark — 라이트 OS 에서 팝업만 어두웠다)
|
||||
const css = buildPopupThemeCss(theme, nativeTheme.shouldUseDarkColors)
|
||||
win.webContents.insertCSS(css).catch((err: unknown) => {
|
||||
logger.warn('팝업 테마 CSS 주입 실패', err)
|
||||
})
|
||||
// 팝업 HTML 은 lang="ko" 로 고정돼 있다 — 앱 언어로 맞춰야 중·일·태국어 글리프 선택과 줄바꿈이 맞다
|
||||
const locale = (configGet('language') as string) || 'ko'
|
||||
win.webContents
|
||||
.executeJavaScript(`document.documentElement.lang = ${JSON.stringify(locale)}`)
|
||||
.catch((err: unknown) => logger.warn('팝업 lang 설정 실패', err))
|
||||
}
|
||||
|
||||
// ── 수명주기 · 로드 전 IPC 보류 ─────────────────────────────
|
||||
|
|
|
|||
35
apps/desktop/src/main/windows/recording-tip-error-message.ts
Normal file
35
apps/desktop/src/main/windows/recording-tip-error-message.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// src/main/windows/recording-tip-error-message.ts
|
||||
// RecordingTip 에 그릴 오류 문구 정책 — 코드별 i18n 문자열로 매핑한다(순수 함수).
|
||||
//
|
||||
// VoiceModeService 의 D3ROError.message 는 로그용 영문 문구("STT model not installed: ...")라
|
||||
// 그대로 팁에 그리면 사용자 언어와 무관한 개발자 문구가 보인다. 코드 분류는 @d3ro/core 의
|
||||
// classifyVoiceError 한 곳이 정하고(렌더러 알림도 같은 분류를 쓴다), 여기서는 번역 키만 고른다.
|
||||
// 분류되지 않는 코드는 기존 동작대로 원문을 그린다.
|
||||
|
||||
import { classifyVoiceError, type VoiceErrorMessageId } from '@d3ro/core/voice-error-message'
|
||||
import type { TranslationKey } from '@d3ro/i18n'
|
||||
|
||||
export type RecordingTipTranslate = (key: TranslationKey, params?: Record<string, string | number>) => string
|
||||
|
||||
const MESSAGE_KEY: Record<Exclude<VoiceErrorMessageId, 'quota'>, TranslationKey> = {
|
||||
modelMissing: 'voice.error.modelMissing',
|
||||
engine: 'voice.error.engine',
|
||||
micOpen: 'voice.error.micOpen',
|
||||
micLost: 'voice.error.micLost',
|
||||
noSpeech: 'voice.error.noSpeech',
|
||||
tooShort: 'voice.error.tooShort',
|
||||
transcription: 'voice.error.transcription',
|
||||
captionActive: 'voice.error.captionActive',
|
||||
llmFailed: 'voice.error.llmFailed',
|
||||
}
|
||||
|
||||
export function resolveRecordingTipErrorMessage(
|
||||
code: number | undefined,
|
||||
fallbackMessage: string,
|
||||
t: RecordingTipTranslate,
|
||||
): string {
|
||||
const id = classifyVoiceError(code)
|
||||
if (id === null) return fallbackMessage
|
||||
if (id === 'quota') return t('license.quotaExceeded.title', { feature: t('license.feature.dictation') })
|
||||
return t(MESSAGE_KEY[id])
|
||||
}
|
||||
|
|
@ -9,19 +9,29 @@
|
|||
|
||||
import {
|
||||
showRecordingTip,
|
||||
showRecordingTipError,
|
||||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
sendAudioLevelToTip,
|
||||
sendPartialTranscriptToTip,
|
||||
showResultPopup,
|
||||
getPopupTranslator,
|
||||
} from './WindowManager'
|
||||
import { resolveRecordingTipErrorMessage } from './recording-tip-error-message'
|
||||
|
||||
/** 프레젠터가 그리는 대상(창) 포트 — 테스트에서는 가짜로 바꾼다 */
|
||||
export interface RecordingTipView {
|
||||
showRecording(): void
|
||||
hide(): void
|
||||
showThinking(): void
|
||||
showError(message: string): void
|
||||
/** 받아쓴 글을 넣었다 — 짧게 확인 표시를 보인다 */
|
||||
showDone(): void
|
||||
/**
|
||||
* 오류를 그리고 팁을 띄운다. 쿼터·자막 사용 중·모델 미설치처럼 'recording' phase 전에 끝난
|
||||
* 세션의 오류도 보여야 하므로, 숨겨진 팁이면 창을 띄워야 한다(상태 IPC 만으로는 부족).
|
||||
* code 가 있으면 사용자 언어 문구로 바꿔 그린다.
|
||||
*/
|
||||
showError(message: string, code?: number): void
|
||||
sendAudioLevel(level: number): void
|
||||
sendPartialTranscript(text: string): void
|
||||
showResult(text: string, durationMs: number): void
|
||||
|
|
@ -37,6 +47,8 @@ export interface RecordingTipPresenterOptions {
|
|||
errorHideMs?: number
|
||||
/** 자동 삽입 실패 시 결과 팝업 표시 시간(ms). 기본 10000 */
|
||||
resultPopupMs?: number
|
||||
/** 완료 확인 표시 후 팁을 숨기기까지(ms). 기본 700 */
|
||||
doneHideMs?: number
|
||||
timers?: RecordingTipTimers
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +59,7 @@ export class RecordingTipPresenter {
|
|||
private _hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private readonly _errorHideMs: number
|
||||
private readonly _resultPopupMs: number
|
||||
private readonly _doneHideMs: number
|
||||
private readonly _timers: RecordingTipTimers
|
||||
|
||||
constructor(
|
||||
|
|
@ -55,6 +68,7 @@ export class RecordingTipPresenter {
|
|||
) {
|
||||
this._errorHideMs = options.errorHideMs ?? 3000
|
||||
this._resultPopupMs = options.resultPopupMs ?? 10000
|
||||
this._doneHideMs = options.doneHideMs ?? 700
|
||||
this._timers = options.timers ?? {
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimeout: (handle) => clearTimeout(handle),
|
||||
|
|
@ -86,10 +100,11 @@ export class RecordingTipPresenter {
|
|||
/**
|
||||
* 에러 표시 후 한 번만 숨김을 예약한다(이전 예약은 교체).
|
||||
* 'warning' 은 세션을 막지 않는 알림이므로 팁을 건드리지 않는다.
|
||||
* phase 와 무관하게 표시된다 — 녹음 팁이 뜨기 전에 난 오류도 보여야 한다.
|
||||
*/
|
||||
onError(message: string, severity: RecordingTipSeverity = 'error'): void {
|
||||
onError(message: string, severity: RecordingTipSeverity = 'error', code?: number): void {
|
||||
if (severity === 'warning') return
|
||||
this._view.showError(message)
|
||||
this._view.showError(message, code)
|
||||
this._cancelHide()
|
||||
this._hideTimer = this._timers.setTimeout(() => {
|
||||
this._hideTimer = null
|
||||
|
|
@ -97,12 +112,25 @@ export class RecordingTipPresenter {
|
|||
}, this._errorHideMs)
|
||||
}
|
||||
|
||||
/** 세션 완료/취소 — 팁을 즉시 숨긴다 */
|
||||
/** 세션 취소 — 팁을 즉시 숨긴다 */
|
||||
onSessionEnded(): void {
|
||||
this._cancelHide()
|
||||
this._view.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 완료 — "입력함"을 잠깐 보이고 숨긴다. 이전에는 즉시 사라져 성공했는지 알 수 없었다(2026-09-28).
|
||||
* 숨김 예약은 오류와 같은 핸들 하나 — 곧바로 새 세션이 시작되면 onSessionStarted 가 취소한다.
|
||||
*/
|
||||
onSessionCompleted(): void {
|
||||
this._cancelHide()
|
||||
this._view.showDone()
|
||||
this._hideTimer = this._timers.setTimeout(() => {
|
||||
this._hideTimer = null
|
||||
this._view.hide()
|
||||
}, this._doneHideMs)
|
||||
}
|
||||
|
||||
onInsertFailed(text: string): void {
|
||||
this._view.showResult(text, this._resultPopupMs)
|
||||
}
|
||||
|
|
@ -125,7 +153,9 @@ export function createWindowRecordingTipView(): RecordingTipView {
|
|||
showRecording: () => showRecordingTip('recording'),
|
||||
hide: () => hideRecordingTip(),
|
||||
showThinking: () => updateRecordingTipState('thinking'),
|
||||
showError: (message) => updateRecordingTipState('error', { errorMessage: message }),
|
||||
showDone: () => updateRecordingTipState('done'),
|
||||
showError: (message, code) =>
|
||||
showRecordingTipError(resolveRecordingTipErrorMessage(code, message, getPopupTranslator())),
|
||||
sendAudioLevel: (level) => sendAudioLevelToTip(level),
|
||||
sendPartialTranscript: (text) => sendPartialTranscriptToTip(text),
|
||||
showResult: (text, durationMs) => showResultPopup(text, durationMs),
|
||||
|
|
|
|||
101
apps/desktop/src/renderer/popups/_shared/list-popup.css
Normal file
101
apps/desktop/src/renderer/popups/_shared/list-popup.css
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/* popups/_shared/list-popup.css — 기록·명령 팝업(목록형) 공통 모양 (v4, 2026-09-28)
|
||||
* 치수는 main/windows/popup-list-geometry.ts 와 짝이다:
|
||||
* 제목 줄 34px · 목록 한 줄 40px · 빈 상태 56px · 힌트 줄 30px · 판 패딩 6px · 테두리 1px.
|
||||
* 한쪽을 바꾸면 다른 쪽도 바꾼다. */
|
||||
|
||||
@import './popup-base.css';
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.list-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 6px;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 120ms var(--popup-ease), transform 120ms var(--popup-ease);
|
||||
}
|
||||
|
||||
.list-popup.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.list-popup.hiding {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--d3-text-secondary);
|
||||
}
|
||||
|
||||
.list-items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-items::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.d3-list-item {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--d3-text-primary);
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
.list-empty {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
.d3-hints {
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.d3-hints b {
|
||||
font-weight: 400;
|
||||
}
|
||||
146
apps/desktop/src/renderer/popups/_shared/popup-base.css
Normal file
146
apps/desktop/src/renderer/popups/_shared/popup-base.css
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/* popups/_shared/popup-base.css
|
||||
* 팝업 6종의 공통 바탕 — v4 "조용한 계기" (2026-09-28).
|
||||
* 이전에는 팝업마다 radius(999/16/10)·폰트 스택·선택 표시·키 힌트·모션이 제각각이었고,
|
||||
* Pretendard 를 싣지 않아 시스템 폰트로 그려졌다. 이제 모든 팝업이 이 파일을 @import 한다.
|
||||
* 색은 main 이 insertCSS 로 주입하는 CSS 변수(@d3ro/ui theme-vars — 렌더러와 같은 원시값)만 쓴다.
|
||||
* 아래 :root 값은 주입 전 첫 프레임용 폴백(dark 기본값과 같다). */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pretendard Variable';
|
||||
font-weight: 45 920;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('../../assets/fonts/PretendardVariable.woff2') format('woff2-variations');
|
||||
}
|
||||
|
||||
:root {
|
||||
--d3-bg-card: #111a30;
|
||||
--d3-bg-elevated: #1a2540;
|
||||
--d3-bg-inset: #0a111f;
|
||||
--d3-text-primary: #eef2fb;
|
||||
--d3-text-secondary: #93a4c8;
|
||||
--d3-text-tertiary: #7e8eb0;
|
||||
--d3-border-subtle: rgba(148, 180, 255, 0.07);
|
||||
--d3-border-default: rgba(148, 180, 255, 0.12);
|
||||
--d3-border-strong: rgba(148, 180, 255, 0.20);
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-accent-light: #60a5fa;
|
||||
--d3-accent-dim: rgba(59, 130, 246, 0.14);
|
||||
--d3-status-danger: #f87171;
|
||||
--d3-status-success: #34d399;
|
||||
--d3-overlay-soft: rgba(238, 242, 251, 0.06);
|
||||
--d3-overlay-strong: rgba(238, 242, 251, 0.12);
|
||||
--d3-shadow-overlay: 0 12px 32px rgba(3, 7, 18, 0.55);
|
||||
|
||||
/* 팝업 전용 치수 — 한 곳에서만 정한다 */
|
||||
--popup-radius: 10px;
|
||||
--popup-radius-item: 6px;
|
||||
--popup-pad: 6px;
|
||||
--popup-font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
|
||||
'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
--popup-font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Pretendard Variable', 'Malgun Gothic', monospace;
|
||||
--popup-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--popup-font);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--d3-text-primary);
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 떠 있는 판 하나 — 모든 팝업의 겉모양 */
|
||||
.d3-panel {
|
||||
background: var(--d3-bg-card);
|
||||
border: 1px solid var(--d3-border-default);
|
||||
border-radius: var(--popup-radius);
|
||||
box-shadow: var(--d3-shadow-overlay);
|
||||
}
|
||||
|
||||
/* 목록 항목 — 선택은 옅은 오버레이 + 왼쪽 2px 액센트 막대(팝업 공통) */
|
||||
.d3-item {
|
||||
position: relative;
|
||||
border-radius: var(--popup-radius-item);
|
||||
}
|
||||
|
||||
.d3-item:hover {
|
||||
background: var(--d3-overlay-soft);
|
||||
}
|
||||
|
||||
.d3-item.selected {
|
||||
background: var(--d3-overlay-strong);
|
||||
}
|
||||
|
||||
.d3-item.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--d3-accent-main);
|
||||
}
|
||||
|
||||
/* 키 힌트 줄 — 키는 kbd 칩, 설명은 3차 텍스트 (최소 11px) */
|
||||
.d3-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
padding: 6px 10px 4px;
|
||||
border-top: 1px solid var(--d3-border-subtle);
|
||||
font-size: 11px;
|
||||
color: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
.d3-hints span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
kbd,
|
||||
.d3-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--d3-border-strong);
|
||||
border-radius: 4px;
|
||||
font-family: var(--popup-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--d3-text-secondary);
|
||||
}
|
||||
|
||||
.d3-numeral {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 움직임을 줄인 환경: 반복 애니메이션을 멈추고 전환을 즉시로 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,18 @@
|
|||
/* Caption Overlay — Phase 10.1 Live Caption
|
||||
* Precision Broadcast Subtitle HUD
|
||||
*/
|
||||
/* Caption Overlay — Phase 10.1 Live Caption · v4 (2026-09-28)
|
||||
*
|
||||
* 의도된 예외: 자막 받침은 앱 테마를 따르지 않는다. 자막은 영상 통화·동영상 같은 임의의 화면 위에 뜨므로
|
||||
* 방송 자막 관례대로 어두운 받침 + 밝은 글자가 어떤 배경에서도 읽힌다(라이트 테마에서 흰 받침이면 흰 화면 위에서 사라진다).
|
||||
* 그 값은 아래 --caption-* 에서만 정한다. 강조색(최신 줄 테두리·커서)은 테마 액센트를 따른다.
|
||||
* v4: 블러·알약(999px)·굵은 글자(600)를 걷어내고 다른 팝업과 같은 폰트·반경 문법으로. */
|
||||
|
||||
@import '../_shared/popup-base.css';
|
||||
|
||||
:root {
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-accent-glow: rgba(59, 130, 246, 0.45);
|
||||
--d3-accent-glow-dim: rgba(59, 130, 246, 0.2);
|
||||
--d3-accent-light: #93c5fd;
|
||||
--d3-accent-light-glow: rgba(147, 197, 253, 0.5);
|
||||
--d3-accent-light-glow-dim: rgba(147, 197, 253, 0.25);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--caption-bg: rgba(10, 12, 18, 0.9);
|
||||
--caption-text: #f4f4f5;
|
||||
--caption-text-strong: #ffffff;
|
||||
--caption-border: rgba(255, 255, 255, 0.10);
|
||||
--caption-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#root {
|
||||
|
|
@ -45,10 +35,10 @@ html, body {
|
|||
gap: 8px;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 17, 22, 0.88);
|
||||
border: 1px solid var(--d3-accent-glow-dim);
|
||||
color: rgba(244, 244, 245, 0.85);
|
||||
border-radius: 6px;
|
||||
background: var(--caption-bg);
|
||||
border: 1px solid var(--caption-border);
|
||||
color: var(--caption-text);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
|
|
@ -93,36 +83,34 @@ html, body {
|
|||
}
|
||||
|
||||
.caption-line {
|
||||
font-size: 16.5px;
|
||||
font-weight: 600;
|
||||
max-width: 90%;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--caption-border);
|
||||
border-radius: 8px;
|
||||
background: var(--caption-bg);
|
||||
box-shadow: var(--caption-shadow);
|
||||
color: var(--caption-text);
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: #f4f4f5;
|
||||
background: rgba(15, 17, 22, 0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 6px 18px;
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease-out;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.caption-line.fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Newest line (bottom) is brightest with accent tint */
|
||||
/* 최신 줄(아래)이 가장 밝다 — 테마 액센트 테두리 */
|
||||
.caption-line:last-child {
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
color: var(--caption-text-strong);
|
||||
border-color: var(--d3-accent-main);
|
||||
}
|
||||
|
||||
/* Oldest lines are dimmer */
|
||||
/* 오래된 줄은 흐리게 */
|
||||
.caption-line:first-child {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
|
@ -131,7 +119,7 @@ html, body {
|
|||
opacity: 0.82;
|
||||
}
|
||||
|
||||
/* Delta (partial/unfinished) line has pulsing cursor */
|
||||
/* 아직 끝나지 않은 줄 — 깜빡이는 커서 */
|
||||
.caption-line.delta::after {
|
||||
content: '\2588';
|
||||
animation: blink 0.8s step-end infinite;
|
||||
|
|
@ -149,17 +137,17 @@ html, body {
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 문맥 다듬기로 고쳐진 줄 — 잠깐 강조 */
|
||||
/* 문맥 다듬기로 고쳐진 줄 — 잠깐 테두리 강조 */
|
||||
.caption-line.refined {
|
||||
animation: refined-flash 0.9s ease-out;
|
||||
}
|
||||
|
||||
@keyframes refined-flash {
|
||||
0% { border-color: var(--d3-accent-main); box-shadow: 0 0 0 2px var(--d3-accent-glow-dim); }
|
||||
100% { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); }
|
||||
0% { border-color: var(--d3-accent-light); }
|
||||
100% { border-color: var(--caption-border); }
|
||||
}
|
||||
|
||||
/* 로딩 중 표시 — 점멸 애니메이션 */
|
||||
/* 준비 중 표시 */
|
||||
.caption-line.loading {
|
||||
opacity: 0.6;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="command-popup">
|
||||
<div id="title" class="title" data-i18n="commandSelect">COMMAND SELECT</div>
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span data-i18n="hintsSelect">↑↓ 선택</span>
|
||||
<span data-i18n="hintsApply">⏎ 적용</span>
|
||||
<span data-i18n="hintsClear">0 해제</span>
|
||||
<span data-i18n="hintsClose">ESC ×</span>
|
||||
<div id="container" class="list-popup d3-panel">
|
||||
<div id="title" class="list-title" data-i18n="commandTitle"></div>
|
||||
<div id="items" class="list-items" role="listbox" aria-labelledby="title"></div>
|
||||
<div class="d3-hints" aria-hidden="true">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd><b data-i18n="hintMove"></b></span>
|
||||
<span><kbd>Enter</kbd><b data-i18n="hintApply"></b></span>
|
||||
<span><kbd>0–9</kbd><b data-i18n="hintPick"></b></span>
|
||||
<span><kbd>Esc</kbd><b data-i18n="hintClose"></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// CommandPopup 팝업 스크립트
|
||||
// Ctrl+Shift+C로 커서 위치에 명령어 선택 팝업
|
||||
// Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기
|
||||
// CommandPopup 팝업 스크립트 — v4 (2026-09-28)
|
||||
// 활성 명령어 고르기. 맨 위 0번은 항상 "명령 없이 원문 넣기". ↑↓ 이동, Enter 적용, 0-9 바로 고르기, Esc 닫기.
|
||||
// 문구는 전부 main 의 _i18n(영어 폴백 없음). 정의되지 않은 CSS 변수를 쓰던 빈 상태(--d3-overlay-strong) 수정.
|
||||
// 행 높이(40px)·"0번 줄 항상 있음"은 main/windows/popup-list-geometry.ts 와 짝이다.
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
|
@ -10,142 +11,105 @@
|
|||
var items = []
|
||||
var selectedIndex = 0
|
||||
var currentCommands = []
|
||||
var currentActiveId = null
|
||||
var i18nStrings = {}
|
||||
var i18n = {}
|
||||
|
||||
function tr(key) {
|
||||
return (i18n && i18n[key]) || ''
|
||||
}
|
||||
|
||||
// ── 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]
|
||||
}
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
el.textContent = tr(el.getAttribute('data-i18n'))
|
||||
})
|
||||
}
|
||||
|
||||
// ── 아이템 렌더링 ───────────────────────────────────
|
||||
function makeRow(index, number, label, detail, isActive, muted) {
|
||||
var row = document.createElement('div')
|
||||
row.className = 'd3-item d3-list-item' + (index === selectedIndex ? ' selected' : '') + (muted ? ' muted' : '')
|
||||
row.id = 'command-item-' + index
|
||||
row.setAttribute('role', 'option')
|
||||
row.setAttribute('aria-selected', index === selectedIndex ? 'true' : 'false')
|
||||
|
||||
var num = document.createElement('kbd')
|
||||
num.className = 'item-number'
|
||||
num.textContent = number
|
||||
|
||||
var text = document.createElement('span')
|
||||
text.className = 'item-text'
|
||||
text.textContent = label
|
||||
if (detail) {
|
||||
var d = document.createElement('span')
|
||||
d.className = 'item-detail'
|
||||
d.textContent = detail
|
||||
text.appendChild(d)
|
||||
}
|
||||
|
||||
row.appendChild(num)
|
||||
row.appendChild(text)
|
||||
if (isActive) {
|
||||
var badge = document.createElement('span')
|
||||
badge.className = 'item-active'
|
||||
badge.setAttribute('aria-label', 'active')
|
||||
row.appendChild(badge)
|
||||
}
|
||||
row.addEventListener('click', function () {
|
||||
selectAndApply(index)
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
function renderItems(commands, activeId) {
|
||||
currentCommands = commands
|
||||
currentActiveId = activeId
|
||||
itemsContainer.innerHTML = ''
|
||||
items = []
|
||||
|
||||
var none = makeRow(0, '0', tr('commandNone'), '', !activeId, true)
|
||||
itemsContainer.appendChild(none)
|
||||
items.push(none)
|
||||
|
||||
if (commands.length === 0) {
|
||||
var empty = document.createElement('div')
|
||||
empty.style.cssText = 'color: var(--d3-overlay-strong); font-size: 13px; text-align: center; padding: 24px 16px;'
|
||||
empty.textContent = i18nStrings.noCommands || 'No commands'
|
||||
empty.className = 'list-empty'
|
||||
empty.textContent = tr('commandEmpty')
|
||||
itemsContainer.appendChild(empty)
|
||||
return
|
||||
}
|
||||
|
||||
// ── "선택 해제" 항목 (맨 위) ──
|
||||
var noneDiv = document.createElement('div')
|
||||
noneDiv.className = 'item item-none' + (0 === selectedIndex ? ' selected' : '') + (!activeId ? ' active' : '')
|
||||
noneDiv.dataset.index = '0'
|
||||
|
||||
var noneNum = document.createElement('span')
|
||||
noneNum.className = 'item-number'
|
||||
noneNum.textContent = '0'
|
||||
var noneName = document.createElement('span')
|
||||
noneName.className = 'item-name'
|
||||
noneName.style.opacity = '0.5'
|
||||
noneName.textContent = i18nStrings.noCommand || 'No command (insert original)'
|
||||
|
||||
if (!activeId) {
|
||||
var noneBadge = document.createElement('span')
|
||||
noneBadge.className = 'item-active-badge'
|
||||
noneBadge.textContent = '●'
|
||||
noneDiv.appendChild(noneNum)
|
||||
noneDiv.appendChild(noneName)
|
||||
noneDiv.appendChild(noneBadge)
|
||||
} else {
|
||||
noneDiv.appendChild(noneNum)
|
||||
noneDiv.appendChild(noneName)
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(noneDiv)
|
||||
items.push(noneDiv)
|
||||
noneDiv.addEventListener('click', function () {
|
||||
selectAndApply(-1)
|
||||
})
|
||||
|
||||
commands.forEach(function (cmd, index) {
|
||||
var itemIndex = index + 1
|
||||
var div = document.createElement('div')
|
||||
div.className = 'item' + (itemIndex === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '')
|
||||
div.dataset.index = String(itemIndex)
|
||||
|
||||
var num = document.createElement('span')
|
||||
num.className = 'item-number'
|
||||
num.textContent = String(itemIndex)
|
||||
|
||||
var name = document.createElement('span')
|
||||
name.className = 'item-name'
|
||||
name.textContent = cmd.name
|
||||
|
||||
var desc = document.createElement('span')
|
||||
desc.className = 'item-desc'
|
||||
desc.textContent = cmd.description
|
||||
|
||||
div.appendChild(num)
|
||||
div.appendChild(name)
|
||||
|
||||
if (cmd.id === activeId) {
|
||||
var badge = document.createElement('span')
|
||||
badge.className = 'item-active-badge'
|
||||
badge.textContent = '●'
|
||||
div.appendChild(badge)
|
||||
}
|
||||
|
||||
div.appendChild(desc)
|
||||
itemsContainer.appendChild(div)
|
||||
items.push(div)
|
||||
|
||||
div.addEventListener('click', function () {
|
||||
selectAndApply(itemIndex)
|
||||
commands.forEach(function (cmd, i) {
|
||||
var index = i + 1
|
||||
var row = makeRow(index, index <= 9 ? String(index) : '', cmd.name, cmd.description || '', cmd.id === activeId, false)
|
||||
itemsContainer.appendChild(row)
|
||||
items.push(row)
|
||||
})
|
||||
})
|
||||
}
|
||||
itemsContainer.setAttribute('aria-activedescendant', 'command-item-' + selectedIndex)
|
||||
}
|
||||
|
||||
// ── 선택 업데이트 ───────────────────────────────────
|
||||
function updateSelection(newIndex) {
|
||||
var totalItems = currentCommands.length + 1 // +1 for "none" item
|
||||
if (totalItems <= 1) return
|
||||
if (newIndex < 0) newIndex = totalItems - 1
|
||||
if (newIndex >= totalItems) newIndex = 0
|
||||
|
||||
var total = currentCommands.length + 1
|
||||
if (newIndex < 0) newIndex = total - 1
|
||||
if (newIndex >= total) newIndex = 0
|
||||
items.forEach(function (item, i) {
|
||||
if (i === newIndex) {
|
||||
item.classList.add('selected')
|
||||
} else {
|
||||
item.classList.remove('selected')
|
||||
}
|
||||
item.classList.toggle('selected', i === newIndex)
|
||||
item.setAttribute('aria-selected', i === newIndex ? 'true' : 'false')
|
||||
})
|
||||
selectedIndex = newIndex
|
||||
|
||||
if (items[selectedIndex]) {
|
||||
items[selectedIndex].scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
itemsContainer.setAttribute('aria-activedescendant', 'command-item-' + selectedIndex)
|
||||
if (items[selectedIndex]) items[selectedIndex].scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
// ── 선택 적용 ───────────────────────────────────────
|
||||
/** 0 = 명령 없이 원문, 1.. = 명령 */
|
||||
function selectAndApply(index) {
|
||||
// index 0 또는 -1 = "선택 해제"
|
||||
if (index <= 0) {
|
||||
window.popupAPI.send('command:selected', { id: '', name: '' })
|
||||
return
|
||||
}
|
||||
var cmdIndex = index - 1
|
||||
if (cmdIndex < 0 || cmdIndex >= currentCommands.length) return
|
||||
var cmd = currentCommands[cmdIndex]
|
||||
var cmd = currentCommands[index - 1]
|
||||
if (!cmd) return
|
||||
window.popupAPI.send('command:selected', { id: cmd.id, name: cmd.name })
|
||||
}
|
||||
|
||||
// ── IPC 리스너 ───────────────────────────────────────
|
||||
window.popupAPI.on('command:showItems', function (data) {
|
||||
selectedIndex = 0
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
if (data._i18n) i18n = data._i18n
|
||||
applyI18nToHtml()
|
||||
renderItems(data.commands || [], data.activeId || null)
|
||||
container.classList.remove('hiding')
|
||||
|
|
@ -163,22 +127,13 @@
|
|||
|
||||
window.popupAPI.on('command:keyEvent', function (data) {
|
||||
var key = data.key
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
updateSelection(selectedIndex - 1)
|
||||
} else if (key === 'ArrowDown') {
|
||||
updateSelection(selectedIndex + 1)
|
||||
} else if (key === 'Enter') {
|
||||
selectAndApply(selectedIndex)
|
||||
} else if (key === 'Escape') {
|
||||
window.popupAPI.send('command:dismissed', {})
|
||||
} else if (key === '0') {
|
||||
selectAndApply(0)
|
||||
} else if (key >= '1' && key <= '9') {
|
||||
if (key === 'ArrowUp') updateSelection(selectedIndex - 1)
|
||||
else if (key === 'ArrowDown') updateSelection(selectedIndex + 1)
|
||||
else if (key === 'Enter') selectAndApply(selectedIndex)
|
||||
else if (key === 'Escape') window.popupAPI.send('command:dismissed', {})
|
||||
else if (key >= '0' && key <= '9') {
|
||||
var idx = parseInt(key, 10)
|
||||
if (idx <= currentCommands.length) {
|
||||
selectAndApply(idx)
|
||||
}
|
||||
if (idx <= currentCommands.length) selectAndApply(idx)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -1,162 +1,22 @@
|
|||
/* command-popup/style.css
|
||||
* Precision Command Quick-Switcher HUD
|
||||
*/
|
||||
/* command-popup/style.css — v4 (2026-09-28). 목록형 팝업 공통 모양 + 명령 전용 표시. */
|
||||
|
||||
:root {
|
||||
--d3-bg-card: rgba(15, 17, 22, 0.9);
|
||||
--d3-border-default: rgba(255, 255, 255, 0.08);
|
||||
--d3-border-subtle: rgba(255, 255, 255, 0.04);
|
||||
--d3-text-primary: #f4f4f5;
|
||||
--d3-text-inactive: #71717a;
|
||||
--d3-text-muted: rgba(255, 255, 255, 0.35);
|
||||
--d3-text-dimLabel: #a1a1aa;
|
||||
--d3-text-secondary: #a1a1aa;
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-accent-dim: rgba(59, 130, 246, 0.14);
|
||||
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
@import '../_shared/list-popup.css';
|
||||
|
||||
.item-detail {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.command-popup {
|
||||
background: var(--d3-bg-card);
|
||||
border: 1px solid var(--d3-border-default);
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1), transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
min-width: 290px;
|
||||
max-width: 390px;
|
||||
}
|
||||
|
||||
.command-popup.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.command-popup.hiding {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--d3-text-dimLabel);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 10px 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.items {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item.selected {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.item.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
background: var(--d3-accent-main);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--d3-accent-dim);
|
||||
}
|
||||
|
||||
.item-number {
|
||||
color: var(--d3-text-inactive);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
color: var(--d3-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
.d3-list-item.muted .item-text {
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 지금 쓰는 명령 — 색 점 하나(글자 옆 "선택됨" 막대와 구분) */
|
||||
.item-active {
|
||||
flex-shrink: 0;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-none {
|
||||
border-bottom: 1px solid var(--d3-border-subtle);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.item-active-badge {
|
||||
color: var(--d3-accent-main);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 6px 0 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.hints span {
|
||||
color: var(--d3-text-muted);
|
||||
font-size: 10.5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--d3-accent-main);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="history-popup">
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span data-i18n="hintsSelectEn">↑↓ Select</span>
|
||||
<span data-i18n="hintsPaste">⏎ Paste</span>
|
||||
<span data-i18n="hintsClose">ESC ×</span>
|
||||
<div id="container" class="list-popup d3-panel">
|
||||
<div id="title" class="list-title" data-i18n="historyTitle"></div>
|
||||
<div id="items" class="list-items" role="listbox" aria-labelledby="title"></div>
|
||||
<div class="d3-hints" aria-hidden="true">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd><b data-i18n="hintMove"></b></span>
|
||||
<span><kbd>Enter</kbd><b data-i18n="hintPaste"></b></span>
|
||||
<span><kbd>1–9</kbd><b data-i18n="hintPick"></b></span>
|
||||
<span><kbd>Esc</kbd><b data-i18n="hintClose"></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// HistoryPopup 팝업 스크립트
|
||||
// Phase 3.5: 커서 위치에 최근 전사 히스토리 팝업
|
||||
// Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
|
||||
// HistoryPopup 팝업 스크립트 — v4 (2026-09-28)
|
||||
// 커서 곁에 최근 받아쓰기 목록. ↑↓ 이동, Enter 붙여넣기, 1-9 바로 고르기, Esc 닫기(키는 main 이 전역으로 받아 넘긴다).
|
||||
// 문구는 전부 main 의 _i18n. 영어 폴백("just now", "No history yet")을 두지 않는다.
|
||||
// 행 높이(40px)는 main/windows/popup-list-geometry.ts 와 짝이다.
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
|
@ -10,39 +11,28 @@
|
|||
var items = []
|
||||
var selectedIndex = 0
|
||||
var currentEntries = []
|
||||
var i18nStrings = {}
|
||||
var i18n = {}
|
||||
|
||||
function tr(key) {
|
||||
return (i18n && i18n[key]) || ''
|
||||
}
|
||||
|
||||
// ── 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]
|
||||
}
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
el.textContent = tr(el.getAttribute('data-i18n'))
|
||||
})
|
||||
}
|
||||
|
||||
// ── 상대 시간 표시 ───────────────────────────────────
|
||||
function relativeTime(timestamp) {
|
||||
var diff = Date.now() - timestamp
|
||||
var sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return i18nStrings.timeJustNow || 'just now'
|
||||
var sec = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (sec < 60) return tr('timeJustNow')
|
||||
var min = Math.floor(sec / 60)
|
||||
if (min < 60) return (i18nStrings.timeMinutesAgo || '{{m}}m ago').replace('{{m}}', min)
|
||||
if (min < 60) return tr('timeMinutesAgo').replace('{{m}}', min)
|
||||
var hr = Math.floor(min / 60)
|
||||
if (hr < 24) return (i18nStrings.timeHoursAgo || '{{h}}h ago').replace('{{h}}', hr)
|
||||
var day = Math.floor(hr / 24)
|
||||
return (i18nStrings.timeDaysAgo || '{{d}}d ago').replace('{{d}}', day)
|
||||
if (hr < 24) return tr('timeHoursAgo').replace('{{h}}', hr)
|
||||
return tr('timeDaysAgo').replace('{{d}}', Math.floor(hr / 24))
|
||||
}
|
||||
|
||||
// ── 텍스트 truncate ──────────────────────────────────
|
||||
function truncate(text, maxLen) {
|
||||
if (text.length <= maxLen) return text
|
||||
return text.substring(0, maxLen) + '...'
|
||||
}
|
||||
|
||||
// ── 아이템 렌더링 ───────────────────────────────────
|
||||
function renderItems(entries) {
|
||||
currentEntries = entries
|
||||
itemsContainer.innerHTML = ''
|
||||
|
|
@ -50,92 +40,68 @@
|
|||
|
||||
if (entries.length === 0) {
|
||||
var empty = document.createElement('div')
|
||||
empty.className = 'empty-state'
|
||||
empty.textContent = i18nStrings.noHistory || 'No history yet'
|
||||
empty.className = 'list-empty'
|
||||
empty.textContent = tr('historyEmpty')
|
||||
itemsContainer.appendChild(empty)
|
||||
return
|
||||
}
|
||||
|
||||
entries.forEach(function (entry, index) {
|
||||
var div = document.createElement('div')
|
||||
div.className = 'item' + (index === selectedIndex ? ' selected' : '')
|
||||
div.dataset.index = String(index)
|
||||
var row = document.createElement('div')
|
||||
row.className = 'd3-item d3-list-item' + (index === selectedIndex ? ' selected' : '')
|
||||
row.id = 'history-item-' + index
|
||||
row.setAttribute('role', 'option')
|
||||
row.setAttribute('aria-selected', index === selectedIndex ? 'true' : 'false')
|
||||
|
||||
var num = document.createElement('span')
|
||||
var num = document.createElement('kbd')
|
||||
num.className = 'item-number'
|
||||
num.textContent = String(index + 1)
|
||||
num.textContent = index < 9 ? String(index + 1) : ''
|
||||
|
||||
var text = document.createElement('span')
|
||||
text.className = 'item-text'
|
||||
text.textContent = truncate(entry.polishedText || entry.originalText, 50)
|
||||
text.textContent = entry.polishedText || entry.originalText
|
||||
|
||||
var time = document.createElement('span')
|
||||
time.className = 'item-time'
|
||||
time.className = 'item-meta d3-numeral'
|
||||
time.textContent = relativeTime(entry.createdAt)
|
||||
|
||||
div.appendChild(num)
|
||||
div.appendChild(text)
|
||||
div.appendChild(time)
|
||||
itemsContainer.appendChild(div)
|
||||
items.push(div)
|
||||
|
||||
// 클릭으로 선택+삽입
|
||||
div.addEventListener('click', function () {
|
||||
row.appendChild(num)
|
||||
row.appendChild(text)
|
||||
row.appendChild(time)
|
||||
row.addEventListener('click', function () {
|
||||
selectAndInsert(index)
|
||||
})
|
||||
itemsContainer.appendChild(row)
|
||||
items.push(row)
|
||||
})
|
||||
itemsContainer.setAttribute('aria-activedescendant', 'history-item-' + selectedIndex)
|
||||
}
|
||||
|
||||
// ── 선택 업데이트 ───────────────────────────────────
|
||||
function updateSelection(newIndex) {
|
||||
if (currentEntries.length === 0) return
|
||||
if (newIndex < 0) newIndex = currentEntries.length - 1
|
||||
if (newIndex >= currentEntries.length) newIndex = 0
|
||||
|
||||
items.forEach(function (item, i) {
|
||||
if (i === newIndex) {
|
||||
item.classList.add('selected')
|
||||
} else {
|
||||
item.classList.remove('selected')
|
||||
}
|
||||
item.classList.toggle('selected', i === newIndex)
|
||||
item.setAttribute('aria-selected', i === newIndex ? 'true' : 'false')
|
||||
})
|
||||
selectedIndex = newIndex
|
||||
|
||||
// 스크롤 보장
|
||||
if (items[selectedIndex]) {
|
||||
items[selectedIndex].scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
itemsContainer.setAttribute('aria-activedescendant', 'history-item-' + selectedIndex)
|
||||
if (items[selectedIndex]) items[selectedIndex].scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
// ── 선택 후 삽입 ───────────────────────────────────
|
||||
function selectAndInsert(index) {
|
||||
if (index < 0 || index >= currentEntries.length) return
|
||||
var entry = currentEntries[index]
|
||||
var text = entry.polishedText || entry.originalText
|
||||
window.popupAPI.send('history:itemSelected', { id: entry.id, text: text })
|
||||
window.popupAPI.send('history:itemSelected', { id: entry.id, text: entry.polishedText || entry.originalText })
|
||||
}
|
||||
|
||||
// ── 크기 측정 ───────────────────────────────────────
|
||||
function measureAndReport() {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
var rect = container.getBoundingClientRect()
|
||||
window.popupAPI.send('history:popupMeasured', {
|
||||
width: Math.ceil(rect.width) + 4,
|
||||
height: Math.ceil(rect.height) + 4
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── IPC 리스너 ───────────────────────────────────────
|
||||
window.popupAPI.on('history:showItems', function (data) {
|
||||
selectedIndex = 0
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
if (data._i18n) i18n = data._i18n
|
||||
applyI18nToHtml()
|
||||
renderItems(data.entries || [])
|
||||
container.classList.remove('hiding')
|
||||
measureAndReport()
|
||||
})
|
||||
|
||||
window.popupAPI.on('history:show', function () {
|
||||
|
|
@ -148,23 +114,15 @@
|
|||
container.classList.remove('visible')
|
||||
})
|
||||
|
||||
// 키보드 이벤트 (main에서 전달)
|
||||
window.popupAPI.on('history:keyEvent', function (data) {
|
||||
var key = data.key
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
updateSelection(selectedIndex - 1)
|
||||
} else if (key === 'ArrowDown') {
|
||||
updateSelection(selectedIndex + 1)
|
||||
} else if (key === 'Enter') {
|
||||
selectAndInsert(selectedIndex)
|
||||
} else if (key === 'Escape') {
|
||||
window.popupAPI.send('history:popupDismissed', {})
|
||||
} else if (key >= '1' && key <= '9') {
|
||||
if (key === 'ArrowUp') updateSelection(selectedIndex - 1)
|
||||
else if (key === 'ArrowDown') updateSelection(selectedIndex + 1)
|
||||
else if (key === 'Enter') selectAndInsert(selectedIndex)
|
||||
else if (key === 'Escape') window.popupAPI.send('history:popupDismissed', {})
|
||||
else if (key >= '1' && key <= '9') {
|
||||
var idx = parseInt(key, 10) - 1
|
||||
if (idx < currentEntries.length) {
|
||||
selectAndInsert(idx)
|
||||
}
|
||||
if (idx < currentEntries.length) selectAndInsert(idx)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -1,139 +1,3 @@
|
|||
/* history-popup/style.css
|
||||
* Precision Recent Dictation Inserter HUD
|
||||
*/
|
||||
/* history-popup/style.css — v4 (2026-09-28). 목록형 팝업 공통 모양을 그대로 쓴다. */
|
||||
|
||||
:root {
|
||||
--d3-bg-card: rgba(15, 17, 22, 0.9);
|
||||
--d3-border-default: rgba(255, 255, 255, 0.08);
|
||||
--d3-border-subtle: rgba(255, 255, 255, 0.04);
|
||||
--d3-text-primary: #f4f4f5;
|
||||
--d3-text-inactive: #71717a;
|
||||
--d3-text-muted: rgba(255, 255, 255, 0.35);
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.history-popup {
|
||||
background: var(--d3-bg-card);
|
||||
border: 1px solid var(--d3-border-default);
|
||||
border-radius: 16px;
|
||||
padding: 8px;
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1), transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
min-width: 310px;
|
||||
max-width: 430px;
|
||||
}
|
||||
|
||||
.history-popup.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.history-popup.hiding {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.items {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item.selected {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.item.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 3px;
|
||||
background: var(--d3-accent-main);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
color: var(--d3-text-inactive);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
color: var(--d3-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
color: var(--d3-text-inactive);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 6px 0 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.hints span {
|
||||
color: var(--d3-text-muted);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--d3-text-inactive);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
@import '../_shared/list-popup.css';
|
||||
|
|
|
|||
|
|
@ -8,29 +8,30 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="recording-tip">
|
||||
<!-- recording 상태 -->
|
||||
<div id="recording-view" class="view view-recording">
|
||||
<div class="recording-row">
|
||||
<div id="wave-bars" class="wave-bars"></div>
|
||||
<span id="duration-text" class="duration">0:00</span>
|
||||
</div>
|
||||
<!-- 실시간 부분 전사 미리보기 -->
|
||||
<div id="partial-text" class="partial-text hidden"></div>
|
||||
<!-- 상태는 aria-live 로 읽힌다. 문구는 main 이 보낸 _i18n 으로 채운다(하드코딩 금지). -->
|
||||
<div id="container" class="recording-tip d3-panel" data-state="idle">
|
||||
<!-- recording / thinking 공통 머리 줄 -->
|
||||
<div id="head-row" class="head-row">
|
||||
<div id="wave-bars" class="wave-bars" aria-hidden="true"></div>
|
||||
<span id="duration-text" class="duration d3-numeral">0:00</span>
|
||||
<span id="status-text" class="status-text" role="status" aria-live="polite"></span>
|
||||
<kbd id="cancel-hint" class="cancel-hint"></kbd>
|
||||
</div>
|
||||
<!-- 처리 중: 진행을 꾸며내지 않는 무한 막대 -->
|
||||
<div id="progress" class="progress hidden" aria-hidden="true"><div class="progress-bar"></div></div>
|
||||
<!-- 실시간 부분 전사 — 처리 중에도 남겨 둔다(무엇을 받아쓰는지 보인다) -->
|
||||
<div id="partial-text" class="partial-text hidden"></div>
|
||||
|
||||
<!-- done: 넣었다 -->
|
||||
<div id="done-view" class="line-view hidden">
|
||||
<span class="done-icon" aria-hidden="true">✓</span>
|
||||
<span id="done-text" role="status" aria-live="polite"></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>
|
||||
<!-- error -->
|
||||
<div id="error-view" class="line-view hidden">
|
||||
<span class="error-icon" aria-hidden="true">!</span>
|
||||
<span id="error-text" class="error-label" role="alert"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,50 +1,54 @@
|
|||
// RecordingTip 팝업 스크립트
|
||||
// 설계서 03: 9개 웨이브바, cos 분포 가중치, 100ms 애니메이션
|
||||
// Speakly 패턴 준수
|
||||
// RecordingTip 팝업 스크립트 — v4 (2026-09-28)
|
||||
// 상태: recording → thinking → done(잠깐) | error. 9개 웨이브바·cos 가중치·100ms 갱신은 설계서 03 을 따른다.
|
||||
//
|
||||
// 정직성 규칙:
|
||||
// - 입력이 0이면 막대는 바닥에 머문다(이전: 최소 0.08 로 "살아있는 척" 움직여, 마이크가 켜지기 전에도 파형이 흔들렸다).
|
||||
// - 처리 중에는 꾸며낸 진행률(95%에 점근) 대신 무한 막대. 받아쓰던 문장은 지우지 않고 흐리게 남긴다.
|
||||
// - 클릭 취소는 녹음 중에만. 처리 중 클릭은 받아쓴 글을 버리는 사고였다 — 처리 중 취소는 Esc(의도가 분명한 입력)로만.
|
||||
// 사이트 데모(site/src/components/AppLoopDemo.tsx)가 이 캡슐을 재현한다 — 수치를 바꾸면 함께 고친다.
|
||||
|
||||
;(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
|
||||
var BAR_COUNT = 9
|
||||
var UPDATE_INTERVAL = 100
|
||||
var MIN_HEIGHT = 2
|
||||
var MAX_HEIGHT = 22
|
||||
var SMOOTHING = 0.5
|
||||
var RANDOM_FACTOR = 0.35
|
||||
|
||||
// 코사인 분포 가중치 (중앙이 가장 높음)
|
||||
// 설계서 03: cos((n - 4) * PI / 9)
|
||||
const weights = Array.from({ length: BAR_COUNT }, function (_, i) {
|
||||
var 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)
|
||||
return Math.cos(((i - center) / center) * 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 statusText = document.getElementById('status-text')
|
||||
var cancelHint = document.getElementById('cancel-hint')
|
||||
var progress = document.getElementById('progress')
|
||||
var partialText = document.getElementById('partial-text')
|
||||
var doneView = document.getElementById('done-view')
|
||||
var doneText = document.getElementById('done-text')
|
||||
var errorView = document.getElementById('error-view')
|
||||
var errorText = document.getElementById('error-text')
|
||||
var headRow = document.getElementById('head-row')
|
||||
|
||||
// ── 상태 ─────────────────────────────────────────────
|
||||
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'
|
||||
var i18nStrings = {}
|
||||
var i18n = {}
|
||||
|
||||
function tr(key, fallback) {
|
||||
return (i18n && i18n[key]) || fallback || ''
|
||||
}
|
||||
|
||||
// ── 웨이브 바 생성 ───────────────────────────────────
|
||||
function createWaveBars() {
|
||||
for (var i = 0; i < BAR_COUNT; i++) {
|
||||
var bar = document.createElement('div')
|
||||
|
|
@ -55,23 +59,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ── 웨이브 바 애니메이션 ─────────────────────────────
|
||||
function updateBars() {
|
||||
// 최소 진동: audioLevel이 0이어도 바가 미세하게 움직여 "살아있음" 표현
|
||||
var effectiveLevel = Math.max(0.08, audioLevel)
|
||||
|
||||
for (var i = 0; i < BAR_COUNT; i++) {
|
||||
var baseTarget = effectiveLevel * 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))
|
||||
|
||||
// 스무딩 보간
|
||||
var base = audioLevel * MAX_HEIGHT * weights[i]
|
||||
// 흔들림은 입력 크기에 비례 — 입력이 0이면 0
|
||||
var jitter = base * (Math.random() - 0.5) * 2 * RANDOM_FACTOR
|
||||
var target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, base + jitter))
|
||||
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)
|
||||
|
|
@ -79,129 +77,122 @@
|
|||
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')
|
||||
function stopTimers() {
|
||||
clearInterval(animInterval)
|
||||
clearInterval(durationInterval)
|
||||
if (thinkingRaf) cancelAnimationFrame(thinkingRaf)
|
||||
animInterval = null
|
||||
durationInterval = null
|
||||
thinkingRaf = null
|
||||
}
|
||||
|
||||
function show(el, visible) {
|
||||
el.classList.toggle('hidden', !visible)
|
||||
}
|
||||
|
||||
function setState(state) {
|
||||
currentState = state
|
||||
container.setAttribute('data-state', state)
|
||||
}
|
||||
|
||||
function showRecording() {
|
||||
currentState = 'recording'
|
||||
hideAllViews()
|
||||
recordingView.classList.remove('hidden')
|
||||
stopTimers()
|
||||
setState('recording')
|
||||
show(headRow, true)
|
||||
show(progress, false)
|
||||
show(doneView, false)
|
||||
show(errorView, false)
|
||||
show(durationText, true)
|
||||
statusText.textContent = ''
|
||||
statusText.setAttribute('aria-label', tr('tipRecording'))
|
||||
cancelHint.textContent = tr('tipCancelHint', 'Esc')
|
||||
show(cancelHint, true)
|
||||
|
||||
recordingStartTime = Date.now()
|
||||
durationText.textContent = '0:00'
|
||||
audioLevel = 0
|
||||
currentHeights.fill(MIN_HEIGHT)
|
||||
for (var i = 0; i < bars.length; i++) bars[i].style.height = MIN_HEIGHT + 'px'
|
||||
|
||||
// 이전 세션의 부분 전사 리셋
|
||||
partialText.textContent = ''
|
||||
partialText.classList.add('hidden')
|
||||
show(partialText, false)
|
||||
partialText.classList.remove('dim')
|
||||
|
||||
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)
|
||||
stopTimers()
|
||||
setState('thinking')
|
||||
show(headRow, true)
|
||||
show(durationText, false)
|
||||
// 막대는 바닥으로 — 더 이상 듣지 않는다
|
||||
for (var i = 0; i < bars.length; i++) bars[i].style.height = MIN_HEIGHT + 'px'
|
||||
statusText.textContent = tr('tipProcessing')
|
||||
statusText.removeAttribute('aria-label')
|
||||
cancelHint.textContent = tr('tipCancelHint', 'Esc')
|
||||
show(cancelHint, true)
|
||||
show(progress, true)
|
||||
show(doneView, false)
|
||||
show(errorView, false)
|
||||
// 받아쓰던 문장은 남긴다(흐리게)
|
||||
if (partialText.textContent) partialText.classList.add('dim')
|
||||
}
|
||||
|
||||
function showDone() {
|
||||
stopTimers()
|
||||
setState('done')
|
||||
show(headRow, false)
|
||||
show(progress, false)
|
||||
show(partialText, false)
|
||||
show(errorView, false)
|
||||
doneText.textContent = tr('tipInserted')
|
||||
show(doneView, true)
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
currentState = 'error'
|
||||
hideAllViews()
|
||||
errorView.classList.remove('hidden')
|
||||
errorText.textContent = message || i18nStrings.errorDefault || '오류가 발생했습니다'
|
||||
stopTimers()
|
||||
setState('error')
|
||||
show(headRow, false)
|
||||
show(progress, false)
|
||||
show(partialText, false)
|
||||
show(doneView, false)
|
||||
errorText.textContent = message || tr('errorDefault')
|
||||
show(errorView, true)
|
||||
}
|
||||
|
||||
// ── 크기 측정 (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
|
||||
})
|
||||
})
|
||||
})
|
||||
function applyState(data) {
|
||||
if (data && data._i18n) i18n = data._i18n
|
||||
var state = data && data.state
|
||||
if (state === 'recording') showRecording()
|
||||
else if (state === 'thinking') showThinking()
|
||||
else if (state === 'done') showDone()
|
||||
else if (state === 'error') showError(data.errorMessage)
|
||||
container.style.opacity = '1'
|
||||
}
|
||||
|
||||
// ── IPC 리스너 ───────────────────────────────────────
|
||||
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()
|
||||
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
|
||||
audioLevel = (data && data.level) || 0
|
||||
})
|
||||
|
||||
// 실시간 부분 전사 — 녹음 중 말하는 내용 미리보기
|
||||
// 실시간 부분 전사 — 녹음 중 말하는 내용 미리보기. 최근 문장이 보이도록 아래쪽을 남긴다(CSS: flex-end).
|
||||
window.popupAPI.on('voice:partialTranscript', function (data) {
|
||||
if (currentState !== 'recording') return
|
||||
var text = (data && data.text) || ''
|
||||
if (text.length === 0) return
|
||||
partialText.textContent = text
|
||||
partialText.classList.remove('hidden')
|
||||
// 항상 끝부분(최근 발화)이 보이도록 스크롤
|
||||
partialText.scrollTop = partialText.scrollHeight
|
||||
show(partialText, true)
|
||||
})
|
||||
|
||||
// 상태 변경 (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()
|
||||
else if (state === 'error') showError(data.errorMessage)
|
||||
// 항상 가시성 보장
|
||||
container.style.opacity = '1'
|
||||
})
|
||||
window.popupAPI.on('window:tipStateChanged', applyState)
|
||||
|
||||
// 클릭 시 녹음 취소
|
||||
// 클릭 취소는 녹음 중에만 — 처리 중 클릭이 받아쓴 글을 버리던 사고 방지
|
||||
container.addEventListener('click', function () {
|
||||
if (currentState !== 'recording') return
|
||||
window.popupAPI.send('voice:cancelRecording', {})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 초기화 ───────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
createWaveBars()
|
||||
setupListeners()
|
||||
|
|
|
|||
|
|
@ -1,40 +1,8 @@
|
|||
/* recording-tip/style.css
|
||||
* Precision Hardware Voice Capsule HUD (Push-To-Talk Telemetry)
|
||||
* Pure CSS Custom Properties with Midnight fallback
|
||||
*/
|
||||
/* recording-tip/style.css — v4 (2026-09-28)
|
||||
* 커서 곁 녹음 캡슐. 다른 팝업과 같은 판(.d3-panel)·같은 폰트. 막대는 액센트 한 색.
|
||||
* 반투명·블러·다색 파형·알약(999px) 모양을 버렸다 — 팝업 6종이 한 앱처럼 보이게. */
|
||||
|
||||
:root {
|
||||
--d3-bg-tip: rgba(15, 17, 22, 0.88);
|
||||
--d3-text-primary: #f4f4f5;
|
||||
--d3-text-secondary: #a1a1aa;
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-border-default: rgba(255, 255, 255, 0.08);
|
||||
--d3-border-strong: rgba(255, 255, 255, 0.16);
|
||||
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
--d3-wave-1: #93c5fd;
|
||||
--d3-wave-2: #60a5fa;
|
||||
--d3-wave-3: #4f8dfa;
|
||||
--d3-wave-4: #3b82f6;
|
||||
--d3-wave-5: #3b82f6;
|
||||
--d3-wave-6: #5b75f7;
|
||||
--d3-wave-7: #6e71f7;
|
||||
--d3-wave-8: #818cf8;
|
||||
--d3-wave-9: #a78bfa;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
|
||||
}
|
||||
@import '../_shared/popup-base.css';
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
|
|
@ -45,135 +13,157 @@ body {
|
|||
}
|
||||
|
||||
.recording-tip {
|
||||
background: var(--d3-bg-tip);
|
||||
border: 1px solid var(--d3-border-default);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 168px;
|
||||
max-width: 300px;
|
||||
padding: 8px 12px;
|
||||
cursor: default;
|
||||
transition: border-color 160ms var(--popup-ease);
|
||||
}
|
||||
|
||||
.recording-tip[data-state='recording'] {
|
||||
cursor: pointer; /* 녹음 중에만 눌러서 취소 */
|
||||
border-color: var(--d3-border-strong);
|
||||
}
|
||||
|
||||
.head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
transition: all 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wave-bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 28px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 3px;
|
||||
min-height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--d3-accent-main);
|
||||
border-radius: 999px;
|
||||
transition: height 80ms ease-out;
|
||||
min-height: 3px;
|
||||
}
|
||||
|
||||
.wave-bar:nth-child(1) { background: var(--d3-wave-1); opacity: 0.85; }
|
||||
.wave-bar:nth-child(2) { background: var(--d3-wave-2); opacity: 0.9; }
|
||||
.wave-bar:nth-child(3) { background: var(--d3-wave-3); opacity: 0.95; }
|
||||
.wave-bar:nth-child(4) { background: var(--d3-wave-4); opacity: 1; }
|
||||
.wave-bar:nth-child(5) { background: var(--d3-wave-5); opacity: 1; }
|
||||
.wave-bar:nth-child(6) { background: var(--d3-wave-6); opacity: 1; }
|
||||
.wave-bar:nth-child(7) { background: var(--d3-wave-7); opacity: 0.95; }
|
||||
.wave-bar:nth-child(8) { background: var(--d3-wave-8); opacity: 0.9; }
|
||||
.wave-bar:nth-child(9) { background: var(--d3-wave-9); opacity: 0.85; }
|
||||
.recording-tip[data-state='thinking'] .wave-bar {
|
||||
background: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--d3-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
min-width: 34px;
|
||||
font-weight: 500;
|
||||
min-width: 32px;
|
||||
color: var(--d3-text-primary);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
.status-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--d3-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cancel-hint {
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
padding: 0 5px;
|
||||
color: var(--d3-text-tertiary);
|
||||
font-family: var(--popup-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 처리 중 — 꾸며낸 퍼센트 대신 무한 막대 */
|
||||
.progress {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--d3-border-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 36%;
|
||||
border-radius: 1px;
|
||||
background: var(--d3-accent-main);
|
||||
border-radius: 999px;
|
||||
width: 0%;
|
||||
transition: width 100ms linear;
|
||||
animation: tip-indeterminate 1.1s var(--popup-ease) infinite;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
color: var(--d3-text-secondary);
|
||||
@keyframes tip-indeterminate {
|
||||
from { left: -36%; }
|
||||
to { left: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-bar {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 부분 전사 — 최근 두 줄이 보이도록 아래쪽을 남긴다 */
|
||||
.partial-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--d3-border-subtle);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: var(--d3-text-primary);
|
||||
text-align: left;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.partial-text.dim {
|
||||
color: var(--d3-text-tertiary);
|
||||
}
|
||||
|
||||
.line-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.done-icon {
|
||||
color: var(--d3-status-success);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--d3-status-danger);
|
||||
color: var(--d3-status-danger);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: var(--d3-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.view.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── 실시간 부분 전사 (recording 상태) ─────────────── */
|
||||
.view-recording {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.recording-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.partial-text {
|
||||
color: var(--d3-text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
border-top: 1px solid var(--d3-border-default);
|
||||
padding-top: 6px;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.partial-text.hidden {
|
||||
display: none;
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,27 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="result-popup">
|
||||
<!-- 자동 입력이 실패했을 때만 뜬다 — 왜 떴는지 제목이 말한다 -->
|
||||
<div id="container" class="result-popup d3-panel" role="alertdialog" aria-labelledby="result-title" aria-describedby="result-text">
|
||||
<div class="result-head">
|
||||
<span id="result-title" class="result-title" data-i18n="resultTitle"></span>
|
||||
<button id="close-btn" class="icon-btn" type="button" data-i18n-label="close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="result-text" class="result-text"></div>
|
||||
<div id="actions" class="actions">
|
||||
<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">
|
||||
<div class="result-actions">
|
||||
<button id="copy-btn" class="copy-btn" type="button">
|
||||
<svg id="copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<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">
|
||||
<svg id="check-icon" class="hidden" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
<span id="copy-label"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// ResultPopup 팝업 스크립트
|
||||
// 설계서 03: 2-phase 리사이즈, auto-close, 마우스 호버 시 유지
|
||||
// ResultPopup 팝업 스크립트 — v4 (2026-09-28)
|
||||
// 자동 입력이 실패했을 때 받아쓴 글을 보여 주고 복사하게 한다. 2-phase 리사이즈(설계서 03)·자동 닫힘·호버 시 유지.
|
||||
// v4: 왜 떴는지 제목, 글자 있는 복사 버튼, 닫기 버튼. 문구는 main 의 _i18n.
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
|
@ -9,30 +10,37 @@
|
|||
var copyBtn = document.getElementById('copy-btn')
|
||||
var copyIcon = document.getElementById('copy-icon')
|
||||
var checkIcon = document.getElementById('check-icon')
|
||||
var i18nStrings = {}
|
||||
var copyLabel = document.getElementById('copy-label')
|
||||
var closeBtn = document.getElementById('close-btn')
|
||||
var i18n = {}
|
||||
|
||||
var autoCloseTimer = null
|
||||
var copiedTimer = 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])
|
||||
}
|
||||
})
|
||||
function tr(key) {
|
||||
return (i18n && i18n[key]) || ''
|
||||
}
|
||||
|
||||
function applyI18nToHtml() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
el.textContent = tr(el.getAttribute('data-i18n'))
|
||||
})
|
||||
document.querySelectorAll('[data-i18n-label]').forEach(function (el) {
|
||||
var label = tr(el.getAttribute('data-i18n-label'))
|
||||
el.setAttribute('aria-label', label)
|
||||
el.setAttribute('title', label)
|
||||
})
|
||||
copyLabel.textContent = tr('copy')
|
||||
}
|
||||
|
||||
// ── Auto-close 제어 ─────────────────────────────────
|
||||
function startAutoCloseTimer(ms) {
|
||||
remainingTime = ms
|
||||
lastTick = Date.now()
|
||||
clearInterval(autoCloseTimer)
|
||||
autoCloseTimer = setInterval(function () {
|
||||
remainingTime -= (Date.now() - lastTick)
|
||||
remainingTime -= Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(autoCloseTimer)
|
||||
|
|
@ -51,33 +59,40 @@
|
|||
startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000)
|
||||
}
|
||||
|
||||
// ── 복사 버튼 ───────────────────────────────────────
|
||||
copyBtn.addEventListener('click', function () {
|
||||
// navigator.clipboard는 팝업에서 작동 안 할 수 있으므로 IPC 사용
|
||||
window.popupAPI.send('clipboard:copy', resultText.textContent)
|
||||
function resetCopied() {
|
||||
clearTimeout(copiedTimer)
|
||||
copyBtn.classList.remove('copied')
|
||||
copyIcon.classList.remove('hidden')
|
||||
checkIcon.classList.add('hidden')
|
||||
copyLabel.textContent = tr('copy')
|
||||
}
|
||||
|
||||
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)
|
||||
copyLabel.textContent = tr('copied')
|
||||
clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(resetCopied, 2000)
|
||||
})
|
||||
|
||||
closeBtn.addEventListener('click', function () {
|
||||
pauseAutoClose()
|
||||
window.popupAPI.send('window:hideResultPopup')
|
||||
})
|
||||
|
||||
// ── 마우스 호버 시 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 || ''
|
||||
if (data._i18n) i18nStrings = data._i18n
|
||||
if (data._i18n) i18n = data._i18n
|
||||
applyI18nToHtml()
|
||||
resetCopied()
|
||||
resultText.textContent = data.text || ''
|
||||
resultText.scrollTop = 0
|
||||
container.classList.remove('visible')
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
|
|
@ -85,7 +100,7 @@
|
|||
var rect = container.getBoundingClientRect()
|
||||
window.popupAPI.send('result:measured', {
|
||||
width: Math.ceil(rect.width) + 4,
|
||||
height: Math.ceil(rect.height) + 4
|
||||
height: Math.ceil(rect.height) + 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -95,12 +110,9 @@
|
|||
window.popupAPI.on('result:show', function (data) {
|
||||
container.classList.add('visible')
|
||||
var autoHideMs = (data && data.autoHideMs) || 5000
|
||||
if (autoHideMs > 0) {
|
||||
startAutoCloseTimer(autoHideMs)
|
||||
}
|
||||
if (autoHideMs > 0) startAutoCloseTimer(autoHideMs)
|
||||
})
|
||||
|
||||
// hide
|
||||
window.popupAPI.on('result:hide', function () {
|
||||
container.classList.remove('visible')
|
||||
clearInterval(autoCloseTimer)
|
||||
|
|
|
|||
|
|
@ -1,107 +1,116 @@
|
|||
/* result-popup/style.css
|
||||
* Precision Result Card HUD with Spring Physics
|
||||
*/
|
||||
/* result-popup/style.css — v4 (2026-09-28)
|
||||
* 입력 실패 시 받아쓴 글. 긴 글은 높이를 제한하고 스크롤(이전: 높이 상한 없음 → 화면보다 커질 수 있었다). */
|
||||
|
||||
:root {
|
||||
--d3-bg-result: rgba(15, 17, 22, 0.9);
|
||||
--d3-border-result: rgba(255, 255, 255, 0.08);
|
||||
--d3-text-result: #f4f4f5;
|
||||
--d3-action-btn: rgba(255, 255, 255, 0.65);
|
||||
--d3-action-btn-hover-bg: rgba(255, 255, 255, 0.1);
|
||||
--d3-action-btn-hover: #ffffff;
|
||||
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
--d3-status-success: #10b981;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
|
||||
}
|
||||
@import '../_shared/popup-base.css';
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.result-popup {
|
||||
background: var(--d3-bg-result);
|
||||
border: 1px solid var(--d3-border-result);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
min-width: 240px;
|
||||
max-width: 400px;
|
||||
padding: 10px 12px 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 140ms var(--popup-ease), transform 140ms var(--popup-ease);
|
||||
}
|
||||
|
||||
.result-popup.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.result-text {
|
||||
flex: 1;
|
||||
color: var(--d3-text-result);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
.result-head {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
.result-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--d3-text-secondary);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
color: var(--d3-action-btn);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--d3-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--d3-action-btn-hover-bg);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--d3-action-btn-hover);
|
||||
transform: translateY(-1px);
|
||||
.icon-btn:hover {
|
||||
background: var(--d3-overlay-soft);
|
||||
color: var(--d3-text-primary);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0) scale(0.96);
|
||||
.result-text {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--d3-text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.action-btn.copied {
|
||||
.result-text::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--d3-accent-solid, var(--d3-accent-main));
|
||||
color: var(--d3-accent-ink, #fff);
|
||||
font-family: var(--popup-font);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms var(--popup-ease);
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--d3-accent-solidHover, var(--d3-accent-main));
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: transparent;
|
||||
color: var(--d3-status-success);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
box-shadow: inset 0 0 0 1px var(--d3-border-strong);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--d3-accent-main);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,15 @@
|
|||
|
||||
function applyPayload(payload) {
|
||||
if (!payload) return
|
||||
if (payload._i18n) i18nStrings = payload._i18n
|
||||
if (payload._i18n) {
|
||||
i18nStrings = payload._i18n
|
||||
// 닫기 버튼은 아이콘뿐이라 이름을 붙인다(2026-09-28 감사: 스크린리더가 읽을 이름이 없었다)
|
||||
var closeButton = document.getElementById('close')
|
||||
if (closeButton && i18nStrings.close) {
|
||||
closeButton.setAttribute('aria-label', i18nStrings.close)
|
||||
closeButton.setAttribute('title', i18nStrings.close)
|
||||
}
|
||||
}
|
||||
if (payload._keyHints) keyHints = payload._keyHints
|
||||
candidates = (payload.candidates || []).map(function (candidate) {
|
||||
return candidate && candidate.text ? candidate.text : String(candidate)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@
|
|||
* 그 파일도 함께 고친다. 각 줄은 내용과 무관하게 높이를 고정한다.
|
||||
*/
|
||||
|
||||
@import '../_shared/popup-base.css';
|
||||
|
||||
/* 공통 바탕의 body 글자 크기·줄 높이가 고정 치수 계산을 흔들지 않도록 이 오버레이는 브라우저 기본값을 유지한다 */
|
||||
body {
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--d3-bg-result: #111a30;
|
||||
--d3-border-result: rgba(148, 180, 255, 0.12);
|
||||
|
|
@ -36,7 +44,7 @@ body {
|
|||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: var(--popup-font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +224,7 @@ body {
|
|||
padding: 6px 8px 2px;
|
||||
border-top: 1px solid var(--d3-border-result);
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 10.5px;
|
||||
font-size: 11px; /* 11px 미만 금지(v4) — 줄 높이 14px 고정이라 창 크기 계산은 그대로 */
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +295,7 @@ kbd {
|
|||
border-radius: 4px;
|
||||
color: var(--d3-text-result);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface FakeWebContents {
|
|||
send: (channel: string, data: unknown) => void
|
||||
isDestroyed: () => boolean
|
||||
insertCSS: () => Promise<string>
|
||||
executeJavaScript: () => Promise<unknown>
|
||||
invalidate: () => void
|
||||
setWindowOpenHandler: () => void
|
||||
}
|
||||
|
|
@ -56,6 +57,7 @@ vi.mock('electron', () => {
|
|||
},
|
||||
isDestroyed: () => false,
|
||||
insertCSS: async () => '',
|
||||
executeJavaScript: async () => undefined,
|
||||
invalidate: () => undefined,
|
||||
setWindowOpenHandler: () => undefined
|
||||
}
|
||||
|
|
@ -91,6 +93,8 @@ vi.mock('electron', () => {
|
|||
|
||||
const scaled = (value: number): number => Math.round(value / state.scale)
|
||||
return {
|
||||
// 팝업 테마: 시스템 테마 해석(2026-09-28)
|
||||
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
|
||||
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
|
||||
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
|
||||
screen: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
// redteam r3-4 회귀
|
||||
// 1) 녹음 phase 전에 끝난 세션의 오류(STT 모델 미설치·쿼터·자막 사용 중)도 RecordingTip 을 띄워 보인다.
|
||||
// 예전엔 showError 가 상태 IPC 만 보내 숨겨진 창에 그려졌고 사용자에겐 오류음만 남았다.
|
||||
// 2) macOS 에서 애플리케이션 메뉴를 null 로 지우지 않는다(Cmd+C/V/X/A/Z/Q 는 role 메뉴로만 동작).
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { getI18n } from '@d3ro/i18n'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
type Listener = (...args: unknown[]) => void
|
||||
|
||||
interface FakeWindow {
|
||||
options: Record<string, unknown>
|
||||
visible: boolean
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }>
|
||||
sent: Array<{ channel: string; data: unknown }>
|
||||
contentsListeners: Map<string, Listener[]>
|
||||
showInactive: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
windows: [] as FakeWindow[],
|
||||
setApplicationMenu: vi.fn(),
|
||||
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
|
||||
}))
|
||||
|
||||
vi.unmock('../../../src/main/windows/WindowManager')
|
||||
|
||||
vi.mock('electron', () => {
|
||||
class FakeBrowserWindow {
|
||||
options: Record<string, unknown>
|
||||
visible = false
|
||||
destroyed = false
|
||||
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
|
||||
sent: Array<{ channel: string; data: unknown }> = []
|
||||
listeners = new Map<string, Listener>()
|
||||
contentsListeners = new Map<string, Listener[]>()
|
||||
webContents = {
|
||||
on: (event: string, listener: Listener) => {
|
||||
const list = this.contentsListeners.get(event) ?? []
|
||||
list.push(listener)
|
||||
this.contentsListeners.set(event, list)
|
||||
},
|
||||
send: (channel: string, data: unknown) => {
|
||||
this.sent.push({ channel, data })
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
isLoading: () => false,
|
||||
insertCSS: vi.fn(async () => ''),
|
||||
executeJavaScript: async () => undefined,
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
}
|
||||
showInactive = vi.fn(() => {
|
||||
this.visible = true
|
||||
})
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
state.windows.push(this as unknown as FakeWindow)
|
||||
}
|
||||
on(event: string, listener: Listener): this {
|
||||
this.listeners.set(event, listener)
|
||||
return this
|
||||
}
|
||||
once(event: string, listener: Listener): this {
|
||||
return this.on(event, listener)
|
||||
}
|
||||
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
|
||||
this.bounds.push(bounds)
|
||||
}
|
||||
isVisible(): boolean {
|
||||
return this.visible
|
||||
}
|
||||
isDestroyed(): boolean {
|
||||
return this.destroyed
|
||||
}
|
||||
hide(): void {
|
||||
this.visible = false
|
||||
}
|
||||
show(): void {
|
||||
this.visible = true
|
||||
}
|
||||
setAlwaysOnTop = vi.fn()
|
||||
moveTop = vi.fn()
|
||||
setSkipTaskbar = vi.fn()
|
||||
setMenu = vi.fn()
|
||||
center = vi.fn()
|
||||
restore = vi.fn()
|
||||
focus = vi.fn()
|
||||
flashFrame = vi.fn()
|
||||
isMinimized = vi.fn(() => false)
|
||||
loadURL = vi.fn(async () => undefined)
|
||||
loadFile = vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
// 팝업 테마: 시스템 테마 해석(2026-09-28)
|
||||
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
|
||||
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
|
||||
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
|
||||
screen: {
|
||||
getPrimaryDisplay: vi.fn(() => ({
|
||||
workAreaSize: { width: 1920, height: 1080 },
|
||||
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
})),
|
||||
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]),
|
||||
getCursorScreenPoint: vi.fn(() => ({ x: 500, y: 500 })),
|
||||
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
|
||||
screenToDipRect: vi.fn((_win: unknown, rect: unknown) => rect),
|
||||
},
|
||||
ipcMain: { on: vi.fn(), once: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
|
||||
Menu: {
|
||||
setApplicationMenu: (...args: unknown[]) => state.setApplicationMenu(...args),
|
||||
buildFromTemplate: (...args: unknown[]) => state.buildFromTemplate(...args),
|
||||
},
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: vi.fn(async () => undefined) },
|
||||
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
|
||||
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
|
||||
Tray: vi.fn(),
|
||||
dialog: { showErrorBox: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
|
||||
|
||||
const config: Record<string, unknown> = { theme: 'dark', language: 'ko', closeToTray: true }
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: (key: string) => config[key],
|
||||
configSet: (key: string, value: unknown) => {
|
||||
config[key] = value
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/lifecycle', () => ({
|
||||
getIsQuitting: () => false,
|
||||
setIsQuitting: () => undefined,
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
state.windows.length = 0
|
||||
state.setApplicationMenu.mockClear()
|
||||
state.buildFromTemplate.mockClear()
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
})
|
||||
|
||||
function tipStates(win: FakeWindow): Array<{ state?: string; errorMessage?: string }> {
|
||||
for (const listener of win.contentsListeners.get('did-finish-load') ?? []) listener()
|
||||
return win.sent
|
||||
.filter((m) => m.channel === IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED)
|
||||
.map((m) => m.data as { state?: string; errorMessage?: string })
|
||||
}
|
||||
|
||||
describe('RecordingTip 오류 표시 — recording phase 전에 난 오류도 보인다', () => {
|
||||
it('phase 없이 onError → 숨겨진 팁 창을 띄우고 오류 문구를 보낸다', async () => {
|
||||
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
|
||||
'../../../src/main/windows/recording-tip-presenter'
|
||||
)
|
||||
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
|
||||
|
||||
presenter.onSessionStarted()
|
||||
presenter.onError('Cannot start dictation: caption mode active')
|
||||
|
||||
expect(state.windows).toHaveLength(1)
|
||||
const tip = state.windows[0]
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tip.showInactive).toHaveBeenCalledTimes(1)
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({
|
||||
state: 'error',
|
||||
errorMessage: 'Cannot start dictation: caption mode active',
|
||||
})
|
||||
presenter.dispose()
|
||||
})
|
||||
|
||||
it('STT 모델 미설치 코드는 사용자 언어 문구로 바꿔 그린다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
|
||||
'../../../src/main/windows/recording-tip-presenter'
|
||||
)
|
||||
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
|
||||
presenter.onError('STT model not installed: base', 'error', ErrorCode.STTModelNotFound)
|
||||
|
||||
const tip = wm.getRecordingTipWindow() as unknown as FakeWindow
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({
|
||||
state: 'error',
|
||||
errorMessage: getI18n('ko').t('voice.error.modelMissing'),
|
||||
})
|
||||
presenter.dispose()
|
||||
})
|
||||
|
||||
it('녹음 도중 난 오류는 떠 있는 팁 자리를 옮기지 않는다', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showRecordingTip('recording')
|
||||
const tip = state.windows[0]
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
|
||||
wm.showRecordingTipError('boom')
|
||||
|
||||
expect(tip.bounds).toHaveLength(1)
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tipStates(tip).map((m) => m.state)).toEqual(['recording', 'error'])
|
||||
})
|
||||
|
||||
it('숨겨진 팁은 오류 때 다시 뜬다(이전 세션이 숨긴 뒤)', async () => {
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.showRecordingTip('recording')
|
||||
wm.hideRecordingTip()
|
||||
const tip = state.windows[0]
|
||||
expect(tip.visible).toBe(false)
|
||||
|
||||
wm.showRecordingTipError('boom')
|
||||
|
||||
expect(tip.visible).toBe(true)
|
||||
expect(tip.showInactive).toHaveBeenCalledTimes(2)
|
||||
expect(tipStates(tip).at(-1)).toMatchObject({ state: 'error', errorMessage: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRecordingTipErrorMessage', () => {
|
||||
it('코드별로 번역 문구를 고르고, 모르는 코드는 원문을 그린다', async () => {
|
||||
const { resolveRecordingTipErrorMessage } = await import('../../../src/main/windows/recording-tip-error-message')
|
||||
const { t } = getI18n('ko')
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTModelNotFound, 'raw', t)).toBe(t('voice.error.modelMissing'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.QuotaExceeded, 'raw', t)).toBe(
|
||||
t('license.quotaExceeded.title', { feature: t('license.feature.dictation') }),
|
||||
)
|
||||
// 2026-09-28: 받아쓰기가 실제로 내보내는 코드(자막 충돌·마이크·무음·전사 실패·다듬기 실패)는 전부 사용자 문구로
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.CaptionAlreadyActive, 'raw', t)).toBe(t('voice.error.captionActive'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureStartFailed, 'raw', t)).toBe(t('voice.error.micOpen'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureFailed, 'raw', t)).toBe(t('voice.error.micLost'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTNoAudioData, 'raw', t)).toBe(t('voice.error.noSpeech'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.STTTranscriptionFailed, 'raw', t)).toBe(t('voice.error.transcription'))
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.LLMProcessingFailed, 'raw', t)).toBe(t('voice.error.llmFailed'))
|
||||
// 분류 밖의 코드와 코드 없음은 원문
|
||||
expect(resolveRecordingTipErrorMessage(ErrorCode.TTSVoiceNotFound, 'raw', t)).toBe('raw')
|
||||
expect(resolveRecordingTipErrorMessage(undefined, 'raw', t)).toBe('raw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('애플리케이션 메뉴 — macOS 는 편집/종료 단축키용 role 메뉴를 유지한다', () => {
|
||||
it('darwin 에서 메인 창을 만들면 editMenu 가 든 메뉴를 설치한다(null 아님)', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.createMainWindow()
|
||||
|
||||
expect(state.setApplicationMenu).toHaveBeenCalledTimes(1)
|
||||
const installed = state.setApplicationMenu.mock.calls[0][0] as { template: Array<{ role: string }> } | null
|
||||
expect(installed).not.toBeNull()
|
||||
expect(installed?.template.map((item) => item.role)).toEqual(['appMenu', 'editMenu', 'windowMenu'])
|
||||
})
|
||||
|
||||
it('win32 에서는 지금처럼 메뉴를 제거한다(Alt 키 메뉴 활성화 방지)', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const wm = await import('../../../src/main/windows/WindowManager')
|
||||
wm.createMainWindow()
|
||||
|
||||
expect(state.setApplicationMenu).toHaveBeenCalledWith(null)
|
||||
expect(state.buildFromTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('buildApplicationMenuTemplate 는 darwin 만 템플릿을 준다', async () => {
|
||||
const { buildApplicationMenuTemplate } = await import('../../../src/main/windows/app-menu')
|
||||
expect(buildApplicationMenuTemplate('linux')).toBeNull()
|
||||
expect(buildApplicationMenuTemplate('win32')).toBeNull()
|
||||
expect(buildApplicationMenuTemplate('darwin')?.map((item) => item.role)).toEqual([
|
||||
'appMenu',
|
||||
'editMenu',
|
||||
'windowMenu',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ function fakeView(): RecordingTipView & { calls: string[] } {
|
|||
showRecording: () => calls.push('show'),
|
||||
hide: () => calls.push('hide'),
|
||||
showThinking: () => calls.push('thinking'),
|
||||
showDone: () => calls.push('done'),
|
||||
showError: (message) => calls.push(`error:${message}`),
|
||||
sendAudioLevel: (level) => calls.push(`level:${level}`),
|
||||
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
|
||||
|
|
@ -91,4 +92,24 @@ describe('RecordingTipPresenter', () => {
|
|||
vi.advanceTimersByTime(5000)
|
||||
expect(view.calls).toEqual(['error:x'])
|
||||
})
|
||||
|
||||
it('완료는 "입력함"을 잠깐 보이고 숨기며, 곧바로 시작한 새 세션의 팁은 숨기지 않는다', () => {
|
||||
const view = fakeView()
|
||||
const presenter = new RecordingTipPresenter(view)
|
||||
presenter.onPhase('recording')
|
||||
presenter.onSessionCompleted()
|
||||
vi.advanceTimersByTime(699)
|
||||
expect(view.calls).toEqual(['show', 'done'])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(view.calls).toEqual(['show', 'done', 'hide'])
|
||||
|
||||
const view2 = fakeView()
|
||||
const presenter2 = new RecordingTipPresenter(view2)
|
||||
presenter2.onSessionCompleted()
|
||||
vi.advanceTimersByTime(300)
|
||||
presenter2.onSessionStarted()
|
||||
presenter2.onPhase('recording')
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(view2.calls).toEqual(['done', 'show'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,19 +8,21 @@ import { PauseIcon, PlayIcon } from './icons'
|
|||
* (apps/desktop/src/renderer/popups/recording-tip)을 같은 수치로 재현한다.
|
||||
*
|
||||
* 대기 → 오른쪽 Alt 누른 채 말하기(캡슐·파형·경과 시간·부분 전사)
|
||||
* → 키를 떼면 처리 중(점근 진행 막대) → 커서 자리에 다듬어진 문장 입력 → 다음 예시
|
||||
* → 키를 떼면 받아쓰는 중(무한 진행 막대, 받아쓰던 문장은 흐리게 남김)
|
||||
* → 커서 자리에 다듬어진 문장 입력 + 캡슐 "입력함" 잠깐 → 다음 예시
|
||||
* 2026-09-28 앱 캡슐 v4 에 맞췄다: 판 모양(알약 아님)·단색 막대·입력 없으면 막대 정지·Esc 안내.
|
||||
*
|
||||
* WCAG 2.2.2: 5초 넘게 움직이므로 일시정지 버튼을 둔다. 화면 밖이거나 탭이 숨겨지면 멈춘다.
|
||||
* 감소 모션이면 반복하지 않고 한 장면(말한 내용과 입력 결과)을 정지 화면으로 보여 준다.
|
||||
*/
|
||||
|
||||
type Phase = 'idle' | 'listening' | 'thinking' | 'typed'
|
||||
type Phase = 'idle' | 'listening' | 'thinking' | 'done' | 'typed'
|
||||
|
||||
// 앱 recording-tip/script.js 와 같은 값
|
||||
const BAR_COUNT = 9
|
||||
const BAR_INTERVAL_MS = 100
|
||||
const BAR_MIN = 2
|
||||
const BAR_MAX = 28
|
||||
const BAR_MAX = 22
|
||||
const SMOOTHING = 0.5
|
||||
const RANDOM_FACTOR = 0.35
|
||||
const BAR_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
|
|
@ -33,7 +35,9 @@ const IDLE_MS = 1400
|
|||
const WORD_MS = 260
|
||||
const LISTEN_TAIL_MS = 700
|
||||
const THINK_MS = 1100
|
||||
const TYPED_MS = 3200
|
||||
/** 앱 recording-tip-presenter 의 doneHideMs 와 같다 */
|
||||
const DONE_MS = 700
|
||||
const TYPED_MS = 2500
|
||||
|
||||
/**
|
||||
* 부분 전사가 나타나는 단위. 띄어 쓰는 언어는 낱말(뒤 공백 포함), 띄어 쓰지 않는
|
||||
|
|
@ -65,7 +69,6 @@ export function AppLoopDemo() {
|
|||
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const barRefs = useRef<(HTMLSpanElement | null)[]>([])
|
||||
const progressRef = useRef<HTMLSpanElement>(null)
|
||||
const timers = useRef<number[]>([])
|
||||
|
||||
const running = !reducedMotion && !userPaused && !offscreen
|
||||
|
|
@ -113,7 +116,8 @@ export function AppLoopDemo() {
|
|||
const listenStart = IDLE_MS
|
||||
const listenEnd = listenStart + words.length * WORD_MS + LISTEN_TAIL_MS
|
||||
const thinkEnd = listenEnd + THINK_MS
|
||||
const loopEnd = thinkEnd + TYPED_MS
|
||||
const doneEnd = thinkEnd + DONE_MS
|
||||
const loopEnd = doneEnd + TYPED_MS
|
||||
|
||||
schedule(() => setPhase('listening'), listenStart)
|
||||
words.forEach((_, i) => schedule(() => setWordCount(i + 1), listenStart + (i + 1) * WORD_MS))
|
||||
|
|
@ -121,7 +125,8 @@ export function AppLoopDemo() {
|
|||
schedule(() => setElapsed(s), listenStart + s)
|
||||
}
|
||||
schedule(() => setPhase('thinking'), listenEnd)
|
||||
schedule(() => setPhase('typed'), thinkEnd)
|
||||
schedule(() => setPhase('done'), thinkEnd)
|
||||
schedule(() => setPhase('typed'), doneEnd)
|
||||
schedule(() => setSampleIndex((i) => i + 1), loopEnd)
|
||||
|
||||
return clearTimers
|
||||
|
|
@ -135,7 +140,8 @@ export function AppLoopDemo() {
|
|||
const paint = (level: number, jitter: boolean) => {
|
||||
bars.forEach((bar, i) => {
|
||||
if (!bar) return
|
||||
const base = Math.max(0.08, level) * BAR_MAX * BAR_WEIGHTS[i]
|
||||
// 앱과 같이 입력이 0이면 바닥 — "살아있는 척" 최소 흔들림 없음
|
||||
const base = level * BAR_MAX * BAR_WEIGHTS[i]
|
||||
const randomized = jitter ? base * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) : base
|
||||
const target = Math.max(BAR_MIN, Math.min(BAR_MAX, randomized))
|
||||
heights[i] = jitter ? heights[i] + (target - heights[i]) * SMOOTHING : target
|
||||
|
|
@ -144,7 +150,7 @@ export function AppLoopDemo() {
|
|||
}
|
||||
|
||||
if (!running || phase !== 'listening') {
|
||||
paint(reducedMotion ? 0.7 : 0.08, false)
|
||||
paint(reducedMotion ? 0.7 : 0, false)
|
||||
return
|
||||
}
|
||||
let tick = 0
|
||||
|
|
@ -157,21 +163,6 @@ export function AppLoopDemo() {
|
|||
return () => window.clearInterval(id)
|
||||
}, [phase, running, reducedMotion])
|
||||
|
||||
// 처리 중 진행 막대: min(95, (1 - 1/(1 + 1.5t)) * 100)%
|
||||
useEffect(() => {
|
||||
const bar = progressRef.current
|
||||
if (!bar || phase !== 'thinking') return
|
||||
const start = performance.now()
|
||||
let raf = 0
|
||||
const step = () => {
|
||||
const sec = (performance.now() - start) / 1000
|
||||
bar.style.width = `${Math.min(95, (1 - 1 / (1 + 1.5 * sec)) * 100)}%`
|
||||
if (running) raf = requestAnimationFrame(step)
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [phase, running])
|
||||
|
||||
// 감소 모션: 한 장면을 정지 화면으로.
|
||||
const shownPhase: Phase = reducedMotion ? 'typed' : phase
|
||||
const capsulePhase: Phase = reducedMotion ? 'listening' : phase
|
||||
|
|
@ -182,6 +173,7 @@ export function AppLoopDemo() {
|
|||
idle: t.demo.stateIdle,
|
||||
listening: t.demo.stateListening,
|
||||
thinking: t.demo.statePolishing,
|
||||
done: t.demo.stateDone,
|
||||
typed: t.demo.stateDone,
|
||||
}[reducedMotion ? 'typed' : phase]
|
||||
|
||||
|
|
@ -204,31 +196,39 @@ export function AppLoopDemo() {
|
|||
<div className="demo-doc keep-ko">
|
||||
<p className="text-neutral-400">{t.demo.docLine}</p>
|
||||
<p className="mt-3 text-neutral-50">
|
||||
{shownPhase === 'typed' && <span className="demo-typed">{sample.clean}</span>}
|
||||
{(shownPhase === 'typed' || shownPhase === 'done') && <span className="demo-typed">{sample.clean}</span>}
|
||||
<span className="demo-caret" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 녹음 캡슐 — 앱과 같은 자리(화면 아래 가운데)에 뜬다 */}
|
||||
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' ? 'is-visible' : ''}`}>
|
||||
<div className="capsule">
|
||||
{capsulePhase === 'thinking' ? (
|
||||
<div className="capsule-row">
|
||||
<span className="capsule-progress"><span ref={progressRef} className="capsule-progress-bar" /></span>
|
||||
<span className="capsule-thinking">{t.demo.processing}</span>
|
||||
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' || capsulePhase === 'done' ? 'is-visible' : ''}`}>
|
||||
<div className={`capsule is-${capsulePhase}`}>
|
||||
{capsulePhase === 'done' ? (
|
||||
<div className="capsule-row capsule-done">
|
||||
<span className="capsule-done-icon">✓</span>
|
||||
<span>{t.demo.inserted}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="capsule-recording">
|
||||
<div className="capsule-row justify-center">
|
||||
<>
|
||||
<div className="capsule-row">
|
||||
<span className="capsule-bars">
|
||||
{BAR_WEIGHTS.map((_, i) => (
|
||||
<span key={i} ref={(el) => { barRefs.current[i] = el }} className="capsule-bar" />
|
||||
))}
|
||||
</span>
|
||||
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
|
||||
{capsulePhase === 'thinking' ? (
|
||||
<span className="capsule-thinking">{t.demo.processing}</span>
|
||||
) : (
|
||||
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
|
||||
)}
|
||||
<kbd className="capsule-cancel">{t.demo.cancelHint}</kbd>
|
||||
</div>
|
||||
{capsulePhase === 'thinking' && (
|
||||
<span className="capsule-progress"><span className="capsule-progress-bar" /></span>
|
||||
)}
|
||||
{partial && <p className="capsule-partial keep-ko">{partial}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export interface Translations {
|
|||
docLine: string
|
||||
/** 캡슐의 처리 중 문구 */
|
||||
processing: string
|
||||
/** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */
|
||||
inserted: string
|
||||
/** 캡슐 오른쪽 키 안내 */
|
||||
cancelHint: string
|
||||
stateIdle: string
|
||||
stateListening: string
|
||||
statePolishing: string
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const de: Translations = {
|
|||
note: 'Ein vorbereitetes Beispiel, keine echte Aufnahme.',
|
||||
windowTitle: 'Notizen',
|
||||
docLine: 'Notizen zum Team-Meeting am Freitag',
|
||||
processing: 'Wird verarbeitet...',
|
||||
processing: 'Wird transkribiert',
|
||||
inserted: 'Eingefügt',
|
||||
cancelHint: 'Esc zum Abbrechen',
|
||||
stateIdle: 'Wartet',
|
||||
stateListening: 'Gedrückt halten und sprechen',
|
||||
statePolishing: 'Losgelassen, wird bereinigt',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const en: Translations = {
|
|||
note: 'A scripted example, not a real recording.',
|
||||
windowTitle: 'Notes',
|
||||
docLine: 'Friday team meeting notes',
|
||||
processing: 'Processing...',
|
||||
processing: 'Transcribing',
|
||||
inserted: 'Inserted',
|
||||
cancelHint: 'Esc to cancel',
|
||||
stateIdle: 'Waiting',
|
||||
stateListening: 'Holding and speaking',
|
||||
statePolishing: 'Released, cleaning up',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const es: Translations = {
|
|||
note: 'Un ejemplo guionado, no una grabación real.',
|
||||
windowTitle: 'Notas',
|
||||
docLine: 'Notas de la reunión de equipo del viernes',
|
||||
processing: 'Procesando...',
|
||||
processing: 'Transcribiendo',
|
||||
inserted: 'Insertado',
|
||||
cancelHint: 'Esc para cancelar',
|
||||
stateIdle: 'Esperando',
|
||||
stateListening: 'Manteniendo presionado y hablando',
|
||||
statePolishing: 'Al soltar, limpiando',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const fr: Translations = {
|
|||
note: 'Un exemple scénarisé, pas un véritable enregistrement.',
|
||||
windowTitle: 'Notes',
|
||||
docLine: 'Notes de la réunion d\'équipe du vendredi',
|
||||
processing: 'Traitement en cours...',
|
||||
processing: 'Transcription',
|
||||
inserted: 'Inséré',
|
||||
cancelHint: 'Échap pour annuler',
|
||||
stateIdle: 'En attente',
|
||||
stateListening: 'Maintien et parole',
|
||||
statePolishing: 'Relâché, mise en forme',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ja: Translations = {
|
|||
note: '台本による例であり、実際の録音ではありません。',
|
||||
windowTitle: 'メモ',
|
||||
docLine: '金曜日のチーム会議メモ',
|
||||
processing: '処理中...',
|
||||
processing: '文字起こし中',
|
||||
inserted: '入力しました',
|
||||
cancelHint: 'Esc で取消',
|
||||
stateIdle: '待機中',
|
||||
stateListening: '押しながら話している',
|
||||
statePolishing: '離すと整形',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ko: Translations = {
|
|||
note: '실제 녹음이 아닌 예시 화면입니다.',
|
||||
windowTitle: '메모',
|
||||
docLine: '금요일 팀 회의 메모',
|
||||
processing: '다듬는 중...',
|
||||
processing: '받아쓰는 중',
|
||||
inserted: '입력함',
|
||||
cancelHint: 'Esc 취소',
|
||||
stateIdle: '기다리는 중',
|
||||
stateListening: '듣는 중',
|
||||
statePolishing: '다듬는 중',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const pt: Translations = {
|
|||
note: 'Um exemplo roteirizado, não uma gravação real.',
|
||||
windowTitle: 'Notas',
|
||||
docLine: 'Notas da reunião de equipe de sexta-feira',
|
||||
processing: 'Processando...',
|
||||
processing: 'Transcrevendo',
|
||||
inserted: 'Inserido',
|
||||
cancelHint: 'Esc para cancelar',
|
||||
stateIdle: 'Aguardando',
|
||||
stateListening: 'Segurando e falando',
|
||||
statePolishing: 'Solto, corrigindo',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ru: Translations = {
|
|||
note: 'Заранее подготовленный пример, а не настоящая запись.',
|
||||
windowTitle: 'Заметки',
|
||||
docLine: 'Заметки с пятничной встречи команды',
|
||||
processing: 'Обработка...',
|
||||
processing: 'Расшифровка',
|
||||
inserted: 'Вставлено',
|
||||
cancelHint: 'Esc — отмена',
|
||||
stateIdle: 'Ожидание',
|
||||
stateListening: 'Зажато, говорю',
|
||||
statePolishing: 'Отпущено, идёт очистка',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const vi: Translations = {
|
|||
note: 'Đây là ví dụ dựng sẵn, không phải bản ghi âm thật.',
|
||||
windowTitle: 'Ghi chú',
|
||||
docLine: 'Ghi chú họp nhóm thứ Sáu',
|
||||
processing: 'Đang xử lý...',
|
||||
processing: 'Đang chép lời',
|
||||
inserted: 'Đã chèn',
|
||||
cancelHint: 'Esc để hủy',
|
||||
stateIdle: 'Đang chờ',
|
||||
stateListening: 'Đang giữ và nói',
|
||||
statePolishing: 'Đã thả, đang làm sạch',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const zh: Translations = {
|
|||
note: '这是脚本示例,并非真实录音。',
|
||||
windowTitle: '备忘录',
|
||||
docLine: '周五团队会议记录',
|
||||
processing: '处理中...',
|
||||
processing: '正在转写',
|
||||
inserted: '已输入',
|
||||
cancelHint: 'Esc 取消',
|
||||
stateIdle: '等待中',
|
||||
stateListening: '按住说话中',
|
||||
statePolishing: '松开后润色中',
|
||||
|
|
|
|||
|
|
@ -147,28 +147,23 @@
|
|||
transform: none;
|
||||
}
|
||||
|
||||
/* ── 녹음 캡슐 — 앱 recording-tip/style.css 와 같은 값 ─── */
|
||||
/* ── 녹음 캡슐 — 앱 popups/recording-tip/style.css(v4, 2026-09-28)와 같은 값 ───
|
||||
* 판 모양(radius 10)·단색 막대·입력 없으면 막대 정지·처리 중 무한 막대·완료 "입력함".
|
||||
* 색은 앱 다크 테마 원시값(packages/ui/src/theme-raw.ts)과 같다 — 값이 바뀌면 함께 고친다. */
|
||||
.capsule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 17, 22, 0.88);
|
||||
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
}
|
||||
.capsule:has(.capsule-partial) {
|
||||
border-radius: 18px;
|
||||
}
|
||||
.capsule-recording {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
min-width: 168px;
|
||||
max-width: 300px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 180, 255, 0.12);
|
||||
background: #111a30;
|
||||
box-shadow: 0 12px 32px rgba(3, 7, 18, 0.55);
|
||||
}
|
||||
.capsule.is-listening {
|
||||
border-color: rgba(148, 180, 255, 0.2);
|
||||
}
|
||||
.capsule-row {
|
||||
display: flex;
|
||||
|
|
@ -179,66 +174,88 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 28px;
|
||||
height: 22px;
|
||||
}
|
||||
.capsule-bar {
|
||||
display: block;
|
||||
width: 3px;
|
||||
min-height: 3px;
|
||||
min-height: 2px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
border-radius: 2px;
|
||||
background: #3b82f6;
|
||||
transition: height 80ms ease-out;
|
||||
}
|
||||
.capsule-bar:nth-child(1) { background: #93c5fd; opacity: 0.85; }
|
||||
.capsule-bar:nth-child(2) { background: #60a5fa; opacity: 0.9; }
|
||||
.capsule-bar:nth-child(3) { background: #4f8dfa; opacity: 0.95; }
|
||||
.capsule-bar:nth-child(4) { background: #3b82f6; }
|
||||
.capsule-bar:nth-child(5) { background: #3b82f6; }
|
||||
.capsule-bar:nth-child(6) { background: #5b75f7; }
|
||||
.capsule-bar:nth-child(7) { background: #6e71f7; opacity: 0.95; }
|
||||
.capsule-bar:nth-child(8) { background: #818cf8; opacity: 0.9; }
|
||||
.capsule-bar:nth-child(9) { background: #a78bfa; opacity: 0.85; }
|
||||
.capsule.is-thinking .capsule-bar {
|
||||
background: #7e8eb0;
|
||||
}
|
||||
.capsule-duration {
|
||||
min-width: 34px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
min-width: 32px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
.capsule-partial {
|
||||
display: -webkit-box;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
.capsule-progress {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.capsule-progress-bar {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: #3b82f6;
|
||||
transition: width 100ms linear;
|
||||
color: #eef2fb;
|
||||
}
|
||||
.capsule-thinking {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #a1a1aa;
|
||||
color: #93a4c8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.capsule-cancel {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid rgba(148, 180, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #7e8eb0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.capsule-progress {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 1px;
|
||||
background: rgba(148, 180, 255, 0.12);
|
||||
}
|
||||
.capsule-progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 36%;
|
||||
border-radius: 1px;
|
||||
background: #3b82f6;
|
||||
animation: capsule-indeterminate 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite;
|
||||
}
|
||||
@keyframes capsule-indeterminate {
|
||||
from { left: -36%; }
|
||||
to { left: 100%; }
|
||||
}
|
||||
.capsule-partial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid rgba(148, 180, 255, 0.07);
|
||||
padding-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #eef2fb;
|
||||
}
|
||||
.capsule.is-thinking .capsule-partial {
|
||||
color: #7e8eb0;
|
||||
}
|
||||
.capsule-done {
|
||||
font-size: 12px;
|
||||
color: #eef2fb;
|
||||
}
|
||||
.capsule-done-icon {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
/* ── Grid Background Pattern (히어로) ─────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue