diff --git a/bun.lock b/bun.lock index d539ce6..2f69fb4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,14 @@ "": { "name": "thing", "dependencies": { - "@lucide/vue": "^1.25.0", + "@lucide/vue": "^1.28.0", "@tailwindcss/vite": "^4.3.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-autostart": "~2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-opener": "^2", - "@vueuse/core": "^14.3.0", + "@vueuse/core": "^14.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "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=="], - "@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=="], @@ -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-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=="], "@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=="], - "@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=="], @@ -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/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=="], } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1337b79..7ce683f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,6 +56,7 @@ windows-sys = { version = "0.52", features = [ "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_Graphics_Gdi", + "Win32_Graphics_Dwm", "Win32_Storage_Xps", ] } diff --git a/src-tauri/capabilities/screenshot.json b/src-tauri/capabilities/screenshot.json index 5bfcf4c..3b119a0 100644 --- a/src-tauri/capabilities/screenshot.json +++ b/src-tauri/capabilities/screenshot.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "screenshot", "description": "Capability for screenshot overlay and editor windows", - "windows": ["screenshot-overlay", "screenshot-editor"], + "windows": ["screenshot-overlay*", "screenshot-editor-*"], "permissions": [ "core:default", "core:window:allow-hide", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cabb634..36a14cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use tauri::Emitter; use tauri::Manager; mod clipboard; @@ -45,10 +46,12 @@ use process_manager::{ stop_all_processes, stop_process, ProcessManager, }; use screenshot::commands::{ - screenshot_capture_fullscreen, screenshot_capture_window, screenshot_copy_image, - screenshot_crop_stored, screenshot_enum_windows, screenshot_get_editor_image, - screenshot_save_png, screenshot_set_editor_image, screenshot_take_fullscreen, - screenshot_window_from_point, + screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen, + screenshot_copy_image, screenshot_crop_copy_stored, screenshot_crop_stored, + screenshot_cursor_pos, screenshot_disable_transitions, screenshot_enum_windows, + 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::{ ClipboardManager, @@ -94,6 +97,8 @@ pub fn run() { .plugin(tauri_plugin_autostart::Builder::new().build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + // 截图快捷键改由 screenshot::shortcut::register_shortcut(on_shortcut)按需注册, + // 支持自定义;默认 Ctrl+Alt+A 由前端 store 启动时注册 .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin( @@ -195,15 +200,22 @@ pub fn run() { tray_menu_ready, snap_fix::fix_snap_background, screenshot_capture_fullscreen, - screenshot_take_fullscreen, + screenshot_get_fullscreen_bmp, + screenshot_fullscreen_png, + screenshot_clear_fullscreen, screenshot_crop_stored, + screenshot_crop_copy_stored, screenshot_window_from_point, + screenshot_cursor_pos, screenshot_enum_windows, screenshot_capture_window, screenshot_set_editor_image, screenshot_get_editor_image, screenshot_copy_image, - screenshot_save_png + screenshot_save_png, + screenshot_register_shortcut, + screenshot_unregister_shortcut, + screenshot_disable_transitions ]) .setup(|app| { // 初始化日志系统,日志目录: {app_data_dir}/logs/ @@ -296,6 +308,9 @@ pub fn run() { }); } + // 截图快捷键由前端 screenshotStore 启动时调用 screenshot_register_shortcut 注册 + // (支持自定义,默认 Ctrl+Alt+A),此处不再硬编码注册 + Ok(()) }) .on_window_event(|window, event| { diff --git a/src-tauri/src/screenshot/capture.rs b/src-tauri/src/screenshot/capture.rs index 9ff18d6..558d087 100644 --- a/src-tauri/src/screenshot/capture.rs +++ b/src-tauri/src/screenshot/capture.rs @@ -3,7 +3,7 @@ //! 实现: //! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素 //! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口) -//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口 +//! - 窗口拾取:EnumWindows 按 Z 序命中测试(排除本进程窗口,避免命中覆盖层自身) //! - 顶层窗口枚举:EnumWindows //! - 像素 → PNG / CF_DIB 转换 //! @@ -15,7 +15,7 @@ //! 直接编码 PNG 会得到全透明图,故强制不透明。 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::{ BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, 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::Ole::CF_DIB; use windows_sys::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetAncestor, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect, - GetWindowTextLengthW, GetWindowTextW, GWL_EXSTYLE, GA_ROOT, WS_EX_TOOLWINDOW, WindowFromPoint, + EnumWindows, GetCursorPos, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect, + GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, GWL_EXSTYLE, + WS_EX_TOOLWINDOW, }; use super::{CaptureData, ScreenRect, WindowInfo}; @@ -64,9 +65,54 @@ const SM_CYVIRTUALSCREEN: i32 = 79; const PW_RENDERFULLCONTENT: u32 = 0x00000002; const BI_RGB: u32 = 0; +/// 将 BGRA top-down 像素编码为 BMP(强制 alpha=255) +/// +/// 用于覆盖层/编辑器快速显示:相比 PNG 编码几乎零 CPU 开销, +/// Chromium(WebView2)原生支持 top-down 32bpp BMP。 +fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result, 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) +/// +/// 使用 Fast 压缩 + 无过滤:历史缩略图/自动保存不需要最优压缩比, +/// 大幅降低"点击完成 → 关闭窗口"的编码延迟。 fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> { - use image::codecs::png::PngEncoder; + use image::codecs::png::{CompressionType, FilterType, PngEncoder}; use image::ImageEncoder; if width <= 0 || height <= 0 { return Err("无效尺寸".into()); @@ -81,9 +127,13 @@ fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> rgba[i * 4 + 3] = 255; // 强制不透明 } let mut buf = Vec::with_capacity(w * h * 4 / 3); - PngEncoder::new(&mut buf) - .write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8) - .map_err(|e| format!("PNG 编码失败: {}", e))?; + PngEncoder::new_with_quality( + &mut buf, + CompressionType::Fast, + FilterType::NoFilter, + ) + .write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8) + .map_err(|e| format!("PNG 编码失败: {}", e))?; Ok(buf) } @@ -164,13 +214,11 @@ pub fn capture_virtual_screen() -> Result { let result = if ok == 0 { Err("BitBlt 失败".into()) } else { - extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| { - bgra_to_png(&bgra, w, h).map(|png| CapturedImage { - width: w, - height: h, - png, - bgra, - }) + extract_pixels(hdc_mem, hbm, w, h).map(|bgra| CapturedImage { + width: w, + height: h, + png: Vec::new(), // 全屏捕获不做 PNG 编码,显示/裁剪走 raw BMP + bgra, }) }; @@ -232,34 +280,117 @@ pub fn capture_window(hwnd: isize) -> Result { /// 获取指定屏幕坐标下的顶层窗口(窗口拾取) /// /// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。 +/// +/// 不能直接用 WindowFromPoint:覆盖层是 alwaysOnTop 全屏窗口,会命中覆盖层自身。 +/// 改为 EnumWindows 按 Z 序(顶→底)枚举顶层窗口做命中测试,并排除本进程 +/// (覆盖层/主窗口/编辑器)的窗口,从而取到覆盖层下面的目标窗口。 pub fn window_from_point(x: i32, y: i32) -> Option { + struct PickContext { + my_pid: u32, + pt: POINT, + found: Option, + } + + 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 { - let pt = POINT { x, y }; - let mut hwnd = WindowFromPoint(pt); - if hwnd == 0 { - return None; + EnumWindows(Some(enum_proc), &mut ctx as *mut _ as isize); + } + ctx.found +} + +/// 获取当前鼠标物理屏幕坐标(覆盖层打开时定位初始悬停窗口) +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 可能返回子窗口) - let root = GetAncestor(hwnd, GA_ROOT); - if root != 0 { - hwnd = root; - } - if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 { - return None; - } - let mut rect: RECT = std::mem::zeroed(); - if GetWindowRect(hwnd, &mut rect) == 0 { - return None; - } - let srect = ScreenRect::from(rect); - if srect.width <= 0 || srect.height <= 0 { - return None; - } - Some(WindowInfo { + Ok((pt.x, pt.y)) + } +} + +/// DWM 扩展边框矩形(视觉边界) +/// +/// GetWindowRect 对最大化窗口包含屏幕外的隐形缩放边框(各向 7~8px), +/// 导致窗口高亮框比实际窗口大一圈;DWMWA_EXTENDED_FRAME_BOUNDS 返回可视边界。 +fn extended_frame_bounds(hwnd: HWND) -> Option { + use windows_sys::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}; + let mut r: RECT = unsafe { std::mem::zeroed() }; + let hr = unsafe { + DwmGetWindowAttribute( hwnd, - title: get_window_title(hwnd), - rect: srect, - }) + DWMWA_EXTENDED_FRAME_BOUNDS as u32, + &mut r as *mut _ as *mut core::ffi::c_void, + std::mem::size_of::() 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::() as u32, + ) + }; + if hr == 0 { + Ok(()) + } else { + Err(format!("DwmSetWindowAttribute 失败: {}", hr)) } } @@ -292,6 +423,7 @@ pub fn enum_visible_windows() -> Vec { hwnd, title, rect: srect, + visual_rect: extended_frame_bounds(hwnd), }); } 1 @@ -334,6 +466,67 @@ pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result Result { + 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_DIB(bottom-up,alpha 强制 255) +fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec { + let header_size = std::mem::size_of::() 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::(), + ) + }; + dib.extend_from_slice(raw); + // 从 top-down 行序倒序拷贝为 bottom-up,alpha 强制 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( src: &[u8], src_width: usize, @@ -352,30 +545,40 @@ fn crop_bgra( Ok(out) } -/// 存储全屏捕获(覆盖层选区裁剪时取用) -pub fn store_fullscreen(img: CapturedImage) -> CaptureData { - let data = CaptureData { - png_base64: base64_encode(&img.png), - width: img.width, - height: img.height, - }; - if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() { - *g = Some(img); - } - data +/// 存储全屏捕获(覆盖层显示/裁剪取用),返回是否成功 +pub fn store_fullscreen(img: CapturedImage) -> Result<(), String> { + FULLSCREEN_CAPTURE + .lock() + .map(|mut g| *g = Some(img)) + .map_err(|e| e.to_string()) } -/// 取出并清除全屏捕获 -pub fn take_fullscreen() -> Option { - let mut g = FULLSCREEN_CAPTURE.lock().ok()?; - let img = g.take()?; - Some(CaptureData { - png_base64: base64_encode(&img.png), +/// 取出全屏捕获的 BMP 字节用于显示(不移除,裁剪仍依赖原始像素) +pub fn fullscreen_bmp() -> Result, String> { + let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?; + let img = guard.as_ref().ok_or("无已存储的全屏捕获")?; + bgra_to_bmp(&img.bgra, img.width, img.height) +} + +/// 全屏捕获编码为 PNG base64 并移除(全屏截图直接进编辑器时用) +pub fn fullscreen_png() -> Result { + 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, height: img.height, }) } +/// 清除全屏捕获(覆盖层关闭/取消时释放内存) +pub fn clear_fullscreen() { + if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() { + *g = None; + } +} + // ===== 输出:剪贴板 / 文件 ===== /// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板 diff --git a/src-tauri/src/screenshot/commands.rs b/src-tauri/src/screenshot/commands.rs index 9404f5c..70025cb 100644 --- a/src-tauri/src/screenshot/commands.rs +++ b/src-tauri/src/screenshot/commands.rs @@ -1,8 +1,10 @@ //! Tauri 命令:截图模块 //! //! 命令清单: -//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64 -//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获 +//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码) +//! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除) +//! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用) +//! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时) //! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获 //! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口 //! - screenshot_enum_windows:枚举可见顶层窗口 @@ -10,21 +12,68 @@ //! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递 //! - screenshot_copy_image:写入剪贴板(CF_DIB) //! - screenshot_save_png:写入文件 +//! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画) use super::{CaptureData, WindowInfo}; +use tauri::Manager; -/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64 +/// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 #[tauri::command] -pub async fn screenshot_capture_fullscreen() -> Result { +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)] { // 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度 - let img = tauri::async_runtime::spawn_blocking(|| { - super::capture::capture_virtual_screen() + tauri::async_runtime::spawn_blocking(|| { + let img = super::capture::capture_virtual_screen()?; + super::capture::store_fullscreen(img) }) .await - .map_err(|e| format!("捕获任务失败: {}", e))??; - Ok(super::capture::store_fullscreen(img)) + .map_err(|e| format!("捕获任务失败: {}", e))? } #[cfg(not(windows))] { @@ -32,16 +81,50 @@ pub async fn screenshot_capture_fullscreen() -> Result { } } -/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用) +/// 取出全屏捕获的 BMP 原始字节(raw IPC → 前端 ArrayBuffer),不移除 #[tauri::command] -pub async fn screenshot_take_fullscreen() -> Result, String> { +pub async fn screenshot_get_fullscreen_bmp() -> Result { #[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))] { - Ok(None) + Err("截图仅支持 Windows".into()) + } +} + +/// 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) +#[tauri::command] +pub async fn screenshot_fullscreen_png() -> Result { + #[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 { + #[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] 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] pub async fn screenshot_enum_windows() -> Result, String> { diff --git a/src-tauri/src/screenshot/mod.rs b/src-tauri/src/screenshot/mod.rs index c560106..c52334e 100644 --- a/src-tauri/src/screenshot/mod.rs +++ b/src-tauri/src/screenshot/mod.rs @@ -8,6 +8,7 @@ use std::sync::Mutex; #[cfg(windows)] pub mod capture; pub mod commands; +pub mod shortcut; /// 前端可见的捕获数据 #[derive(serde::Serialize)] @@ -25,6 +26,8 @@ pub struct WindowInfo { pub hwnd: isize, pub title: String, pub rect: ScreenRect, + /// DWM 扩展边框矩形(视觉边界,去掉最大化窗口的隐形缩放边框),命中测试用 rect,高亮用 visual_rect + pub visual_rect: Option, } #[derive(serde::Serialize, Clone, Copy)] diff --git a/src-tauri/src/screenshot/shortcut.rs b/src-tauri/src/screenshot/shortcut.rs new file mode 100644 index 0000000..376ba11 --- /dev/null +++ b/src-tauri/src/screenshot/shortcut.rs @@ -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> = Mutex::new(None); + +/// 解析快捷键字符串为 Shortcut(格式如 "Ctrl+Alt+A"、"Shift+PrintScreen") +pub fn parse_shortcut(s: &str) -> Option { + s.trim().parse::().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; + } +} diff --git a/src/App.vue b/src/App.vue index 3db346b..4ef36bf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,7 +6,7 @@ import Sidebar from '@/components/layout/Sidebar.vue' import ModuleContainer from '@/components/layout/ModuleContainer.vue' import { Toaster } from '@/components/ui/sonner' import { useAppStore } from '@/stores/appStore' -import { useScreenshotStore, type CaptureMode } from '@/stores/screenshotStore' +import { useScreenshotStore } from '@/stores/screenshotStore' import { TooltipProvider } from '@/components/ui/tooltip' import { moduleRegistry } from '@/modules/registry' import type { ModuleMeta } from '@/types/module' @@ -159,10 +159,10 @@ onMounted(async () => { handleModuleChange('settings') }) ) - // 托盘截图:直接触发截图流程(不切换模块,覆盖层/编辑器独立窗口) + // 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区) trayUnlisteners.push( - await listen('tray:start-screenshot', (e) => { - screenshotStore.startCapture(e.payload || 'region').catch(err => + await listen('tray:start-screenshot', () => { + screenshotStore.startCapture().catch(err => console.error('[screenshot] 托盘触发截图失败:', err) ) }) diff --git a/src/main.ts b/src/main.ts index 9e937c3..3cc729c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,6 @@ window.addEventListener('unhandledrejection', (event) => { // ===== OSD 窗口模式检测 ===== // 通过 URL hash 识别独立窗口:#osd-overlay / #clipboard-popup / #tray-menu / #screenshot-overlay / #screenshot-editor // 这些窗口是精简的独立 Vue 应用,不加载主应用的 store 和模块 -// 注意:screenshot-overlay 带 query(?mode=region),用 startsWith 匹配 const winHash = window.location.hash if (winHash === '#osd-overlay') { logger.info(`OSD 窗口启动: ${winHash}`) @@ -84,6 +83,16 @@ if (winHash === '#osd-overlay') { void import('./stores/processStore').then(({ useProcessStore }) => { useProcessStore().initListener().catch(e => console.error('Process listener init error:', e)) }) + + // 截图模块:加载设置 + 预创建常驻覆盖层窗口 + 监听导出事件(历史/自动保存)+ 注册并监听全局快捷键 + void import('./stores/screenshotStore').then(({ useScreenshotStore }) => { + const screenshotStore = useScreenshotStore() + screenshotStore.loadSettings() + screenshotStore.initOverlay().catch(e => console.error('Screenshot overlay init error:', e)) + screenshotStore.initExportListener().catch(e => console.error('Screenshot export listener init error:', e)) + screenshotStore.initShortcutListener().catch(e => console.error('Screenshot shortcut listener init error:', e)) + screenshotStore.initShortcutRegistration().catch(e => console.error('Screenshot shortcut registration init error:', e)) + }) }) } diff --git a/src/modules/screenshot/ScreenshotModule.vue b/src/modules/screenshot/ScreenshotModule.vue index 4195ee2..9a9e61e 100644 --- a/src/modules/screenshot/ScreenshotModule.vue +++ b/src/modules/screenshot/ScreenshotModule.vue @@ -1,29 +1,30 @@ @@ -69,7 +164,7 @@ onUnmounted(() => {
- 截图 + 设置 历史 @@ -79,50 +174,152 @@ onUnmounted(() => {
- - + + -
- -
- - -
- -
-
{{ action.label }}
-
{{ action.desc }}
-
-
-
- - - - - - 正在捕获屏幕… - - - - +
+ - - 使用说明 + + 截图快捷键 - -

· 区域截图:进入覆盖层后拖动鼠标选择区域,松开后可编辑、复制或保存。

-

· 窗口截图:移动鼠标高亮目标窗口,点击即可捕获该窗口。

-

· 全屏截图:直接捕获所有显示器拼接画面并进入编辑器。

-

· 选区/编辑器中按 Esc 取消。

+ +
+
+
+ +
+ + + +
+ +
+

+ 点击方框录入自定义快捷键,留空则禁用。默认 Ctrl + Alt + A +

+
+ +
+ +
+

· 进入截图后 移动鼠标 自动识别窗口,点击 选中窗口

+

· 长按拖动 自由选择区域,选区可拖动 / 缩放手柄调整大小

+

· 选区右下方编辑栏可标注(矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮)

+

+ · Enter / 双击复制并完成, + Esc 逐级取消 +

+
+
+
+ + + + + + + 截图选项 + + + + +
+
+ +

按下快捷键后倒计时再截图,便于切到目标画面

+
+
+ +
+
+ + +
+
+ +

+ 截图完成后自动保存 PNG 到指定目录 +

+
+ +
+ + +
+
+ +

+ {{ store.settings.saveDir || '未设置目录,自动保存不会生效' }} +

+
+ +
+ + +
+
+ +

超过上限的旧截图将自动移除

+
+
+ +
+
@@ -140,7 +337,7 @@ onUnmounted(() => { >

暂无截图历史

-

截图并导出后会显示在这里

+

按 Ctrl + Alt + A 截图并导出后会显示在这里

diff --git a/src/modules/screenshot/ScreenshotOverlay.vue b/src/modules/screenshot/ScreenshotOverlay.vue index 2363429..14f7c23 100644 --- a/src/modules/screenshot/ScreenshotOverlay.vue +++ b/src/modules/screenshot/ScreenshotOverlay.vue @@ -1,8 +1,32 @@