Phase 3.5 구현: 커서 위치 히스토리 팝업 (D3RO 고유 기능)
- HistoryPopup: Vanilla JS, 다크 카드(#242427), 앰버 악센트(#f25b29) - Ctrl+Shift+V → 커서 위치에 최근 10건 히스토리 팝업 - Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기 - focusable: false → 활성 앱 포커스 유지 - 2-phase 리사이즈, 등장/퇴장 애니메이션 - WindowManager: HistoryPopup 관리 + 프리로딩 - Bootstrap: globalShortcut 등록 + IPC 연동
This commit is contained in:
parent
291e2a29d0
commit
f131b581a9
7 changed files with 459 additions and 5 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||
|
||||
import { app, dialog } from 'electron'
|
||||
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||
import { initConfigService } from './services/ConfigService'
|
||||
import { getHotkeyService } from './services/HotkeyService'
|
||||
|
|
@ -15,7 +15,11 @@ import {
|
|||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
sendAudioLevelToTip,
|
||||
showResultPopup
|
||||
showResultPopup,
|
||||
showHistoryPopup,
|
||||
hideHistoryPopup,
|
||||
sendKeyToHistoryPopup,
|
||||
isHistoryPopupVisible
|
||||
} from './windows/WindowManager'
|
||||
import { createTray } from './windows/TrayManager'
|
||||
import { registerAllIpcHandlers } from './ipc'
|
||||
|
|
@ -92,6 +96,17 @@ async function initHotkey(): Promise<void> {
|
|||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
||||
// Ctrl+Shift+V → 히스토리 팝업 토글
|
||||
globalShortcut.register('Ctrl+Shift+V', () => {
|
||||
if (isHistoryPopupVisible()) {
|
||||
hideHistoryPopup()
|
||||
} else {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function initVoiceMode(): Promise<void> {
|
||||
|
|
@ -149,3 +164,33 @@ async function initLLMPolling(): Promise<void> {
|
|||
const llm = getLocalLLMService()
|
||||
llm.startPolling()
|
||||
}
|
||||
|
||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
||||
|
||||
function setupHistoryPopupIPC(): void {
|
||||
const { ipcMain } = require('electron')
|
||||
const { getTextInsertService } = require('./services/TextInsertService')
|
||||
|
||||
// 히스토리 팝업 열기 (핫키에서 호출)
|
||||
ipcMain.on('history:showPopup', () => {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
})
|
||||
|
||||
// 아이템 선택 → 텍스트 삽입
|
||||
ipcMain.on('history:itemSelected', (_event: Electron.IpcMainEvent, data: { text: string }) => {
|
||||
hideHistoryPopup()
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await getTextInsertService().insertText(data.text)
|
||||
} catch (error) {
|
||||
logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
|
||||
// 팝업 닫기
|
||||
ipcMain.on('history:popupDismissed', () => {
|
||||
hideHistoryPopup()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const logger = getLogger('WindowManager')
|
|||
let mainWindow: BrowserWindow | null = null
|
||||
let recordingTipWindow: BrowserWindow | null = null
|
||||
let resultPopupWindow: BrowserWindow | null = null
|
||||
let historyPopupWindow: BrowserWindow | null = null
|
||||
|
||||
// ── 메인 윈도우 ───────────────────────────────────────
|
||||
|
||||
|
|
@ -262,11 +263,106 @@ export function hideResultPopup(): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ── HistoryPopup 팝업 ─────────────────────────────────
|
||||
|
||||
function createHistoryPopupWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 360,
|
||||
height: 400,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/popup.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/history-popup/index.html`)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/popups/history-popup/index.html'))
|
||||
}
|
||||
|
||||
win.on('closed', () => {
|
||||
historyPopupWindow = null
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function getHistoryPopupWindow(): BrowserWindow {
|
||||
if (!historyPopupWindow || historyPopupWindow.isDestroyed()) {
|
||||
historyPopupWindow = createHistoryPopupWindow()
|
||||
logger.info('HistoryPopup window created')
|
||||
}
|
||||
return historyPopupWindow
|
||||
}
|
||||
|
||||
export function showHistoryPopup(entries: Array<Record<string, unknown>>): void {
|
||||
const win = getHistoryPopupWindow()
|
||||
|
||||
win.webContents.send('history:showItems', { entries })
|
||||
|
||||
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
|
||||
ipcMain.removeListener('history:popupMeasured', handler)
|
||||
|
||||
const cursorPos = screen.getCursorScreenPoint()
|
||||
const display = screen.getDisplayNearestPoint(cursorPos)
|
||||
|
||||
let x = cursorPos.x - Math.round(data.width / 2)
|
||||
let y = cursorPos.y - data.height - 20
|
||||
|
||||
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - data.width))
|
||||
if (y < display.workArea.y) {
|
||||
y = cursorPos.y + 20
|
||||
}
|
||||
|
||||
win.setBounds({ x, y, width: data.width, height: data.height })
|
||||
|
||||
if (!win.isVisible()) {
|
||||
win.showInactive()
|
||||
}
|
||||
|
||||
win.webContents.send('history:show', {})
|
||||
}
|
||||
|
||||
ipcMain.on('history:popupMeasured', handler)
|
||||
}
|
||||
|
||||
export function hideHistoryPopup(): void {
|
||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
||||
historyPopupWindow.webContents.send('history:hide', {})
|
||||
setTimeout(() => {
|
||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed()) {
|
||||
historyPopupWindow.hide()
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendKeyToHistoryPopup(key: string): void {
|
||||
if (historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) {
|
||||
historyPopupWindow.webContents.send('history:keyEvent', { key })
|
||||
}
|
||||
}
|
||||
|
||||
export function isHistoryPopupVisible(): boolean {
|
||||
return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible())
|
||||
}
|
||||
|
||||
// ── 프리로딩 ──────────────────────────────────────────
|
||||
|
||||
export function preloadPopupWindows(): void {
|
||||
getRecordingTipWindow()
|
||||
getResultPopupWindow()
|
||||
getHistoryPopupWindow()
|
||||
logger.info('Popup windows preloaded')
|
||||
}
|
||||
|
||||
|
|
|
|||
22
src/renderer/popups/history-popup/index.html
Normal file
22
src/renderer/popups/history-popup/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<title>History Popup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="history-popup">
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span>↑↓ Select</span>
|
||||
<span>⏎ Paste</span>
|
||||
<span>ESC ×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
156
src/renderer/popups/history-popup/script.js
Normal file
156
src/renderer/popups/history-popup/script.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// HistoryPopup 팝업 스크립트
|
||||
// Phase 3.5: 커서 위치에 최근 전사 히스토리 팝업
|
||||
// Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
var container = document.getElementById('container')
|
||||
var itemsContainer = document.getElementById('items')
|
||||
var items = []
|
||||
var selectedIndex = 0
|
||||
var currentEntries = []
|
||||
|
||||
// ── 상대 시간 표시 ───────────────────────────────────
|
||||
function relativeTime(timestamp) {
|
||||
var diff = Date.now() - timestamp
|
||||
var sec = Math.floor(diff / 1000)
|
||||
if (sec < 60) return 'just now'
|
||||
var min = Math.floor(sec / 60)
|
||||
if (min < 60) return min + 'm ago'
|
||||
var hr = Math.floor(min / 60)
|
||||
if (hr < 24) return hr + 'h ago'
|
||||
var day = Math.floor(hr / 24)
|
||||
return day + 'd ago'
|
||||
}
|
||||
|
||||
// ── 텍스트 truncate ──────────────────────────────────
|
||||
function truncate(text, maxLen) {
|
||||
if (text.length <= maxLen) return text
|
||||
return text.substring(0, maxLen) + '...'
|
||||
}
|
||||
|
||||
// ── 아이템 렌더링 ───────────────────────────────────
|
||||
function renderItems(entries) {
|
||||
currentEntries = entries
|
||||
itemsContainer.innerHTML = ''
|
||||
items = []
|
||||
|
||||
if (entries.length === 0) {
|
||||
var empty = document.createElement('div')
|
||||
empty.className = 'empty-state'
|
||||
empty.textContent = 'No history yet'
|
||||
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 num = document.createElement('span')
|
||||
num.className = 'item-number'
|
||||
num.textContent = String(index + 1)
|
||||
|
||||
var text = document.createElement('span')
|
||||
text.className = 'item-text'
|
||||
text.textContent = truncate(entry.polishedText || entry.originalText, 50)
|
||||
|
||||
var time = document.createElement('span')
|
||||
time.className = 'item-time'
|
||||
time.textContent = relativeTime(entry.createdAt)
|
||||
|
||||
div.appendChild(num)
|
||||
div.appendChild(text)
|
||||
div.appendChild(time)
|
||||
itemsContainer.appendChild(div)
|
||||
items.push(div)
|
||||
|
||||
// 클릭으로 선택+삽입
|
||||
div.addEventListener('click', function () {
|
||||
selectAndInsert(index)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 선택 업데이트 ───────────────────────────────────
|
||||
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')
|
||||
}
|
||||
})
|
||||
selectedIndex = newIndex
|
||||
|
||||
// 스크롤 보장
|
||||
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 })
|
||||
}
|
||||
|
||||
// ── 크기 측정 ───────────────────────────────────────
|
||||
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
|
||||
renderItems(data.entries || [])
|
||||
container.classList.remove('hiding')
|
||||
measureAndReport()
|
||||
})
|
||||
|
||||
window.popupAPI.on('history:show', function () {
|
||||
container.classList.add('visible')
|
||||
container.classList.remove('hiding')
|
||||
})
|
||||
|
||||
window.popupAPI.on('history:hide', function () {
|
||||
container.classList.add('hiding')
|
||||
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') {
|
||||
var idx = parseInt(key, 10) - 1
|
||||
if (idx < currentEntries.length) {
|
||||
selectAndInsert(idx)
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
124
src/renderer/popups/history-popup/style.css
Normal file
124
src/renderer/popups/history-popup/style.css
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.history-popup {
|
||||
background: #242427;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.items {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 100ms;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item.selected {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.item.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
background: #f25b29;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
.item-number {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 11px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 11px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
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: 4px;
|
||||
}
|
||||
|
||||
.hints span {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue