From 66575c61662801401aafc08f42933fb586a4527d Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Fri, 31 Jul 2026 18:30:55 +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 --- package-lock.json | 42 ++- package.json | 4 +- src-tauri/Cargo.toml | 1 + src-tauri/src/clipboard/popup.rs | 82 +++-- src-tauri/src/clipboard/reader.rs | 23 +- src-tauri/src/lib.rs | 23 +- src-tauri/src/mihomo_manager.rs | 4 +- src-tauri/src/tray_menu.rs | 150 +++++--- src/App.vue | 13 + src/components/layout/ModuleContainer.vue | 2 +- src/components/layout/TitleBar.vue | 100 ++++-- src/main.ts | 15 +- src/modules/clipboard/ClipboardModule.vue | 74 ++-- src/modules/clipboard/ClipboardPopup.vue | 372 ++++++++++---------- src/modules/screenshot/ScreenshotModule.vue | 217 +++++++++++- src/modules/tray/TrayMenu.vue | 370 ++++++++++++------- 16 files changed, 1011 insertions(+), 481 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9db02cc..4969890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,14 @@ "name": "thing", "version": "0.1.0", "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", @@ -603,9 +604,9 @@ } }, "node_modules/@lucide/vue": { - "version": "1.25.0", - "resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.25.0.tgz", - "integrity": "sha512-hkEetV+v48ScIn3uwqwWQ66sI8foeP2q6OMI09GzLFH4SfvBlfe3JHYlMBdBCqFC7WRlhFsndyDn/awRKRc2OQ==", + "version": "1.28.0", + "resolved": "https://registry.npmmirror.com/@lucide/vue/-/vue-1.28.0.tgz", + "integrity": "sha512-cO89UyNM/0i2Srdi04nFWeEOQkqzDZlM2ehtWyBhcMpRTJDKpMZPCGAVlmVBgBgr5HaeL5L6FsU8RHsOdwXtKw==", "license": "ISC", "peerDependencies": { "vue": ">=3.0.1" @@ -1455,6 +1456,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-global-shortcut": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.2.tgz", + "integrity": "sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "license": "MIT OR Apache-2.0", @@ -1671,14 +1681,14 @@ "license": "MIT" }, "node_modules/@vueuse/core": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", - "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.3.0", - "@vueuse/shared": "14.3.0" + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -1688,18 +1698,18 @@ } }, "node_modules/@vueuse/metadata": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", - "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "14.3.0", - "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", - "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" diff --git a/package.json b/package.json index 1b95aff..ee4c525 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,14 @@ "tauri": "tauri" }, "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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 45007b6..1337b79 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_Storage_Xps", ] } [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/src/clipboard/popup.rs b/src-tauri/src/clipboard/popup.rs index 8a1573b..1976936 100644 --- a/src-tauri/src/clipboard/popup.rs +++ b/src-tauri/src/clipboard/popup.rs @@ -83,30 +83,28 @@ pub fn show_popup(app: &AppHandle) { let w = 380.0_f64; let h = 460.0_f64; - // 获取屏幕工作区(物理像素,Per-Monitor DPI V2) - let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0)); + // 获取光标所在显示器的工作区(物理像素,与 get_cursor_pos 同一坐标系) + let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx, my) + .unwrap_or((0, 0, 1920, 1040)); - // 获取 scale factor,将窗口逻辑尺寸换算为物理像素用于边界裁剪 - let scale = app - .get_webview_window(POPUP_LABEL) - .as_ref() - .and_then(|w| w.scale_factor().ok()) - .or_else(|| { - app.get_webview_window("main") - .and_then(|w| w.scale_factor().ok()) - }) - .unwrap_or(1.0); + // 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP) + let dpi = get_dpi_for_point(mx, my).unwrap_or(96); + let scale = dpi as f64 / 96.0; - let w_phys = (w * scale) as i32; - let h_phys = (h * scale) as i32; + let mx_l = mx as f64 / scale; + let my_l = my as f64 / scale; + let wa_left_l = wa_left as f64 / scale; + let wa_top_l = wa_top as f64 / scale; + let wa_right_l = wa_right as f64 / scale; + let wa_bottom_l = wa_bottom as f64 / scale; - // 物理坐标 clamping - let x = mx.max(0).min(screen_w as i32 - w_phys); - let y = my.max(0).min(screen_h as i32 - h_phys); + // 逻辑坐标 clamping(窗口尺寸 w/h 也是逻辑像素) + let x = mx_l.max(wa_left_l).min(wa_right_l - w); + let y = my_l.max(wa_top_l).min(wa_bottom_l - h); // 窗口已存在:移动 + 显示 + 请求焦点 if let Some(win) = app.get_webview_window(POPUP_LABEL) { - let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { + let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y, })); @@ -126,7 +124,7 @@ pub fn show_popup(app: &AppHandle) { ) .title("剪贴板") .inner_size(w, h) - .position(x as f64 / scale, y as f64 / scale) + .position(x, y) .decorations(false) .transparent(true) .shadow(true) @@ -233,7 +231,7 @@ mod win_api { use windows_sys::Win32::Foundation::POINT; use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA}; - /// 获取鼠标位置(屏幕坐标,逻辑像素) + /// 获取鼠标位置(屏幕坐标,物理像素) pub fn get_cursor_pos() -> Option<(i32, i32)> { let mut pt = POINT { x: 0, y: 0 }; unsafe { @@ -245,7 +243,7 @@ mod win_api { } } - /// 获取屏幕工作区(排除任务栏,逻辑像素) + /// 获取主屏工作区尺寸(排除任务栏,物理像素) pub fn get_work_area() -> Option<(f64, f64)> { use windows_sys::Win32::Foundation::RECT; let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 }; @@ -257,12 +255,52 @@ mod win_api { } } } + + /// 获取指定点所在显示器的工作区(排除任务栏),返回 (left, top, right, bottom) 物理像素。 + /// 使用 MonitorFromPoint 支持多显示器环境。 + pub fn get_work_area_at_point(x: i32, y: i32) -> Option<(i32, i32, i32, i32)> { + use windows_sys::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromPoint, MONITORINFO, MONITOR_DEFAULTTONEAREST, + }; + let pt = POINT { x, y }; + let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) }; + let mut mi: MONITORINFO = unsafe { std::mem::zeroed() }; + mi.cbSize = std::mem::size_of::() as u32; + unsafe { + if GetMonitorInfoW(hmon, &mut mi) != 0 { + let rc = mi.rcWork; + Some((rc.left, rc.top, rc.right, rc.bottom)) + } else { + None + } + } + } + + /// 获取指定点所在显示器的有效 DPI。 + /// scale factor = dpi / 96。 + pub fn get_dpi_for_point(x: i32, y: i32) -> Option { + use windows_sys::Win32::Graphics::Gdi::{MonitorFromPoint, MONITOR_DEFAULTTONEAREST}; + use windows_sys::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + let pt = POINT { x, y }; + let hmon = unsafe { MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST) }; + let mut dpi_x: u32 = 0; + let mut dpi_y: u32 = 0; + unsafe { + if GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) == 0 { + Some(dpi_x) + } else { + None + } + } + } } #[cfg(not(windows))] mod win_api { pub fn get_cursor_pos() -> Option<(i32, i32)> { None } pub fn get_work_area() -> Option<(f64, f64)> { None } + pub fn get_work_area_at_point(_x: i32, _y: i32) -> Option<(i32, i32, i32, i32)> { None } + pub fn get_dpi_for_point(_x: i32, _y: i32) -> Option { None } } -pub use win_api::{get_cursor_pos, get_work_area}; +pub use win_api::{get_cursor_pos, get_work_area, get_work_area_at_point, get_dpi_for_point}; diff --git a/src-tauri/src/clipboard/reader.rs b/src-tauri/src/clipboard/reader.rs index 9681a33..f90acc5 100644 --- a/src-tauri/src/clipboard/reader.rs +++ b/src-tauri/src/clipboard/reader.rs @@ -232,10 +232,19 @@ pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> { Some((width as u32, height.abs() as u32)) } -/// 将 CF_DIB 字节转换为 PNG 字节(仅支持 24/32bpp BI_RGB)。 +/// 将 CF_DIB 字节转换为 PNG 字节(支持 24/32bpp BI_RGB / BI_BITFIELDS)。 pub fn dib_to_png(dib: &[u8]) -> Option> { use image::codecs::png::PngEncoder; use image::ImageEncoder; + + // 兜底:数据本身就是 PNG / JPEG(极少数来源直接存放压缩数据) + if dib.len() >= 4 && &dib[0..4] == b"\x89PNG" { + return Some(dib.to_vec()); + } + if dib.len() >= 3 && dib[0] == 0xFF && dib[1] == 0xD8 && dib[2] == 0xFF { + return Some(dib.to_vec()); + } + if dib.len() < 40 { return None; } @@ -244,7 +253,14 @@ pub fn dib_to_png(dib: &[u8]) -> Option> { let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]); let bpp = u16::from_le_bytes([dib[14], dib[15]]); let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]); - if width <= 0 || compression != 0 { + if width <= 0 { + return None; + } + // 接受 BI_RGB(0) 和 BI_BITFIELDS(3)。 + // Windows 截图工具(Win+Shift+S / 截图工具)常用 BI_BITFIELDS 标记 32bpp BGRA, + // 像素数据本身未压缩,与 BI_RGB 解码方式一致。 + // 拒绝 BI_RLE4/8(1/2) 和 BI_JPEG/PNG(4/5) 等真正压缩格式。 + if compression != 0 && compression != 3 { return None; } if bpp != 24 && bpp != 32 { @@ -269,7 +285,8 @@ pub fn dib_to_png(dib: &[u8]) -> Option> { rgba[dp] = dib[sp + 2]; // R rgba[dp + 1] = dib[sp + 1]; // G rgba[dp + 2] = dib[sp]; // B - rgba[dp + 3] = 255; // A + // 32bpp 保留 alpha 通道(截图工具常用);24bpp 不透明 + rgba[dp + 3] = if bpp == 32 { dib[sp + 3] } else { 255 }; } } let mut buf = Vec::new(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 830d405..cabb634 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod monitor_kernel; mod network_monitor; mod osd_window; mod process_manager; +mod screenshot; mod snap_fix; mod tray_menu; @@ -43,6 +44,12 @@ use process_manager::{ get_all_process_status, get_process_status, start_monitoring_thread, start_process, 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, +}; use clipboard::{ ClipboardManager, clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history, @@ -51,7 +58,7 @@ use clipboard::{ clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start, clipboard_status, clipboard_stop, clipboard_unregister_shortcut, }; -use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_show_window}; +use tray_menu::{tray_menu_action, tray_menu_hide, tray_menu_ready}; #[tauri::command] fn greet(name: &str) -> String { @@ -185,8 +192,18 @@ pub fn run() { clipboard_paste_to_target, tray_menu_action, tray_menu_hide, - tray_menu_show_window, - snap_fix::fix_snap_background + tray_menu_ready, + snap_fix::fix_snap_background, + screenshot_capture_fullscreen, + screenshot_take_fullscreen, + screenshot_crop_stored, + screenshot_window_from_point, + screenshot_enum_windows, + screenshot_capture_window, + screenshot_set_editor_image, + screenshot_get_editor_image, + screenshot_copy_image, + screenshot_save_png ]) .setup(|app| { // 初始化日志系统,日志目录: {app_data_dir}/logs/ diff --git a/src-tauri/src/mihomo_manager.rs b/src-tauri/src/mihomo_manager.rs index df7ea27..890cbba 100644 --- a/src-tauri/src/mihomo_manager.rs +++ b/src-tauri/src/mihomo_manager.rs @@ -1101,7 +1101,7 @@ fn clear_system_proxy_windows() -> Result<(), String> { } #[cfg(windows)] -fn get_system_proxy_windows() -> bool { +pub(crate) fn get_system_proxy_windows() -> bool { use winreg::enums::*; use winreg::RegKey; let hkcu = RegKey::predef(HKEY_CURRENT_USER); @@ -1130,7 +1130,7 @@ fn clear_system_proxy_windows() -> Result<(), String> { Err("系统代理仅支持 Windows".into()) } #[cfg(not(windows))] -fn get_system_proxy_windows() -> bool { +pub(crate) fn get_system_proxy_windows() -> bool { false } diff --git a/src-tauri/src/tray_menu.rs b/src-tauri/src/tray_menu.rs index c01ca9f..2625f6c 100644 --- a/src-tauri/src/tray_menu.rs +++ b/src-tauri/src/tray_menu.rs @@ -20,7 +20,11 @@ use tauri::{ /// 记录窗口最后显示时间,用于失焦防抖(避免显示瞬间因焦点未稳定而被立即隐藏) static LAST_SHOW_TIME: Mutex> = Mutex::new(None); -use crate::clipboard::popup::{get_cursor_pos, get_work_area}; +/// 保存最近一次右键时计算出的定位参数(逻辑坐标),供 `tray_menu_ready` 使用 +/// (x, tray_top_l, wa_top_l, wa_bottom_l, scale) +static LAST_MENU_LAYOUT: Mutex> = Mutex::new(None); + +use crate::clipboard::popup::{get_work_area, get_work_area_at_point, get_dpi_for_point}; use crate::clipboard::ClipboardManager; use crate::download_engine::DownloadEngine; use crate::mihomo_manager::MihomoManager; @@ -211,7 +215,8 @@ pub async fn get_tray_menu_state(app: &AppHandle) -> TrayMenuState { // ===== 显示/隐藏托盘菜单窗口 ===== /// 托盘菜单窗口尺寸(逻辑像素) -const MENU_W: f64 = 260.0; +/// 宽度需与前端 TrayMenu.vue 的 MENU_WIDTH 保持一致,避免 resize 时右对齐错位 +const MENU_W: f64 = 220.0; const MENU_H: f64 = 420.0; /// 预创建隐藏的托盘菜单窗口(在应用启动时调用)。 @@ -272,26 +277,37 @@ pub fn precreate_tray_menu_window(app: &AppHandle) { eprintln!("[tray-menu] 菜单窗口已预创建(隐藏渲染)"); } -/// 在鼠标当前位置显示托盘菜单窗口。 -/// 窗口不存在则创建并显示,已存在则移动到鼠标位置并显示。 -pub fn show_tray_menu(app: &AppHandle) { - let (mx, my) = match get_cursor_pos() { - Some(p) => p, - None => return, - }; +/// 右键托盘时调用:计算定位参数、发送状态给前端,但不立即显示窗口。 +/// 窗口等待前端测量内容高度后调用 `tray_menu_ready` 才显示,确保底部精确对齐托盘图标。 +/// +/// `cursor_pos`: 事件报告的鼠标物理坐标;`tray_rect`: 托盘图标区域(物理像素)。 +pub fn show_tray_menu(app: &AppHandle, cursor_pos: (f64, f64), tray_rect: (f64, f64, f64, f64)) { + let (mx, my) = (cursor_pos.0, cursor_pos.1); + let tray_top = tray_rect.1; - // GetCursorPos 在 Per-Monitor DPI V2 下返回物理像素,直接用作 PhysicalPosition - let win = app.get_webview_window(TRAY_MENU_LABEL); - let scale = win.as_ref() - .and_then(|w| w.scale_factor().ok()) - .unwrap_or(1.0); + // 获取光标所在显示器的工作区(物理像素) + let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(mx as i32, my as i32) + .unwrap_or((0, 0, 1920, 1040)); - let w_phys = (MENU_W * scale) as i32; - let h_phys = (MENU_H * scale) as i32; + // 获取光标所在显示器的 DPI,将物理坐标转为逻辑坐标(DIP) + let dpi = get_dpi_for_point(mx as i32, my as i32).unwrap_or(96); + let scale = dpi as f64 / 96.0; - // 托盘位于屏幕右下角,菜单始终出现在鼠标左上方 - let x = (mx - w_phys).max(0); - let y = (my - h_phys - 8).max(0); + let mx_l = mx / scale; + let tray_top_l = tray_top / scale; + let wa_left_l = wa_left as f64 / scale; + let wa_right_l = wa_right as f64 / scale; + let wa_top_l = wa_top as f64 / scale; + let wa_bottom_l = wa_bottom as f64 / scale; + + // 水平:菜单左边缘对齐鼠标 X(向右延伸),超出右边界则左移 + let x = mx_l.max(wa_left_l).min(wa_right_l - MENU_W); + + // 保存布局参数,供 tray_menu_ready 使用 + { + let mut layout = LAST_MENU_LAYOUT.lock().unwrap(); + *layout = Some((x, tray_top_l, wa_top_l, wa_bottom_l, scale)); + } // 记录显示时间,用于失焦防抖 { @@ -299,42 +315,17 @@ pub fn show_tray_menu(app: &AppHandle) { *t = Some(Instant::now()); } - // 窗口已存在(预创建或之前显示过):移动 + 显示 + 发送状态 - if let Some(win) = win { - let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })); - let _ = win.show(); - let _ = win.set_focus(); - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - let state = get_tray_menu_state(&app_clone).await; - let _ = app_clone.emit("tray-menu-show", state); - }); - return; + // 确保窗口存在(兖底) + if app.get_webview_window(TRAY_MENU_LABEL).is_none() { + precreate_tray_menu_window(app); } - // 兜底:窗口未预创建(理论上不会走到,因为 create_tray_menu 已预创建) - precreate_tray_menu_window(app); - if let Some(win) = app.get_webview_window(TRAY_MENU_LABEL) { - let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })); - let _ = win.show(); - let _ = win.set_focus(); - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - let state = get_tray_menu_state(&app_clone).await; - let _ = app_clone.emit("tray-menu-show", state); - }); - } -} - -/// 预创建窗口完成后由前端调用(仅触发状态推送,不显示窗口——预创建模式下窗口保持隐藏)。 -pub fn show_window(app: &AppHandle) { - if app.get_webview_window(TRAY_MENU_LABEL).is_some() { - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - let state = get_tray_menu_state(&app_clone).await; - let _ = app_clone.emit("tray-menu-show", state); - }); - } + // 发送状态给前端(前端测量内容高度后调用 tray_menu_ready 显示窗口) + let app_clone = app.clone(); + tauri::async_runtime::spawn(async move { + let state = get_tray_menu_state(&app_clone).await; + let _ = app_clone.emit("tray-menu-show", state); + }); } /// 隐藏托盘菜单窗口(不销毁,保留复用) @@ -403,6 +394,11 @@ 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(); @@ -433,10 +429,35 @@ pub async fn tray_menu_hide(app: AppHandle) -> Result<(), String> { Ok(()) } -/// 显示已创建的菜单窗口(前端 onMounted 后调用) +/// 前端测量内容高度后调用:调整窗口大小、精确定位、然后显示。 +/// 这样窗口从一开始就是正确尺寸和位置,不会出现间隙。 #[tauri::command] -pub async fn tray_menu_show_window(app: AppHandle) -> Result<(), String> { - show_window(&app); +pub async fn tray_menu_ready(content_height: f64, app: AppHandle) -> Result<(), String> { + let win = app.get_webview_window(TRAY_MENU_LABEL) + .ok_or("tray-menu window not found")?; + + let (x, tray_top_l, wa_top_l, wa_bottom_l, _scale) = { + let layout = LAST_MENU_LAYOUT.lock().unwrap(); + layout.unwrap_or((0.0, 1040.0, 0.0, 1040.0, 1.0)) + }; + + // 将内容高度限制在合理范围内 + let h = content_height.max(100.0).min(520.0); + + // 调整窗口尺寸 + let _ = win.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: MENU_W, + height: h, + })); + + // 垂直:菜单下边缘紧贴托盘图标顶部(向上弹出) + let y = (tray_top_l - h).max(wa_top_l).min(wa_bottom_l - h); + + let pos = tauri::Position::Logical(tauri::LogicalPosition { x, y }); + let _ = win.set_position(pos); + let _ = win.show(); + let _ = win.set_focus(); + Ok(()) } @@ -695,10 +716,23 @@ pub fn create_tray_menu(app: &AppHandle) -> Result<(), tauri::Error> { } TrayIconEvent::Click { button: MouseButton::Right, + position, + rect, .. } => { - // 右键:显示自定义菜单窗口 - show_tray_menu(&app); + // 右键:显示自定义菜单窗口(使用事件中的精确坐标) + let cursor = (position.x, position.y); + // 从 Rect 的 Position/Size 枚举中提取物理像素值 + let (rx, ry) = match rect.position { + tauri::Position::Physical(p) => (p.x as f64, p.y as f64), + tauri::Position::Logical(p) => (p.x, p.y), + }; + let (_rw, rh) = match rect.size { + tauri::Size::Physical(s) => (s.width as f64, s.height as f64), + tauri::Size::Logical(s) => (s.width, s.height), + }; + let tray_r = (rx, ry, _rw, rh); + show_tray_menu(&app, cursor, tray_r); } _ => {} } diff --git a/src/App.vue b/src/App.vue index 6d4ae0a..3db346b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,12 +6,14 @@ 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 { TooltipProvider } from '@/components/ui/tooltip' import { moduleRegistry } from '@/modules/registry' import type { ModuleMeta } from '@/types/module' import { pendingNewDownload, pendingOpenSettings } from '@/lib/trayEvents' const appStore = useAppStore() +const screenshotStore = useScreenshotStore() /** 侧边栏 / 标题栏需要的模块信息(id + name + icon) */ interface NavModule { @@ -157,12 +159,23 @@ onMounted(async () => { handleModuleChange('settings') }) ) + // 托盘截图:直接触发截图流程(不切换模块,覆盖层/编辑器独立窗口) + trayUnlisteners.push( + await listen('tray:start-screenshot', (e) => { + screenshotStore.startCapture(e.payload || 'region').catch(err => + console.error('[screenshot] 托盘触发截图失败:', err) + ) + }) + ) + // 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史) + screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e)) }) const trayUnlisteners: UnlistenFn[] = [] onUnmounted(() => { trayUnlisteners.forEach(fn => fn()) + screenshotStore.destroyExportListener() }) diff --git a/src/components/layout/ModuleContainer.vue b/src/components/layout/ModuleContainer.vue index 04a9da2..0cf7746 100644 --- a/src/components/layout/ModuleContainer.vue +++ b/src/components/layout/ModuleContainer.vue @@ -13,7 +13,7 @@ defineProps<{ class="flex-1" :style="{ backgroundColor: 'var(--effect-bg)', backdropFilter: 'var(--effect-blur)' }" > - +
-import { ref, computed, onMounted, onUnmounted } from 'vue' -import { Search, Settings, ChevronRight } from '@lucide/vue' +import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' +import { Search, Settings, ChevronRight, ArrowUp } from '@lucide/vue' import { Input } from '@/components/ui/input' import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' @@ -137,6 +137,48 @@ if (tauriWindow) { // 初始时窗口已显示,恢复 hover restoreHover(300) +// ===== 回到顶部按钮 ===== +const showScrollTop = ref(false) +let scrollViewport: HTMLElement | null = null +let rafId: number | null = null + +const handleMainScroll = () => { + if (!scrollViewport) return + showScrollTop.value = scrollViewport.scrollTop > 200 +} + +const scrollToTop = () => { + if (!scrollViewport) return + const start = scrollViewport.scrollTop + if (start === 0) return + // 物理变速滚动:cubic ease-out,先快后慢 + const duration = 500 + const startTime = performance.now() + const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3) + if (rafId !== null) cancelAnimationFrame(rafId) + const animate = (now: number) => { + const elapsed = now - startTime + const progress = Math.min(elapsed / duration, 1) + scrollViewport!.scrollTop = start * (1 - easeOutCubic(progress)) + if (progress < 1) { + rafId = requestAnimationFrame(animate) + } else { + rafId = null + } + } + rafId = requestAnimationFrame(animate) +} + +const initScrollListener = () => { + scrollViewport = document.querySelector( + '[data-main-scroll] [data-reka-scroll-area-viewport]' + ) + if (scrollViewport) { + scrollViewport.addEventListener('scroll', handleMainScroll, { passive: true }) + handleMainScroll() + } +} + onMounted(async () => { if (tauriWindow) { try { @@ -152,12 +194,16 @@ onMounted(async () => { invoke('fix_snap_background').catch(() => {}) }, 1000) } + // 等待模块容器渲染后绑定滚动监听 + nextTick(initScrollListener) }) onUnmounted(() => { window.removeEventListener('mousemove', handleFirstMouseMove) if (restoreHoverTimer) clearTimeout(restoreHoverTimer) if (unlistenMaximize) unlistenMaximize() + if (scrollViewport) scrollViewport.removeEventListener('scroll', handleMainScroll) + if (rafId !== null) cancelAnimationFrame(rafId) }) const handleBlur = () => { @@ -178,28 +224,41 @@ const handleBlur = () => { > Thing - - -
+ +
+ +
+ +
+
+ -
- + +
@@ -310,6 +369,7 @@ const handleBlur = () => { .hover-suppressed button:hover, .hover-suppressed button.is-hovered { background-color: transparent !important; + color: var(--foreground) !important; } /* 最大化按钮:snap-layout 插件的原生子窗口遮挡了指针事件, diff --git a/src/main.ts b/src/main.ts index a45ccab..9e937c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,8 +16,9 @@ window.addEventListener('unhandledrejection', (event) => { }) // ===== OSD 窗口模式检测 ===== -// 通过 URL hash 识别:#osd-overlay(悬浮窗)/ #clipboard-popup(剪贴板快捷弹窗)/ #tray-menu(托盘菜单) +// 通过 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}`) @@ -37,6 +38,18 @@ if (winHash === '#osd-overlay') { const app = createApp(TrayMenu) app.mount('#app') }) +} else if (winHash.startsWith('#screenshot-overlay')) { + logger.info(`截图覆盖层窗口启动: ${winHash}`) + void import('./modules/screenshot/ScreenshotOverlay.vue').then(({ default: ScreenshotOverlay }) => { + const app = createApp(ScreenshotOverlay) + app.mount('#app') + }) +} else if (winHash === '#screenshot-editor') { + logger.info(`截图编辑器窗口启动: ${winHash}`) + void import('./modules/screenshot/ScreenshotEditor.vue').then(({ default: ScreenshotEditor }) => { + const app = createApp(ScreenshotEditor) + app.mount('#app') + }) } else { // ===== 主应用模式 ===== void import('./App.vue').then(async ({ default: App }) => { diff --git a/src/modules/clipboard/ClipboardModule.vue b/src/modules/clipboard/ClipboardModule.vue index f35483f..7be12a3 100644 --- a/src/modules/clipboard/ClipboardModule.vue +++ b/src/modules/clipboard/ClipboardModule.vue @@ -3,12 +3,12 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon, FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard, - ChevronLeft, ChevronRight, } from '@lucide/vue' import { toast } from 'vue-sonner' -import { useClipboardStore, type ClipboardItem, type ClipboardKind } from '@/stores/clipboardStore' +import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore' import { useModuleTabs } from '@/lib/useModuleTabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { ScrollArea } from '@/components/ui/scroll-area' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -22,6 +22,9 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { + Pagination, PaginationContent, PaginationItem, PaginationEllipsis, +} from '@/components/ui/pagination' const store = useClipboardStore() @@ -66,7 +69,7 @@ const gotoPage = async (p: number) => { // 详情弹窗 const detailOpen = ref(false) const detailLoading = ref(false) -const detail = ref & { content?: string | null; imageBase64?: string | null; kind?: string } | null>(null) +const detail = ref(null) const openDetail = async (item: ClipboardItem) => { detailOpen.value = true detailLoading.value = true @@ -76,6 +79,15 @@ const openDetail = async (item: ClipboardItem) => { detailLoading.value = false } +/** 根据 base64 前缀判断图片 MIME 类型(dib_to_png 可能直接透传 PNG/JPEG) */ +const imageSrc = computed(() => { + const b64 = detail.value?.imageBase64 + if (!b64) return '' + // PNG base64 以 iVBORw0KGgo 开头,JPEG 以 /9j/ 开头 + const mime = b64.startsWith('/9j/') ? 'image/jpeg' : 'image/png' + return `data:${mime};base64,${b64}` +}) + // 清空确认 const clearOpen = ref(false) @@ -226,7 +238,6 @@ onUnmounted(() => {
- {{ store.historyTotal }} 条
-
+ +
+ + + + + +
+ + +

暂无历史记录,复制内容后将自动收录

@@ -270,18 +311,8 @@ onUnmounted(() => {
-
- - -
- - {{ currentPage }} / {{ totalPages }} - -
+
+ @@ -428,11 +459,14 @@ onUnmounted(() => {