截图模块调整
This commit is contained in:
+4
-4
@@ -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
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
// 必须 true:resizable: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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user