From 89e5b7bed54451ee18ad2db694d8cc0aa1cc842c Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Fri, 31 Jul 2026 18:31:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=AA=E5=9B=BE=E6=A8=A1=E5=9D=97=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/capabilities/screenshot.json | 23 + src-tauri/src/screenshot/capture.rs | 475 ++++++++++++++ src-tauri/src/screenshot/commands.rs | 177 +++++ src-tauri/src/screenshot/mod.rs | 52 ++ src/modules/screenshot/ScreenshotEditor.vue | 653 +++++++++++++++++++ src/modules/screenshot/ScreenshotOverlay.vue | 501 ++++++++++++++ src/stores/screenshotStore.ts | 201 ++++++ 7 files changed, 2082 insertions(+) create mode 100644 src-tauri/capabilities/screenshot.json create mode 100644 src-tauri/src/screenshot/capture.rs create mode 100644 src-tauri/src/screenshot/commands.rs create mode 100644 src-tauri/src/screenshot/mod.rs create mode 100644 src/modules/screenshot/ScreenshotEditor.vue create mode 100644 src/modules/screenshot/ScreenshotOverlay.vue create mode 100644 src/stores/screenshotStore.ts diff --git a/src-tauri/capabilities/screenshot.json b/src-tauri/capabilities/screenshot.json new file mode 100644 index 0000000..5bfcf4c --- /dev/null +++ b/src-tauri/capabilities/screenshot.json @@ -0,0 +1,23 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "screenshot", + "description": "Capability for screenshot overlay and editor windows", + "windows": ["screenshot-overlay", "screenshot-editor"], + "permissions": [ + "core:default", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-start-dragging", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-always-on-top", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-decorations", + "core:window:allow-close", + "core:event:allow-emit", + "core:event:allow-listen", + "dialog:default", + "snap-layout:default" + ] +} diff --git a/src-tauri/src/screenshot/capture.rs b/src-tauri/src/screenshot/capture.rs new file mode 100644 index 0000000..9ff18d6 --- /dev/null +++ b/src-tauri/src/screenshot/capture.rs @@ -0,0 +1,475 @@ +//! Windows 屏幕捕获核心(仅 Windows 编译) +//! +//! 实现: +//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素 +//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口) +//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口 +//! - 顶层窗口枚举:EnumWindows +//! - 像素 → PNG / CF_DIB 转换 +//! +//! 设计说明: +//! - 不使用 Windows Graphics Capture:避免引入 windows crate(WinRT)增大体积, +//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。 +//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。 +//! - 32bpp 捕获后强制 alpha=255:BitBlt 取出的 alpha 通道未定义(常为 0), +//! 直接编码 PNG 会得到全透明图,故强制不透明。 + +use std::sync::Mutex; +use windows_sys::Win32::Foundation::{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, + RGBQUAD, SRCCOPY, +}; +use windows_sys::Win32::Storage::Xps::PrintWindow; +use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData, +}; +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, +}; + +use super::{CaptureData, ScreenRect, WindowInfo}; + +/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码) +pub struct CapturedImage { + pub width: i32, + pub height: i32, + pub png: Vec, + /// 32bpp BGRA,top-down(与 GetDIBits negative height 一致) + pub bgra: Vec, +} + +impl From for ScreenRect { + fn from(r: RECT) -> Self { + Self { + x: r.left, + y: r.top, + width: r.right - r.left, + height: r.bottom - r.top, + } + } +} + +/// 全屏捕获静态存储(供覆盖层选区后裁剪取用) +static FULLSCREEN_CAPTURE: Mutex> = Mutex::new(None); + +const SM_XVIRTUALSCREEN: i32 = 76; +const SM_YVIRTUALSCREEN: i32 = 77; +const SM_CXVIRTUALSCREEN: i32 = 78; +const SM_CYVIRTUALSCREEN: i32 = 79; +const PW_RENDERFULLCONTENT: u32 = 0x00000002; +const BI_RGB: u32 = 0; + +/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255) +fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result, String> { + use image::codecs::png::PngEncoder; + use image::ImageEncoder; + if width <= 0 || height <= 0 { + return Err("无效尺寸".into()); + } + let w = width as usize; + let h = height as usize; + let mut rgba = vec![0u8; w * h * 4]; + for i in 0..(w * h) { + rgba[i * 4] = bgra[i * 4 + 2]; // R + rgba[i * 4 + 1] = bgra[i * 4 + 1]; // G + rgba[i * 4 + 2] = bgra[i * 4]; // B + 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))?; + Ok(buf) +} + +/// 从 HBITMAP 提取 32bpp BGRA top-down 像素 +unsafe fn extract_pixels( + hdc_mem: isize, + hbm: isize, + width: i32, + height: i32, +) -> Result, String> { + let bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, // 负值 = top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: 0, + biXPelsPerMeter: 0, + biYPelsPerMeter: 0, + biClrUsed: 0, + biClrImportant: 0, + }, + bmiColors: [RGBQUAD { + rgbBlue: 0, + rgbGreen: 0, + rgbRed: 0, + rgbReserved: 0, + }], + }; + let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4]; + let got = GetDIBits( + hdc_mem, + hbm, + 0, + height as u32, + pixels.as_mut_ptr() as *mut _, + &bi as *const _ as *mut _, + DIB_RGB_COLORS, + ); + if got == 0 { + return Err("GetDIBits 失败".into()); + } + Ok(pixels) +} + +/// 捕获整个虚拟屏(所有显示器拼接为一张图) +pub fn capture_virtual_screen() -> Result { + unsafe { + let x = GetSystemMetrics(SM_XVIRTUALSCREEN); + let y = GetSystemMetrics(SM_YVIRTUALSCREEN); + let w = GetSystemMetrics(SM_CXVIRTUALSCREEN); + let h = GetSystemMetrics(SM_CYVIRTUALSCREEN); + if w <= 0 || h <= 0 { + return Err("无法获取虚拟屏尺寸".into()); + } + + let hdc_screen = GetDC(0); + if hdc_screen == 0 { + return Err("GetDC 失败".into()); + } + let hdc_mem = CreateCompatibleDC(hdc_screen); + if hdc_mem == 0 { + ReleaseDC(0, hdc_screen); + return Err("CreateCompatibleDC 失败".into()); + } + let hbm = CreateCompatibleBitmap(hdc_screen, w, h); + if hbm == 0 { + DeleteDC(hdc_mem); + ReleaseDC(0, hdc_screen); + return Err("CreateCompatibleBitmap 失败".into()); + } + let old = SelectObject(hdc_mem, hbm); + + let ok = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, x, y, SRCCOPY); + + 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, + }) + }) + }; + + SelectObject(hdc_mem, old); + DeleteObject(hbm); + DeleteDC(hdc_mem); + ReleaseDC(0, hdc_screen); + result + } +} + +/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口) +pub fn capture_window(hwnd: isize) -> Result { + unsafe { + let mut rect: RECT = std::mem::zeroed(); + if GetWindowRect(hwnd, &mut rect) == 0 { + return Err("GetWindowRect 失败".into()); + } + let w = rect.right - rect.left; + let h = rect.bottom - rect.top; + if w <= 0 || h <= 0 { + return Err("窗口尺寸无效".into()); + } + + let hdc_screen = GetDC(0); + if hdc_screen == 0 { + return Err("GetDC 失败".into()); + } + let hdc_mem = CreateCompatibleDC(hdc_screen); + let hbm = CreateCompatibleBitmap(hdc_screen, w, h); + let old = SelectObject(hdc_mem, hbm); + + // 先用黑色填充(PrintWindow 对部分窗口不绘制透明区域) + PatBlt(hdc_mem, 0, 0, w, h, BLACKNESS); + + let ok = PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT); + + let result = if ok == 0 { + Err("PrintWindow 失败(可能窗口无响应或权限不足)".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, + }) + }) + }; + + SelectObject(hdc_mem, old); + DeleteObject(hbm); + DeleteDC(hdc_mem); + ReleaseDC(0, hdc_screen); + result + } +} + +/// 获取指定屏幕坐标下的顶层窗口(窗口拾取) +/// +/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。 +pub fn window_from_point(x: i32, y: i32) -> Option { + unsafe { + let pt = POINT { x, y }; + let mut hwnd = WindowFromPoint(pt); + if hwnd == 0 { + return None; + } + // 取顶层父窗口(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 { + hwnd, + title: get_window_title(hwnd), + rect: srect, + }) + } +} + +/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择) +pub fn enum_visible_windows() -> Vec { + extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 { + unsafe { + let vec = &mut *(lparam as *mut Vec); + if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 { + return 1; + } + let mut rect: RECT = std::mem::zeroed(); + if GetWindowRect(hwnd, &mut rect) == 0 { + return 1; + } + let srect = ScreenRect::from(rect); + if srect.width < 20 || srect.height < 20 { + return 1; + } + // 跳过工具窗口(本应用 OSD / 托盘菜单 / 截图覆盖层) + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + if ex & (WS_EX_TOOLWINDOW as isize) != 0 { + return 1; + } + let title = get_window_title(hwnd); + if title.is_empty() { + return 1; + } + vec.push(WindowInfo { + hwnd, + title, + rect: srect, + }); + } + 1 + } + + let mut out: Vec = Vec::new(); + unsafe { + EnumWindows(Some(enum_proc), &mut out as *mut _ as isize); + } + out +} + +unsafe fn get_window_title(hwnd: HWND) -> String { + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return String::new(); + } + let mut buf = vec![0u16; (len as usize) + 1]; + let got = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32); + if got <= 0 { + return String::new(); + } + String::from_utf16_lossy(&buf[..got as usize]) +} + +/// 裁剪已存储的全屏捕获(按物理像素坐标) +pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> 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 png = bgra_to_png(&sub, w, h)?; + Ok(CaptureData { + png_base64: base64_encode(&png), + width: w, + height: h, + }) +} + +fn crop_bgra( + src: &[u8], + src_width: usize, + x: i32, + y: i32, + w: i32, + h: i32, +) -> Result, String> { + let mut out = vec![0u8; (w as usize) * (h as usize) * 4]; + for row in 0..(h as usize) { + let src_off = ((y as usize) + row) * src_width * 4 + (x as usize) * 4; + let dst_off = row * (w as usize) * 4; + out[dst_off..dst_off + (w as usize) * 4] + .copy_from_slice(&src[src_off..src_off + (w as usize) * 4]); + } + 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 take_fullscreen() -> Option { + let mut g = FULLSCREEN_CAPTURE.lock().ok()?; + let img = g.take()?; + Some(CaptureData { + png_base64: base64_encode(&img.png), + width: img.width, + height: img.height, + }) +} + +// ===== 输出:剪贴板 / 文件 ===== + +/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板 +pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> { + let png = base64_decode(png_base64).ok_or("base64 解码失败")?; + let img = image::load_from_memory(&png) + .map_err(|e| format!("PNG 解码失败: {}", e))? + .to_rgba8(); + let (w, h) = (img.width() as i32, img.height() as i32); + let dib = rgba_to_dib(&img, w, h); + write_dib_to_clipboard(&dib) +} + +/// RGBA → CF_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 255) +fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec { + let header_size = std::mem::size_of::() as u32; + let row_size = (width as usize) * 4; + let mut dib = Vec::with_capacity(header_size as usize + row_size * height as usize); + let header = BITMAPINFOHEADER { + biSize: header_size, + biWidth: width, + biHeight: height, // 正值 = bottom-up + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: (row_size * height as usize) 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); + // bottom-up 行序,BGRA 像素,alpha 强制 255 + for y in (0..height as usize).rev() { + for x in 0..width as usize { + let p = rgba.get_pixel(x as u32, y as u32).0; + dib.push(p[2]); // B + dib.push(p[1]); // G + dib.push(p[0]); // R + dib.push(255); // A + } + } + dib +} + +fn write_dib_to_clipboard(dib: &[u8]) -> Result<(), String> { + unsafe { + if OpenClipboard(0) == 0 { + return Err("OpenClipboard 失败".into()); + } + let r = (|| { + if EmptyClipboard() == 0 { + return Err("EmptyClipboard 失败".to_string()); + } + let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len()); + if hglob.is_null() { + return Err("GlobalAlloc 失败".to_string()); + } + let ptr = GlobalLock(hglob) as *mut u8; + if ptr.is_null() { + return Err("GlobalLock 失败".to_string()); + } + std::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len()); + GlobalUnlock(hglob); + if SetClipboardData(CF_DIB as u32, hglob as isize) == 0 { + return Err("SetClipboardData 失败".to_string()); + } + Ok(()) + })(); + CloseClipboard(); + r + } +} + +/// 将 PNG base64 写入文件 +pub fn save_png_to_file(png_base64: &str, path: &str) -> Result<(), String> { + let png = base64_decode(png_base64).ok_or("base64 解码失败")?; + std::fs::write(path, &png).map_err(|e| format!("写入文件失败: {}", e)) +} + +pub fn base64_encode(b: &[u8]) -> String { + use base64::engine::general_purpose::STANDARD; + use base64::Engine as _; + STANDARD.encode(b) +} + +fn base64_decode(s: &str) -> Option> { + use base64::engine::general_purpose::STANDARD; + use base64::Engine as _; + STANDARD.decode(s).ok() +} diff --git a/src-tauri/src/screenshot/commands.rs b/src-tauri/src/screenshot/commands.rs new file mode 100644 index 0000000..9404f5c --- /dev/null +++ b/src-tauri/src/screenshot/commands.rs @@ -0,0 +1,177 @@ +//! Tauri 命令:截图模块 +//! +//! 命令清单: +//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64 +//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获 +//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获 +//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口 +//! - screenshot_enum_windows:枚举可见顶层窗口 +//! - screenshot_capture_window:按 hwnd 捕获指定窗口 +//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递 +//! - screenshot_copy_image:写入剪贴板(CF_DIB) +//! - screenshot_save_png:写入文件 + +use super::{CaptureData, WindowInfo}; + +/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64 +#[tauri::command] +pub async fn screenshot_capture_fullscreen() -> Result { + #[cfg(windows)] + { + // 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度 + let img = tauri::async_runtime::spawn_blocking(|| { + super::capture::capture_virtual_screen() + }) + .await + .map_err(|e| format!("捕获任务失败: {}", e))??; + Ok(super::capture::store_fullscreen(img)) + } + #[cfg(not(windows))] + { + Err("截图仅支持 Windows".into()) + } +} + +/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用) +#[tauri::command] +pub async fn screenshot_take_fullscreen() -> Result, String> { + #[cfg(windows)] + { + Ok(super::capture::take_fullscreen()) + } + #[cfg(not(windows))] + { + Ok(None) + } +} + +/// 按物理像素坐标裁剪已存储的全屏捕获 +#[tauri::command] +pub async fn screenshot_crop_stored( + x: i32, + y: i32, + w: i32, + h: i32, +) -> Result { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || { + super::capture::crop_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( + x: i32, + y: i32, +) -> Result, String> { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || { + Ok(super::capture::window_from_point(x, y)) + }) + .await + .map_err(|e| format!("查询失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = (x, y); + Ok(None) + } +} + +/// 枚举所有可见顶层窗口 +#[tauri::command] +pub async fn screenshot_enum_windows() -> Result, String> { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows()) + .await + .map_err(|e| format!("枚举失败: {}", e)) + } + #[cfg(not(windows))] + { + Ok(vec![]) + } +} + +/// 按 hwnd 捕获指定窗口 +#[tauri::command] +pub async fn screenshot_capture_window(hwnd: isize) -> Result { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || { + let img = super::capture::capture_window(hwnd)?; + Ok(super::CaptureData { + png_base64: super::capture::base64_encode(&img.png), + width: img.width, + height: img.height, + }) + }) + .await + .map_err(|e| format!("捕获任务失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = hwnd; + Err("截图仅支持 Windows".into()) + } +} + +/// 存入编辑器图片(base64 PNG) +#[tauri::command] +pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> { + super::set_editor_image(png_base64); + Ok(()) +} + +/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除) +#[tauri::command] +pub async fn screenshot_get_editor_image() -> Result, String> { + Ok(super::take_editor_image()) +} + +/// 将 PNG base64 写入系统剪贴板(转 CF_DIB) +#[tauri::command] +pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> { + #[cfg(windows)] + { + tauri::async_runtime::spawn_blocking(move || { + super::capture::copy_png_to_clipboard(&png_base64) + }) + .await + .map_err(|e| format!("剪贴板任务失败: {}", e))? + } + #[cfg(not(windows))] + { + let _ = png_base64; + Err("剪贴板仅支持 Windows".into()) + } +} + +/// 将 PNG base64 写入文件 +#[tauri::command] +pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(windows)] + { + super::capture::save_png_to_file(&png_base64, &path) + } + #[cfg(not(windows))] + { + let _ = (png_base64, path); + Err("文件写入仅支持 Windows".to_string()) + } + }) + .await + .map_err(|e| format!("保存任务失败: {}", e))? +} diff --git a/src-tauri/src/screenshot/mod.rs b/src-tauri/src/screenshot/mod.rs new file mode 100644 index 0000000..c560106 --- /dev/null +++ b/src-tauri/src/screenshot/mod.rs @@ -0,0 +1,52 @@ +//! 截图模块 +//! +//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。 +//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。 + +use std::sync::Mutex; + +#[cfg(windows)] +pub mod capture; +pub mod commands; + +/// 前端可见的捕获数据 +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureData { + pub png_base64: String, + pub width: i32, + pub height: i32, +} + +/// 窗口信息(窗口拾取 / 枚举) +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowInfo { + pub hwnd: isize, + pub title: String, + pub rect: ScreenRect, +} + +#[derive(serde::Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct ScreenRect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出) +static EDITOR_IMAGE: Mutex> = Mutex::new(None); + +/// 存储编辑器图片(base64 PNG) +pub fn set_editor_image(png_base64: String) { + if let Ok(mut g) = EDITOR_IMAGE.lock() { + *g = Some(png_base64); + } +} + +/// 取出并清除编辑器图片 +pub fn take_editor_image() -> Option { + EDITOR_IMAGE.lock().ok()?.take() +} diff --git a/src/modules/screenshot/ScreenshotEditor.vue b/src/modules/screenshot/ScreenshotEditor.vue new file mode 100644 index 0000000..0cd1e43 --- /dev/null +++ b/src/modules/screenshot/ScreenshotEditor.vue @@ -0,0 +1,653 @@ + + + diff --git a/src/modules/screenshot/ScreenshotOverlay.vue b/src/modules/screenshot/ScreenshotOverlay.vue new file mode 100644 index 0000000..2363429 --- /dev/null +++ b/src/modules/screenshot/ScreenshotOverlay.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/src/stores/screenshotStore.ts b/src/stores/screenshotStore.ts new file mode 100644 index 0000000..67d4aff --- /dev/null +++ b/src/stores/screenshotStore.ts @@ -0,0 +1,201 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' +import { WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { availableMonitors } from '@tauri-apps/api/window' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import { toast } from 'vue-sonner' + +interface CaptureData { + pngBase64: string + width: number + height: number +} + +export interface RecentCapture { + id: string + pngBase64: string + width: number + height: number + time: number + mode: string +} + +export type CaptureMode = 'region' | 'window' | 'fullscreen' + +const OVERLAY_LABEL = 'screenshot-overlay' +const EDITOR_LABEL = 'screenshot-editor' + +export const useScreenshotStore = defineStore('screenshot', () => { + const capturing = ref(false) + const recent = ref([]) + let exportUnlisten: UnlistenFn | null = null + + /** 计算所有显示器的逻辑像素联合矩形(用于覆盖层窗口定位/尺寸) */ + async function computeVirtualLogicalRect() { + const monitors = await availableMonitors() + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const m of monitors) { + const s = m.scaleFactor || 1 + const lx = m.position.x / s + const ly = m.position.y / s + const lw = m.size.width / s + const lh = m.size.height / s + if (lx < minX) minX = lx + if (ly < minY) minY = ly + if (lx + lw > maxX) maxX = lx + lw + if (ly + lh > maxY) maxY = ly + lh + } + if (!Number.isFinite(minX)) { + minX = 0 + minY = 0 + maxX = 800 + maxY = 600 + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } + } + + /** 启动截图:region=区域选择,window=窗口拾取,fullscreen=直接进编辑器 */ + async function startCapture(mode: CaptureMode) { + if (capturing.value) return + capturing.value = true + try { + // 1. 捕获虚拟屏(覆盖层尚未创建 → 不会出现在截图中) + const data = await invoke('screenshot_capture_fullscreen') + + if (mode === 'fullscreen') { + // 全屏截图直接送入编辑器 + await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 }) + // 清掉静态全屏缓存(编辑器用 cropped/全图,不再需要原始 BGRA) + await invoke('screenshot_take_fullscreen').catch(() => {}) + await openEditor() + return + } + + // 2. 区域/窗口模式:创建覆盖层选区窗口 + await openOverlay(mode) + } catch (e) { + console.error('[screenshot] 捕获失败', e) + toast.error('截图启动失败:' + (e as Error).message) + // 失败时清理静态缓存 + await invoke('screenshot_take_fullscreen').catch(() => {}) + } finally { + capturing.value = false + } + } + + async function openOverlay(mode: CaptureMode) { + const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL) + if (existing) await existing.close() + + const rect = await computeVirtualLogicalRect() + const url = `index.html#screenshot-overlay?mode=${mode}` + new WebviewWindow(OVERLAY_LABEL, { + url, + title: '截图', + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + decorations: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + shadow: false, + focus: true, + visible: true, + }) + } + + async function openEditor() { + const existing = await WebviewWindow.getByLabel(EDITOR_LABEL) + if (existing) { + await existing.show() + await existing.setFocus() + return + } + new WebviewWindow(EDITOR_LABEL, { + url: 'index.html#screenshot-editor', + title: '截图编辑器', + width: 960, + height: 720, + minWidth: 640, + minHeight: 480, + decorations: false, + transparent: true, + alwaysOnTop: false, + skipTaskbar: false, + resizable: true, + shadow: true, + focus: true, + visible: true, + }) + } + + function addRecent(pngBase64: string, width: number, height: number, mode: string) { + recent.value.unshift({ + id: crypto.randomUUID(), + pngBase64, + width, + height, + time: Date.now(), + mode, + }) + if (recent.value.length > 12) recent.value.pop() + } + + async function copyImage(pngBase64: string) { + await invoke('screenshot_copy_image', { pngBase64 }) + toast.success('已复制到剪贴板') + } + + async function saveImage(pngBase64: string) { + const { save } = await import('@tauri-apps/plugin-dialog') + const ts = new Date() + .toISOString() + .replace(/[:.]/g, '-') + .slice(0, 19) + const path = await save({ + defaultPath: `screenshot_${ts}.png`, + filters: [{ name: 'PNG', extensions: ['png'] }], + }) + if (!path) return + await invoke('screenshot_save_png', { pngBase64, path }) + toast.success('已保存到文件') + } + + /** 监听编辑器导出事件(主窗口记录历史 + 提示) */ + async function initExportListener() { + if (exportUnlisten) return + exportUnlisten = await listen<{ + pngBase64: string + width: number + height: number + }>('screenshot-exported', (e) => { + addRecent(e.payload.pngBase64, e.payload.width, e.payload.height, 'edited') + }) + } + + function destroyExportListener() { + if (exportUnlisten) { + exportUnlisten() + exportUnlisten = null + } + } + + return { + capturing, + recent, + startCapture, + openEditor, + addRecent, + copyImage, + saveImage, + initExportListener, + destroyExportListener, + } +})