From 1361b95a4d25d4840d02b439787591e8db552c0c Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 13:26:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=ED=8C=9D=EC=97=85=20(Ctrl+Shift+C):=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EC=9C=84=EC=B9=98=EC=97=90=EC=84=9C=20=EB=B9=A0?= =?UTF-8?q?=EB=A5=B8=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - command-popup: Vanilla JS 팝업 (히스토리 팝업과 동일 패턴) Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기 - WindowManager: showCommandPopup/hideCommandPopup/sendKeyToCommandPopup - bootstrap: Ctrl+Shift+C 단축키 등록 + command:selected IPC - electron.vite.config: command-popup HTML 빌드 엔트리 추가 - 선택한 명령어가 즉시 activeInstructionId로 설정 → 다음 녹음에 적용 --- electron.vite.config.ts | 4 + src/main/bootstrap.ts | 49 ++++++- src/main/windows/WindowManager.ts | 82 +++++++++++ src/renderer/popups/command-popup/index.html | 23 ++++ src/renderer/popups/command-popup/script.js | 129 ++++++++++++++++++ src/renderer/popups/command-popup/style.css | 136 +++++++++++++++++++ 6 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 src/renderer/popups/command-popup/index.html create mode 100644 src/renderer/popups/command-popup/script.js create mode 100644 src/renderer/popups/command-popup/style.css diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 0ea5795..d37446b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -43,6 +43,10 @@ export default defineConfig({ 'popups/history-popup': resolve( __dirname, 'src/renderer/popups/history-popup/index.html' + ), + 'popups/command-popup': resolve( + __dirname, + 'src/renderer/popups/command-popup/index.html' ) } } diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index c49a729..82abc10 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -3,7 +3,7 @@ import { join } from 'path' import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron' import { initLoggerService, getLogger } from './services/LoggerService' -import { initConfigService } from './services/ConfigService' +import { initConfigService, configGet, configSet } from './services/ConfigService' import { getHotkeyService } from './services/HotkeyService' import { getVoiceModeService } from './services/VoiceModeService' import { getLocalLLMService } from './services/LocalLLMService' @@ -19,7 +19,11 @@ import { showHistoryPopup, hideHistoryPopup, sendKeyToHistoryPopup, - isHistoryPopupVisible + isHistoryPopupVisible, + showCommandPopup, + hideCommandPopup, + sendKeyToCommandPopup, + isCommandPopupVisible, } from './windows/WindowManager' import { createTray } from './windows/TrayManager' import { registerAllIpcHandlers } from './ipc' @@ -125,6 +129,20 @@ async function initPopupWindows(): Promise { } }) + // Ctrl+Shift+C → 커맨드 선택 팝업 토글 + globalShortcut.register('Ctrl+Shift+C', () => { + if (isCommandPopupVisible()) { + hideCommandPopup() + unregisterPopupNavKeys() + } else { + const instructions = getCustomInstructionService().getAll() + const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string + showCommandPopup(instructions as unknown as Array>, activeId || null) + registerPopupNavKeys('command') + } + }) + + setupCommandPopupIPC() } // 히스토리 팝업 키 네비게이션 등록/해제 @@ -138,12 +156,14 @@ const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [ { accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, ] -function registerPopupNavKeys(): void { +function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void { for (const { accel, key } of POPUP_NAV_KEYS) { try { globalShortcut.register(accel, () => { - if (isHistoryPopupVisible()) { + if (target === 'history' && isHistoryPopupVisible()) { sendKeyToHistoryPopup(key) + } else if (target === 'command' && isCommandPopupVisible()) { + sendKeyToCommandPopup(key) } }) } catch { @@ -242,3 +262,24 @@ function setupHistoryPopupIPC(): void { unregisterPopupNavKeys() }) } + +// ── CommandPopup IPC 연동 ──────────────────────────── + +function setupCommandPopupIPC(): void { + // 명령어 선택 → 활성 명령어로 설정 + ipcMainRef.on('command:selected', (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => { + hideCommandPopup() + unregisterPopupNavKeys() + + // ConfigService에 활성 명령어 저장 + configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, data.id as never) + configSet('defaultLLMAction', 'custom') + logger.info(`Active command set: ${data.name} (${data.id})`) + }) + + // 팝업 닫기 + ipcMainRef.on('command:dismissed', () => { + hideCommandPopup() + unregisterPopupNavKeys() + }) +} diff --git a/src/main/windows/WindowManager.ts b/src/main/windows/WindowManager.ts index 1f88d99..2cafbcf 100644 --- a/src/main/windows/WindowManager.ts +++ b/src/main/windows/WindowManager.ts @@ -17,6 +17,7 @@ let mainWindow: BrowserWindow | null = null let recordingTipWindow: BrowserWindow | null = null let resultPopupWindow: BrowserWindow | null = null let historyPopupWindow: BrowserWindow | null = null +let commandPopupWindow: BrowserWindow | null = null // ── 메인 윈도우 ─────────────────────────────────────── @@ -355,12 +356,93 @@ export function isHistoryPopupVisible(): boolean { return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible()) } +// ── CommandPopup 팝업 ───────────────────────────────── + +function createCommandPopupWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 340, + height: 300, + show: false, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + focusable: false, + webPreferences: { + preload: join(__dirname, '../preload/popup.js'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false + } + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`) + } else { + win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html')) + } + + win.on('closed', () => { commandPopupWindow = null }) + return win +} + +export function getCommandPopupWindow(): BrowserWindow { + if (!commandPopupWindow || commandPopupWindow.isDestroyed()) { + commandPopupWindow = createCommandPopupWindow() + } + return commandPopupWindow +} + +export function showCommandPopup(commands: Array>, activeId: string | null): void { + const win = getCommandPopupWindow() + const cursorPos = screen.getCursorScreenPoint() + const display = screen.getDisplayNearestPoint(cursorPos) + + const popupWidth = 340 + const popupHeight = Math.min(80 + commands.length * 48, 400) + + let x = cursorPos.x - Math.round(popupWidth / 2) + let y = cursorPos.y - popupHeight - 20 + + x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth)) + if (y < display.workArea.y) { y = cursorPos.y + 20 } + + win.setBounds({ x, y, width: popupWidth, height: popupHeight }) + win.webContents.send('command:showItems', { commands, activeId }) + + if (!win.isVisible()) { win.showInactive() } + win.webContents.send('command:show', {}) +} + +export function hideCommandPopup(): void { + if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { + commandPopupWindow.webContents.send('command:hide', {}) + setTimeout(() => { + if (commandPopupWindow && !commandPopupWindow.isDestroyed()) { + commandPopupWindow.hide() + } + }, 150) + } +} + +export function sendKeyToCommandPopup(key: string): void { + if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) { + commandPopupWindow.webContents.send('command:keyEvent', { key }) + } +} + +export function isCommandPopupVisible(): boolean { + return !!(commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) +} + // ── 프리로딩 ────────────────────────────────────────── export function preloadPopupWindows(): void { getRecordingTipWindow() getResultPopupWindow() getHistoryPopupWindow() + getCommandPopupWindow() logger.info('Popup windows preloaded') } diff --git a/src/renderer/popups/command-popup/index.html b/src/renderer/popups/command-popup/index.html new file mode 100644 index 0000000..07a81cc --- /dev/null +++ b/src/renderer/popups/command-popup/index.html @@ -0,0 +1,23 @@ + + + + + + + Command Popup + + +
+
+
COMMAND SELECT
+
+
+ ↑↓ 선택 + ⏎ 적용 + ESC × +
+
+
+ + + diff --git a/src/renderer/popups/command-popup/script.js b/src/renderer/popups/command-popup/script.js new file mode 100644 index 0000000..0088fee --- /dev/null +++ b/src/renderer/popups/command-popup/script.js @@ -0,0 +1,129 @@ +// CommandPopup 팝업 스크립트 +// Ctrl+Shift+C로 커서 위치에 명령어 선택 팝업 +// Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기 + +;(function () { + 'use strict' + + var container = document.getElementById('container') + var itemsContainer = document.getElementById('items') + var items = [] + var selectedIndex = 0 + var currentCommands = [] + var currentActiveId = null + + // ── 아이템 렌더링 ─────────────────────────────────── + function renderItems(commands, activeId) { + currentCommands = commands + currentActiveId = activeId + itemsContainer.innerHTML = '' + items = [] + + if (commands.length === 0) { + var empty = document.createElement('div') + empty.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 24px 16px;' + empty.textContent = '명령어가 없습니다' + itemsContainer.appendChild(empty) + return + } + + commands.forEach(function (cmd, index) { + var div = document.createElement('div') + div.className = 'item' + (index === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '') + div.dataset.index = String(index) + + var num = document.createElement('span') + num.className = 'item-number' + num.textContent = String(index + 1) + + 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(index) + }) + }) + } + + // ── 선택 업데이트 ─────────────────────────────────── + function updateSelection(newIndex) { + if (currentCommands.length === 0) return + if (newIndex < 0) newIndex = currentCommands.length - 1 + if (newIndex >= currentCommands.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 selectAndApply(index) { + if (index < 0 || index >= currentCommands.length) return + var cmd = currentCommands[index] + window.popupAPI.send('command:selected', { id: cmd.id, name: cmd.name }) + } + + // ── IPC 리스너 ─────────────────────────────────────── + window.popupAPI.on('command:showItems', function (data) { + selectedIndex = 0 + renderItems(data.commands || [], data.activeId || null) + container.classList.remove('hiding') + }) + + window.popupAPI.on('command:show', function () { + container.classList.add('visible') + container.classList.remove('hiding') + }) + + window.popupAPI.on('command:hide', function () { + container.classList.add('hiding') + container.classList.remove('visible') + }) + + 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 >= '1' && key <= '9') { + var idx = parseInt(key, 10) - 1 + if (idx < currentCommands.length) { + selectAndApply(idx) + } + } + }) +})() diff --git a/src/renderer/popups/command-popup/style.css b/src/renderer/popups/command-popup/style.css new file mode 100644 index 0000000..6c44e4f --- /dev/null +++ b/src/renderer/popups/command-popup/style.css @@ -0,0 +1,136 @@ +* { + 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%; +} + +.command-popup { + background: #242427; /* d3roPalette.bg.card */ + 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: 280px; + max-width: 380px; +} + +.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; + transition-timing-function: ease-in; +} + +.title { + color: #5c2615; /* d3roPalette.text.dimLabel */ + font-size: 9px; + font-weight: 700; + letter-spacing: 1.5px; + padding: 6px 10px 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.items { + max-height: 320px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: center; + padding: 10px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 100ms; + 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: #f25b29; /* d3roPalette.accent.amber */ + border-radius: 1.5px; +} + +.item.active { + background: rgba(242, 91, 41, 0.08); +} + +.item-number { + color: rgba(255, 255, 255, 0.3); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + min-width: 16px; + text-align: center; +} + +.item-name { + flex: 1; + color: rgba(255, 255, 255, 0.87); + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-desc { + color: rgba(255, 255, 255, 0.35); + font-size: 11px; + white-space: nowrap; + flex-shrink: 0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-active-badge { + color: #f25b29; + font-size: 10px; + font-weight: 700; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 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: 4px; +} + +.hints span { + color: rgba(255, 255, 255, 0.25); + font-size: 10px; +}