Files
Thing/src/stores/screenshotStore.ts
T
2026-08-07 18:09:13 +08:00

653 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 URLJPEG~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<RecentCapture[]>([])
const settings = ref<ScreenshotSettings>({ ...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<void> | 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<void> | 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<ScreenshotSettings> & { 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<ScreenshotSettings>) {
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<void>((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<void>((resolve) => {
overlayReadyResolve = resolve
})
}
/** 等待覆盖层就绪(首次创建时等 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
}
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()
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 URLcanvas 缩放至 ~256px 宽,JPEG 压缩) */
function makeThumb(pngBase64: string, width: number, height: number): Promise<string> {
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<string> {
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<void>((resolve) => {
pinReadyResolve = resolve
})
}
/** 等待贴图窗口就绪(首次创建时等 onMounted 完成,之后立即返回) */
async function waitPinReady() {
await Promise.race([
pinReadyPromise,
new Promise<void>((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:trueWindows 上非 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,
}
})