截图模块优化调整
This commit is contained in:
@@ -134,6 +134,10 @@ pub async fn clipboard_save_settings(
|
|||||||
// 快捷键变化时重新注册
|
// 快捷键变化时重新注册
|
||||||
if settings.shortcut != prev_shortcut {
|
if settings.shortcut != prev_shortcut {
|
||||||
super::popup::register_shortcut(&app, &settings.shortcut)?;
|
super::popup::register_shortcut(&app, &settings.shortcut)?;
|
||||||
|
// 新快捷键非空时确保弹窗窗口已预创建
|
||||||
|
if !settings.shortcut.trim().is_empty() {
|
||||||
|
super::popup::ensure_popup_window(&app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! 6. 窗口失焦自动隐藏
|
//! 6. 窗口失焦自动隐藏
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
|
||||||
use tauri::window::{Effect, EffectsBuilder};
|
use tauri::window::{Effect, EffectsBuilder};
|
||||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
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);
|
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")
|
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V")
|
||||||
/// 失败返回 None。
|
/// 失败返回 None。
|
||||||
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
|
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 显示)。
|
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
|
||||||
/// 窗口已存在则移动到鼠标位置并显示。
|
/// 窗口已存在则移动到鼠标位置并显示。
|
||||||
@@ -115,49 +172,18 @@ pub fn show_popup(app: &AppHandle) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
|
// 兜底:窗口被销毁时重新创建(隐藏),等前端 onMounted 回调 show_window
|
||||||
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
|
POPUP_PENDING_SHOW.store(true, Ordering::SeqCst);
|
||||||
let win = match WebviewWindowBuilder::new(
|
create_popup_window(app);
|
||||||
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 后调用)。
|
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
|
||||||
|
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
|
||||||
|
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
|
||||||
pub fn show_window(app: &AppHandle) {
|
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) {
|
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
|
||||||
let _ = win.show();
|
let _ = win.show();
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ use process_manager::{
|
|||||||
};
|
};
|
||||||
use screenshot::commands::{
|
use screenshot::commands::{
|
||||||
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
|
screenshot_capture_fullscreen, screenshot_capture_window, screenshot_clear_fullscreen,
|
||||||
screenshot_copy_image, screenshot_crop_copy_stored, screenshot_crop_stored,
|
screenshot_compose_copy, screenshot_copy_image, screenshot_crop_copy_stored,
|
||||||
screenshot_cursor_pos, screenshot_disable_transitions, screenshot_enum_windows,
|
screenshot_crop_stored, screenshot_cursor_pos, screenshot_disable_transitions,
|
||||||
screenshot_fullscreen_png, screenshot_get_editor_image, screenshot_get_fullscreen_bmp,
|
screenshot_enum_windows, screenshot_fullscreen_png, screenshot_get_editor_image,
|
||||||
screenshot_register_shortcut, screenshot_save_png, screenshot_set_editor_image,
|
screenshot_get_fullscreen_bmp, screenshot_register_shortcut, screenshot_save_png,
|
||||||
screenshot_unregister_shortcut, screenshot_window_from_point,
|
screenshot_set_editor_image, screenshot_unregister_shortcut, screenshot_window_from_point,
|
||||||
};
|
};
|
||||||
use clipboard::{
|
use clipboard::{
|
||||||
ClipboardManager,
|
ClipboardManager,
|
||||||
@@ -215,7 +215,8 @@ pub fn run() {
|
|||||||
screenshot_save_png,
|
screenshot_save_png,
|
||||||
screenshot_register_shortcut,
|
screenshot_register_shortcut,
|
||||||
screenshot_unregister_shortcut,
|
screenshot_unregister_shortcut,
|
||||||
screenshot_disable_transitions
|
screenshot_disable_transitions,
|
||||||
|
screenshot_compose_copy
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// 初始化日志系统,日志目录: {app_data_dir}/logs/
|
// 初始化日志系统,日志目录: {app_data_dir}/logs/
|
||||||
@@ -280,6 +281,8 @@ pub fn run() {
|
|||||||
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
|
if let Err(e) = clipboard::popup::register_shortcut(&app_handle, &shortcut) {
|
||||||
eprintln!("[clipboard] 快捷键注册失败: {}", e);
|
eprintln!("[clipboard] 快捷键注册失败: {}", e);
|
||||||
}
|
}
|
||||||
|
// 预创建弹窗窗口(隐藏),首次按快捷键时直接 show,避免首次创建时序问题
|
||||||
|
clipboard::popup::ensure_popup_window(&app_handle);
|
||||||
}
|
}
|
||||||
app.manage(clipboard);
|
app.manage(clipboard);
|
||||||
|
|
||||||
|
|||||||
@@ -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 data_size = w * h * 4;
|
||||||
let file_size = 14 + 40 + data_size;
|
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);
|
let mut bmp = Vec::with_capacity(file_size);
|
||||||
// BITMAPFILEHEADER
|
// BITMAPFILEHEADER
|
||||||
bmp.extend_from_slice(b"BM");
|
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()); // y ppm
|
||||||
bmp.extend_from_slice(&0u32.to_le_bytes()); // colors used
|
bmp.extend_from_slice(&0u32.to_le_bytes()); // colors used
|
||||||
bmp.extend_from_slice(&0u32.to_le_bytes()); // important colors
|
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)
|
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 {
|
if width <= 0 || height <= 0 {
|
||||||
return Err("无效尺寸".into());
|
return Err("无效尺寸".into());
|
||||||
}
|
}
|
||||||
let w = width as usize;
|
let pixel_count = (width as usize) * (height as usize);
|
||||||
let h = height as usize;
|
let mut rgba = vec![0u8; pixel_count * 4];
|
||||||
let mut rgba = vec![0u8; w * h * 4];
|
// chunks_exact 迭代器写法:4 字节对齐连续读取,编译器更容易自动向量化
|
||||||
for i in 0..(w * h) {
|
for (src, dst) in bgra.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
|
||||||
rgba[i * 4] = bgra[i * 4 + 2]; // R
|
dst[0] = src[2]; // R
|
||||||
rgba[i * 4 + 1] = bgra[i * 4 + 1]; // G
|
dst[1] = src[1]; // G
|
||||||
rgba[i * 4 + 2] = bgra[i * 4]; // B
|
dst[2] = src[0]; // B
|
||||||
rgba[i * 4 + 3] = 255; // 强制不透明
|
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(
|
PngEncoder::new_with_quality(
|
||||||
&mut buf,
|
&mut buf,
|
||||||
CompressionType::Fast,
|
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 header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||||
let w = width as usize;
|
let w = width as usize;
|
||||||
let h = height 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 {
|
let header = BITMAPINFOHEADER {
|
||||||
biSize: header_size,
|
biSize: header_size,
|
||||||
biWidth: width,
|
biWidth: width,
|
||||||
@@ -501,7 +502,7 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
|
|||||||
biPlanes: 1,
|
biPlanes: 1,
|
||||||
biBitCount: 32,
|
biBitCount: 32,
|
||||||
biCompression: BI_RGB,
|
biCompression: BI_RGB,
|
||||||
biSizeImage: (w * h * 4) as u32,
|
biSizeImage: (pixel_size) as u32,
|
||||||
biXPelsPerMeter: 0,
|
biXPelsPerMeter: 0,
|
||||||
biYPelsPerMeter: 0,
|
biYPelsPerMeter: 0,
|
||||||
biClrUsed: 0,
|
biClrUsed: 0,
|
||||||
@@ -514,15 +515,17 @@ fn bgra_to_dib(bgra: &[u8], width: i32, height: i32) -> Vec<u8> {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
dib.extend_from_slice(raw);
|
dib.extend_from_slice(raw);
|
||||||
// 从 top-down 行序倒序拷贝为 bottom-up,alpha 强制 255
|
// 预分配像素区,按行 memcpy 拷贝(top-down → bottom-up 行序翻转),再原地修正 alpha
|
||||||
for y in (0..h).rev() {
|
let pixel_start = dib.len();
|
||||||
let row = &bgra[y * w * 4..(y + 1) * w * 4];
|
dib.resize(dib.len() + pixel_size, 0);
|
||||||
for x in 0..w {
|
for y in 0..h {
|
||||||
dib.push(row[x * 4]); // B
|
let src = y * row_size;
|
||||||
dib.push(row[x * 4 + 1]); // G
|
let dst = (h - 1 - y) * row_size;
|
||||||
dib.push(row[x * 4 + 2]); // R
|
dib[pixel_start + dst..pixel_start + dst + row_size]
|
||||||
dib.push(255); // A
|
.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
|
dib
|
||||||
}
|
}
|
||||||
@@ -592,11 +595,17 @@ pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
|
|||||||
write_dib_to_clipboard(&dib)
|
write_dib_to_clipboard(&dib)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RGBA → CF_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 255)
|
/// raw RGBA 像素 → CF_DIB(直接从连续像素切片构造,无需 image crate 解码)
|
||||||
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
|
///
|
||||||
|
/// 用于有标注导出路径:前端 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 header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||||
let row_size = (width as usize) * 4;
|
let w = width as usize;
|
||||||
let mut dib = Vec::with_capacity(header_size as usize + row_size * height 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 {
|
let header = BITMAPINFOHEADER {
|
||||||
biSize: header_size,
|
biSize: header_size,
|
||||||
biWidth: width,
|
biWidth: width,
|
||||||
@@ -604,7 +613,7 @@ fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
|
|||||||
biPlanes: 1,
|
biPlanes: 1,
|
||||||
biBitCount: 32,
|
biBitCount: 32,
|
||||||
biCompression: BI_RGB,
|
biCompression: BI_RGB,
|
||||||
biSizeImage: (row_size * height as usize) as u32,
|
biSizeImage: (pixel_size) as u32,
|
||||||
biXPelsPerMeter: 0,
|
biXPelsPerMeter: 0,
|
||||||
biYPelsPerMeter: 0,
|
biYPelsPerMeter: 0,
|
||||||
biClrUsed: 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);
|
dib.extend_from_slice(raw);
|
||||||
// bottom-up 行序,BGRA 像素,alpha 强制 255
|
let pixel_start = dib.len();
|
||||||
for y in (0..height as usize).rev() {
|
dib.resize(dib.len() + pixel_size, 0);
|
||||||
for x in 0..width as usize {
|
// RGBA top-down → BGRA bottom-up,alpha 强制 255
|
||||||
let p = rgba.get_pixel(x as u32, y as u32).0;
|
for y in 0..h {
|
||||||
dib.push(p[2]); // B
|
let src_off = (h - 1 - y) * row_size;
|
||||||
dib.push(p[1]); // G
|
let dst_off = y * row_size;
|
||||||
dib.push(p[0]); // R
|
for x in 0..w {
|
||||||
dib.push(255); // A
|
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_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 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
|
dib
|
||||||
|
|||||||
@@ -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 body(ArrayBuffer)".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 写入文件
|
/// 将 PNG base64 写入文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
|
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
|
||||||
|
|||||||
@@ -394,11 +394,6 @@ pub async fn tray_menu_action(
|
|||||||
let _ = app.emit("tray:new-download", ());
|
let _ = app.emit("tray:new-download", ());
|
||||||
hide_tray_menu(&app);
|
hide_tray_menu(&app);
|
||||||
}
|
}
|
||||||
"screenshot_region" => {
|
|
||||||
hide_tray_menu(&app);
|
|
||||||
// 主窗口(隐藏运行中)接收事件后调用 screenshotStore.startCapture('region')
|
|
||||||
let _ = app.emit("tray:start-screenshot", "region");
|
|
||||||
}
|
|
||||||
"settings" => {
|
"settings" => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
window.show().ok();
|
window.show().ok();
|
||||||
|
|||||||
@@ -159,14 +159,6 @@ onMounted(async () => {
|
|||||||
handleModuleChange('settings')
|
handleModuleChange('settings')
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
// 托盘截图:直接触发截图流程(不切换模块,覆盖层独立窗口,进入后点选窗口或长按拖选区)
|
|
||||||
trayUnlisteners.push(
|
|
||||||
await listen('tray:start-screenshot', () => {
|
|
||||||
screenshotStore.startCapture().catch(err =>
|
|
||||||
console.error('[screenshot] 托盘触发截图失败:', err)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
|
// 截图导出监听(应用级常驻,确保任何来源的截图都记录到历史)
|
||||||
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
|
screenshotStore.initExportListener().catch(e => console.error('[screenshot] 导出监听初始化失败:', e))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { Search, Settings, ChevronRight, ArrowUp } from '@lucide/vue'
|
import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
@@ -261,6 +261,21 @@ const handleBlur = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
<!-- 保存设置按钮(当前模块在设置 tab 且注册了保存函数时显示,从右向左弹出) -->
|
||||||
|
<Transition name="save-btn">
|
||||||
|
<button
|
||||||
|
v-if="tabsStore.saveVisible"
|
||||||
|
class="flex items-center gap-1.5 h-7 px-3 mr-1 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60 disabled:pointer-events-none transition-colors pointer-events-auto"
|
||||||
|
:disabled="tabsStore.saving"
|
||||||
|
title="保存当前模块设置"
|
||||||
|
@click="tabsStore.runSave"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<Loader2 v-if="tabsStore.saving" class="size-3.5 animate-spin" />
|
||||||
|
<Check v-else class="size-3.5" />
|
||||||
|
保存设置
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
<div class="relative max-w-xs mr-3 pointer-events-auto">
|
<div class="relative max-w-xs mr-3 pointer-events-auto">
|
||||||
<Search
|
<Search
|
||||||
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
||||||
@@ -388,4 +403,15 @@ const handleBlur = () => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-12px);
|
transform: translateX(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 保存设置按钮进出动画:淡入 + 从右侧滑入(从右向左弹出) */
|
||||||
|
.save-btn-enter-active,
|
||||||
|
.save-btn-leave-active {
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.save-btn-enter-from,
|
||||||
|
.save-btn-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(12px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SliderRootEmits, SliderRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<SliderRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<SliderRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SliderRoot
|
||||||
|
v-slot="{ modelValue }"
|
||||||
|
data-slot="slider"
|
||||||
|
:class="cn(
|
||||||
|
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<SliderTrack
|
||||||
|
data-slot="slider-track"
|
||||||
|
class="bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
>
|
||||||
|
<SliderRange
|
||||||
|
data-slot="slider-range"
|
||||||
|
class="bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
/>
|
||||||
|
</SliderTrack>
|
||||||
|
|
||||||
|
<SliderThumb
|
||||||
|
v-for="(_, key) in modelValue"
|
||||||
|
:key="key"
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
class="bg-white border-primary ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</SliderRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Slider } from "./Slider.vue"
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
ClipboardList, Copy, Pin, PinOff, Trash2, Search, Image as ImageIcon,
|
||||||
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Check, Keyboard,
|
FileText, Files, Settings as SettingsIcon, Loader2, Eraser, Keyboard,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
import { useClipboardStore, type ClipboardItem, type ClipboardKind, type ClipboardItemDetail } from '@/stores/clipboardStore'
|
||||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||||
|
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
const store = useClipboardStore()
|
const store = useClipboardStore()
|
||||||
|
|
||||||
const activeTab = ref('history')
|
const activeTab = ref('history')
|
||||||
|
const tabsStore = useModuleTabsStore()
|
||||||
const tabsListRef = useModuleTabs(activeTab, [
|
const tabsListRef = useModuleTabs(activeTab, [
|
||||||
{ value: 'history', label: '历史' },
|
{ value: 'history', label: '历史' },
|
||||||
{ value: 'pinned', label: '固定' },
|
{ value: 'pinned', label: '固定' },
|
||||||
@@ -114,6 +116,9 @@ const handleSaveSettings = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||||||
|
tabsStore.registerSave(handleSaveSettings)
|
||||||
|
|
||||||
const handleShortcutChange = async () => {
|
const handleShortcutChange = async () => {
|
||||||
// 快捷键变化立即保存并注册(不等点击"保存设置")
|
// 快捷键变化立即保存并注册(不等点击"保存设置")
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +129,86 @@ const handleShortcutChange = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 快捷键录入器 =====
|
||||||
|
const recording = ref(false)
|
||||||
|
const recorderRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
/** 把存储的快捷键字符串格式化为展示形式:alt+v → Alt + V */
|
||||||
|
function displayShortcut(s: string): string {
|
||||||
|
if (!s) return ''
|
||||||
|
return s
|
||||||
|
.split('+')
|
||||||
|
.map(p => {
|
||||||
|
const t = p.trim()
|
||||||
|
if (!t) return ''
|
||||||
|
if (t.length === 1) return t.toUpperCase()
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1)
|
||||||
|
})
|
||||||
|
.join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把键盘事件转为 Tauri 快捷键字符串(小写,+ 分隔) */
|
||||||
|
function eventToShortcut(e: KeyboardEvent): string | null {
|
||||||
|
const mods: string[] = []
|
||||||
|
if (e.ctrlKey) mods.push('ctrl')
|
||||||
|
if (e.altKey) mods.push('alt')
|
||||||
|
if (e.shiftKey) mods.push('shift')
|
||||||
|
if (e.metaKey) mods.push('super')
|
||||||
|
let main = ''
|
||||||
|
const code = e.code || ''
|
||||||
|
if (/^Key[A-Z]$/.test(code)) main = code.slice(3).toLowerCase()
|
||||||
|
else if (/^Digit[0-9]$/.test(code)) main = code.slice(5)
|
||||||
|
else if (/^F([1-9]|1[0-2])$/.test(code)) main = code.toLowerCase()
|
||||||
|
else if (code === 'Space') main = 'space'
|
||||||
|
else if (code === 'PrintScreen') main = 'printscreen'
|
||||||
|
else if (code.startsWith('Numpad')) main = code.slice(6).toLowerCase()
|
||||||
|
else return null
|
||||||
|
// 必须至少一个修饰键(功能键 F1-F12 / PrintScreen 例外)
|
||||||
|
const isFunctionKey = /^f([1-9]|1[0-2])$/.test(main) || main === 'printscreen'
|
||||||
|
if (mods.length === 0 && !isFunctionKey) return null
|
||||||
|
return [...mods, main].join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecordKey(e: KeyboardEvent) {
|
||||||
|
if (!recording.value) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
recording.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 仅修饰键按下时不结束(等待主键)
|
||||||
|
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return
|
||||||
|
const combo = eventToShortcut(e)
|
||||||
|
if (!combo) {
|
||||||
|
toast.warning('不支持的按键组合,请使用字母/数字/功能键 + 修饰键')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recording.value = false
|
||||||
|
void commitShortcut(combo)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitShortcut(combo: string) {
|
||||||
|
form.value.shortcut = combo
|
||||||
|
await handleShortcutChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecord() {
|
||||||
|
recording.value = true
|
||||||
|
await nextTick()
|
||||||
|
recorderRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(recording, (on) => {
|
||||||
|
if (on) window.addEventListener('keydown', onRecordKey, true)
|
||||||
|
else window.removeEventListener('keydown', onRecordKey, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function clearShortcut() {
|
||||||
|
form.value.shortcut = ''
|
||||||
|
await handleShortcutChange()
|
||||||
|
}
|
||||||
|
|
||||||
const handleTestPopup = async () => {
|
const handleTestPopup = async () => {
|
||||||
try {
|
try {
|
||||||
await store.showPopup()
|
await store.showPopup()
|
||||||
@@ -204,6 +289,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.dispose()
|
store.dispose()
|
||||||
|
window.removeEventListener('keydown', onRecordKey, true)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -245,33 +331,39 @@ onUnmounted(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页(置于列表上方,靠左显示) -->
|
<!-- 分页 + 快捷键提示(置于列表上方,分页靠左,快捷弹窗提示靠右) -->
|
||||||
<div v-if="totalPages > 1" class="flex items-center justify-start pb-2 shrink-0">
|
<div class="flex items-center justify-between pb-2 shrink-0">
|
||||||
<Pagination
|
<template v-if="totalPages > 1">
|
||||||
v-slot="{ page }"
|
<Pagination
|
||||||
:page="currentPage"
|
v-slot="{ page }"
|
||||||
:total="store.historyTotal"
|
:page="currentPage"
|
||||||
:items-per-page="PAGE_SIZE"
|
:total="store.historyTotal"
|
||||||
:sibling-count="1"
|
:items-per-page="PAGE_SIZE"
|
||||||
show-edges
|
:sibling-count="1"
|
||||||
@update:page="gotoPage"
|
show-edges
|
||||||
class="justify-start"
|
@update:page="gotoPage"
|
||||||
>
|
class="justify-start"
|
||||||
<PaginationContent v-slot="{ items }" class="gap-1">
|
>
|
||||||
<template v-for="(item, index) in items" :key="index">
|
<PaginationContent v-slot="{ items }" class="gap-1">
|
||||||
<PaginationItem
|
<template v-for="(item, index) in items" :key="index">
|
||||||
v-if="item.type === 'page'"
|
<PaginationItem
|
||||||
:value="item.value"
|
v-if="item.type === 'page'"
|
||||||
:is-active="item.value === page"
|
:value="item.value"
|
||||||
size="icon"
|
:is-active="item.value === page"
|
||||||
class="size-7 text-xs"
|
size="icon"
|
||||||
>
|
class="size-7 text-xs"
|
||||||
{{ item.value }}
|
>
|
||||||
</PaginationItem>
|
{{ item.value }}
|
||||||
<PaginationEllipsis v-else class="size-7" />
|
</PaginationItem>
|
||||||
</template>
|
<PaginationEllipsis v-else class="size-7" />
|
||||||
</PaginationContent>
|
</template>
|
||||||
</Pagination>
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</template>
|
||||||
|
<div v-else />
|
||||||
|
<span class="text-xs text-muted-foreground shrink-0">
|
||||||
|
快捷弹窗:<span v-if="form.shortcut" class="font-medium text-foreground">{{ displayShortcut(form.shortcut) }}</span><span v-else>未设置</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea class="flex-1 min-h-0">
|
<ScrollArea class="flex-1 min-h-0">
|
||||||
@@ -356,11 +448,10 @@ onUnmounted(() => {
|
|||||||
<!-- 设置 -->
|
<!-- 设置 -->
|
||||||
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
|
<TabsContent value="settings" class="flex-1 min-h-0 overflow-y-auto mt-4 tab-animate">
|
||||||
<div class="max-w-xl space-y-4">
|
<div class="max-w-xl space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center">
|
||||||
<h3 class="text-base font-medium flex items-center gap-2">
|
<h3 class="text-base font-medium flex items-center gap-2">
|
||||||
<SettingsIcon class="h-4 w-4" />基本设置
|
<SettingsIcon class="h-4 w-4" />基本设置
|
||||||
</h3>
|
</h3>
|
||||||
<Button @click="handleSaveSettings"><Check class="h-4 w-4 mr-1" />保存设置</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -434,12 +525,29 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
<Button variant="outline" size="sm" @click="handleTestPopup">测试弹窗</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
v-model="form.shortcut"
|
<div
|
||||||
placeholder="如 Alt+V / Ctrl+Shift+V"
|
ref="recorderRef"
|
||||||
@change="handleShortcutChange"
|
class="hotkey-recorder"
|
||||||
/>
|
:class="{ recording }"
|
||||||
<p class="text-xs text-muted-foreground">格式:修饰键+主键,如 Ctrl+C、Alt+V、Shift+F1。修改后自动生效。</p>
|
tabindex="0"
|
||||||
|
@click="startRecord"
|
||||||
|
>
|
||||||
|
<template v-if="recording">按下快捷键…(Esc 取消)</template>
|
||||||
|
<template v-else-if="form.shortcut">
|
||||||
|
{{ displayShortcut(form.shortcut) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>未设置(点击录入)</template>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="form.shortcut"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-muted-foreground"
|
||||||
|
@click="clearShortcut"
|
||||||
|
>清除</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">点击方框录入快捷键,需至少一个修饰键 + 字母/数字/功能键。默认 Alt+V</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||||
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
|
import { useDownloaderStore, type DownloadTask, type TaskStatus, type CheckUrlResult } from '@/stores/downloaderStore'
|
||||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||||
|
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import { pendingNewDownload } from '@/lib/trayEvents'
|
import { pendingNewDownload } from '@/lib/trayEvents'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -42,6 +43,7 @@ const logger = createLogger('downloader')
|
|||||||
|
|
||||||
// ===== 主 Tab 状态 =====
|
// ===== 主 Tab 状态 =====
|
||||||
const activeTab = ref('tasks')
|
const activeTab = ref('tasks')
|
||||||
|
const tabsStore = useModuleTabsStore()
|
||||||
const tabsListRef = useModuleTabs(activeTab, [
|
const tabsListRef = useModuleTabs(activeTab, [
|
||||||
{ value: 'tasks', label: '下载任务' },
|
{ value: 'tasks', label: '下载任务' },
|
||||||
{ value: 'settings', label: '设置' },
|
{ value: 'settings', label: '设置' },
|
||||||
@@ -483,6 +485,9 @@ const handleSaveSettings = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||||||
|
tabsStore.registerSave(handleSaveSettings)
|
||||||
|
|
||||||
// Dialog 保存:成功后关闭弹窗
|
// Dialog 保存:成功后关闭弹窗
|
||||||
const handleDialogSave = async () => {
|
const handleDialogSave = async () => {
|
||||||
const ok = await handleSaveSettings()
|
const ok = await handleSaveSettings()
|
||||||
@@ -963,14 +968,6 @@ const toggleSortOrder = () => {
|
|||||||
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
|
<TabsContent value="settings" class="flex-1 mt-4 min-h-0 tab-animate">
|
||||||
<ScrollArea class="h-full pr-3">
|
<ScrollArea class="h-full pr-3">
|
||||||
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
<div class="flex flex-col gap-4 pb-4 max-w-2xl">
|
||||||
<!-- 顶部保存按钮 -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button @click="handleSaveSettings">
|
|
||||||
<Check class="h-4 w-4" />
|
|
||||||
保存设置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 下载设置 -->
|
<!-- 下载设置 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { appDataDir } from '@tauri-apps/api/path'
|
|||||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||||
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
|
import { useProxyStore, type ProxyNode } from '@/stores/proxyStore'
|
||||||
import { useModuleTabs } from '@/lib/useModuleTabs'
|
import { useModuleTabs } from '@/lib/useModuleTabs'
|
||||||
|
import { useModuleTabsStore } from '@/stores/moduleTabsStore'
|
||||||
import { createLogger } from '@/lib/logger'
|
import { createLogger } from '@/lib/logger'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -71,6 +72,7 @@ const onConfirmOpenChange = (open: boolean) => {
|
|||||||
|
|
||||||
const activeTab = ref('overview')
|
const activeTab = ref('overview')
|
||||||
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
|
// 浮动标签切换器:注册到 TitleBar,滚动遮挡时自动显示
|
||||||
|
const tabsStore = useModuleTabsStore()
|
||||||
const tabsListRef = useModuleTabs(activeTab, [
|
const tabsListRef = useModuleTabs(activeTab, [
|
||||||
{ value: 'overview', label: '概览' },
|
{ value: 'overview', label: '概览' },
|
||||||
{ value: 'proxies', label: '节点' },
|
{ value: 'proxies', label: '节点' },
|
||||||
@@ -856,10 +858,8 @@ const syncLocalSettings = () => {
|
|||||||
|
|
||||||
watch(() => store.settings, syncLocalSettings, { immediate: true })
|
watch(() => store.settings, syncLocalSettings, { immediate: true })
|
||||||
|
|
||||||
const savingSettings = ref(false)
|
|
||||||
const saveSettingsForm = async () => {
|
const saveSettingsForm = async () => {
|
||||||
if (!store.settings) return
|
if (!store.settings) return
|
||||||
savingSettings.value = true
|
|
||||||
try {
|
try {
|
||||||
await store.saveSettings({
|
await store.saveSettings({
|
||||||
...store.settings,
|
...store.settings,
|
||||||
@@ -868,10 +868,11 @@ const saveSettingsForm = async () => {
|
|||||||
toast.success('设置已保存')
|
toast.success('设置已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('保存失败', { description: String(e) })
|
toast.error('保存失败', { description: String(e) })
|
||||||
} finally {
|
|
||||||
savingSettings.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注册保存处理函数到标签栏 store(TitleBar 保存按钮调用)
|
||||||
|
tabsStore.registerSave(saveSettingsForm)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1513,16 +1514,10 @@ const saveSettingsForm = async () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<!-- 保存设置(顶部醒目位置) -->
|
<!-- 说明文字(保存按钮已移至顶部标签栏) -->
|
||||||
<div class="flex items-center gap-3 rounded-md border border-primary/30 bg-primary/5 p-3">
|
<p class="text-xs text-muted-foreground rounded-md border border-primary/30 bg-primary/5 p-3">
|
||||||
<Button size="sm" :disabled="savingSettings" @click="saveSettingsForm">
|
修改端口/接口/密钥/模式后需重启 mihomo 生效。DNS、规则等高级配置请直接编辑订阅文件。
|
||||||
<Loader2 v-if="savingSettings" key="saving" class="size-3.5 animate-spin" />
|
</p>
|
||||||
<Check v-else key="saved-icon" class="size-3.5" />保存设置
|
|
||||||
</Button>
|
|
||||||
<p class="text-xs text-muted-foreground flex-1">
|
|
||||||
修改端口/接口/密钥/模式后需重启 mihomo 生效。DNS、规则等高级配置请直接编辑订阅文件。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|||||||
@@ -160,24 +160,22 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full p-6 overflow-hidden flex flex-col">
|
||||||
<Tabs v-model="activeTab" class="h-full flex flex-col">
|
<Tabs v-model="activeTab" class="flex-1 min-h-0 flex flex-col">
|
||||||
<div ref="tabsListRef" class="px-4 pt-3 pb-2 shrink-0">
|
<div ref="tabsListRef" class="shrink-0">
|
||||||
<TabsList>
|
<TabsList class="grid w-full max-w-md grid-cols-2 !bg-transparent !p-0 !shadow-none">
|
||||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
<TabsTrigger value="settings" class="gap-1.5"><Settings class="size-3.5" />设置</TabsTrigger>
|
||||||
<TabsTrigger value="history">
|
<TabsTrigger value="history" class="gap-1.5">
|
||||||
历史
|
<ImageIcon class="size-3.5" />历史
|
||||||
<span v-if="store.recent.length" class="ml-1 text-xs text-muted-foreground">
|
<span v-if="store.recent.length" class="text-xs text-muted-foreground">({{ store.recent.length }})</span>
|
||||||
({{ store.recent.length }})
|
|
||||||
</span>
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 截图设置 -->
|
<!-- 截图设置 -->
|
||||||
<TabsContent value="settings" class="flex-1 min-h-0 mt-0">
|
<TabsContent value="settings" class="flex-1 min-h-0 mt-4">
|
||||||
<ScrollArea class="h-full">
|
<ScrollArea class="h-full">
|
||||||
<div class="p-4 pt-0 space-y-4 max-w-3xl mx-auto">
|
<div class="space-y-4 max-w-3xl">
|
||||||
<!-- 快捷键 -->
|
<!-- 快捷键 -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -230,7 +228,8 @@ onUnmounted(() => {
|
|||||||
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1.5">
|
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1.5">
|
||||||
<p>· 进入截图后 <span class="text-foreground">移动鼠标</span> 自动识别窗口,<span class="text-foreground">点击</span> 选中窗口</p>
|
<p>· 进入截图后 <span class="text-foreground">移动鼠标</span> 自动识别窗口,<span class="text-foreground">点击</span> 选中窗口</p>
|
||||||
<p>· <span class="text-foreground">长按拖动</span> 自由选择区域,选区可拖动 / 缩放手柄调整大小</p>
|
<p>· <span class="text-foreground">长按拖动</span> 自由选择区域,选区可拖动 / 缩放手柄调整大小</p>
|
||||||
<p>· 选区右下方编辑栏可标注(矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮)</p>
|
<p>· 编辑栏支持矩形 / 椭圆 / 箭头 / 序号 / 画笔 / 文字 / 马赛克 / 高亮,颜色与粗细可随时调整</p>
|
||||||
|
<p>· 点击标注可选中移动,文字标注点击内容可重新编辑</p>
|
||||||
<p>
|
<p>
|
||||||
· <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Enter</kbd> / 双击复制并完成,
|
· <kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Enter</kbd> / 双击复制并完成,
|
||||||
<kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 逐级取消
|
<kbd class="px-1 py-0.5 text-xs rounded bg-muted border">Esc</kbd> 逐级取消
|
||||||
@@ -329,7 +328,7 @@ onUnmounted(() => {
|
|||||||
<!-- 历史记录 -->
|
<!-- 历史记录 -->
|
||||||
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
|
<TabsContent value="history" class="flex-1 min-h-0 mt-0">
|
||||||
<ScrollArea class="h-full">
|
<ScrollArea class="h-full">
|
||||||
<div class="p-4 pt-0">
|
<div>
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="store.recent.length === 0"
|
v-if="store.recent.length === 0"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ export const moduleConfig: ModuleConfig = {
|
|||||||
id: 'screenshot',
|
id: 'screenshot',
|
||||||
name: '截图',
|
name: '截图',
|
||||||
icon: 'screenshot',
|
icon: 'screenshot',
|
||||||
description: '区域截图、窗口截图与图片编辑',
|
description: '截图、标注与图片编辑',
|
||||||
category: 'media',
|
category: 'media',
|
||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
loader: () => import('./ScreenshotModule.vue'),
|
loader: () => import('./ScreenshotModule.vue'),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
|
|||||||
import { Effect, EffectState } from '@tauri-apps/api/window'
|
import { Effect, EffectState } from '@tauri-apps/api/window'
|
||||||
import {
|
import {
|
||||||
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
|
Globe, Power, PowerOff, RefreshCw, Check, Monitor, Download,
|
||||||
Settings, LogOut, Camera,
|
Settings, LogOut,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
@@ -526,10 +526,6 @@ onUnmounted(() => {
|
|||||||
<Download class="h-4 w-4" />
|
<Download class="h-4 w-4" />
|
||||||
<span>新建下载</span>
|
<span>新建下载</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tray-item" @click="handleAction('screenshot_region')">
|
|
||||||
<Camera class="h-4 w-4" />
|
|
||||||
<span>区域截图</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="tray-separator"></div>
|
<div class="tray-separator"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模块标签栏跨组件状态。
|
* 模块标签栏跨组件状态。
|
||||||
@@ -28,6 +28,13 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
|
|||||||
/** 是否显示浮动切换器(TabsList 滚出可视区时为 true) */
|
/** 是否显示浮动切换器(TabsList 滚出可视区时为 true) */
|
||||||
const floatingVisible = ref<boolean>(false)
|
const floatingVisible = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 当前模块注册的保存处理函数(null 表示无保存按钮,如自动保存模块) */
|
||||||
|
const saveHandler = ref<(() => unknown) | null>(null)
|
||||||
|
/** 保存中状态(驱动按钮 disabled + loading 图标) */
|
||||||
|
const saving = ref<boolean>(false)
|
||||||
|
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
|
||||||
|
const saveVisible = computed(() => saveHandler.value !== null && activeTab.value === 'settings')
|
||||||
|
|
||||||
/** 模块注册标签(onMounted 时调用) */
|
/** 模块注册标签(onMounted 时调用) */
|
||||||
const registerTabs = (tabList: ModuleTab[], current: string) => {
|
const registerTabs = (tabList: ModuleTab[], current: string) => {
|
||||||
tabs.value = tabList
|
tabs.value = tabList
|
||||||
@@ -40,6 +47,8 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
|
|||||||
tabs.value = []
|
tabs.value = []
|
||||||
activeTab.value = ''
|
activeTab.value = ''
|
||||||
floatingVisible.value = false
|
floatingVisible.value = false
|
||||||
|
saveHandler.value = null
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置浮动切换器可见性 */
|
/** 设置浮动切换器可见性 */
|
||||||
@@ -63,13 +72,35 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
|
|||||||
activeTab.value = value
|
activeTab.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模块注册保存处理函数(setup 中定义保存方法后调用) */
|
||||||
|
const registerSave = (handler: (() => unknown) | null) => {
|
||||||
|
saveHandler.value = handler
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行保存(TitleBar 按钮点击时调用) */
|
||||||
|
const runSave = async () => {
|
||||||
|
if (!saveHandler.value || saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await saveHandler.value()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
floatingVisible,
|
floatingVisible,
|
||||||
|
saveHandler,
|
||||||
|
saving,
|
||||||
|
saveVisible,
|
||||||
registerTabs,
|
registerTabs,
|
||||||
unregisterTabs,
|
unregisterTabs,
|
||||||
setFloatingVisible,
|
setFloatingVisible,
|
||||||
setActiveTab
|
setActiveTab,
|
||||||
|
registerSave,
|
||||||
|
runSave
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -198,4 +198,39 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷键录入器(全局,截图/剪切板等模块共用) */
|
||||||
|
.hotkey-recorder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 120px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid hsl(var(--input));
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.hotkey-recorder:hover {
|
||||||
|
border-color: hsl(var(--ring));
|
||||||
|
}
|
||||||
|
.hotkey-recorder:focus-visible {
|
||||||
|
border-color: hsl(var(--ring));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4);
|
||||||
|
}
|
||||||
|
.hotkey-recorder.recording {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
|
||||||
|
animation: recordPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes recordPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user