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
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -77,11 +77,18 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 5 완료
|
Phase: 5 + 3.5 완료
|
||||||
마지막 완료: Phase 5 — SQLite DB + HistoryService + DictionaryService + UI 완성
|
마지막 완료: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능)
|
||||||
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 6 — 커스텀 명령어
|
다음 작업: Phase 6 — 커스텀 명령어 + 설정 UI 고도화 + i18n
|
||||||
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
||||||
|
|
||||||
|
### Phase 3.5 구현 내용
|
||||||
|
- HistoryPopup: Vanilla JS 팝업, 다크 카드(#242427), 앰버 악센트(#f25b29)
|
||||||
|
- Ctrl+Shift+V 글로벌 단축키 → 커서 위치에 최근 10건 히스토리 팝업
|
||||||
|
- Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
|
||||||
|
- focusable: false → 활성 앱 포커스 유지
|
||||||
|
- 2-phase 리사이즈, 등장/퇴장 애니메이션 (0.15s/0.1s)
|
||||||
|
|
||||||
### Phase 5 구현 내용
|
### Phase 5 구현 내용
|
||||||
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats 테이블, WAL 모드)
|
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats 테이블, WAL 모드)
|
||||||
- HistoryService: CRUD + 검색 + 통계 + 보존 정책(30일), 세션 완료 시 자동 이력 저장
|
- HistoryService: CRUD + 검색 + 통계 + 보존 정책(30일), 세션 완료 시 자동 이력 저장
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ export default defineConfig({
|
||||||
'popups/result-popup': resolve(
|
'popups/result-popup': resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'src/renderer/popups/result-popup/index.html'
|
'src/renderer/popups/result-popup/index.html'
|
||||||
|
),
|
||||||
|
'popups/history-popup': resolve(
|
||||||
|
__dirname,
|
||||||
|
'src/renderer/popups/history-popup/index.html'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// src/main/bootstrap.ts — 초기화 시퀀스
|
// 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 { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
import { initConfigService } from './services/ConfigService'
|
import { initConfigService } from './services/ConfigService'
|
||||||
import { getHotkeyService } from './services/HotkeyService'
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
|
|
@ -15,7 +15,11 @@ import {
|
||||||
hideRecordingTip,
|
hideRecordingTip,
|
||||||
updateRecordingTipState,
|
updateRecordingTipState,
|
||||||
sendAudioLevelToTip,
|
sendAudioLevelToTip,
|
||||||
showResultPopup
|
showResultPopup,
|
||||||
|
showHistoryPopup,
|
||||||
|
hideHistoryPopup,
|
||||||
|
sendKeyToHistoryPopup,
|
||||||
|
isHistoryPopupVisible
|
||||||
} from './windows/WindowManager'
|
} from './windows/WindowManager'
|
||||||
import { createTray } from './windows/TrayManager'
|
import { createTray } from './windows/TrayManager'
|
||||||
import { registerAllIpcHandlers } from './ipc'
|
import { registerAllIpcHandlers } from './ipc'
|
||||||
|
|
@ -92,6 +96,17 @@ async function initHotkey(): Promise<void> {
|
||||||
|
|
||||||
async function initPopupWindows(): Promise<void> {
|
async function initPopupWindows(): Promise<void> {
|
||||||
preloadPopupWindows()
|
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> {
|
async function initVoiceMode(): Promise<void> {
|
||||||
|
|
@ -149,3 +164,33 @@ async function initLLMPolling(): Promise<void> {
|
||||||
const llm = getLocalLLMService()
|
const llm = getLocalLLMService()
|
||||||
llm.startPolling()
|
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 mainWindow: BrowserWindow | null = null
|
||||||
let recordingTipWindow: BrowserWindow | null = null
|
let recordingTipWindow: BrowserWindow | null = null
|
||||||
let resultPopupWindow: 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 {
|
export function preloadPopupWindows(): void {
|
||||||
getRecordingTipWindow()
|
getRecordingTipWindow()
|
||||||
getResultPopupWindow()
|
getResultPopupWindow()
|
||||||
|
getHistoryPopupWindow()
|
||||||
logger.info('Popup windows preloaded')
|
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