截图模块优化调整

This commit is contained in:
zhongluofeng
2026-08-03 17:36:07 +08:00
parent f51a7f894e
commit c913379e2b
19 changed files with 1744 additions and 300 deletions
+4
View File
@@ -134,6 +134,10 @@ pub async fn clipboard_save_settings(
// 快捷键变化时重新注册
if settings.shortcut != prev_shortcut {
super::popup::register_shortcut(&app, &settings.shortcut)?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
super::popup::ensure_popup_window(&app);
}
}
Ok(())
}
+65 -39
View File
@@ -9,6 +9,7 @@
//! 6. 窗口失焦自动隐藏
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
@@ -19,6 +20,10 @@ pub const POPUP_LABEL: &str = "clipboard-popup";
/// 当前注册的快捷键(用于注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
@@ -69,6 +74,58 @@ pub fn unregister_shortcut(app: &AppHandle) {
}
}
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,show_popup 时会重新定位。
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
fn create_popup_window(app: &AppHandle) {
let win = match WebviewWindowBuilder::new(
app,
POPUP_LABEL,
WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(380.0, 460.0)
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false) // 不抢占焦点,避免创建即触发 Focused(false)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
return;
}
};
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
}
});
eprintln!("[clipboard] 弹窗窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建弹窗窗口(隐藏)。
/// 这样首次按快捷键时窗口已存在,直接 show + 定位,避免首次创建时序问题。
pub fn ensure_popup_window(app: &AppHandle) {
if app.get_webview_window(POPUP_LABEL).is_some() {
return;
}
create_popup_window(app);
}
/// 在鼠标当前位置显示弹窗。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
/// 窗口已存在则移动到鼠标位置并显示。
@@ -115,49 +172,18 @@ pub fn show_popup(app: &AppHandle) {
return;
}
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
let win = match WebviewWindowBuilder::new(
app,
POPUP_LABEL,
WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(w, h)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window
.focused(true)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
return;
}
};
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
}
});
eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)");
// 兜底:窗口被销毁时重新创建(隐藏,等前端 onMounted 回调 show_window
POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
create_popup_window(app);
}
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
pub fn show_window(app: &AppHandle) {
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
return;
}
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.show();
let _ = win.set_focus();
+9 -6
View File
@@ -47,11 +47,11 @@ use process_manager::{
};
use screenshot::commands::{
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,
screenshot_compose_copy, 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,
@@ -215,7 +215,8 @@ pub fn run() {
screenshot_save_png,
screenshot_register_shortcut,
screenshot_unregister_shortcut,
screenshot_disable_transitions
screenshot_disable_transitions,
screenshot_compose_copy
])
.setup(|app| {
// 初始化日志系统,日志目录: {app_data_dir}/logs/
@@ -280,6 +281,8 @@ pub fn run() {
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
eprintln!("[clipboard] 快捷键注册失败: {}", e);
}
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
clipboard::popup::ensure_popup_window(&app_handle);
}
app.manage(clipboard);
+140 -40
View File
@@ -78,12 +78,6 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String>
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");
@@ -103,7 +97,12 @@ fn bgra_to_bmp(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String>
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);
// 像素数据:memcpy 一次性拷贝,再原地修正 alpha(避免 to_vec 中间分配)
let pixel_start = bmp.len();
bmp.extend_from_slice(bgra);
for i in (pixel_start + 3..bmp.len()).step_by(4) {
bmp[i] = 255;
}
Ok(bmp)
}
@@ -117,16 +116,16 @@ fn bgra_to_png(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 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 pixel_count = (width as usize) * (height as usize);
let mut rgba = vec![0u8; pixel_count * 4];
// chunks_exact 迭代器写法:4 字节对齐连续读取,编译器更容易自动向量化
for (src, dst) in bgra.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
dst[0] = src[2]; // R
dst[1] = src[1]; // G
dst[2] = src[0]; // B
dst[3] = 255; // A 强制不透明
}
let mut buf = Vec::with_capacity(w * h * 4 / 3);
let mut buf = Vec::with_capacity(pixel_count * 4 / 3);
PngEncoder::new_with_quality(
&mut buf,
CompressionType::Fast,
@@ -493,7 +492,9 @@ 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 row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
@@ -501,7 +502,7 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (w * h * 4) as u32,
biSizeImage: (pixel_size) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
@@ -514,15 +515,17 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
)
};
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
}
// 预分配像素区,按行 memcpy 拷贝(top-down bottom-up 行序翻转),再原地修正 alpha
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
for y in 0..h {
let src = y * row_size;
let dst = (h - 1 - y) * row_size;
dib[pixel_start + dst..pixel_start + dst + row_size]
.copy_from_slice(&bgra[src..src + row_size]);
}
for i in (pixel_start + 3..pixel_start + pixel_size).step_by(4) {
dib[i] = 255;
}
dib
}
@@ -592,11 +595,17 @@ pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
write_dib_to_clipboard(&dib)
}
/// RGBA → CF_DIBBITMAPINFOHEADER + BGRA bottom-upalpha 强制 255
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
/// raw RGBA 像素 → CF_DIB直接从连续像素切片构造,无需 image crate 解码
///
/// 用于有标注导出路径:前端 canvas.getImageData() 直传 raw RGBA
/// 省去 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 的三次往返。
fn rgba_raw_to_dib(rgba: &[u8], width: i32, height: i32) -> Vec<u8> {
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() 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 w = width as usize;
let h = height as usize;
let row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
@@ -604,7 +613,7 @@ fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (row_size * height as usize) as u32,
biSizeImage: (pixel_size) as u32,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
@@ -617,14 +626,105 @@ fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
)
};
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
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
// RGBA top-down → BGRA bottom-upalpha 强制 255
for y in 0..h {
let src_off = (h - 1 - y) * row_size;
let dst_off = y * row_size;
for x in 0..w {
let si = src_off + x * 4;
let di = pixel_start + dst_off + x * 4;
dib[di] = rgba[si + 2]; // B
dib[di + 1] = rgba[si + 1]; // G
dib[di + 2] = rgba[si]; // R
dib[di + 3] = 255; // A
}
}
dib
}
/// raw RGBA → PNG(直接从连续像素切片编码,跳过 image crate 的 RgbaImage 包装)
fn rgba_raw_to_png(rgba: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::ImageEncoder;
if width <= 0 || height <= 0 {
return Err("无效尺寸".into());
}
let mut buf = Vec::with_capacity(rgba.len() / 3);
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)
}
/// 有标注导出:raw RGBA → 剪贴板 DIB + PNG base64(一次调用完成复制+编码)
///
/// 替代原 composeBase64 + screenshot_copy_image 两步:
/// 省去前端 toDataURL(PNG 编码+base64) + Rust base64 解码 + PNG 解码,
/// 直接 raw RGBA 传输 → DIB 写剪贴板 + PNG 编码返回。
pub fn compose_copy_rgba(rgba: &[u8], width: i32, height: i32) -> Result<CaptureData, String> {
let expected = (width as usize) * (height as usize) * 4;
if rgba.len() < expected {
return Err(format!("像素数据不足: {} < {}", rgba.len(), expected));
}
let dib = rgba_raw_to_dib(rgba, width, height);
write_dib_to_clipboard(&dib)?;
let png = rgba_raw_to_png(rgba, width, height)?;
Ok(CaptureData {
png_base64: base64_encode(&png),
width,
height,
})
}
/// RGBA → CF_DIBBITMAPINFOHEADER + BGRA bottom-upalpha 强制 255
fn rgba_to_dib(rgba: &image::RgbaImage, 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 row_size = w * 4;
let pixel_size = row_size * h;
let mut dib = Vec::with_capacity(header_size as usize + pixel_size);
let header = BITMAPINFOHEADER {
biSize: header_size,
biWidth: width,
biHeight: height, // 正值 = bottom-up
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: (pixel_size) 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);
// 预分配像素区,用 as_raw() 连续切片替代逐像素 get_pixel
let pixel_start = dib.len();
dib.resize(dib.len() + pixel_size, 0);
let src = rgba.as_raw(); // &[u8] RGBA top-down
for y in 0..h {
// bottom-up: DIB 第一行 = 图像最后一行
let src_off = (h - 1 - y) * row_size;
let dst_off = y * row_size;
for x in 0..w {
let si = src_off + x * 4;
let di = pixel_start + dst_off + x * 4;
dib[di] = src[si + 2]; // B
dib[di + 1] = src[si + 1]; // G
dib[di + 2] = src[si]; // R
dib[di + 3] = 255; // A
}
}
dib
+33
View File
@@ -279,6 +279,39 @@ pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
}
}
/// 有标注导出:接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 剪贴板+PNG base64。
///
/// 省去前端 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 三次往返。
/// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。
#[tauri::command]
pub async fn screenshot_compose_copy(
request: tauri::ipc::Request<'_>,
) -> Result<super::CaptureData, String> {
#[cfg(windows)]
{
let body = match request.body() {
tauri::ipc::InvokeBody::Raw(data) => data.clone(),
_ => return Err("需要 raw bodyArrayBuffer".into()),
};
if body.len() < 8 {
return Err("数据不足:缺少尺寸头".into());
}
let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]);
let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]);
let rgba = body[8..].to_vec();
tauri::async_runtime::spawn_blocking(move || {
super::capture::compose_copy_rgba(&rgba, width, height)
})
.await
.map_err(|e| format!("合成复制任务失败: {}", e))?
}
#[cfg(not(windows))]
{
let _ = request;
Err("截图仅支持 Windows".into())
}
}
/// 将 PNG base64 写入文件
#[tauri::command]
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
-5
View File
@@ -394,11 +394,6 @@ pub async fn tray_menu_action(
let _ = app.emit("tray:new-download", ());
hide_tray_menu(&app);
}
"screenshot_region" => {
hide_tray_menu(&app);
// 主窗口(隐藏运行中)接收事件后调用 screenshotStore.startCapture('region')
let _ = app.emit("tray:start-screenshot", "region");
}
"settings" => {
if let Some(window) = app.get_webview_window("main") {
window.show().ok();