截图模块调整

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
+15 -6
View File
@@ -5,13 +5,14 @@
"": { "": {
"name": "thing", "name": "thing",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.25.0", "@lucide/vue": "^1.28.0",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-autostart": "~2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-global-shortcut": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@@ -116,7 +117,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/vue": ["@lucide/vue@1.25.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ=="], "@lucide/vue": ["@lucide/vue@1.28.0", "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
@@ -234,6 +235,8 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.2", "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -280,11 +283,11 @@
"@vue/shared": ["@vue/shared@3.5.39", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@vue/shared": ["@vue/shared@3.5.39", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="],
"@vueuse/core": ["@vueuse/core@14.3.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="], "@vueuse/core": ["@vueuse/core@14.4.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.4.0", "@vueuse/shared": "14.4.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ=="],
"@vueuse/metadata": ["@vueuse/metadata@14.3.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="], "@vueuse/metadata": ["@vueuse/metadata@14.4.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", {}, "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g=="],
"@vueuse/shared": ["@vueuse/shared@14.3.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="], "@vueuse/shared": ["@vueuse/shared@14.4.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g=="],
"alien-signals": ["alien-signals@1.0.13", "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="], "alien-signals": ["alien-signals@1.0.13", "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
@@ -433,5 +436,11 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"reka-ui/@vueuse/core": ["@vueuse/core@14.3.0", "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="],
"reka-ui/@vueuse/shared": ["@vueuse/shared@14.3.0", "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="],
"reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@14.3.0", "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="],
} }
} }
+1
View File
@@ -56,6 +56,7 @@ windows-sys = { version = "0.52", features = [
"Win32_UI_HiDpi", "Win32_UI_HiDpi",
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_Graphics_Dwm",
"Win32_Storage_Xps", "Win32_Storage_Xps",
] } ] }
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "screenshot", "identifier": "screenshot",
"description": "Capability for screenshot overlay and editor windows", "description": "Capability for screenshot overlay and editor windows",
"windows": ["screenshot-overlay", "screenshot-editor"], "windows": ["screenshot-overlay*", "screenshot-editor-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-hide", "core:window:allow-hide",
+21 -6
View File
@@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use tauri::Emitter;
use tauri::Manager; use tauri::Manager;
mod clipboard; mod clipboard;
@@ -45,10 +46,12 @@ use process_manager::{
stop_all_processes, stop_process, ProcessManager, stop_all_processes, stop_process, ProcessManager,
}; };
use screenshot::commands::{ use screenshot::commands::{
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_copy_image, screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
screenshot_crop_stored, screenshot_enum_windows, screenshot_get_editor_image, screenshot_copy_image, screenshot_crop_copy_stored, screenshot_crop_stored,
screenshot_save_png, screenshot_set_editor_image, screenshot_take_fullscreen, screenshot_cursor_pos, screenshot_disable_transitions, screenshot_enum_windows,
screenshot_window_from_point, screenshot_fullscreen_png, screenshot_get_editor_image, screenshot_get_fullscreen_bmp,
screenshot_register_shortcut, screenshot_save_png, screenshot_set_editor_image,
screenshot_unregister_shortcut, screenshot_window_from_point,
}; };
use clipboard::{ use clipboard::{
ClipboardManager, ClipboardManager,
@@ -94,6 +97,8 @@ pub fn run() {
.plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_autostart::Builder::new().build())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
// 截图快捷键改由 screenshot::shortcut::register_shortcuton_shortcut)按需注册,
// 支持自定义;默认 Ctrl+Alt+A 由前端 store 启动时注册
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin( .plugin(
@@ -195,15 +200,22 @@ pub fn run() {
tray_menu_ready, tray_menu_ready,
snap_fix::fix_snap_background, snap_fix::fix_snap_background,
screenshot_capture_fullscreen, screenshot_capture_fullscreen,
screenshot_take_fullscreen, screenshot_get_fullscreen_bmp,
screenshot_fullscreen_png,
screenshot_clear_fullscreen,
screenshot_crop_stored, screenshot_crop_stored,
screenshot_crop_copy_stored,
screenshot_window_from_point, screenshot_window_from_point,
screenshot_cursor_pos,
screenshot_enum_windows, screenshot_enum_windows,
screenshot_capture_window, screenshot_capture_window,
screenshot_set_editor_image, screenshot_set_editor_image,
screenshot_get_editor_image, screenshot_get_editor_image,
screenshot_copy_image, screenshot_copy_image,
screenshot_save_png screenshot_save_png,
screenshot_register_shortcut,
screenshot_unregister_shortcut,
screenshot_disable_transitions
]) ])
.setup(|app| { .setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/ // 初始化日志系统,日志目录: {app_data_dir}/logs/
@@ -296,6 +308,9 @@ pub fn run() {
}); });
} }
// 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册
// (支持自定义,默认 Ctrl+Alt+A),此处不再硬编码注册
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
+259 -56
View File
@@ -3,7 +3,7 @@
//! 实现: //! 实现:
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素 //! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口) //! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口 //! - 窗口拾取:EnumWindows 按 Z 序命中测试(排除本进程窗口,避免命中覆盖层自身)
//! - 顶层窗口枚举:EnumWindows //! - 顶层窗口枚举:EnumWindows
//! - 像素 → PNG / CF_DIB 转换 //! - 像素 → PNG / CF_DIB 转换
//! //!
@@ -15,7 +15,7 @@
//! 直接编码 PNG 会得到全透明图,故强制不透明。 //! 直接编码 PNG 会得到全透明图,故强制不透明。
use std::sync::Mutex; use std::sync::Mutex;
use windows_sys::Win32::Foundation::{HWND, POINT, RECT}; use windows_sys::Win32::Foundation::{BOOL, HWND, POINT, RECT};
use windows_sys::Win32::Graphics::Gdi::{ use windows_sys::Win32::Graphics::Gdi::{
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS, PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
@@ -28,8 +28,9 @@ use windows_sys::Win32::System::DataExchange::{
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
use windows_sys::Win32::System::Ole::CF_DIB; use windows_sys::Win32::System::Ole::CF_DIB;
use windows_sys::Win32::UI::WindowsAndMessaging::{ use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetAncestor, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect, EnumWindows, GetCursorPos, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect,
GetWindowTextLengthW, GetWindowTextW, GWL_EXSTYLE, GA_ROOT, WS_EX_TOOLWINDOW, WindowFromPoint, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, GWL_EXSTYLE,
WS_EX_TOOLWINDOW,
}; };
use super::{CaptureData, ScreenRect, WindowInfo}; use super::{CaptureData, ScreenRect, WindowInfo};
@@ -64,9 +65,54 @@ const SM_CYVIRTUALSCREEN: i32 = 79;
const PW_RENDERFULLCONTENT: u32 = 0x00000002; const PW_RENDERFULLCONTENT: u32 = 0x00000002;
const BI_RGB: u32 = 0; const BI_RGB: u32 = 0;
/// 将 BGRA top-down 像素编码为 BMP(强制 alpha=255
///
/// 用于覆盖层/编辑器快速显示:相比 PNG 编码几乎零 CPU 开销,
/// ChromiumWebView2)原生支持 top-down 32bpp BMP。
fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let w = width as usize;
let h = height as usize;
let data_size = w * h * 4;
let file_size = 14 + 40 + data_size;
// BitBlt 取出的 alpha 通道未定义(常为 0),强制不透明
let mut pixels = bgra.to_vec();
for a in pixels.iter_mut().skip(3).step_by(4) {
*a = 255;
}
let mut bmp = Vec::with_capacity(file_size);
// BITMAPFILEHEADER
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&(file_size as u32).to_le_bytes());
bmp.extend_from_slice(&0u16.to_le_bytes()); // reserved
bmp.extend_from_slice(&0u16.to_le_bytes()); // reserved
bmp.extend_from_slice(&54u32.to_le_bytes()); // 像素数据偏移
// BITMAPINFOHEADER
bmp.extend_from_slice(&40u32.to_le_bytes()); // header size
bmp.extend_from_slice(&(width as i32).to_le_bytes());
bmp.extend_from_slice(&(-(height as i32)).to_le_bytes()); // 负值 = top-down
bmp.extend_from_slice(&1u16.to_le_bytes()); // planes
bmp.extend_from_slice(&32u16.to_le_bytes()); // bpp
bmp.extend_from_slice(&0u32.to_le_bytes()); // BI_RGB
bmp.extend_from_slice(&(data_size as u32).to_le_bytes());
bmp.extend_from_slice(&0u32.to_le_bytes()); // x ppm
bmp.extend_from_slice(&0u32.to_le_bytes()); // y ppm
bmp.extend_from_slice(&0u32.to_le_bytes()); // colors used
bmp.extend_from_slice(&0u32.to_le_bytes()); // important colors
bmp.extend_from_slice(&pixels);
Ok(bmp)
}
/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255 /// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255
///
/// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比,
/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> { fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::PngEncoder; use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder; use image::ImageEncoder;
if width <= 0 || height <= 0 { if width <= 0 || height <= 0 {
return Err("无效尺寸".into()); return Err("无效尺寸".into());
@@ -81,9 +127,13 @@ fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String>
rgba[i * 4 + 3] = 255; // 强制不透明 rgba[i * 4 + 3] = 255; // 强制不透明
} }
let mut buf = Vec::with_capacity(w * h * 4 / 3); let mut buf = Vec::with_capacity(w * h * 4 / 3);
PngEncoder::new(&mut buf) PngEncoder::new_with_quality(
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8) &mut buf,
.map_err(|e| format!("PNG 编码失败: {}", e))?; CompressionType::Fast,
FilterType::NoFilter,
)
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("PNG 编码失败: {}", e))?;
Ok(buf) Ok(buf)
} }
@@ -164,13 +214,11 @@ pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
let result = if ok == 0 { let result = if ok == 0 {
Err("BitBlt 失败".into()) Err("BitBlt 失败".into())
} else { } else {
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| { extract_pixels(hdc_mem, hbm, w, h).map(|bgra| CapturedImage {
bgra_to_png(&bgra, w, h).map(|png| CapturedImage { width: w,
width: w, height: h,
height: h, png: Vec::new(), // 全屏捕获不做 PNG 编码,显示/裁剪走 raw BMP
png, bgra,
bgra,
})
}) })
}; };
@@ -232,34 +280,117 @@ pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取) /// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
/// ///
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。 /// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
///
/// 不能直接用 WindowFromPoint:覆盖层是 alwaysOnTop 全屏窗口,会命中覆盖层自身。
/// 改为 EnumWindows 按 Z 序(顶→底)枚举顶层窗口做命中测试,并排除本进程
/// (覆盖层/主窗口/编辑器)的窗口,从而取到覆盖层下面的目标窗口。
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> { pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
struct PickContext {
my_pid: u32,
pt: POINT,
found: Option<WindowInfo>,
}
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
unsafe {
let ctx = &mut *(lparam as *mut PickContext);
// 跳过本进程窗口(覆盖层 / 主窗口 / 编辑器等)
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid == ctx.my_pid {
return 1;
}
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
return 1;
}
// 跳过工具窗口(如本应用 OSD / 托盘菜单)
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
return 1;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
// 命中测试(物理坐标),Z 序最顶层的第一个命中即为目标
let pt = ctx.pt;
if pt.x >= rect.left && pt.x < rect.right && pt.y >= rect.top && pt.y < rect.bottom {
ctx.found = Some(WindowInfo {
hwnd,
title: get_window_title(hwnd),
rect: ScreenRect::from(rect),
visual_rect: extended_frame_bounds(hwnd),
});
return 0; // 停止枚举
}
}
1
}
let mut ctx = PickContext {
my_pid: std::process::id(),
pt: POINT { x, y },
found: None,
};
unsafe { unsafe {
let pt = POINT { x, y }; EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize);
let mut hwnd = WindowFromPoint(pt); }
if hwnd == 0 { ctx.found
return None; }
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
pub fn cursor_pos() -> Result<(i32, i32), String> {
unsafe {
let mut pt: POINT = std::mem::zeroed();
if GetCursorPos(&mut pt) == 0 {
return Err("GetCursorPos 失败".into());
} }
// 取顶层父窗口(WindowFromPoint 可能返回子窗口) Ok((pt.x, pt.y))
let root = GetAncestor(hwnd, GA_ROOT); }
if root != 0 { }
hwnd = root;
} /// DWM 扩展边框矩形(视觉边界)
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 { ///
return None; /// GetWindowRect 对最大化窗口包含屏幕外的隐形缩放边框(各向 7~8px),
} /// 导致窗口高亮框比实际窗口大一圈;DWMWA_EXTENDED_FRAME_BOUNDS 返回可视边界。
let mut rect: RECT = std::mem::zeroed(); fn extended_frame_bounds(hwnd: HWND) -> Option<ScreenRect> {
if GetWindowRect(hwnd, &mut rect) == 0 { use windows_sys::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
return None; let mut r: RECT = unsafe { std::mem::zeroed() };
} let hr = unsafe {
let srect = ScreenRect::from(rect); DwmGetWindowAttribute(
if srect.width <= 0 || srect.height <= 0 {
return None;
}
Some(WindowInfo {
hwnd, hwnd,
title: get_window_title(hwnd), DWMWA_EXTENDED_FRAME_BOUNDS as u32,
rect: srect, &mut r as *mut _ as *mut core::ffi::c_void,
}) std::mem::size_of::<RECT>() as u32,
)
};
if hr == 0 {
Some(ScreenRect::from(r))
} else {
None
}
}
/// 禁用指定窗口的显示/隐藏过渡动画
///
/// Windows 默认对 show/hide 播放"缩放+淡入淡出"动画,截图覆盖层会出现
/// "从小变大/从大变小"的过渡;DWMWA_TRANSITIONS_FORCEDISABLED 对该窗口永久关闭过渡。
pub fn disable_window_transitions(hwnd: isize) -> Result<(), String> {
use windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute;
const DWMWA_TRANSITIONS_FORCEDISABLED: u32 = 3;
let mut disabled: BOOL = 1;
let hr = unsafe {
DwmSetWindowAttribute(
hwnd as HWND,
DWMWA_TRANSITIONS_FORCEDISABLED,
&mut disabled as *mut _ as *mut core::ffi::c_void,
std::mem::size_of::<BOOL>() as u32,
)
};
if hr == 0 {
Ok(())
} else {
Err(format!("DwmSetWindowAttribute 失败: {}", hr))
} }
} }
@@ -292,6 +423,7 @@ pub fn enum_visible_windows() -> Vec<WindowInfo> {
hwnd, hwnd,
title, title,
rect: srect, rect: srect,
visual_rect: extended_frame_bounds(hwnd),
}); });
} }
1 1
@@ -334,6 +466,67 @@ pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String
}) })
} }
/// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制"
///
/// 相比前端 crop_stored → copy_image 两次大 base64 往返:
/// 直接从原始 BGRA 构造成 bottom-up DIB,省去 PNG 解码,显著降低"点击完成"延迟。
pub fn crop_copy_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
return Err("裁剪区域越界".into());
}
let sw = img.width as usize;
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
let dib = bgra_to_dib(&sub, w, h);
write_dib_to_clipboard(&dib)?;
let png = bgra_to_png(&sub, w, h)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: w,
height: h,
})
}
/// 将 BGRA top-down 像素构造为 CF_DIBbottom-upalpha 强制 255
fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
let w = width as usize;
let h = height as usize;
let mut dib = Vec::with_capacity(header_size as usize + w * h * 4);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (w * h * 4) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
};
let raw = unsafe {
std::slice::from_raw_parts(
&header as *const _ as *const u8,
std::mem::size_of::<BITMAPINFOHEADER>(),
)
};
dib.extend_from_slice(raw);
// 从 top-down 行序倒序拷贝为 bottom-upalpha 强制 255
for y in (0..h).rev() {
let row = &bgra[y * w * 4..(y + 1) * w * 4];
for x in 0..w {
dib.push(row[x * 4]); // B
dib.push(row[x * 4 + 1]); // G
dib.push(row[x * 4 + 2]); // R
dib.push(255); // A
}
}
dib
}
fn crop_bgra( fn crop_bgra(
src: &[u8], src: &[u8],
src_width: usize, src_width: usize,
@@ -352,30 +545,40 @@ fn crop_bgra(
Ok(out) Ok(out)
} }
/// 存储全屏捕获(覆盖层选区裁剪取用) /// 存储全屏捕获(覆盖层显示/裁剪取用),返回是否成功
pub fn store_fullscreen(img: CapturedImage) -> CaptureData { pub fn store_fullscreen(img: CapturedImage) -> Result<(), String> {
let data = CaptureData { FULLSCREEN_CAPTURE
png_base64: base64_encode(&img.png), .lock()
width: img.width, .map(|mut g| *g = Some(img))
height: img.height, .map_err(|e| e.to_string())
};
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
*g = Some(img);
}
data
} }
/// 取出并清除全屏捕获 /// 取出全屏捕获的 BMP 字节用于显示(不移除,裁剪仍依赖原始像素)
pub fn take_fullscreen() -> Option<CaptureData> { pub fn fullscreen_bmp() -> Result<Vec<u8>, String> {
let mut g = FULLSCREEN_CAPTURE.lock().ok()?; let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = g.take()?; let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
Some(CaptureData { bgra_to_bmp(&img.bgra, img.width, img.height)
png_base64: base64_encode(&img.png), }
/// 全屏捕获编码为 PNG base64 并移除(全屏截图直接进编辑器时用)
pub fn fullscreen_png() -> Result<CaptureData, String> {
let mut g = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
let img = g.take().ok_or("无已存储的全屏捕获")?;
let png = bgra_to_png(&img.bgra, img.width, img.height)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width: img.width, width: img.width,
height: img.height, height: img.height,
}) })
} }
/// 清除全屏捕获(覆盖层关闭/取消时释放内存)
pub fn clear_fullscreen() {
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
*g = None;
}
}
// ===== 输出:剪贴板 / 文件 ===== // ===== 输出:剪贴板 / 文件 =====
/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板 /// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板
+133 -12
View File
@@ -1,8 +1,10 @@
//! Tauri 命令:截图模块 //! Tauri 命令:截图模块
//! //!
//! 命令清单: //! 命令清单:
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64 //! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码)
//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获 //! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除)
//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用)
//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时)
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获 //! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口 //! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
//! - screenshot_enum_windows:枚举可见顶层窗口 //! - screenshot_enum_windows:枚举可见顶层窗口
@@ -10,21 +12,68 @@
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递 //! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
//! - screenshot_copy_image:写入剪贴板(CF_DIB //! - screenshot_copy_image:写入剪贴板(CF_DIB
//! - screenshot_save_png:写入文件 //! - screenshot_save_png:写入文件
//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画)
use super::{CaptureData, WindowInfo}; use super::{CaptureData, WindowInfo};
use tauri::Manager;
/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64 /// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画
#[tauri::command] #[tauri::command]
pub async fn screenshot_capture_fullscreen() -> Result<CaptureData, String> { pub async fn screenshot_disable_transitions(
app: tauri::AppHandle,
label: String,
) -> Result<(), String> {
#[cfg(windows)]
{
use raw_window_handle::HasWindowHandle;
let win = app
.get_webview_window(&label)
.ok_or_else(|| format!("窗口不存在: {}", label))?;
let handle = win
.window_handle()
.map_err(|e| format!("获取窗口句柄失败: {}", e))?;
match handle.as_raw() {
raw_window_handle::RawWindowHandle::Win32(h) => {
super::capture::disable_window_transitions(h.hwnd.get() as isize)
}
_ => Ok(()),
}
}
#[cfg(not(windows))]
{
let _ = (app, label);
Ok(())
}
}
/// 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。
#[tauri::command]
pub async fn screenshot_register_shortcut(
app: tauri::AppHandle,
shortcut: String,
) -> Result<(), String> {
super::shortcut::register_shortcut(&app, &shortcut)
}
/// 注销截图全局快捷键
#[tauri::command]
pub async fn screenshot_unregister_shortcut(app: tauri::AppHandle) -> Result<(), String> {
super::shortcut::unregister_shortcut(&app);
Ok(())
}
/// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码
#[tauri::command]
pub async fn screenshot_capture_fullscreen() -> Result<(), String> {
#[cfg(windows)] #[cfg(windows)]
{ {
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度 // 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
let img = tauri::async_runtime::spawn_blocking(|| { tauri::async_runtime::spawn_blocking(|| {
super::capture::capture_virtual_screen() let img = super::capture::capture_virtual_screen()?;
super::capture::store_fullscreen(img)
}) })
.await .await
.map_err(|e| format!("捕获任务失败: {}", e))??; .map_err(|e| format!("捕获任务失败: {}", e))?
Ok(super::capture::store_fullscreen(img))
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
@@ -32,16 +81,50 @@ pub async fn screenshot_capture_fullscreen() -> Result<CaptureData, String> {
} }
} }
/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用) /// 取出全屏捕获的 BMP 原始字节(raw IPC → 前端 ArrayBuffer),不移除
#[tauri::command] #[tauri::command]
pub async fn screenshot_take_fullscreen() -> Result<Option<CaptureData>, String> { pub async fn screenshot_get_fullscreen_bmp() -> Result<tauri::ipc::Response, String> {
#[cfg(windows)] #[cfg(windows)]
{ {
Ok(super::capture::take_fullscreen()) tauri::async_runtime::spawn_blocking(|| {
let bmp = super::capture::fullscreen_bmp()?;
Ok(tauri::ipc::Response::new(bmp))
})
.await
.map_err(|e| format!("读取捕获失败: {}", e))?
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
Ok(None) Err("截图仅支持 Windows".into())
}
}
/// 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器)
#[tauri::command]
pub async fn screenshot_fullscreen_png() -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(|| super::capture::fullscreen_png())
.await
.map_err(|e| format!("编码任务失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 清除静态全屏捕获(覆盖层关闭/取消时释放内存)
#[tauri::command]
pub async fn screenshot_clear_fullscreen() -> Result<(), String> {
#[cfg(windows)]
{
super::capture::clear_fullscreen();
Ok(())
}
#[cfg(not(windows))]
{
Ok(())
} }
} }
@@ -68,6 +151,29 @@ pub async fn screenshot_crop_stored(
} }
} }
/// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制"
#[tauri::command]
pub async fn screenshot_crop_copy_stored(
x: i32,
y: i32,
w: i32,
h: i32,
) -> Result<CaptureData, String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(move || {
super::capture::crop_copy_stored(x, y, w, h)
})
.await
.map_err(|e| format!("裁剪复制任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = (x, y, w, h);
Err("截图仅支持 Windows".into())
}
}
/// 拾取指定物理屏幕坐标下的顶层窗口 /// 拾取指定物理屏幕坐标下的顶层窗口
#[tauri::command] #[tauri::command]
pub async fn screenshot_window_from_point( pub async fn screenshot_window_from_point(
@@ -89,6 +195,21 @@ pub async fn screenshot_window_from_point(
} }
} }
/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口)
#[tauri::command]
pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> {
#[cfg(windows)]
{
tauri::async_runtime::spawn_blocking(super::capture::cursor_pos)
.await
.map_err(|e| format!("查询任务失败: {}", e))?
}
#[cfg(not(windows))]
{
Err("截图仅支持 Windows".into())
}
}
/// 枚举所有可见顶层窗口 /// 枚举所有可见顶层窗口
#[tauri::command] #[tauri::command]
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> { pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
+3
View File
@@ -8,6 +8,7 @@ use std::sync::Mutex;
#[cfg(windows)] #[cfg(windows)]
pub mod capture; pub mod capture;
pub mod commands; pub mod commands;
pub mod shortcut;
/// 前端可见的捕获数据 /// 前端可见的捕获数据
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -25,6 +26,8 @@ pub struct WindowInfo {
pub hwnd: isize, pub hwnd: isize,
pub title: String, pub title: String,
pub rect: ScreenRect, pub rect: ScreenRect,
/// DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect
pub visual_rect: Option<ScreenRect>,
} }
#[derive(serde::Serialize, Clone, Copy)] #[derive(serde::Serialize, Clone, Copy)]
+60
View File
@@ -0,0 +1,60 @@
//! 截图全局快捷键:可自定义注册/注销(默认 Ctrl+Alt+A)。
//!
//! 与剪贴板快捷弹窗(clipboard::popup)的实现一致:
//! 用 `on_shortcut` 为每个快捷键绑定独立处理器,切换时先注销旧的再注册新的。
//! 按下时 emit `screenshot-shortcut` 事件,前端 store 监听后触发 startCapture。
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 当前已注册的快捷键字符串(用于切换时注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 解析快捷键字符串为 Shortcut(格式如 "Ctrl+Alt+A"、"Shift+PrintScreen"
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().ok()
}
/// 注册全局快捷键。重复调用会先注销旧快捷键。
/// 传入空字符串则仅注销不注册(禁用快捷键)。
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
unregister_shortcut(app);
if shortcut_str.trim().is_empty() {
return Ok(());
}
let shortcut = parse_shortcut(shortcut_str)
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
let app_handle = app.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |_app, _shortcut, event| {
// 仅在按下时触发(松开不触发)
if event.state == ShortcutState::Pressed {
let _ = app_handle.emit("screenshot-shortcut", ());
}
})
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = Some(shortcut_str.to_string());
}
eprintln!("[screenshot] 已注册快捷键: {}", shortcut_str);
Ok(())
}
/// 注销当前快捷键
pub fn unregister_shortcut(app: &AppHandle) {
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
if let Some(ref s) = *cur {
if let Some(shortcut) = parse_shortcut(s) {
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = None;
}
}
+4 -4
View File
@@ -6,7 +6,7 @@ import Sidebar from '@/components/layout/Sidebar.vue'
import ModuleContainer from '@/components/layout/ModuleContainer.vue' import ModuleContainer from '@/components/layout/ModuleContainer.vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { useAppStore } from '@/stores/appStore' import { useAppStore } from '@/stores/appStore'
import { useScreenshotStore, type CaptureMode } from '@/stores/screenshotStore' import { useScreenshotStore } from '@/stores/screenshotStore'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { moduleRegistry } from '@/modules/registry' import { moduleRegistry } from '@/modules/registry'
import type { ModuleMeta } from '@/types/module' import type { ModuleMeta } from '@/types/module'
@@ -159,10 +159,10 @@ onMounted(async () => {
handleModuleChange('settings') handleModuleChange('settings')
}) })
) )
// 托盘截图:直接触发截图流程(不切换模块,覆盖层/编辑器独立窗口) // 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区
trayUnlisteners.push( trayUnlisteners.push(
await listen<CaptureMode>('tray:start-screenshot', (e) => { await listen('tray:start-screenshot', () => {
screenshotStore.startCapture(e.payload || 'region').catch(err => screenshotStore.startCapture().catch(err =>
console.error('[screenshot] 托盘触发截图失败:', err) console.error('[screenshot] 托盘触发截图失败:', err)
) )
}) })
+10 -1
View File
@@ -18,7 +18,6 @@ window.addEventListener('unhandledrejection', (event) => {
// ===== OSD 窗口模式检测 ===== // ===== OSD 窗口模式检测 =====
// 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor // 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor
// 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 // 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块
// 注意:screenshot-overlay 带 query?mode=region),用 startsWith 匹配
const winHash = window.location.hash const winHash = window.location.hash
if (winHash === '#osd-overlay') { if (winHash === '#osd-overlay') {
logger.info(`OSD 窗口启动: ${winHash}`) logger.info(`OSD 窗口启动: ${winHash}`)
@@ -84,6 +83,16 @@ if (winHash === '#osd-overlay') {
void import('./stores/processStore').then(({ useProcessStore }) => { void import('./stores/processStore').then(({ useProcessStore }) => {
useProcessStore().initListener().catch(e => console.error('Process listener init error:', e)) 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"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { 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' } from '@lucide/vue'
import { toast } from 'vue-sonner' 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 { useModuleTabs } from '@/lib/useModuleTabs'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
const store = useScreenshotStore() const store = useScreenshotStore()
const activeTab = ref('capture') const activeTab = ref('settings')
const tabsListRef = useModuleTabs(activeTab, [ const tabsListRef = useModuleTabs(activeTab, [
{ value: 'capture', label: '截图' }, { value: 'settings', label: '设置' },
{ value: 'history', label: '历史' }, { value: 'history', label: '历史' },
]) ])
const captureActions: { mode: CaptureMode; icon: typeof Square; label: string; desc: string }[] = [ const HISTORY_LIMITS = [6, 12, 24, 48]
{ mode: 'region', icon: Square, label: '区域截图', desc: '拖动选择屏幕任意区域' }, const DELAY_OPTIONS = [0, 1, 2, 3, 5]
{ mode: 'window', icon: AppWindow, label: '窗口截图', desc: '点击捕获指定窗口' },
{ mode: 'fullscreen', icon: Maximize, label: '全屏截图', desc: '直接捕获整个虚拟屏' },
]
function formatTime(t: number): string { function formatTime(t: number): string {
const d = new Date(t) const d = new Date(t)
@@ -37,8 +38,16 @@ function thumbSrc(item: RecentCapture): string {
return `data:image/png;base64,${item.pngBase64}` return `data:image/png;base64,${item.pngBase64}`
} }
async function handleCapture(mode: CaptureMode) { async function handleCapture() {
await store.startCapture(mode) 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) { async function handleCopy(item: RecentCapture) {
@@ -55,12 +64,98 @@ function handleDelete(item: RecentCapture) {
toast.success('已从历史移除') 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(() => { onMounted(() => {
store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e)) store.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
}) })
onUnmounted(() => { onUnmounted(() => {
store.destroyExportListener() store.destroyExportListener()
window.removeEventListener('keydown', onRecordKey, true)
}) })
</script> </script>
@@ -69,7 +164,7 @@ onUnmounted(() => {
<Tabs v-model="activeTab" class="h-full flex flex-col"> <Tabs v-model="activeTab" class="h-full flex flex-col">
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0"> <div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
<TabsList> <TabsList>
<TabsTrigger value="capture">截图</TabsTrigger> <TabsTrigger value="settings">设置</TabsTrigger>
<TabsTrigger value="history"> <TabsTrigger value="history">
历史 历史
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground"> <span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
@@ -79,50 +174,152 @@ onUnmounted(() => {
</TabsList> </TabsList>
</div> </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"> <ScrollArea class="h-full">
<div class="p-4 pt-0 space-y-4"> <div class="p-4 pt-0 space-y-4 max-w-3xl mx-auto">
<!-- 截图模式卡片 --> <!-- 快捷键 -->
<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>
<!-- 说明 -->
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="text-sm flex items-center gap-2"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="text-sm text-muted-foreground space-y-1.5"> <CardContent>
<p>· <span class="text-foreground">区域截图</span>进入覆盖层后拖动鼠标选择区域松开后可编辑复制或保存</p> <div class="flex items-center justify-between gap-4 py-1">
<p>· <span class="text-foreground">窗口截图</span>移动鼠标高亮目标窗口点击即可捕获该窗口</p> <div class="space-y-1 min-w-0">
<p>· <span class="text-foreground">全屏截图</span>直接捕获所有显示器拼接画面并进入编辑器</p> <div class="flex items-center gap-2 flex-wrap">
<p>· 选区/编辑器中按 <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 取消</p> <!-- 快捷键录入器 -->
<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> </CardContent>
</Card> </Card>
</div> </div>
@@ -140,7 +337,7 @@ onUnmounted(() => {
> >
<ImageIcon class="h-12 w-12 opacity-40" /> <ImageIcon class="h-12 w-12 opacity-40" />
<p>暂无截图历史</p> <p>暂无截图历史</p>
<p class="text-xs">截图并导出后会显示在这里</p> <p class="text-xs">按 Ctrl + Alt + A 截图并导出后会显示在这里</p>
</div> </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 { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow' import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'
import { availableMonitors } from '@tauri-apps/api/window' 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' import { toast } from 'vue-sonner'
interface CaptureData {
pngBase64: string
width: number
height: number
}
export interface RecentCapture { export interface RecentCapture {
id: string id: string
pngBase64: string pngBase64: string
@@ -21,104 +16,186 @@ export interface RecentCapture {
mode: string 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 SETTINGS_KEY = 'screenshot-settings'
const EDITOR_LABEL = 'screenshot-editor' const DEFAULT_SETTINGS: ScreenshotSettings = {
autoSave: false,
saveDir: '',
historyLimit: 12,
shortcut: 'ctrl+alt+a',
delay: 0,
}
export const useScreenshotStore = defineStore('screenshot', () => { export const useScreenshotStore = defineStore('screenshot', () => {
const capturing = ref(false) const capturing = ref(false)
const recent = ref<RecentCapture[]>([]) const recent = ref<RecentCapture[]>([])
let exportUnlisten: UnlistenFn | null = null const settings = ref<ScreenshotSettings>({ ...DEFAULT_SETTINGS })
/** 计算所有显示器的逻辑像素联合矩形(用于覆盖层窗口定位/尺寸) */ let exportUnlisten: UnlistenFn | null = null
async function computeVirtualLogicalRect() { 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() const monitors = await availableMonitors()
let minX = Infinity let minX = Infinity
let minY = Infinity let minY = Infinity
let maxX = -Infinity let maxX = -Infinity
let maxY = -Infinity let maxY = -Infinity
for (const m of monitors) { for (const m of monitors) {
const s = m.scaleFactor || 1 if (m.position.x < minX) minX = m.position.x
const lx = m.position.x / s if (m.position.y < minY) minY = m.position.y
const ly = m.position.y / s if (m.position.x + m.size.width > maxX) maxX = m.position.x + m.size.width
const lw = m.size.width / s if (m.position.y + m.size.height > maxY) maxY = m.position.y + m.size.height
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 (!Number.isFinite(minX)) return { x: 0, y: 0, width: 800, height: 600 }
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } 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 if (capturing.value) return
capturing.value = true capturing.value = true
try { try {
// 1. 捕获虚拟屏(覆盖层尚未创建 → 不会出现在截图中) await ensureOverlay()
const data = await invoke<CaptureData>('screenshot_capture_fullscreen') // 若覆盖层当前可见(上一次会话未结束),先隐藏再捕获,避免覆盖层自身出现在截图里
if (overlayWin) {
if (mode === 'fullscreen') { try {
// 全屏截图直接送入编辑器 if (await overlayWin.isVisible()) await overlayWin.hide()
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 }) } catch {
// 清掉静态全屏缓存(编辑器用 cropped/全图,不再需要原始 BGRA /* 窗口可能尚未就绪,忽略 */
await invoke('screenshot_take_fullscreen').catch(() => {}) }
await openEditor()
return
} }
// 延时倒计时(>0 时逐秒提示,避免用户错过截图时机)
// 2. 区域/窗口模式:创建覆盖层选区窗口 const delaySec = Math.max(0, Math.floor(settings.value.delay || 0))
await openOverlay(mode) 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) { } catch (e) {
console.error('[screenshot] 捕获失败', e) console.error('[screenshot] 捕获失败', e)
toast.error('截图启动失败:' + (e as Error).message) toast.error('截图启动失败:' + (e as Error).message)
// 失败时清理静态缓存 await invoke('screenshot_clear_fullscreen').catch(() => {})
await invoke('screenshot_take_fullscreen').catch(() => {})
} finally { } finally {
capturing.value = false capturing.value = false
} }
} }
async function openOverlay(mode: CaptureMode) { function resetOverlayReady() {
const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL) overlayReadyPromise = new Promise<void>((resolve) => {
if (existing) await existing.close() overlayReadyResolve = resolve
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,
}) })
} }
async function openEditor() { /** 等待覆盖层就绪(首次创建时等 onMounted 完成,之后立即返回) */
const existing = await WebviewWindow.getByLabel(EDITOR_LABEL) async function waitOverlayReady() {
if (existing) { await Promise.race([
await existing.show() overlayReadyPromise,
await existing.setFocus() new Promise<void>((r) => setTimeout(r, 3000)),
return ])
}
/** 创建常驻覆盖层窗口(隐藏、透明、置顶、无任务栏;启动时创建,复用直到应用退出) */
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', url: 'index.html#screenshot-editor',
title: '截图编辑器', title: '截图编辑器',
width: 960, width: 960,
@@ -136,6 +213,7 @@ export const useScreenshotStore = defineStore('screenshot', () => {
}) })
} }
// ===== 历史 / 导出 =====
function addRecent(pngBase64: string, width: number, height: number, mode: string) { function addRecent(pngBase64: string, width: number, height: number, mode: string) {
recent.value.unshift({ recent.value.unshift({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -145,7 +223,12 @@ export const useScreenshotStore = defineStore('screenshot', () => {
time: Date.now(), time: Date.now(),
mode, 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) { async function copyImage(pngBase64: string) {
@@ -168,7 +251,26 @@ export const useScreenshotStore = defineStore('screenshot', () => {
toast.success('已保存到文件') 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() { async function initExportListener() {
if (exportUnlisten) return if (exportUnlisten) return
exportUnlisten = await listen<{ exportUnlisten = await listen<{
@@ -176,10 +278,41 @@ export const useScreenshotStore = defineStore('screenshot', () => {
width: number width: number
height: number height: number
}>('screenshot-exported', (e) => { }>('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() { function destroyExportListener() {
if (exportUnlisten) { if (exportUnlisten) {
exportUnlisten() exportUnlisten()
@@ -187,15 +320,32 @@ export const useScreenshotStore = defineStore('screenshot', () => {
} }
} }
function destroyShortcutListener() {
if (shortcutUnlisten) {
shortcutUnlisten()
shortcutUnlisten = null
}
}
return { return {
capturing, capturing,
recent, recent,
settings,
startCapture, startCapture,
initOverlay,
openEditor, openEditor,
addRecent, addRecent,
clearHistory,
copyImage, copyImage,
saveImage, saveImage,
handleExport,
loadSettings,
setSettings,
setShortcut,
initExportListener, initExportListener,
destroyExportListener, destroyExportListener,
initShortcutListener,
initShortcutRegistration,
destroyShortcutListener,
} }
}) })