638 lines
19 KiB
Vue
638 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
|
||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
|
||
import { commands } from '@/lib/bindings'
|
||
import {
|
||
ClipboardList, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||
FileText, Files, Loader2,
|
||
} from '@lucide/vue'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import { Input } from '@/components/ui/input'
|
||
import {
|
||
Pagination, PaginationContent, PaginationItem, PaginationEllipsis,
|
||
} from '@/components/ui/pagination'
|
||
|
||
// ===== 与 Rust 端对应的数据结构(bindings 提供,camelCase) =====
|
||
// kind 为 bindings 生成的 string,前端按字符串比较即可
|
||
import type { ClipboardItem, HistoryPage } from '@/lib/bindings'
|
||
|
||
// ===== 状态 =====
|
||
const items = ref<ClipboardItem[]>([])
|
||
const total = ref(0)
|
||
const currentPage = ref(1)
|
||
const PAGE_SIZE = 50
|
||
const searchQuery = ref('')
|
||
const selectedIndex = ref(0)
|
||
const loading = ref(false)
|
||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||
let unlistenFns: UnlistenFn[] = []
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||
|
||
// ===== 数据加载 =====
|
||
/** 加载请求序号:翻页/搜索快速操作时丢弃过期请求结果,避免旧请求覆盖新结果 */
|
||
let loadSeq = 0
|
||
async function loadData() {
|
||
const seq = ++loadSeq
|
||
loading.value = true
|
||
try {
|
||
const q = searchQuery.value.trim()
|
||
const offset = (currentPage.value - 1) * PAGE_SIZE
|
||
let res: HistoryPage
|
||
if (q) {
|
||
res = await commands.clipboardSearch(q, PAGE_SIZE, offset)
|
||
} else {
|
||
res = await commands.clipboardGetHistory(PAGE_SIZE, offset, 'all')
|
||
}
|
||
if (seq !== loadSeq) return // 过期请求丢弃
|
||
items.value = res.items
|
||
total.value = res.total
|
||
selectedIndex.value = 0
|
||
} catch (e) {
|
||
if (seq !== loadSeq) return
|
||
console.error('[clipboard-popup] 加载失败:', e)
|
||
} finally {
|
||
// 仅最新请求可结束 loading,避免旧请求提前清除新请求的加载态
|
||
if (seq === loadSeq) loading.value = false
|
||
}
|
||
if (seq === loadSeq) {
|
||
await nextTick()
|
||
scrollSelectedIntoView()
|
||
}
|
||
}
|
||
|
||
async function gotoPage(p: number) {
|
||
currentPage.value = Math.min(Math.max(1, p), totalPages.value)
|
||
await loadData()
|
||
}
|
||
|
||
// 防抖搜索
|
||
watch(searchQuery, () => {
|
||
currentPage.value = 1
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(loadData, 200)
|
||
})
|
||
|
||
// ===== 选择与粘贴 =====
|
||
/// 选中条目 → 写回剪贴板 → 隐藏窗口 → 模拟 Ctrl+V 粘贴到原窗口
|
||
async function selectAndPaste(item: ClipboardItem) {
|
||
try {
|
||
await commands.clipboardCopyBack(item.id)
|
||
// paste_to_target 会先隐藏窗口,再延迟模拟 Ctrl+V
|
||
await commands.clipboardPasteToTarget()
|
||
} catch (e) {
|
||
console.error('[clipboard-popup] 粘贴失败:', e)
|
||
// 失败时至少隐藏窗口
|
||
await hideWindow()
|
||
}
|
||
}
|
||
|
||
async function togglePin(item: ClipboardItem, ev: Event) {
|
||
ev.stopPropagation()
|
||
try {
|
||
await commands.clipboardSetPinned(item.id, !item.pinned)
|
||
await loadData()
|
||
} catch (e) {
|
||
console.error('[clipboard-popup] 固定失败:', e)
|
||
}
|
||
}
|
||
|
||
async function deleteItem(item: ClipboardItem, ev: Event) {
|
||
ev.stopPropagation()
|
||
try {
|
||
await commands.clipboardDelete(item.id)
|
||
items.value = items.value.filter((i) => i.id !== item.id)
|
||
} catch (e) {
|
||
console.error('[clipboard-popup] 删除失败:', e)
|
||
}
|
||
}
|
||
|
||
async function hideWindow() {
|
||
try {
|
||
await commands.clipboardHidePopup()
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
}
|
||
|
||
// ===== 键盘导航 =====
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault()
|
||
selectedIndex.value = Math.min(selectedIndex.value + 1, items.value.length - 1)
|
||
cancelHoverTimer()
|
||
previewVisible.value = false
|
||
scrollSelectedIntoView()
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault()
|
||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||
cancelHoverTimer()
|
||
previewVisible.value = false
|
||
scrollSelectedIntoView()
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
const item = items.value[selectedIndex.value]
|
||
if (item) selectAndPaste(item)
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
hideWindow()
|
||
}
|
||
}
|
||
|
||
function scrollSelectedIntoView() {
|
||
nextTick(() => {
|
||
const el = document.querySelector('.popup-item-selected') as HTMLElement | null
|
||
el?.scrollIntoView({ block: 'nearest' })
|
||
})
|
||
}
|
||
|
||
// ===== 显示辅助(kind 为 bindings 生成的 string,按字符串比较) =====
|
||
const kindIcon = (k: string) => {
|
||
if (k === 'text') return FileText
|
||
if (k === 'image') return ImageIcon
|
||
return Files
|
||
}
|
||
const kindLabel = (k: string) => (k === 'text' ? '文本' : k === 'image' ? '图片' : '文件')
|
||
const kindBadgeClass = (k: string) =>
|
||
k === 'text'
|
||
? 'badge-text'
|
||
: k === 'image'
|
||
? 'badge-image'
|
||
: 'badge-files'
|
||
const formatTime = (ms: number) => {
|
||
const diff = Date.now() - ms
|
||
if (diff < 60_000) return '刚刚'
|
||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}分钟前`
|
||
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)}小时前`
|
||
const d = new Date(ms)
|
||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||
}
|
||
|
||
const hasItems = computed(() => items.value.length > 0)
|
||
|
||
// ===== 图片悬停预览(悬停 100ms 后显示缩略图) =====
|
||
const previewSrc = ref('')
|
||
const previewVisible = ref(false)
|
||
let hoverTimer: ReturnType<typeof setTimeout> | null = null
|
||
// 缓存已加载的图片 id → dataUrl,避免重复请求
|
||
const imageCache = new Map<number, string>()
|
||
|
||
/** 根据 base64 前缀判断 MIME 类型 */
|
||
function buildImageDataUrl(b64: string): string {
|
||
const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png'
|
||
return `data:${mime};base64,${b64}`
|
||
}
|
||
|
||
async function onItemHover(idx: number, item: ClipboardItem) {
|
||
selectedIndex.value = idx
|
||
// 仅图片类型触发预览
|
||
if (item.kind !== 'image') {
|
||
cancelHoverTimer()
|
||
previewVisible.value = false
|
||
return
|
||
}
|
||
// 先取消之前的定时器和预览
|
||
cancelHoverTimer()
|
||
// 100ms 后加载并显示(快速响应悬停意图)
|
||
hoverTimer = setTimeout(async () => {
|
||
try {
|
||
let src = imageCache.get(item.id)
|
||
if (!src) {
|
||
const detail = await commands.clipboardGetItem(item.id)
|
||
if (detail?.imageBase64) {
|
||
src = buildImageDataUrl(detail.imageBase64)
|
||
imageCache.set(item.id, src)
|
||
}
|
||
}
|
||
if (src) {
|
||
previewSrc.value = src
|
||
previewVisible.value = true
|
||
}
|
||
} catch {
|
||
/* 忽略加载失败 */
|
||
}
|
||
}, 100)
|
||
}
|
||
|
||
function cancelHoverTimer() {
|
||
if (hoverTimer) {
|
||
clearTimeout(hoverTimer)
|
||
hoverTimer = null
|
||
}
|
||
}
|
||
|
||
function onItemLeave() {
|
||
cancelHoverTimer()
|
||
previewVisible.value = false
|
||
}
|
||
|
||
// ===== 主题应用(与主应用同步) =====
|
||
/** 从 localStorage 读取主应用的主题设置 */
|
||
function readMainTheme(): { theme: string; effect: string } {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
|
||
if (raw) {
|
||
const s = JSON.parse(raw)
|
||
return {
|
||
theme: s.theme ?? 'system',
|
||
effect: s.effect ?? 'mica',
|
||
}
|
||
}
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
return { theme: 'system', effect: 'mica' }
|
||
}
|
||
|
||
/** 判断当前是否应为深色主题。
|
||
* 弹窗是独立窗口,主应用的 setTheme 不影响弹窗的 matchMedia,
|
||
* 因此 system 模式下用 matchMedia 是可靠的。 */
|
||
function resolveIsDark(theme: string): boolean {
|
||
if (theme === 'dark') return true
|
||
if (theme === 'light') return false
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
}
|
||
|
||
/** 应用主题和窗口效果(与主应用同步)。
|
||
* 关键:先 setTheme 让窗口主题正确,再用通用 Effect.Mica(自动跟随窗口主题深浅)。 */
|
||
async function applyTheme() {
|
||
const root = document.documentElement
|
||
const { theme, effect } = readMainTheme()
|
||
|
||
// 1. 先设置窗口原生主题(system → null 跟随系统)
|
||
try {
|
||
const tauriWin = getCurrentWindow()
|
||
if (theme === 'system') {
|
||
await tauriWin.setTheme(null)
|
||
} else {
|
||
await tauriWin.setTheme(theme as 'dark' | 'light')
|
||
}
|
||
} catch {
|
||
/* 非 Tauri 环境忽略 */
|
||
}
|
||
|
||
// 2. 窗口主题已正确,用 matchMedia 判断深浅(弹窗自身不受主应用污染)
|
||
const isDark = resolveIsDark(theme)
|
||
|
||
// 3. 设置 DOM class
|
||
root.classList.remove('dark', 'effect-mica', 'effect-acrylic', 'effect-normal')
|
||
root.classList.add(`effect-${effect}`)
|
||
if (isDark) root.classList.add('dark')
|
||
|
||
// 4. 设置窗口效果与背景色
|
||
// 弹窗窗口创建时 transparent=true,透明窗口下原生背景色不显示,
|
||
// 需在实际可见的 DOM 元素(.popup-root)上设置背景色。
|
||
// 用 CSS 变量 --popup-bg 控制,mica/acrylic 模式下保持透明。
|
||
try {
|
||
const tauriWin = getCurrentWindow()
|
||
await tauriWin.clearEffects()
|
||
if (effect === 'mica') {
|
||
await tauriWin.setEffects({
|
||
effects: [Effect.Mica],
|
||
state: EffectState.FollowsWindowActiveState,
|
||
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||
})
|
||
await tauriWin.setBackgroundColor('#00000000')
|
||
root.style.setProperty('--popup-bg', 'transparent')
|
||
} else if (effect === 'acrylic') {
|
||
await tauriWin.setEffects({
|
||
effects: [Effect.Acrylic],
|
||
state: EffectState.FollowsWindowActiveState,
|
||
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0],
|
||
})
|
||
await tauriWin.setBackgroundColor('#00000000')
|
||
root.style.setProperty('--popup-bg', 'transparent')
|
||
} else {
|
||
// 普通模式:透明窗口下原生背景不显示,由 DOM 提供背景色
|
||
await tauriWin.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
|
||
root.style.setProperty('--popup-bg', isDark ? '#0f172a' : '#ffffff')
|
||
}
|
||
} catch {
|
||
/* 非 Tauri 环境忽略 */
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 先应用主题(含窗口效果)
|
||
await applyTheme()
|
||
|
||
// 监听系统主题变化(仅在 system 模式下有意义)
|
||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||
const onThemeChange = () => applyTheme()
|
||
mq.addEventListener('change', onThemeChange)
|
||
unlistenFns.push(() => mq.removeEventListener('change', onThemeChange))
|
||
|
||
// 监听弹窗显示事件:每次显示时重新同步主题 + 刷新数据
|
||
unlistenFns.push(await listen(EVENTS.clipboardPopupShow, async () => {
|
||
// 主应用可能切换了主题,每次显示前重新应用
|
||
await applyTheme()
|
||
searchQuery.value = ''
|
||
currentPage.value = 1
|
||
// 重置预览状态,清空缓存避免历史图片占用内存
|
||
cancelHoverTimer()
|
||
previewVisible.value = false
|
||
imageCache.clear()
|
||
await loadData()
|
||
await nextTick()
|
||
searchInputRef.value?.focus()
|
||
}))
|
||
|
||
// 加载初始数据
|
||
await loadData()
|
||
await nextTick()
|
||
searchInputRef.value?.focus()
|
||
|
||
// 主题和数据都就绪后,调用 Rust 端显示窗口
|
||
try {
|
||
await commands.clipboardShowWindow()
|
||
} catch {
|
||
/* 忽略 */
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
cancelHoverTimer()
|
||
unlistenFns.forEach((fn) => fn())
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="popup-root flex flex-col h-screen w-screen" @keydown="onKeydown">
|
||
<!-- 搜索栏(与剪切板主页统一样式) -->
|
||
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||
<div class="relative flex-1 max-w-sm">
|
||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
ref="searchInputRef"
|
||
v-model="searchQuery"
|
||
placeholder="搜索剪贴板历史..."
|
||
class="pl-8 text-sm"
|
||
spellcheck="false"
|
||
/>
|
||
</div>
|
||
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ total }} 条</span>
|
||
</div>
|
||
|
||
<!-- 图片悬停预览浮层 -->
|
||
<Transition name="popup-preview">
|
||
<div v-if="previewVisible && previewSrc" class="popup-preview">
|
||
<img :src="previewSrc" alt="预览" />
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- 列表 -->
|
||
<ScrollArea class="popup-list flex-1 min-h-0">
|
||
<div class="space-y-1.5 p-2">
|
||
<div v-if="loading && !hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||
<Loader2 class="h-6 w-6 animate-spin" />
|
||
</div>
|
||
<div v-else-if="!hasItems" class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||
<ClipboardList class="h-10 w-10 mb-2 opacity-40" />
|
||
<p class="text-sm">
|
||
{{ searchQuery ? '无匹配结果' : '暂无历史记录' }}
|
||
</p>
|
||
</div>
|
||
<div
|
||
v-for="(item, idx) in items"
|
||
:key="item.id"
|
||
class="popup-item group"
|
||
:class="{ 'popup-item-selected': idx === selectedIndex }"
|
||
@click="selectAndPaste(item)"
|
||
@mouseenter="onItemHover(idx, item)"
|
||
@mouseleave="onItemLeave"
|
||
>
|
||
<component :is="kindIcon(item.kind)" class="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm break-all line-clamp-1" :title="item.preview">{{ item.preview }}</p>
|
||
<div class="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||
<span class="popup-item-kind px-1.5 py-0 text-[10px] border rounded-sm" :class="kindBadgeClass(item.kind)">{{ kindLabel(item.kind) }}</span>
|
||
<span>{{ formatTime(item.createdAt) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="popup-item-actions shrink-0">
|
||
<button class="popup-action-btn h-7 w-7" title="固定" @click="togglePin(item, $event)">
|
||
<component :is="item.pinned ? PinOff : Pin" class="h-3.5 w-3.5" />
|
||
</button>
|
||
<button class="popup-action-btn h-7 w-7 hover:text-destructive" title="删除" @click="deleteItem(item, $event)">
|
||
<Trash2 class="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<!-- 分页(与剪切板历史统一样式) -->
|
||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-1 px-2 py-1 border-t border-border">
|
||
<Pagination
|
||
v-slot="{ page }"
|
||
:page="currentPage"
|
||
:total="total"
|
||
:items-per-page="PAGE_SIZE"
|
||
:sibling-count="1"
|
||
show-edges
|
||
@update:page="gotoPage"
|
||
>
|
||
<PaginationContent v-slot="{ items: pageItems }" class="gap-1">
|
||
<template v-for="(item, index) in pageItems" :key="index">
|
||
<PaginationItem
|
||
v-if="item.type === 'page'"
|
||
:value="item.value"
|
||
:is-active="item.value === page"
|
||
size="icon"
|
||
class="size-7 text-xs"
|
||
>
|
||
{{ item.value }}
|
||
</PaginationItem>
|
||
<PaginationEllipsis v-else class="size-7" />
|
||
</template>
|
||
</PaginationContent>
|
||
</Pagination>
|
||
</div>
|
||
|
||
<!-- 底部提示 -->
|
||
<div class="popup-footer shrink-0">
|
||
<span><kbd>↑</kbd><kbd>↓</kbd> 导航</span>
|
||
<span><kbd>Enter</kbd> 粘贴</span>
|
||
<span><kbd>Esc</kbd> 关闭</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 弹窗根容器:背景色由 --popup-bg 控制(mica/acrylic 透明,普通模式不透明) */
|
||
.popup-root {
|
||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||
background: var(--popup-bg, transparent);
|
||
color: var(--foreground);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* 列表 */
|
||
.popup-list {
|
||
padding: 0;
|
||
}
|
||
|
||
/* reka-ui ScrollAreaViewport 内部会出现一个 div,需保证高度撑满 */
|
||
.popup-list :deep([data-slot="scroll-area-viewport"] > div) {
|
||
min-height: 100%;
|
||
}
|
||
|
||
/* 条目卡片样式(与主界面 Card 统一) */
|
||
.popup-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
padding: 8px 12px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
transition: box-shadow 0.2s, background-color 0.1s;
|
||
}
|
||
|
||
/* hover 使用 shadow(同主界面 hover:shadow-md),键盘选中保留轻微高亮 */
|
||
.popup-item:hover {
|
||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||
}
|
||
|
||
.popup-item-selected {
|
||
background: var(--accent);
|
||
}
|
||
|
||
/* 类型 badge 配色(与主界面一致) */
|
||
.popup-item-kind.badge-text {
|
||
background: rgba(59, 130, 246, 0.12);
|
||
color: rgb(37, 99, 235);
|
||
border-color: rgba(59, 130, 246, 0.4);
|
||
}
|
||
.popup-item-kind.badge-image {
|
||
background: rgba(16, 185, 129, 0.12);
|
||
color: rgb(5, 150, 105);
|
||
border-color: rgba(16, 185, 129, 0.4);
|
||
}
|
||
.popup-item-kind.badge-files {
|
||
background: rgba(245, 158, 11, 0.12);
|
||
color: rgb(217, 119, 6);
|
||
border-color: rgba(245, 158, 11, 0.4);
|
||
}
|
||
|
||
/* 深色主题下调整 badge 文字色 */
|
||
.dark .popup-item-kind.badge-text {
|
||
color: rgb(96, 165, 250);
|
||
}
|
||
.dark .popup-item-kind.badge-image {
|
||
color: rgb(52, 211, 153);
|
||
}
|
||
.dark .popup-item-kind.badge-files {
|
||
color: rgb(251, 191, 36);
|
||
}
|
||
|
||
.popup-item-actions {
|
||
display: flex;
|
||
gap: 2px;
|
||
opacity: 0;
|
||
transition: opacity 0.1s;
|
||
}
|
||
|
||
/* 选中项和悬停项都显示操作按钮 */
|
||
.popup-item:hover .popup-item-actions,
|
||
.popup-item-selected .popup-item-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
.popup-action-btn {
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--muted-foreground);
|
||
transition: background-color 0.1s, color 0.1s;
|
||
}
|
||
|
||
.popup-action-btn:hover {
|
||
background: var(--muted);
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* 空状态(使用 Tailwind 类,无需额外 CSS) */
|
||
|
||
/* 底部 */
|
||
.popup-footer {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
padding: 6px 12px;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 11px;
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
/* 图片悬停预览浮层:固定在弹窗右上角,不遮挡列表操作 */
|
||
.popup-preview {
|
||
position: absolute;
|
||
top: 50px;
|
||
right: 10px;
|
||
z-index: 100;
|
||
max-width: 180px;
|
||
max-height: 180px;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||
background: var(--popover, var(--background));
|
||
pointer-events: none;
|
||
}
|
||
|
||
.popup-preview img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 180px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
/* 预览浮层淡入淡出 */
|
||
.popup-preview-enter-active,
|
||
.popup-preview-leave-active {
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
.popup-preview-enter-from,
|
||
.popup-preview-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.popup-footer kbd {
|
||
background: var(--muted);
|
||
color: var(--foreground);
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
margin-right: 2px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
/* 滚动条 */
|
||
.popup-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.popup-list::-webkit-scrollbar-thumb {
|
||
background: var(--muted-foreground);
|
||
opacity: 0.3;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.popup-list::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
</style>
|