细节调整及优化(26.8.3)

This commit is contained in:
zhongluofeng
2026-08-21 17:25:47 +08:00
parent 0a19b4b38a
commit 4dd60f42a1
72 changed files with 4779 additions and 1395 deletions
+95 -12
View File
@@ -7,7 +7,7 @@ use specta::Type;
use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png;
use super::reader::{dib_to_png, dib_to_thumbnail};
use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize, Type)]
@@ -95,6 +95,21 @@ pub async fn clipboard_get_item(
.map_err(|e| format!("查询任务失败: {}", e))
}
/// 获取图片缩略图 PNG base64(弹窗悬停预览用,避免加载全尺寸图片)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_get_thumb(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<Option<String>, String> {
let storage = manager.storage().clone();
tauri::async_runtime::spawn_blocking(move || {
storage.get_thumb(id, |dib| dib_to_thumbnail(dib, 256))
})
.await
.map_err(|e| format!("查询任务失败: {}", e))
}
#[tauri::command]
#[specta::specta]
pub async fn clipboard_set_pinned(
@@ -162,25 +177,44 @@ pub async fn clipboard_save_settings(
app: AppHandle,
manager: State<'_, ClipboardManager>,
) -> Result<(), String> {
let prev_enabled = manager.get_settings().enabled;
let prev_shortcut = manager.get_settings().shortcut.clone();
let prev = manager.get_settings();
let prev_enabled = prev.enabled;
let prev_shortcut = prev.shortcut.clone();
// 快捷键变化且新值非空:先注册(原子化 + 冲突检测),成功后才保存设置。
// 注册失败时恢复旧快捷键并中止保存,避免设置被写入无法生效的组合键、旧键丢失。
if settings.shortcut != prev_shortcut && !settings.shortcut.trim().is_empty() {
if let Err(e) = crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
super::popup::show_popup(a)
}) {
// register_shortcut 内部已注销旧快捷键,失败时需重新注册旧键以恢复
if !prev_shortcut.trim().is_empty() {
let _ = crate::shortcut::register_shortcut(&app, "剪贴板", &prev_shortcut, |a| {
super::popup::show_popup(a)
});
}
return Err(e);
}
// 新快捷键非空时确保弹窗/预览窗口已预创建
super::popup::ensure_popup_window(&app);
super::popup::ensure_preview_window(&app);
}
// 保存设置
manager.save_settings(settings.clone());
// 监听开关变化时联动启停
if settings.enabled && !prev_enabled {
manager.start(&app);
} else if !settings.enabled && prev_enabled {
manager.stop();
}
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut {
crate::shortcut::register_shortcut(&app, "剪贴板", &settings.shortcut, |a| {
super::popup::show_popup(a)
})?;
// 新快捷键非空时确保弹窗窗口已预创建
if !settings.shortcut.trim().is_empty() {
super::popup::ensure_popup_window(&app);
}
// 快捷键改为空字符串(禁用):注销旧快捷键
if settings.shortcut.trim().is_empty() && settings.shortcut != prev_shortcut {
crate::shortcut::unregister_shortcut(&app, "剪贴板");
}
Ok(())
}
@@ -262,3 +296,52 @@ pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app);
Ok(())
}
/// 在弹窗旁显示独立预览窗口(悬停/键盘选中时调用)
#[tauri::command]
#[specta::specta]
pub async fn clipboard_show_preview(app: AppHandle, id: i64) -> Result<(), String> {
super::popup::show_preview(&app, id);
Ok(())
}
/// 隐藏独立预览窗口
#[tauri::command]
#[specta::specta]
pub async fn clipboard_hide_preview(app: AppHandle) -> Result<(), String> {
super::popup::hide_preview(&app);
Ok(())
}
/// 按内容自适应调整预览窗大小(逻辑像素)。前端加载内容(文本测高、图片按宽高比)后调用,
/// 窗口贴合内容消除留白;后端按弹窗所在屏工作区钳制并重新对齐弹窗。
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false(保持原侧)。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_resize_preview(
app: AppHandle,
width: f64,
height: f64,
allow_flip: bool,
) -> Result<(), String> {
super::popup::resize_preview(&app, width, height, allow_flip);
Ok(())
}
/// 显示已就绪的预览窗口。前端完成内容加载与 resize 后调用,窗口以最终尺寸出现,
/// 消除"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_reveal_preview(app: AppHandle) -> Result<(), String> {
super::popup::reveal_preview(&app);
Ok(())
}
/// 预览窗交互锁定:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
/// 此后弹窗+预览不因失焦/鼠标离开而关闭,仅当点击外部或弹窗重新聚焦时退出锁定。
#[tauri::command]
#[specta::specta]
pub async fn clipboard_preview_interacted(app: AppHandle) -> Result<(), String> {
super::popup::preview_interacted(&app);
Ok(())
}
+9 -6
View File
@@ -12,6 +12,7 @@ use specta::Type;
use super::monitor::start_monitor;
use super::reader::{write_dib, write_files, write_text};
use super::storage::Storage;
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 剪贴板设置(持久化到 clipboard/settings.json
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -55,7 +56,8 @@ impl Default for ClipboardSettings {
pub struct ClipboardManager {
storage: Arc<Storage>,
settings: Arc<Mutex<ClipboardSettings>>,
suppress: Arc<AtomicBool>,
/// 本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录
suppress: Arc<Mutex<Option<u32>>>,
monitor_stop: Arc<AtomicBool>,
monitor_handle: Mutex<Option<JoinHandle<()>>>,
settings_path: PathBuf,
@@ -73,7 +75,7 @@ impl ClipboardManager {
};
let settings_path = clip_dir.join("settings.json");
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
let suppress = Arc::new(AtomicBool::new(false));
let suppress = Arc::new(Mutex::new(None));
let monitor_stop = Arc::new(AtomicBool::new(true));
Self {
storage,
@@ -139,13 +141,12 @@ impl ClipboardManager {
save_settings(&self.settings_path, &s);
}
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录
/// 将某条历史写回剪贴板。写回成功后记录剪贴板序列号,供监听跳过自身写入
pub fn copy_back(&self, id: i64) -> Result<(), String> {
let (kind, content, blob) = self
.storage
.get_raw_for_copy(id)
.ok_or_else(|| "条目不存在".to_string())?;
self.suppress.store(true, Ordering::SeqCst);
let ok = match kind.as_str() {
"text" => content.as_deref().map(write_text).unwrap_or(false),
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
@@ -163,10 +164,12 @@ impl ClipboardManager {
_ => false,
};
if ok {
// 绑定到写入完成后的剪贴板序列号:仅跳过本次写入产生的记录,
// 用户后续复制(序列号不同)不会被误吞。
let seq = unsafe { GetClipboardSequenceNumber() };
*self.suppress.lock().unwrap_or_else(|e| e.into_inner()) = Some(seq);
Ok(())
} else {
// 写入失败也清除 suppress,避免误吞下次复制
self.suppress.store(false, Ordering::SeqCst);
Err("写回剪贴板失败".into())
}
}
+6 -3
View File
@@ -9,9 +9,12 @@ pub mod storage;
pub use commands::{
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_get_thumb,
clipboard_hide_popup, clipboard_hide_preview, clipboard_paste_to_target,
clipboard_preview_interacted, clipboard_register_shortcut,
clipboard_reveal_preview,
clipboard_save_settings, clipboard_search, clipboard_set_pinned, clipboard_resize_preview,
clipboard_show_popup, clipboard_show_preview, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
};
pub use manager::ClipboardManager;
+14 -7
View File
@@ -1,7 +1,7 @@
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
//!
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
//! 250ms 间隔兼顾响应速度与开销,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -15,18 +15,19 @@ use super::storage::{NewItem, Storage};
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 启动监听线程,返回 JoinHandle。
/// `suppress` 记录本应用 copy_back 写入后的剪贴板序列号,用于跳过自身写入产生的记录。
pub fn start_monitor(
storage: Arc<Storage>,
app: AppHandle,
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
suppress: Arc<AtomicBool>,
suppress: Arc<Mutex<Option<u32>>>,
stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
thread::spawn(move || loop {
if stop.load(Ordering::SeqCst) {
break;
}
thread::sleep(Duration::from_millis(800));
thread::sleep(Duration::from_millis(250));
if stop.load(Ordering::SeqCst) {
break;
}
@@ -40,10 +41,13 @@ pub fn start_monitor(
if seq == last {
continue;
}
// 序列号变化,处理一次
if suppress.swap(false, Ordering::SeqCst) {
// 由本应用 copy_back 触发,跳过记录
continue;
// 序列号变化:仅当变化来自本应用 copy_back(序列号精确匹配)时跳过
// 避免旧布尔标志在用户后续复制时被误吞。
if let Ok(mut s) = suppress.lock() {
if *s == Some(seq) {
*s = None;
continue;
}
}
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
let s = settings.lock().unwrap_or_else(|e| e.into_inner());
@@ -99,6 +103,7 @@ fn build_text_item(t: &str) -> NewItem {
kind: "text".into(),
content: Some(t.to_string()),
blob: None,
thumb: None,
preview: make_preview(t, 200),
size: t.len() as i64,
hash: hash_str(t),
@@ -110,6 +115,7 @@ fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
kind: "image".into(),
content: None,
blob: Some(dib.to_vec()),
thumb: super::reader::dib_to_thumbnail(dib, 256),
preview: format!("图片 {}×{}", w, h),
size: dib.len() as i64,
hash: hash_bytes(dib),
@@ -132,6 +138,7 @@ fn build_files_item(files: &[String]) -> NewItem {
kind: "files".into(),
content: Some(content),
blob: None,
thumb: None,
preview,
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
hash,
+531 -5
View File
@@ -10,12 +10,27 @@
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder};
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "clipboard-popup";
/// 预览窗口标签(悬停/键盘选中条目时在弹窗旁显示内容)
pub const PREVIEW_LABEL: &str = "clipboard-preview";
/// 预览窗口初始逻辑尺寸(后续由前端按内容自适应调整,见 resize_preview
const PREVIEW_W: f64 = 340.0;
const PREVIEW_H: f64 = 300.0;
/// 预览窗口最小逻辑尺寸(防止内容过小时窗口退化)
const PREVIEW_MIN_W: f64 = 240.0;
const PREVIEW_MIN_H: f64 = 160.0;
/// 弹窗与预览窗之间的可视间距(逻辑像素,左右对称)
const PREVIEW_GAP: f64 = 12.0;
/// 标志:show_popup 兜底创建路径设为 true,前端 onMounted 回调 show_window 时据此判断是否显示。
/// 预创建路径不设置,避免应用启动时弹窗自动弹出。
static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
@@ -24,6 +39,50 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 弹窗当前是否可见(同步单一事实来源)。
/// show_preview 用它代替 popup.is_visible():后者在 hide 异步在途时可能返回过期 true,
/// 导致"弹窗已隐藏但残留的悬停/键盘请求重新弹出孤立预览窗"。该标志在隐藏路径
/// 同步置 false,任何在途请求都会直接拒绝。
static POPUP_VISIBLE: AtomicBool = AtomicBool::new(false);
/// 预览窗当前是否可见(同步标志,与 POPUP_VISIBLE 同理:预览窗以原生
/// SW_SHOWNOACTIVATE 显示,Tauri 的 is_visible() 可能与其真实状态不同步,
/// 故用该标志代替 is_visible 判断预览交互状态)。
static PREVIEW_VISIBLE: AtomicBool = AtomicBool::new(false);
/// 弹窗因预览交互(光标位于预览窗内)而推迟隐藏的标记:
/// 点击预览窗(选择/复制文本、放大图片)时,弹窗会收到 Focused(false),但用户正在
/// 预览窗内交互,此时不应隐藏弹窗。置该标记后保持弹窗显示,待用户离开预览窗
/// hide_preview)时再连同弹窗一起隐藏,避免"点击预览即关闭弹窗"导致无法交互。
static POPUP_DEFER_HIDE: AtomicBool = AtomicBool::new(false);
/// 失焦隐藏的宽限期:WebView2 透明窗口在 show 后激活期间焦点可能短暂弹跳
/// (与快速面板同因),导致 Focused(false) 紧跟在 show 之后触发并立即隐藏刚显示的弹窗。
const SHOW_GRACE: Duration = Duration::from_millis(500);
/// 最近一次 show 的时间,用于失焦宽限期判断。
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
fn mark_shown() {
if let Ok(mut t) = LAST_SHOWN.lock() {
*t = Some(Instant::now());
}
POPUP_VISIBLE.store(true, Ordering::SeqCst);
// 重新显示弹窗时复位推迟隐藏标记(上一轮预览交互的遗留状态不应影响本轮)
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
}
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
fn within_show_grace() -> bool {
LAST_SHOWN
.lock()
.ok()
.and_then(|t| *t)
.map(|t| t.elapsed() < SHOW_GRACE)
.unwrap_or(false)
}
/// 创建弹窗窗口(隐藏状态)并注册失焦监听。
/// 位置默认在屏幕外,show_popup 时会重新定位。
/// 预创建后首次按快捷键走"窗口已存在"分支直接 show,避免首次创建的时序问题。
@@ -54,13 +113,41 @@ fn create_popup_window(app: &AppHandle) {
}
};
// 监听窗口失焦:自动隐藏
// 监听窗口失焦:自动隐藏(同时隐藏预览窗)
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(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
match event {
tauri::WindowEvent::Focused(false) => {
// 失焦宽限期:show 后激活期间的焦点弹跳不隐藏弹窗,避免弹窗刚显示就被隐藏
if within_show_grace() {
return;
}
// 交互锁定模式(点击过预览窗):弹窗与预览保持显示,仅由看护线程在
// "点击外部"时关闭;此处的失焦是点击预览窗(NoActivate)所致,不隐藏。
if POPUP_INTERACTED.load(Ordering::SeqCst) {
return;
}
// 鼠标当前位于预览窗或弹窗内(用户正在交互:选择/复制文本、放大图片、或
// 已移回弹窗准备继续浏览):弹窗失焦是点击预览窗或焦点反弹所致,不应隐藏弹窗。
// 置推迟标记保持弹窗显示,待用户离开整个区域(hide_preview / 再次失焦)再隐藏。
if is_cursor_in_preview(&app_handle) || is_cursor_in_popup(&app_handle) {
POPUP_DEFER_HIDE.store(true, Ordering::SeqCst);
return;
}
// 先同步标记弹窗不可见,再隐藏窗口/预览:异步 hide 在途时,残留的
// show_preview 请求会因该标志为 false 而拒绝,预览窗不会孤立残留。
POPUP_VISIBLE.store(false, Ordering::SeqCst);
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
let _ = win_handle.hide();
hide_preview(&app_handle);
let _ = app_handle.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
}
tauri::WindowEvent::Focused(true) => {
// 弹窗重新获得焦点(用户点击/移回弹窗):取消推迟隐藏状态
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
}
_ => {}
}
});
@@ -112,6 +199,7 @@ pub fn show_popup(app: &AppHandle) {
y: y as i32,
}));
let _ = win.show();
mark_shown();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
@@ -143,17 +231,27 @@ pub fn show_window(app: &AppHandle) {
}));
}
let _ = win.show();
mark_shown();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_SHOW, ());
}
}
/// 隐藏弹窗(不销毁,保留复用)
/// 隐藏弹窗(不销毁,保留复用),并通知前端清理悬停定时器/预览。
pub fn hide_popup(app: &AppHandle) {
// 复位推迟隐藏标记(先于 hide_preview,避免 hide_preview 重入 hide_popup 形成递归)
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
// 复位交互锁定:弹窗关闭后下次呼出恢复"失焦即隐藏"的常规模式
POPUP_INTERACTED.store(false, Ordering::SeqCst);
// 同步标记弹窗不可见,拒绝此后在途的 show_preview 请求
POPUP_VISIBLE.store(false, Ordering::SeqCst);
hide_preview(app);
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide();
}
// 通知前端:取消悬停定时器并隐藏预览,避免弹窗隐藏后残留定时器重新弹出预览窗
let _ = app.emit(crate::constants::events::CLIPBOARD_POPUP_HIDE, ());
}
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
@@ -207,6 +305,434 @@ fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴
}
// ===== 独立预览窗口 =====
/// 创建预览窗口(隐藏状态),应用 NoActivate 样式避免抢焦点。
fn create_preview_window(app: &AppHandle) {
let win = match WebviewWindowBuilder::new(
app,
PREVIEW_LABEL,
WebviewUrl::App("index.html#clipboard-preview".into()),
)
.title("预览")
.inner_size(PREVIEW_W, PREVIEW_H)
.position(-10000.0, -10000.0) // 屏幕外,避免隐藏时一闪
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.focused(false) // 不抢占焦点
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
crate::logger::log_error("clipboard", &format!("创建预览窗失败: {}", e));
return;
}
};
// NoActivate:预览窗可交互但不激活,弹窗保持前台(键盘导航/粘贴不受影响)
// 圆角:NoActivate 悬浮窗系统不自动圆角,显式指定 DWMWCP_ROUND 与弹窗外观统一
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::apply_no_activate(hwnd.0 as isize);
crate::win32_util::apply_rounded_corners(hwnd.0 as isize);
}
crate::logger::log_info("clipboard", "预览窗口已预创建(隐藏状态)");
}
/// 应用启动时预创建预览窗口(隐藏),随弹窗一起就绪。
pub fn ensure_preview_window(app: &AppHandle) {
if app.get_webview_window(PREVIEW_LABEL).is_some() {
return;
}
create_preview_window(app);
}
/// 预览当前所在侧(default=右,left=左),供放大/还原重定位沿用同侧。
#[derive(Clone, Copy, PartialEq)]
enum PreviewSide {
Right,
Left,
}
static PREVIEW_SIDE: Mutex<Option<PreviewSide>> = Mutex::new(None);
/// 最近一次显示预览时的鼠标 Y 锚点(物理像素)。预览窗垂直中线对齐该位置
/// (贴近鼠标所悬停的条目),resize(切换条目/放大还原)时沿用,保持中线稳定不跳变。
static PREVIEW_ANCHOR_Y: Mutex<Option<i32>> = Mutex::new(None);
/// 弹窗离开看护线程是否已在运行(防重入,见 spawn_popup_leave_watch)。
static POPUP_LEAVE_WATCH: AtomicBool = AtomicBool::new(false);
/// 交互锁定模式:用户点击过预览窗(放大/缩小、复制文本等)后置位。
/// 该模式下弹窗与预览不因失焦/鼠标离开而关闭,仅当点击弹窗+预览之外的
/// 区域(看护线程检测鼠标按下沿)或弹窗重新聚焦(恢复正常模式)时才复位。
static POPUP_INTERACTED: AtomicBool = AtomicBool::new(false);
/// 重新定位预览窗口(物理像素)。preferred 指定优先放置侧,返回实际落位侧。
/// anchor_y 为鼠标 Y 锚点:预览窗垂直中线对齐该位置(贴近悬停条目),
/// 上下 clamp 到工作区;None 时退回弹窗顶部对齐。
///
/// 无边框+阴影窗口(tao 实现)的外框矩形在客户区四周各留一条不可见边框
/// SM_CXSIZEFRAME+SM_CXPADDEDBORDER,典型 8px):outer_position/outer_size 均含该边框,
/// 而可视内容仅占客户区。若直接用外框尺寸计算右侧落位,右侧可视间距会比左侧多出
/// 两侧边框之和(约 16px)。这里以"可视边缘"对齐:frame = (外框宽 - 客户区宽) / 2,
/// 左右两侧使用同一 GAP,保证两侧可视间距一致。
fn reposition_preview(
popup: &tauri::WebviewWindow,
win: &tauri::WebviewWindow,
vw: i32,
vh: i32,
preferred: PreviewSide,
anchor_y: Option<i32>,
allow_flip: bool,
) -> PreviewSide {
// 弹窗当前物理位置与尺寸(外框,含不可见边框)
let Ok(pos) = popup.outer_position() else { return preferred };
let Ok(osz) = popup.outer_size() else { return preferred };
let Ok(isz) = popup.inner_size() else { return preferred };
let (px, py) = (pos.x, pos.y);
let pw = osz.width as i32;
// 不可见边框宽度(外框与客户区之差的一半;无阴影窗口为 0)
let frame = ((osz.width.saturating_sub(isz.width)) / 2) as i32;
// 弹窗所在显示器工作区
let (wa_left, wa_top, wa_right, wa_bottom) = get_work_area_at_point(px, py)
.unwrap_or((0, 0, 1920, 1040));
let gap = (PREVIEW_GAP * popup.scale_factor().unwrap_or(1.0)).round() as i32;
// 可视边缘对齐:
// 右侧落位 = 弹窗可视右缘(px+pw-frame) + GAP - 预览窗自身左边框(frame)
// 左侧落位 = 弹窗可视左缘(px+frame) - GAP - 预览可视宽(vw) - 预览窗左边框(frame)
// (frame 两两抵消,与旧公式一致)
let right_x = px + pw - 2 * frame + gap;
let left_x = px - gap - vw;
// 可视区域(含预览窗自身边框偏移)不越工作区
let fits_right = right_x + frame + vw <= wa_right;
let fits_left = left_x + frame >= wa_left;
let mut x = match preferred {
PreviewSide::Right => right_x,
PreviewSide::Left => left_x,
};
// 仅初始落位(allow_flip=true)允许换侧:优先侧放不下时切到另一侧。
// 内容尺寸变化(放大/还原,allow_flip=false)不允许换侧:窗口跳到另一侧会使
// 鼠标瞬间落在窗外,误触发 mouseleave 而关闭预览/弹窗;此时保持原侧并靠
// 工作区 clamp(大图可能覆盖弹窗边缘,属合理取舍,鼠标仍在预览窗内可交互)。
if allow_flip {
match preferred {
PreviewSide::Right => {
if !fits_right {
x = left_x;
}
}
PreviewSide::Left => {
if !fits_left {
x = right_x;
}
}
}
}
x = (x + frame).max(wa_left).min(wa_right - vw) - frame;
// 仅初始落位(allow_flip)按最终位置更新所在侧(换侧时位置在弹窗另一侧,判定正确);
// 放大/还原(不允许换侧)时即使被工作区 clamp 覆盖到弹窗上方,语义上仍属原侧,
// 必须沿用 preferred——否则 clamp 后按位置误判为另一侧并存档,缩小后会跳侧。
let side = if allow_flip {
if x + vw <= px { PreviewSide::Left } else { PreviewSide::Right }
} else {
preferred
};
// 垂直定位:预览窗中线对齐鼠标 Y 锚点(贴近悬停条目),上下 clamp 到工作区;
// 无锚点时退回弹窗顶部对齐
let y = match anchor_y {
Some(ay) => (ay - vh / 2).max(wa_top).min(wa_bottom - vh),
None => py.max(wa_top).min(wa_bottom - vh),
};
let _ = win.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y }));
if let Ok(mut s) = PREVIEW_SIDE.lock() {
*s = Some(side);
}
side
}
/// 通知预览前端加载指定条目。窗口此时保持隐藏:前端测得内容尺寸后先调用
/// resize_preview(隐藏状态下完成尺寸/位置调整)再 reveal_preview 显示,
/// 避免"先以上次尺寸(可能是放大态大窗)显示再缩回"的闪烁。
pub fn show_preview(app: &AppHandle, id: i64) {
// 弹窗已隐藏(或隐藏中)时拒绝显示预览:同步标志消除 is_visible() 的异步时序竞态,
// 保证"弹窗失焦消失后预览窗不会孤立残留"。
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
if app.get_webview_window(PREVIEW_LABEL).is_none() {
return;
}
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
return;
};
// 弹窗不可见时不显示预览:避免弹窗隐藏后仍在途的悬停请求重新弹出孤立的预览窗
if !popup.is_visible().unwrap_or(false) {
return;
}
// 记录鼠标 Y 锚点:预览窗中线对齐鼠标所悬停的条目位置;resize 时沿用
if let Some((_, y)) = get_cursor_pos() {
if let Ok(mut a) = PREVIEW_ANCHOR_Y.lock() {
*a = Some(y);
}
}
// 通知前端加载条目内容(前端完成后自行 resize + reveal
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_SHOW, id);
}
/// 显示已就绪的预览窗口(前端完成内容加载与 resize 后调用)。
/// 显示前重查弹窗可见性,消除内容加载期间弹窗已隐藏的竞态。
pub fn reveal_preview(app: &AppHandle) {
// 弹窗已隐藏:在途请求作废
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
// 已可见则跳过(放大/还原触发的重复 reveal)
if PREVIEW_VISIBLE.load(Ordering::SeqCst) {
return;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return;
};
// 同步标记预览窗可见(以原生方式显示,Tauri 的 is_visible 可能不同步)
PREVIEW_VISIBLE.store(true, Ordering::SeqCst);
// 不激活显示(SW_SHOWNOACTIVATE),弹窗保持前台
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::show_no_activate(hwnd.0 as isize);
}
#[cfg(not(windows))]
let _ = win.show();
// 显示期间弹窗可能已被隐藏(失焦/关闭):立即收回预览。
// 关闭初次检查与真正 show 之间的竞态——失焦处理器先置 POPUP_VISIBLE=false
// 再隐藏,这里 show 之后重查一次,若已被隐藏则收回预览,避免孤立残留。
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
let _ = win.hide();
}
}
/// 按内容自适应调整预览窗大小(逻辑像素),位置沿用当前所在侧并重新对齐弹窗。
/// 前端加载/切换内容(文本测高、图片按宽高比)后调用,窗口贴合内容消除留白。
/// allow_flip:初始落位为 true(优先侧放不下可换侧);放大/还原为 false
/// (保持原侧靠工作区 clamp,避免窗口跳侧使鼠标落在窗外误触发隐藏)。
pub fn resize_preview(app: &AppHandle, width: f64, height: f64, allow_flip: bool) {
// 弹窗不可见时忽略(无对齐基准)
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return;
};
let Some(popup) = app.get_webview_window(POPUP_LABEL) else {
return;
};
let scale = popup.scale_factor().unwrap_or(1.0);
// 以弹窗所在屏工作区为上限钳制(逻辑像素),并保证不小于最小可视尺寸
let (wa_left, wa_top, wa_right, wa_bottom) = popup
.outer_position()
.ok()
.and_then(|p| get_work_area_at_point(p.x, p.y))
.unwrap_or((0, 0, 1920, 1040));
let max_w = ((wa_right - wa_left).max(0) as f64 / scale).floor().max(PREVIEW_MIN_W);
let max_h = ((wa_bottom - wa_top).max(0) as f64 / scale).floor().max(PREVIEW_MIN_H);
let w = width.clamp(PREVIEW_MIN_W, max_w);
let h = height.clamp(PREVIEW_MIN_H, max_h);
let vw = (w * scale).round() as i32;
let vh = (h * scale).round() as i32;
let side = PREVIEW_SIDE.lock().ok().and_then(|s| *s).unwrap_or(PreviewSide::Right);
// 沿用最近一次的鼠标 Y 锚点:高度变化时中线保持对齐悬停条目,不产生跳变
let anchor_y = PREVIEW_ANCHOR_Y.lock().ok().and_then(|a| *a);
reposition_preview(&popup, &win, vw, vh, side, anchor_y, allow_flip);
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize { width: vw as u32, height: vh as u32 }));
}
/// 交互锁定模式入口:前端预览窗收到 mousedown(放大/缩小、复制、选择文本)时调用。
/// 置位锁定标志并启动看护线程:此后弹窗+预览不因失焦/鼠标离开而关闭,
/// 仅当点击弹窗+预览之外的区域或弹窗重新聚焦时退出锁定。
pub fn preview_interacted(app: &AppHandle) {
POPUP_INTERACTED.store(true, Ordering::SeqCst);
// 交互锁定取代推迟隐藏模式:清除可能残留的 DEFER 标记,
// 避免后续取消钉住时 hide_preview 走 DEFER 分支误关弹窗
POPUP_DEFER_HIDE.store(false, Ordering::SeqCst);
spawn_popup_leave_watch(app.clone());
}
/// 隐藏预览窗口(保留复用),并通知前端清空内容。
pub fn hide_preview(app: &AppHandle) {
// 交互锁定模式:弹窗仍显示时预览保持(生命周期由看护线程管理,点击外部才
// 随弹窗一起关闭),忽略常规隐藏请求(条目离开、键盘切换等)。
if POPUP_INTERACTED.load(Ordering::SeqCst) && POPUP_VISIBLE.load(Ordering::SeqCst) {
return;
}
POPUP_INTERACTED.store(false, Ordering::SeqCst);
// 若此前因预览交互推迟了弹窗隐藏(弹窗已失焦但保持显示),现在用户离开预览窗,
// 需判断去向:光标已回到弹窗内(继续浏览弹窗)→ 仅隐藏预览、保持弹窗显示;
// 光标在弹窗之外(离开整个区域)→ 连同弹窗一起隐藏。
// hide_popup 会先复位该标记,故此处 swap(false) 后调用不会递归重入)
if POPUP_DEFER_HIDE.swap(false, Ordering::SeqCst) {
if !is_cursor_in_popup(app) {
hide_popup(app);
return;
}
// 光标已回到弹窗内。若弹窗仍持有焦点(曾点击弹窗重新激活),仅隐藏预览即可,
// 后续失焦仍走 Focused(false) 正常关闭;若弹窗已失焦(点击预览窗所致),
// 它不会再收到 Focused(false) 事件——光标一旦离开弹窗区域,弹窗将永远
// 无法关闭(无焦点残留窗口)。启动看护线程兜底关闭。
let focused = app
.get_webview_window(POPUP_LABEL)
.map(|w| w.is_focused().unwrap_or(false))
.unwrap_or(false);
if !focused {
spawn_popup_leave_watch(app.clone());
}
}
PREVIEW_VISIBLE.store(false, Ordering::SeqCst);
if let Some(win) = app.get_webview_window(PREVIEW_LABEL) {
let _ = win.hide();
// 预览窗以原生 SW_SHOWNOACTIVATE 方式显示,Tauri 内部可见性状态可能与其
// 不同步;补一次原生 SW_HIDE,确保任何路径下都被可靠隐藏。
#[cfg(windows)]
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::hide_window(hwnd.0 as isize);
}
}
let _ = app.emit(crate::constants::events::CLIPBOARD_PREVIEW_HIDE, ());
}
/// 弹窗失焦但保持显示时的看护线程(防重入单例)。
///
/// 两种工作模式(每轮动态读取 POPUP_INTERACTED,可中途切换):
/// - 常规模式(未点击过预览窗):光标连续 ~300ms 既不在弹窗也不在预览窗内
/// (连续计数防"穿越弹窗-预览间隙"时的瞬时离开误判)→ 关闭弹窗;
/// - 交互锁定模式(点击过预览窗的放大/缩小/复制等):弹窗已失焦不会再收到
/// Focused(false),检测"鼠标按下沿且按下位置在弹窗+预览之外"→ 点击外部,关闭弹窗。
///
/// 公共退出条件:弹窗已隐藏/销毁(其他路径关闭);弹窗重新获得焦点(用户点击
/// 弹窗,恢复正常失焦关闭路径,并复位交互锁定);超时兜底防线程泄漏
/// (常规 30s;交互锁定为事件驱动,放宽至 10min)。
fn spawn_popup_leave_watch(app: AppHandle) {
// 防重入:已有看护在运行则跳过
if POPUP_LEAVE_WATCH.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let start = Instant::now();
let mut outside = 0u32;
let mut was_down = false;
loop {
std::thread::sleep(Duration::from_millis(30));
// 弹窗已隐藏或被销毁:看护结束
if !POPUP_VISIBLE.load(Ordering::SeqCst)
|| app.get_webview_window(POPUP_LABEL).is_none()
{
break;
}
// 弹窗重新获得焦点(用户点击弹窗):恢复正常失焦关闭路径,复位交互锁定
if app
.get_webview_window(POPUP_LABEL)
.map(|w| w.is_focused().unwrap_or(false))
.unwrap_or(false)
{
POPUP_INTERACTED.store(false, Ordering::SeqCst);
break;
}
let interacted = POPUP_INTERACTED.load(Ordering::SeqCst);
// 超长兜底:避免异常路径下看护线程无限轮询
let timeout = if interacted {
Duration::from_secs(600)
} else {
Duration::from_secs(30)
};
if start.elapsed() > timeout {
hide_popup(&app);
break;
}
let cursor_in = is_cursor_in_popup(&app) || is_cursor_in_preview(&app);
if interacted {
// 交互锁定:左键按下沿发生在弹窗+预览之外 → 点击外部,关闭弹窗+预览。
// 按下沿判定(上一轮未按下、本轮按下)配合 30ms 轮询,可捕获常规点击;
// 在弹窗/预览内按下后拖出再松开不算外部点击(以按下位置为准)。
let down = crate::win32_util::is_left_button_down();
if down && !was_down && !cursor_in {
hide_popup(&app);
break;
}
was_down = down;
} else if cursor_in {
outside = 0;
} else {
outside += 1;
// 连续 ~300ms 不在弹窗/预览区域(排除穿越间隙的瞬时状态)才关闭
if outside >= 10 {
hide_popup(&app);
break;
}
}
}
POPUP_LEAVE_WATCH.store(false, Ordering::SeqCst);
});
}
/// 鼠标当前是否位于弹窗矩形内(弹窗可见且光标在其窗口范围内)。
/// 用于 hide_preview 判断"用户是回到弹窗继续浏览,还是离开整个区域"。
fn is_cursor_in_popup(app: &AppHandle) -> bool {
if !POPUP_VISIBLE.load(Ordering::SeqCst) {
return false;
}
let Some(win) = app.get_webview_window(POPUP_LABEL) else {
return false;
};
let Ok(pos) = win.outer_position() else { return false };
let Ok(size) = win.outer_size() else { return false };
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
return false;
};
let x = pos.x;
let y = pos.y;
let w = size.width as i32;
let h = size.height as i32;
cx >= x && cx <= x + w && cy >= y && cy <= y + h
}
/// 鼠标当前是否位于预览窗矩形内(预览窗可见且光标在其窗口范围内)。
/// 用于弹窗失焦时判断是否因预览交互所致,决定是否推迟隐藏弹窗。
fn is_cursor_in_preview(app: &AppHandle) -> bool {
if !PREVIEW_VISIBLE.load(Ordering::SeqCst) {
return false;
}
let Some(win) = app.get_webview_window(PREVIEW_LABEL) else {
return false;
};
let Ok(pos) = win.outer_position() else { return false };
let Ok(size) = win.outer_size() else { return false };
let Some((cx, cy)) = crate::win32_util::get_cursor_pos() else {
return false;
};
let x = pos.x;
let y = pos.y;
let w = size.width as i32;
let h = size.height as i32;
cx >= x && cx <= x + w && cy >= y && cy <= y + h
}
// ===== 屏幕/光标/DPI 工具已迁移至 crate::win32_util(跨模块共享) =====
use crate::win32_util::{get_cursor_pos, get_work_area_at_point, get_dpi_for_point};
+26
View File
@@ -295,3 +295,29 @@ pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
.ok()?;
Some(buf)
}
/// 生成缩略图 PNG(最长边不超过 max_dim 像素)。
/// 小图直接复用 `dib_to_png` 结果;大图降采样后重新编码,供弹窗悬停预览使用。
pub fn dib_to_thumbnail(dib: &[u8], max_dim: u32) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder;
use image::GenericImageView;
use image::ImageEncoder;
let png = dib_to_png(dib)?;
let img = image::load_from_memory(&png).ok()?;
let (w, h) = img.dimensions();
if w.max(h) <= max_dim {
return Some(png); // 小图直接使用,避免重复编码
}
// 让 image 库在 max_dim×max_dim 边界内自动等比缩放,避免手算 nw/nh 与库内部
// resize_dimensions 的取整不一致(如手算 256x10 而库实际输出 250x10),
// 否则 write_image 的缓冲区长度断言会失败。
let resized = img.resize(max_dim, max_dim, image::imageops::FilterType::Triangle);
let (rw, rh) = resized.dimensions();
let rgba = resized.to_rgba8();
let mut buf = Vec::new();
let enc = PngEncoder::new(&mut buf);
enc.write_image(rgba.as_raw(), rw, rh, image::ExtendedColorType::Rgba8)
.ok()?;
Some(buf)
}
+39 -2
View File
@@ -2,6 +2,7 @@
//!
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use base64::Engine as _;
use rusqlite::{params, Connection, OptionalExtension};
use specta::Type;
use std::fs;
@@ -38,6 +39,8 @@ pub struct NewItem {
pub kind: String,
pub content: Option<String>,
pub blob: Option<Vec<u8>>,
/// 图片缩略图 PNG(仅 image 类型,供弹窗悬停预览)
pub thumb: Option<Vec<u8>>,
pub preview: String,
pub size: i64,
pub hash: String,
@@ -88,6 +91,19 @@ impl Storage {
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
);
// 迁移:老库无 thumb 列(图片缩略图,供弹窗悬停预览),补列
let has_thumb = conn
.prepare("PRAGMA table_info(clipboard_history)")
.ok()
.map(|mut stmt| {
stmt.query_map([], |r| r.get::<_, String>(1))
.map(|rows| rows.filter_map(|c| c.ok()).any(|c| c == "thumb"))
.unwrap_or(false)
})
.unwrap_or(false);
if !has_thumb {
let _ = conn.execute("ALTER TABLE clipboard_history ADD COLUMN thumb BLOB", []);
}
}
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
@@ -120,12 +136,13 @@ impl Storage {
let now = now_ms();
let res = conn.execute(
"INSERT INTO clipboard_history
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
(kind, content, blob, thumb, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8)",
params![
item.kind,
item.content,
item.blob.as_deref(),
item.thumb.as_deref(),
item.preview,
item.size,
item.hash,
@@ -249,6 +266,26 @@ impl Storage {
})
}
/// 获取图片缩略图 PNG base64(弹窗悬停预览用)。
/// 老数据无缩略图时回退为 `thumb_from_dib` 现场生成。
pub fn get_thumb(&self, id: i64, thumb_from_dib: impl Fn(&[u8]) -> Option<Vec<u8>>) -> Option<String> {
let (thumb, blob) = {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT thumb, blob FROM clipboard_history WHERE id = ?1 AND kind = 'image'",
params![id],
|r| Ok((r.get::<_, Option<Vec<u8>>>(0)?, r.get::<_, Option<Vec<u8>>>(1)?)),
)
.ok()?
};
// conn 已在此处释放
let png = match thumb {
Some(t) if !t.is_empty() => Some(t),
_ => blob.as_deref().and_then(|b| thumb_from_dib(b)),
};
png.map(|p| base64::engine::general_purpose::STANDARD.encode(&p))
}
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
let conn = self.conn.lock().ok()?;