import { defineStore } from 'pinia' import { ref } from 'vue' import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi' import { availableMonitors } from '@tauri-apps/api/window' import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event' import { toast } from 'vue-sonner' import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' // Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts) import { commands } from '@/lib/bindings' export interface RecentCapture { id: string /** 缩略图 data URL(JPEG,~256px 宽,常驻内存的只有它,完整图已落盘缓存) */ thumb: string /** 完整 PNG 缓存文件路径(按需通过 screenshot_load_cache 加载) */ filePath: string width: number height: number time: number mode: string /** 截图时框选区域的物理屏幕坐标(贴图窗口按原位置显示;编辑器导出可能缺失) */ posX?: number posY?: number } /** 截图设置(localStorage 持久化) */ export interface ScreenshotSettings { /** 截图完成后自动保存到目录 */ autoSave: boolean /** 自动保存目录 */ saveDir: string /** 历史记录保留条数 */ historyLimit: number /** 全局截图快捷键(空字符串表示禁用) */ shortcut: string /** 贴图全局快捷键(空字符串表示禁用),再次按下关闭贴图 */ pinShortcut: string /** 截图延时(秒),0 表示立即截图 */ delay: number } const SETTINGS_KEY = 'screenshot-settings' /** 设置版本:结构变更(默认值/字段增删)时递增,清除旧数据以应用新默认值 */ const SETTINGS_VERSION = 2 const DEFAULT_SETTINGS: ScreenshotSettings = { autoSave: false, saveDir: '', historyLimit: 48, shortcut: 'ctrl+1', pinShortcut: 'alt+1', delay: 0, } export const useScreenshotStore = defineStore('screenshot', () => { const capturing = ref(false) const recent = ref([]) const settings = ref({ ...DEFAULT_SETTINGS }) let exportUnlisten: UnlistenFn | null = null let shortcutUnlisten: UnlistenFn | null = null /** 覆盖层就绪事件监听(initOverlay 注册,destroyExportListener 一并释放) */ let overlayReadyUnlisten: UnlistenFn | null = null /** 常驻截图覆盖层窗口(启动时创建,之后每次截图复用,避免重复 WebView 初始化) */ const OVERLAY_LABEL = WINDOWS.screenshotOverlay let overlayWin: WebviewWindow | null = null let overlayReadyResolve: (() => void) | null = null let overlayReadyPromise: Promise | null = null let readyListenerInit = false // ===== 贴图窗口 ===== const PIN_LABEL = WINDOWS.screenshotPin const PIN_INDEX_KEY = STORAGE_KEYS.screenshotPinIndex /** 常驻贴图窗口(首次贴图时创建,之后复用;无边框透明置顶) */ let pinWin: WebviewWindow | null = null let pinReadyUnlisten: UnlistenFn | null = null let pinReadyListenerInit = false let pinReadyResolve: (() => void) | null = null let pinReadyPromise: Promise | null = null /** 贴图全局快捷键事件监听 */ let pinShortcutUnlisten: UnlistenFn | null = null // ===== 历史持久化 ===== const HISTORY_KEY = STORAGE_KEYS.screenshotHistory const HISTORY_VERSION = 1 // ===== 设置持久化 ===== function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY) if (raw) { const parsed = JSON.parse(raw) as Partial & { version?: number } if (parsed.version !== SETTINGS_VERSION) { // 结构变更:重置为新默认值(应用新的默认快捷键 / 贴图快捷键 / 保留数量) settings.value = { ...DEFAULT_SETTINGS } saveSettings() } else { settings.value = { ...DEFAULT_SETTINGS, ...parsed } } } } catch (e) { console.error('[screenshot] 加载设置失败', e) localStorage.removeItem(SETTINGS_KEY) } } function saveSettings() { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify({ version: SETTINGS_VERSION, ...settings.value })) } catch (e) { console.error('[screenshot] 保存设置失败', e) } } function setSettings(patch: Partial) { settings.value = { ...settings.value, ...patch } saveSettings() // 保留数量变更:立即裁剪历史(含删除超限缓存文件) if (patch.historyLimit !== undefined) { enforceHistoryLimit() persistHistory() } } // ===== 窗口定位 ===== /** 计算所有显示器的物理像素联合矩形(覆盖层必须用物理尺寸,保证底图 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) { 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 } } // ===== 截图流程 ===== /** 启动截图:延时倒计时 → 捕获虚拟屏 → 定位常驻覆盖层 → 通知覆盖层开始 */ async function startCapture() { if (capturing.value) return capturing.value = true try { await ensureOverlay() // 若覆盖层当前可见(上一次会话未结束),先隐藏再捕获,避免覆盖层自身出现在截图里 if (overlayWin) { try { if (await overlayWin.isVisible()) await overlayWin.hide() } catch { /* 窗口可能尚未就绪,忽略 */ } } // 延时倒计时(>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((r) => setTimeout(r, 1000)) } } // 捕获虚拟屏(覆盖层隐藏 → 不会出现在截图中),仅存原始像素,不做 PNG 编码 await commands.screenshotCaptureFullscreen() // 用物理像素把覆盖层对齐到虚拟屏(多显示器/混合 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(EVENTS.screenshotBegin) } catch (e) { console.error('[screenshot] 捕获失败', e) toast.error('截图启动失败:' + (e as Error).message) await commands.screenshotClearFullscreen().catch(() => {}) } finally { capturing.value = false } } function resetOverlayReady() { overlayReadyPromise = new Promise((resolve) => { overlayReadyResolve = resolve }) } /** 等待覆盖层就绪(首次创建时等 onMounted 完成,之后立即返回) */ async function waitOverlayReady() { await Promise.race([ overlayReadyPromise, new Promise((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 } 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() overlayReadyUnlisten = await listen(EVENTS.screenshotOverlayReady, () => { overlayReadyResolve?.() }) await ensureOverlay() } async function openEditor() { new WebviewWindow(`screenshot-editor-${Date.now()}`, { url: 'index.html#screenshot-editor', title: '截图编辑器', width: 960, height: 720, minWidth: 640, minHeight: 480, decorations: false, transparent: true, alwaysOnTop: false, skipTaskbar: false, resizable: true, shadow: true, focus: true, visible: true, }) } // ===== 历史 / 导出 ===== /** 由完整 PNG base64 生成缩略图 data URL(canvas 缩放至 ~256px 宽,JPEG 压缩) */ function makeThumb(pngBase64: string, width: number, height: number): Promise { return new Promise((resolve, reject) => { const img = new Image() img.onload = () => { try { const targetW = 256 const scale = targetW / width const targetH = Math.max(1, Math.round(height * scale)) const canvas = document.createElement('canvas') canvas.width = targetW canvas.height = targetH const ctx = canvas.getContext('2d') if (!ctx) { reject(new Error('无法创建画布上下文')) return } ctx.drawImage(img, 0, 0, targetW, targetH) resolve(canvas.toDataURL('image/jpeg', 0.7)) } catch (e) { reject(e) } } img.onerror = () => reject(new Error('缩略图解码失败')) img.src = `data:image/png;base64,${pngBase64}` }) } /** 按保留数量裁剪历史(超出部分删除缓存文件) */ function enforceHistoryLimit() { const limit = Math.max(1, settings.value.historyLimit) if (recent.value.length > limit) { const removed = recent.value.splice(limit) for (const item of removed) { void commands.screenshotDeleteCache(item.filePath) } } } /** 持久化历史元数据(含缩略图与定位坐标)到 localStorage,供重启恢复 */ function persistHistory() { try { localStorage.setItem( HISTORY_KEY, JSON.stringify({ version: HISTORY_VERSION, items: recent.value }), ) } catch (e) { console.error('[screenshot] 持久化历史失败(可能超出存储限额)', e) } } /** 应用启动时恢复历史:版本不匹配/损坏则丢弃;并按当前保留数量清理超限文件 */ function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY) if (!raw) return const parsed = JSON.parse(raw) as { version?: number; items?: RecentCapture[] } if (parsed.version !== HISTORY_VERSION || !Array.isArray(parsed.items)) { localStorage.removeItem(HISTORY_KEY) return } const limit = Math.max(1, settings.value.historyLimit) const items = parsed.items.slice(0, limit) for (const extra of parsed.items.slice(limit)) { void commands.screenshotDeleteCache(extra.filePath) } recent.value = items } catch (e) { console.error('[screenshot] 恢复历史失败', e) recent.value = [] } } /** 追加历史项:持久化 + 删除超出上限的最旧缓存文件 */ function addRecent( thumb: string, filePath: string, width: number, height: number, mode: string, pos?: { x: number; y: number }, ) { recent.value.unshift({ id: crypto.randomUUID(), thumb, filePath, width, height, time: Date.now(), mode, ...(pos ? { posX: pos.x, posY: pos.y } : {}), }) enforceHistoryLimit() persistHistory() } /** 移除单个历史项(同时删除缓存文件) */ async function removeRecent(item: RecentCapture) { const idx = recent.value.findIndex((r) => r.id === item.id) if (idx >= 0) recent.value.splice(idx, 1) persistHistory() try { await commands.screenshotDeleteCache(item.filePath) } catch (e) { console.error('[screenshot] 删除缓存失败', e) } } async function clearHistory() { for (const item of recent.value) { void commands.screenshotDeleteCache(item.filePath) } recent.value = [] persistHistory() } /** 从历史缓存加载完整 PNG base64(一次性,不常驻) */ async function loadFullImage(item: RecentCapture): Promise { return await commands.screenshotLoadCache(item.filePath) } async function copyImage(pngBase64: string) { await commands.screenshotCopyImage(pngBase64) toast.success('已复制到剪贴板') } async function saveImage(pngBase64: string) { const { save } = await import('@tauri-apps/plugin-dialog') const ts = new Date() .toISOString() .replace(/[:.]/g, '-') .slice(0, 19) const path = await save({ defaultPath: `screenshot_${ts}.png`, filters: [{ name: 'PNG', extensions: ['png'] }], }) if (!path) return await commands.screenshotSavePng(pngBase64, path) toast.success('已保存到文件') } /** 覆盖层/编辑器导出处理:记录历史(缩略图 + 缓存落盘)+ 按设置自动保存 */ async function handleExport(payload: { pngBase64: string width: number height: number /** 截图时框选区域的物理屏幕坐标(贴图窗口按原位置显示;编辑器导出缺失) */ posX?: number posY?: number }) { const { pngBase64, width, height, posX, posY } = payload try { // 完整图落盘缓存目录,历史只保留缩略图,避免完整 base64 常驻内存 const filePath = await commands.screenshotSaveCache(pngBase64) const thumb = await makeThumb(pngBase64, width, height) addRecent( thumb, filePath, width, height, 'capture', posX !== undefined && posY !== undefined ? { x: posX, y: posY } : undefined, ) } catch (e) { console.error('[screenshot] 历史缓存失败', e) } // 自动保存到指定目录 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 commands.screenshotSavePng(pngBase64, path) } catch (e) { console.error('[screenshot] 自动保存失败', e) } } } /** 监听覆盖层/编辑器导出事件(主窗口记录历史 + 自动保存) */ async function initExportListener() { if (exportUnlisten) return exportUnlisten = await listen<{ pngBase64: string width: number height: number posX?: number posY?: number }>(EVENTS.screenshotExported, (e) => { void handleExport(e.payload) }) } // ===== 全局快捷键 ===== /** 监听 Rust 侧 emit 的 'screenshot-shortcut' 事件(快捷键按下时触发) */ async function initShortcutListener() { if (shortcutUnlisten) return shortcutUnlisten = await listen(EVENTS.screenshotShortcut, () => { void startCapture() }) } /** 应用启动时按已保存的快捷键注册全局热键(支持自定义,默认 Ctrl+Alt+A) */ async function initShortcutRegistration() { try { await commands.screenshotRegisterShortcut(settings.value.shortcut) } catch (e) { console.error('[screenshot] 快捷键注册失败', e) } } /** 修改快捷键:持久化 + 重新注册(传空字符串禁用) */ async function setShortcut(shortcut: string) { const next = shortcut.trim() setSettings({ shortcut: next }) try { await commands.screenshotRegisterShortcut(next) return true } catch (e) { console.error('[screenshot] 快捷键注册失败', e) return false } } // ===== 贴图窗口 ===== function resetPinReady() { pinReadyPromise = new Promise((resolve) => { pinReadyResolve = resolve }) } /** 等待贴图窗口就绪(首次创建时等 onMounted 完成,之后立即返回) */ async function waitPinReady() { await Promise.race([ pinReadyPromise, new Promise((r) => setTimeout(r, 3000)), ]) } /** 注册贴图窗口就绪监听(首次贴图前调用) */ async function ensurePinReadyListener() { if (pinReadyListenerInit) return pinReadyListenerInit = true resetPinReady() pinReadyUnlisten = await listen(EVENTS.screenshotPinReady, () => { pinReadyResolve?.() }) } /** 创建常驻贴图窗口(无边框、透明、置顶、无任务栏;首次贴图时创建,之后复用) */ async function ensurePinWindow() { if (pinWin) { const existing = await WebviewWindow.getByLabel(PIN_LABEL).catch(() => null) if (existing) { pinWin = existing return } pinWin = null } resetPinReady() pinWin = new WebviewWindow(PIN_LABEL, { url: 'index.html#screenshot-pin', title: '贴图', x: 0, y: 0, width: 120, height: 120, // 创建时必须 resizable:true(Windows 上非 resizable 窗口 setSize 会失效); // 每次布局结束后在贴图窗口内恢复 setResizable(false),禁用边缘拖动缩放,仅保留左键自由拖动 resizable: true, decorations: false, alwaysOnTop: true, skipTaskbar: true, transparent: true, shadow: false, focus: false, visible: false, }) await waitPinReady() } /** * 切换贴图:已打开则关闭;否则把第 index 张截图贴到屏幕(原框选位置)。 * index 省略时为最近一张截图。 */ async function togglePin(index?: number) { if (!recent.value.length) { toast('暂无截图可贴图') return } await ensurePinReadyListener() if (pinWin) { try { if (await pinWin.isVisible()) { await pinWin.hide() return } } catch { /* 窗口可能尚未就绪,忽略 */ } } const i = Math.max(0, Math.min(index ?? 0, recent.value.length - 1)) localStorage.setItem(PIN_INDEX_KEY, String(i)) await ensurePinWindow() await emit(EVENTS.screenshotPinShow) } /** 监听 Rust 侧 emit 的 'screenshot-pin-shortcut' 事件(贴图快捷键按下时切换) */ async function initPinShortcutListener() { if (pinShortcutUnlisten) return pinShortcutUnlisten = await listen(EVENTS.screenshotPinShortcut, () => { void togglePin() }) } /** 应用启动时按已保存的设置注册贴图全局快捷键(默认 Alt+1) */ async function initPinShortcutRegistration() { try { await commands.screenshotRegisterPinShortcut(settings.value.pinShortcut) } catch (e) { console.error('[screenshot] 贴图快捷键注册失败', e) } } /** 修改贴图快捷键:持久化 + 重新注册(传空字符串禁用) */ async function setPinShortcut(shortcut: string) { const next = shortcut.trim() setSettings({ pinShortcut: next }) try { await commands.screenshotRegisterPinShortcut(next) return true } catch (e) { console.error('[screenshot] 贴图快捷键注册失败', e) return false } } function destroyExportListener() { if (exportUnlisten) { exportUnlisten() exportUnlisten = null } if (overlayReadyUnlisten) { overlayReadyUnlisten() overlayReadyUnlisten = null } if (pinReadyUnlisten) { pinReadyUnlisten() pinReadyUnlisten = null } } function destroyShortcutListener() { if (shortcutUnlisten) { shortcutUnlisten() shortcutUnlisten = null } if (pinShortcutUnlisten) { pinShortcutUnlisten() pinShortcutUnlisten = null } } return { capturing, recent, settings, startCapture, initOverlay, openEditor, addRecent, clearHistory, copyImage, saveImage, handleExport, removeRecent, loadFullImage, loadSettings, loadHistory, setSettings, setShortcut, togglePin, setPinShortcut, initExportListener, destroyExportListener, initShortcutListener, initShortcutRegistration, destroyShortcutListener, initPinShortcutListener, initPinShortcutRegistration, } })