커맨드 선택 팝업 (Ctrl+Shift+C): 커서 위치에서 빠른 명령어 전환
- 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로 설정 → 다음 녹음에 적용
This commit is contained in:
parent
fea923d302
commit
1361b95a4d
6 changed files with 419 additions and 4 deletions
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
}
|
||||
})
|
||||
|
||||
// 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<Record<string, unknown>>, 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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>, activeId: string | null): void {
|
||||
const win = getCommandPopupWindow()
|
||||
const cursorPos = screen.getCursorScreenPoint()
|
||||
const display = screen.getDisplayNearestPoint(cursorPos)
|
||||
|
||||
const popupWidth = 340
|
||||
const popupHeight = Math.min(80 + commands.length * 48, 400)
|
||||
|
||||
let x = cursorPos.x - Math.round(popupWidth / 2)
|
||||
let y = cursorPos.y - popupHeight - 20
|
||||
|
||||
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
|
||||
if (y < display.workArea.y) { y = cursorPos.y + 20 }
|
||||
|
||||
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
|
||||
win.webContents.send('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')
|
||||
}
|
||||
|
||||
|
|
|
|||
23
src/renderer/popups/command-popup/index.html
Normal file
23
src/renderer/popups/command-popup/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!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>Command Popup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="container" class="command-popup">
|
||||
<div id="title" class="title">COMMAND SELECT</div>
|
||||
<div id="items" class="items"></div>
|
||||
<div class="hints">
|
||||
<span>↑↓ 선택</span>
|
||||
<span>⏎ 적용</span>
|
||||
<span>ESC ×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
129
src/renderer/popups/command-popup/script.js
Normal file
129
src/renderer/popups/command-popup/script.js
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
136
src/renderer/popups/command-popup/style.css
Normal file
136
src/renderer/popups/command-popup/style.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue