截图模块调整

This commit is contained in:
2026-08-01 22:24:33 +08:00
parent 89e5b7bed5
commit f51a7f894e
13 changed files with 2408 additions and 527 deletions
+4 -4
View File
@@ -6,7 +6,7 @@ import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore, type CaptureMode } from '@/stores/screenshotStore'
import { useScreenshotStore } from '@/stores/screenshotStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module'
@@ -159,10 +159,10 @@ onMounted(async () => {
handleModuleChange('settings')
})
)
// 托盘截图:直接触发截图流程(不切换模块,覆盖层/编辑器独立窗口)
// 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区
trayUnlisteners.push(
await listen<CaptureMode>('tray:start-screenshot', (e) => {
screenshotStore.startCapture(e.payload || 'region').catch(err =>
await listen('tray:start-screenshot', () => {
screenshotStore.startCapture().catch(err =>
console.error('[screenshot] 托盘触发截图失败:', err)
)
})
+10 -1
View File
@@ -18,7 +18,6 @@ window.addEventListener('unhandledrejection', (event) => {
// ===== OSD 窗口模式检测 =====
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 注意:screenshot-overlay 带 query?mode=region),用 startsWith 匹配
const winHash = window.location.hash
if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`)
@@ -84,6 +83,16 @@ if (winHash === '#osd-overlay') {
void import('./stores/processStore').then(({ useProcessStore }) => {
useProcessStore().initListener().catch(e => console.error('Process listener init error:', e))
})
// 截图模块:加载设置 + 预创建常驻覆盖层窗口 + 监听导出事件(历史/自动保存)+ 注册并监听全局快捷键
void import('./stores/screenshotStore').then(({ useScreenshotStore }) => {
const screenshotStore = useScreenshotStore()
screenshotStore.loadSettings()
screenshotStore.initOverlay().catch(e => console.error('Screenshot overlay init error:', e))
screenshotStore.initExportListener().catch(e => console.error('Screenshot export listener init error:', e))
screenshotStore.initShortcutListener().catch(e => console.error('Screenshot shortcut listener init error:', e))
screenshotStore.initShortcutRegistration().catch(e => console.error('Screenshot shortcut registration init error:', e))
})
})
}
+249 -52
View File
@@ -1,29 +1,30 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Camera, Square, AppWindow, Maximize, Copy, Save, Trash2, Image as ImageIcon, Loader2,
Keyboard, Settings, FolderOpen, Camera, Copy, Save, Trash2, Timer,
Image as ImageIcon, Loader2,
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useScreenshotStore, type CaptureMode, type RecentCapture } from '@/stores/screenshotStore'
import { open } from '@tauri-apps/plugin-dialog'
import { useScreenshotStore, type RecentCapture } from '@/stores/screenshotStore'
import { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
const store = useScreenshotStore()
const activeTab = ref('capture')
const activeTab = ref('settings')
const tabsListRef = useModuleTabs(activeTab, [
{ value: 'capture', label: '截图' },
{ value: 'settings', label: '设置' },
{ value: 'history', label: '历史' },
])
const captureActions: { mode: CaptureMode; icon: typeof Square; label: string; desc: string }[] = [
{ mode: 'region', icon: Square, label: '区域截图', desc: '拖动选择屏幕任意区域' },
{ mode: 'window', icon: AppWindow, label: '窗口截图', desc: '点击捕获指定窗口' },
{ mode: 'fullscreen', icon: Maximize, label: '全屏截图', desc: '直接捕获整个虚拟屏' },
]
const HISTORY_LIMITS = [6, 12, 24, 48]
const DELAY_OPTIONS = [0, 1, 2, 3, 5]
function formatTime(t: number): string {
const d = new Date(t)
@@ -37,8 +38,16 @@ function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}`
}
async function handleCapture(mode: CaptureMode) {
await store.startCapture(mode)
async function handleCapture() {
await store.startCapture()
}
async function chooseSaveDir() {
const dir = await open({ directory: true, title: '选择自动保存目录' })
if (typeof dir === 'string') {
store.setSettings({ saveDir: dir })
toast.success('自动保存目录已更新')
}
}
async function handleCopy(item: RecentCapture) {
@@ -55,12 +64,98 @@ function handleDelete(item: RecentCapture) {
toast.success('已从历史移除')
}
// ===== 快捷键录入器 =====
const recording = ref(false)
const recorderRef = ref<HTMLDivElement | null>(null)
/** 把存储的快捷键字符串格式化为展示形式:ctrl+alt+a → Ctrl + Alt + A */
function displayShortcut(s: string): string {
if (!s) return ''
return s
.split('+')
.map(p => {
const t = p.trim()
if (!t) return ''
if (t.length === 1) return t.toUpperCase()
return t.charAt(0).toUpperCase() + t.slice(1)
})
.join(' + ')
}
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
function eventToShortcut(e: KeyboardEvent): string | null {
const mods: string[] = []
if (e.ctrlKey) mods.push('ctrl')
if (e.altKey) mods.push('alt')
if (e.shiftKey) mods.push('shift')
if (e.metaKey) mods.push('super')
// 主键
let main = ''
const code = e.code || ''
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
else if (code === 'Space') main = 'space'
else if (code === 'PrintScreen') main = 'printscreen'
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
else {
// 退格/回车等单键不允许(避免误触);回车/Escape 由录制器单独处理
return null
}
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
if (mods.length === 0 && !isFunctionKey) return null
return [...mods, main].join('+')
}
function onRecordKey(e: KeyboardEvent) {
if (!recording.value) return
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') {
recording.value = false
return
}
// 仅修饰键按下时不结束(等待主键)
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
const combo = eventToShortcut(e)
if (!combo) {
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
return
}
recording.value = false
void commitShortcut(combo)
}
async function commitShortcut(combo: string) {
const ok = await store.setShortcut(combo)
if (ok) toast.success(`快捷键已更新为 ${displayShortcut(combo)}`)
else toast.error('快捷键注册失败,可能被其他程序占用')
}
async function startRecord() {
recording.value = true
await nextTick()
recorderRef.value?.focus()
}
watch(recording, (on) => {
if (on) window.addEventListener('keydown', onRecordKey, true)
else window.removeEventListener('keydown', onRecordKey, true)
})
async function clearShortcut() {
await store.setShortcut('')
toast.success('已禁用截图快捷键')
}
onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
})
onUnmounted(() => {
store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
})
</script>
@@ -69,7 +164,7 @@ onUnmounted(() => {
<Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
<TabsList>
<TabsTrigger value="capture">截图</TabsTrigger>
<TabsTrigger value="settings">设置</TabsTrigger>
<TabsTrigger value="history">
历史
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
@@ -79,50 +174,152 @@ onUnmounted(() => {
</TabsList>
</div>
<!-- 截图触发 -->
<TabsContent value="capture" class="flex-1 min-h-0 mt-0">
<!-- 截图设置 -->
<TabsContent value="settings" class="flex-1 min-h-0 mt-0">
<ScrollArea class="h-full">
<div class="p-4 pt-0 space-y-4">
<!-- 截图模式卡片 -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
v-for="action in captureActions"
:key="action.mode"
class="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/50"
:class="{ 'pointer-events-none opacity-60': store.capturing }"
@click="handleCapture(action.mode)"
>
<CardContent class="flex flex-col items-center gap-2 py-6 text-center">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-primary/10 text-primary">
<component :is="action.icon" class="h-6 w-6" />
</div>
<div class="font-medium">{{ action.label }}</div>
<div class="text-xs text-muted-foreground">{{ action.desc }}</div>
</CardContent>
</Card>
</div>
<!-- 截图中提示 -->
<Card v-if="store.capturing">
<CardContent class="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 class="h-5 w-5 animate-spin text-primary" />
<span>正在捕获屏幕</span>
</CardContent>
</Card>
<!-- 说明 -->
<div class="p-4 pt-0 space-y-4 max-w-3xl mx-auto">
<!-- 快捷键 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Camera class="h-4 w-4 text-primary" />
使用说明
<Keyboard class="h-4 w-4 text-primary" />
截图快捷键
</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-1.5">
<p>· <span class="text-foreground">区域截图</span>进入覆盖层后拖动鼠标选择区域松开后可编辑复制或保存</p>
<p>· <span class="text-foreground">窗口截图</span>移动鼠标高亮目标窗口点击即可捕获该窗口</p>
<p>· <span class="text-foreground">全屏截图</span>直接捕获所有显示器拼接画面并进入编辑器</p>
<p>· 选区/编辑器中按 <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 取消</p>
<CardContent>
<div class="flex items-center justify-between gap-4 py-1">
<div class="space-y-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<!-- 快捷键录入器 -->
<div
ref="recorderRef"
class="hotkey-recorder"
:class="{ recording }"
tabindex="0"
@click="startRecord"
>
<template v-if="recording">按下快捷键Esc 取消</template>
<template v-else-if="store.settings.shortcut">
{{ displayShortcut(store.settings.shortcut) }}
</template>
<template v-else>未设置点击录入</template>
</div>
<Button
v-if="store.settings.shortcut"
variant="ghost"
size="sm"
class="h-8 text-muted-foreground"
@click="clearShortcut"
>清除</Button>
</div>
<p class="text-sm text-muted-foreground">
点击方框录入自定义快捷键留空则禁用默认 Ctrl + Alt + A
</p>
</div>
<Button
variant="secondary"
:disabled="store.capturing"
@click="handleCapture"
>
<Loader2 v-if="store.capturing" class="h-4 w-4 animate-spin" />
<Camera v-else class="h-4 w-4" />
立即截图
</Button>
</div>
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1.5">
<p>· 进入截图后 <span class="text-foreground">移动鼠标</span> 自动识别窗口<span class="text-foreground">点击</span> 选中窗口</p>
<p>· <span class="text-foreground">长按拖动</span> 自由选择区域选区可拖动 / 缩放手柄调整大小</p>
<p>· 选区右下方编辑栏可标注矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮</p>
<p>
· <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Enter</kbd> / 双击复制并完成
<kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 逐级取消
</p>
</div>
</CardContent>
</Card>
<!-- 截图选项 -->
<Card>
<CardHeader>
<CardTitle class="text-sm flex items-center gap-2">
<Settings class="h-4 w-4 text-primary" />
截图选项
</CardTitle>
</CardHeader>
<CardContent>
<!-- 延时截图 -->
<div class="flex items-center justify-between gap-3 py-2">
<div class="space-y-1">
<Label class="flex items-center gap-2 text-base font-medium">
<Timer class="h-4 w-4" />
延时截图
</Label>
<p class="text-sm text-muted-foreground">按下快捷键后倒计时再截图便于切到目标画面</p>
</div>
<div class="flex gap-1 shrink-0">
<Button
v-for="n in DELAY_OPTIONS"
:key="n"
variant="ghost"
size="sm"
class="h-8 px-2.5"
:class="store.settings.delay === n
? 'bg-primary/10 text-primary hover:bg-primary/10'
: 'text-muted-foreground'"
@click="store.setSettings({ delay: n })"
>{{ n === 0 ? '立即' : n + 's' }}</Button>
</div>
</div>
<!-- 自动保存 -->
<div class="flex items-center justify-between py-2 border-t">
<div class="space-y-1">
<Label class="text-base font-medium">自动保存到目录</Label>
<p class="text-sm text-muted-foreground">
截图完成后自动保存 PNG 到指定目录
</p>
</div>
<Switch
:model-value="store.settings.autoSave"
@update:model-value="(v: boolean) => store.setSettings({ autoSave: v })"
/>
</div>
<!-- 保存目录 -->
<div v-if="store.settings.autoSave" class="flex items-center justify-between gap-3 py-2 border-t">
<div class="space-y-1 min-w-0">
<Label class="text-base font-medium">保存目录</Label>
<p class="text-sm text-muted-foreground truncate">
{{ store.settings.saveDir || '未设置目录,自动保存不会生效' }}
</p>
</div>
<Button variant="outline" size="sm" class="shrink-0" @click="chooseSaveDir">
<FolderOpen class="h-4 w-4" />
选择目录
</Button>
</div>
<!-- 历史保留数量 -->
<div class="flex items-center justify-between gap-3 py-2 border-t">
<div class="space-y-1">
<Label class="text-base font-medium">历史保留数量</Label>
<p class="text-sm text-muted-foreground">超过上限的旧截图将自动移除</p>
</div>
<div class="flex gap-1 shrink-0">
<Button
v-for="n in HISTORY_LIMITS"
:key="n"
variant="ghost"
size="sm"
class="h-8 px-2.5"
:class="store.settings.historyLimit === n
? 'bg-primary/10 text-primary hover:bg-primary/10'
: 'text-muted-foreground'"
@click="store.setSettings({ historyLimit: n })"
>{{ n }}</Button>
</div>
</div>
</CardContent>
</Card>
</div>
@@ -140,7 +337,7 @@ onUnmounted(() => {
>
<ImageIcon class="h-12 w-12 opacity-40" />
<p>暂无截图历史</p>
<p class="text-xs">截图并导出后会显示在这里</p>
<p class="text-xs">按 Ctrl + Alt + A 截图并导出后会显示在这里</p>
</div>
<!-- 历史网格 -->
File diff suppressed because it is too large Load Diff
+226 -76
View File
@@ -2,16 +2,11 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'
import { availableMonitors } from '@tauri-apps/api/window'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { toast } from 'vue-sonner'
interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface RecentCapture {
id: string
pngBase64: string
@@ -21,104 +16,186 @@ export interface RecentCapture {
mode: string
}
export type CaptureMode = 'region' | 'window' | 'fullscreen'
/** 截图设置(localStorage 持久化) */
export interface ScreenshotSettings {
/** 截图完成后自动保存到目录 */
autoSave: boolean
/** 自动保存目录 */
saveDir: string
/** 历史记录保留条数 */
historyLimit: number
/** 全局截图快捷键(空字符串表示禁用) */
shortcut: string
/** 截图延时(秒),0 表示立即截图 */
delay: number
}
const OVERLAY_LABEL = 'screenshot-overlay'
const EDITOR_LABEL = 'screenshot-editor'
const SETTINGS_KEY = 'screenshot-settings'
const DEFAULT_SETTINGS: ScreenshotSettings = {
autoSave: false,
saveDir: '',
historyLimit: 12,
shortcut: 'ctrl+alt+a',
delay: 0,
}
export const useScreenshotStore = defineStore('screenshot', () => {
const capturing = ref(false)
const recent = ref<RecentCapture[]>([])
let exportUnlisten: UnlistenFn | null = null
const settings = ref<ScreenshotSettings>({ ...DEFAULT_SETTINGS })
/** 计算所有显示器的逻辑像素联合矩形(用于覆盖层窗口定位/尺寸) */
async function computeVirtualLogicalRect() {
let exportUnlisten: UnlistenFn | null = null
let shortcutUnlisten: UnlistenFn | null = null
/** 常驻截图覆盖层窗口(启动时创建,之后每次截图复用,避免重复 WebView 初始化) */
const OVERLAY_LABEL = 'screenshot-overlay'
let overlayWin: WebviewWindow | null = null
let overlayReadyResolve: (() => void) | null = null
let overlayReadyPromise: Promise<void> | null = null
let readyListenerInit = false
// ===== 设置持久化 =====
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<ScreenshotSettings>
settings.value = { ...DEFAULT_SETTINGS, ...parsed }
}
} catch (e) {
console.error('[screenshot] 加载设置失败', e)
localStorage.removeItem(SETTINGS_KEY)
}
}
function saveSettings() {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings.value))
} catch (e) {
console.error('[screenshot] 保存设置失败', e)
}
}
function setSettings(patch: Partial<ScreenshotSettings>) {
settings.value = { ...settings.value, ...patch }
saveSettings()
}
// ===== 窗口定位 =====
/** 计算所有显示器的物理像素联合矩形(覆盖层必须用物理尺寸,保证底图 1:1 与坐标一致) */
async function computeVirtualPhysicalRect() {
const monitors = await availableMonitors()
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const m of monitors) {
const s = m.scaleFactor || 1
const lx = m.position.x / s
const ly = m.position.y / s
const lw = m.size.width / s
const lh = m.size.height / s
if (lx < minX) minX = lx
if (ly < minY) minY = ly
if (lx + lw > maxX) maxX = lx + lw
if (ly + lh > maxY) maxY = ly + lh
}
if (!Number.isFinite(minX)) {
minX = 0
minY = 0
maxX = 800
maxY = 600
if (m.position.x < minX) minX = m.position.x
if (m.position.y < minY) minY = m.position.y
if (m.position.x + m.size.width > maxX) maxX = m.position.x + m.size.width
if (m.position.y + m.size.height > maxY) maxY = m.position.y + m.size.height
}
if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 800, height: 600 }
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
/** 启动截图:region=区域选择,window=窗口拾取,fullscreen=直接进编辑器 */
async function startCapture(mode: CaptureMode) {
// ===== 截图流程 =====
/** 启动截图:延时倒计时 → 捕获虚拟屏 → 定位常驻覆盖层 → 通知覆盖层开始 */
async function startCapture() {
if (capturing.value) return
capturing.value = true
try {
// 1. 捕获虚拟屏(覆盖层尚未创建 → 不会出现在截图中)
const data = await invoke<CaptureData>('screenshot_capture_fullscreen')
if (mode === 'fullscreen') {
// 全屏截图直接送入编辑器
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
// 清掉静态全屏缓存(编辑器用 cropped/全图,不再需要原始 BGRA
await invoke('screenshot_take_fullscreen').catch(() => {})
await openEditor()
return
await ensureOverlay()
// 若覆盖层当前可见(上一次会话未结束),先隐藏再捕获,避免覆盖层自身出现在截图里
if (overlayWin) {
try {
if (await overlayWin.isVisible()) await overlayWin.hide()
} catch {
/* 窗口可能尚未就绪,忽略 */
}
}
// 2. 区域/窗口模式:创建覆盖层选区窗口
await openOverlay(mode)
// 延时倒计时(>0 时逐秒提示,避免用户错过截图时机)
const delaySec = Math.max(0, Math.floor(settings.value.delay || 0))
if (delaySec > 0) {
for (let i = delaySec; i > 0; i--) {
toast(`${i} 秒后开始截图`, { duration: 1000 })
await new Promise<void>((r) => setTimeout(r, 1000))
}
}
// 捕获虚拟屏(覆盖层隐藏 → 不会出现在截图中),仅存原始像素,不做 PNG 编码
await invoke('screenshot_capture_fullscreen')
// 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 DPI 下保证底图 1:1 与坐标一致)
const rect = await computeVirtualPhysicalRect()
await overlayWin?.setPosition(new PhysicalPosition(rect.x, rect.y))
await overlayWin?.setSize(new PhysicalSize(rect.width, rect.height))
await waitOverlayReady()
await emit('screenshot-begin')
} catch (e) {
console.error('[screenshot] 捕获失败', e)
toast.error('截图启动失败:' + (e as Error).message)
// 失败时清理静态缓存
await invoke('screenshot_take_fullscreen').catch(() => {})
await invoke('screenshot_clear_fullscreen').catch(() => {})
} finally {
capturing.value = false
}
}
async function openOverlay(mode: CaptureMode) {
const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL)
if (existing) await existing.close()
const rect = await computeVirtualLogicalRect()
const url = `index.html#screenshot-overlay?mode=${mode}`
new WebviewWindow(OVERLAY_LABEL, {
url,
title: '截图',
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
decorations: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
shadow: false,
focus: true,
visible: true,
function resetOverlayReady() {
overlayReadyPromise = new Promise<void>((resolve) => {
overlayReadyResolve = resolve
})
}
async function openEditor() {
const existing = await WebviewWindow.getByLabel(EDITOR_LABEL)
if (existing) {
await existing.show()
await existing.setFocus()
return
/** 等待覆盖层就绪(首次创建时等 onMounted 完成,之后立即返回) */
async function waitOverlayReady() {
await Promise.race([
overlayReadyPromise,
new Promise<void>((r) => setTimeout(r, 3000)),
])
}
/** 创建常驻覆盖层窗口(隐藏、透明、置顶、无任务栏;启动时创建,复用直到应用退出) */
async function ensureOverlay() {
if (overlayWin) {
const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL).catch(() => null)
if (existing) {
overlayWin = existing
return
}
overlayWin = null
}
new WebviewWindow(EDITOR_LABEL, {
resetOverlayReady()
overlayWin = new WebviewWindow(OVERLAY_LABEL, {
url: 'index.html#screenshot-overlay',
title: '截图',
x: 0,
y: 0,
width: 800,
height: 600,
// 必须 trueresizable:false 在 Windows 上会导致 setSize 失效,
// 覆盖层无法铺满虚拟屏,选区被 clamp 到初始 800x600 区域(表现为工具栏始终停在左上角)
resizable: true,
decorations: false,
alwaysOnTop: true,
skipTaskbar: true,
transparent: true,
shadow: false,
focus: false,
visible: false,
})
}
/** 初始化:注册覆盖层就绪监听并预创建常驻覆盖层窗口(应用启动时调用) */
async function initOverlay() {
if (readyListenerInit) return
readyListenerInit = true
resetOverlayReady()
await listen('screenshot-overlay-ready', () => {
overlayReadyResolve?.()
})
await ensureOverlay()
}
async function openEditor() {
new WebviewWindow(`screenshot-editor-${Date.now()}`, {
url: 'index.html#screenshot-editor',
title: '截图编辑器',
width: 960,
@@ -136,6 +213,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
})
}
// ===== 历史 / 导出 =====
function addRecent(pngBase64: string, width: number, height: number, mode: string) {
recent.value.unshift({
id: crypto.randomUUID(),
@@ -145,7 +223,12 @@ export const useScreenshotStore = defineStore('screenshot', () => {
time: Date.now(),
mode,
})
if (recent.value.length > 12) recent.value.pop()
const limit = Math.max(1, settings.value.historyLimit)
if (recent.value.length > limit) recent.value.length = limit
}
function clearHistory() {
recent.value = []
}
async function copyImage(pngBase64: string) {
@@ -168,7 +251,26 @@ export const useScreenshotStore = defineStore('screenshot', () => {
toast.success('已保存到文件')
}
/** 监听编辑器导出事件(主窗口记录历史 + 提示) */
/** 覆盖层/编辑器导出处理:记录历史 + 按设置自动保存 */
async function handleExport(payload: { pngBase64: string; width: number; height: number }) {
const { pngBase64, width, height } = payload
addRecent(pngBase64, width, height, 'capture')
// 自动保存到指定目录
if (settings.value.autoSave && settings.value.saveDir) {
const ts = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19)
const path = `${settings.value.saveDir.replace(/\\$/, '')}\\screenshot_${ts}.png`
try {
await invoke('screenshot_save_png', { pngBase64, path })
} catch (e) {
console.error('[screenshot] 自动保存失败', e)
}
}
}
/** 监听覆盖层/编辑器导出事件(主窗口记录历史 + 自动保存) */
async function initExportListener() {
if (exportUnlisten) return
exportUnlisten = await listen<{
@@ -176,10 +278,41 @@ export const useScreenshotStore = defineStore('screenshot', () => {
width: number
height: number
}>('screenshot-exported', (e) => {
addRecent(e.payload.pngBase64, e.payload.width, e.payload.height, 'edited')
void handleExport(e.payload)
})
}
// ===== 全局快捷键 =====
/** 监听 Rust 侧 emit 的 'screenshot-shortcut' 事件(快捷键按下时触发) */
async function initShortcutListener() {
if (shortcutUnlisten) return
shortcutUnlisten = await listen('screenshot-shortcut', () => {
void startCapture()
})
}
/** 应用启动时按已保存的快捷键注册全局热键(支持自定义,默认 Ctrl+Alt+A */
async function initShortcutRegistration() {
try {
await invoke('screenshot_register_shortcut', { shortcut: settings.value.shortcut })
} catch (e) {
console.error('[screenshot] 快捷键注册失败', e)
}
}
/** 修改快捷键:持久化 + 重新注册(传空字符串禁用) */
async function setShortcut(shortcut: string) {
const next = shortcut.trim()
setSettings({ shortcut: next })
try {
await invoke('screenshot_register_shortcut', { shortcut: next })
return true
} catch (e) {
console.error('[screenshot] 快捷键注册失败', e)
return false
}
}
function destroyExportListener() {
if (exportUnlisten) {
exportUnlisten()
@@ -187,15 +320,32 @@ export const useScreenshotStore = defineStore('screenshot', () => {
}
}
function destroyShortcutListener() {
if (shortcutUnlisten) {
shortcutUnlisten()
shortcutUnlisten = null
}
}
return {
capturing,
recent,
settings,
startCapture,
initOverlay,
openEditor,
addRecent,
clearHistory,
copyImage,
saveImage,
handleExport,
loadSettings,
setSettings,
setShortcut,
initExportListener,
destroyExportListener,
initShortcutListener,
initShortcutRegistration,
destroyShortcutListener,
}
})