截图模块初始化
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "screenshot",
|
||||||
|
"description": "Capability for screenshot overlay and editor windows",
|
||||||
|
"windows": ["screenshot-overlay", "screenshot-editor"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-position",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"core:window:allow-set-decorations",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"dialog:default",
|
||||||
|
"snap-layout:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
//! Windows 屏幕捕获核心(仅 Windows 编译)
|
||||||
|
//!
|
||||||
|
//! 实现:
|
||||||
|
//! - 全屏(虚拟屏)捕获:BitBlt 从屏幕 DC 拷贝到兼容位图,GetDIBits 取像素
|
||||||
|
//! - 窗口捕获:PrintWindow(PW_RENDERFULLCONTENT) 捕获 DWM 内容(覆盖硬件加速窗口)
|
||||||
|
//! - 窗口拾取:WindowFromPoint + GetAncestor(GA_ROOT) 取顶层窗口
|
||||||
|
//! - 顶层窗口枚举:EnumWindows
|
||||||
|
//! - 像素 → PNG / CF_DIB 转换
|
||||||
|
//!
|
||||||
|
//! 设计说明:
|
||||||
|
//! - 不使用 Windows Graphics Capture:避免引入 windows crate(WinRT)增大体积,
|
||||||
|
//! 且 WGC 默认绘制黄色捕获边框,截图工具不可接受。
|
||||||
|
//! - 一次性捕获使用 BitBlt/PrintWindow,延迟低、无用户提示。
|
||||||
|
//! - 32bpp 捕获后强制 alpha=255:BitBlt 取出的 alpha 通道未定义(常为 0),
|
||||||
|
//! 直接编码 PNG 会得到全透明图,故强制不透明。
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use windows_sys::Win32::Foundation::{HWND, POINT, RECT};
|
||||||
|
use windows_sys::Win32::Graphics::Gdi::{
|
||||||
|
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
|
||||||
|
PatBlt, ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BLACKNESS, DIB_RGB_COLORS,
|
||||||
|
RGBQUAD, SRCCOPY,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::Storage::Xps::PrintWindow;
|
||||||
|
use windows_sys::Win32::System::DataExchange::{
|
||||||
|
CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE};
|
||||||
|
use windows_sys::Win32::System::Ole::CF_DIB;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
EnumWindows, GetAncestor, GetSystemMetrics, GetWindowLongPtrW, GetWindowRect,
|
||||||
|
GetWindowTextLengthW, GetWindowTextW, GWL_EXSTYLE, GA_ROOT, WS_EX_TOOLWINDOW, WindowFromPoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{CaptureData, ScreenRect, WindowInfo};
|
||||||
|
|
||||||
|
/// 捕获结果(PNG 字节 + 原始 BGRA 像素,像素用于剪贴板 DIB 构造,避免重复解码)
|
||||||
|
pub struct CapturedImage {
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub png: Vec<u8>,
|
||||||
|
/// 32bpp BGRA,top-down(与 GetDIBits negative height 一致)
|
||||||
|
pub bgra: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RECT> for ScreenRect {
|
||||||
|
fn from(r: RECT) -> Self {
|
||||||
|
Self {
|
||||||
|
x: r.left,
|
||||||
|
y: r.top,
|
||||||
|
width: r.right - r.left,
|
||||||
|
height: r.bottom - r.top,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全屏捕获静态存储(供覆盖层选区后裁剪取用)
|
||||||
|
static FULLSCREEN_CAPTURE: Mutex<Option<CapturedImage>> = Mutex::new(None);
|
||||||
|
|
||||||
|
const SM_XVIRTUALSCREEN: i32 = 76;
|
||||||
|
const SM_YVIRTUALSCREEN: i32 = 77;
|
||||||
|
const SM_CXVIRTUALSCREEN: i32 = 78;
|
||||||
|
const SM_CYVIRTUALSCREEN: i32 = 79;
|
||||||
|
const PW_RENDERFULLCONTENT: u32 = 0x00000002;
|
||||||
|
const BI_RGB: u32 = 0;
|
||||||
|
|
||||||
|
/// 将 BGRA top-down 像素编码为 PNG(强制 alpha=255)
|
||||||
|
fn bgra_to_png(bgra: &[u8], width: i32, height: i32) -> Result<Vec<u8>, String> {
|
||||||
|
use image::codecs::png::PngEncoder;
|
||||||
|
use image::ImageEncoder;
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return Err("无效尺寸".into());
|
||||||
|
}
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let mut rgba = vec![0u8; w * h * 4];
|
||||||
|
for i in 0..(w * h) {
|
||||||
|
rgba[i * 4] = bgra[i * 4 + 2]; // R
|
||||||
|
rgba[i * 4 + 1] = bgra[i * 4 + 1]; // G
|
||||||
|
rgba[i * 4 + 2] = bgra[i * 4]; // B
|
||||||
|
rgba[i * 4 + 3] = 255; // 强制不透明
|
||||||
|
}
|
||||||
|
let mut buf = Vec::with_capacity(w * h * 4 / 3);
|
||||||
|
PngEncoder::new(&mut buf)
|
||||||
|
.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
|
||||||
|
.map_err(|e| format!("PNG 编码失败: {}", e))?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 HBITMAP 提取 32bpp BGRA top-down 像素
|
||||||
|
unsafe fn extract_pixels(
|
||||||
|
hdc_mem: isize,
|
||||||
|
hbm: isize,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let bi = BITMAPINFO {
|
||||||
|
bmiHeader: BITMAPINFOHEADER {
|
||||||
|
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||||
|
biWidth: width,
|
||||||
|
biHeight: -height, // 负值 = top-down
|
||||||
|
biPlanes: 1,
|
||||||
|
biBitCount: 32,
|
||||||
|
biCompression: BI_RGB,
|
||||||
|
biSizeImage: 0,
|
||||||
|
biXPelsPerMeter: 0,
|
||||||
|
biYPelsPerMeter: 0,
|
||||||
|
biClrUsed: 0,
|
||||||
|
biClrImportant: 0,
|
||||||
|
},
|
||||||
|
bmiColors: [RGBQUAD {
|
||||||
|
rgbBlue: 0,
|
||||||
|
rgbGreen: 0,
|
||||||
|
rgbRed: 0,
|
||||||
|
rgbReserved: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
|
||||||
|
let got = GetDIBits(
|
||||||
|
hdc_mem,
|
||||||
|
hbm,
|
||||||
|
0,
|
||||||
|
height as u32,
|
||||||
|
pixels.as_mut_ptr() as *mut _,
|
||||||
|
&bi as *const _ as *mut _,
|
||||||
|
DIB_RGB_COLORS,
|
||||||
|
);
|
||||||
|
if got == 0 {
|
||||||
|
return Err("GetDIBits 失败".into());
|
||||||
|
}
|
||||||
|
Ok(pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获整个虚拟屏(所有显示器拼接为一张图)
|
||||||
|
pub fn capture_virtual_screen() -> Result<CapturedImage, String> {
|
||||||
|
unsafe {
|
||||||
|
let x = GetSystemMetrics(SM_XVIRTUALSCREEN);
|
||||||
|
let y = GetSystemMetrics(SM_YVIRTUALSCREEN);
|
||||||
|
let w = GetSystemMetrics(SM_CXVIRTUALSCREEN);
|
||||||
|
let h = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return Err("无法获取虚拟屏尺寸".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hdc_screen = GetDC(0);
|
||||||
|
if hdc_screen == 0 {
|
||||||
|
return Err("GetDC 失败".into());
|
||||||
|
}
|
||||||
|
let hdc_mem = CreateCompatibleDC(hdc_screen);
|
||||||
|
if hdc_mem == 0 {
|
||||||
|
ReleaseDC(0, hdc_screen);
|
||||||
|
return Err("CreateCompatibleDC 失败".into());
|
||||||
|
}
|
||||||
|
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
|
||||||
|
if hbm == 0 {
|
||||||
|
DeleteDC(hdc_mem);
|
||||||
|
ReleaseDC(0, hdc_screen);
|
||||||
|
return Err("CreateCompatibleBitmap 失败".into());
|
||||||
|
}
|
||||||
|
let old = SelectObject(hdc_mem, hbm);
|
||||||
|
|
||||||
|
let ok = BitBlt(hdc_mem, 0, 0, w, h, hdc_screen, x, y, SRCCOPY);
|
||||||
|
|
||||||
|
let result = if ok == 0 {
|
||||||
|
Err("BitBlt 失败".into())
|
||||||
|
} else {
|
||||||
|
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
|
||||||
|
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
png,
|
||||||
|
bgra,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectObject(hdc_mem, old);
|
||||||
|
DeleteObject(hbm);
|
||||||
|
DeleteDC(hdc_mem);
|
||||||
|
ReleaseDC(0, hdc_screen);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 捕获指定窗口(PrintWindow + PW_RENDERFULLCONTENT,覆盖硬件加速窗口)
|
||||||
|
pub fn capture_window(hwnd: isize) -> Result<CapturedImage, String> {
|
||||||
|
unsafe {
|
||||||
|
let mut rect: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||||
|
return Err("GetWindowRect 失败".into());
|
||||||
|
}
|
||||||
|
let w = rect.right - rect.left;
|
||||||
|
let h = rect.bottom - rect.top;
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return Err("窗口尺寸无效".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hdc_screen = GetDC(0);
|
||||||
|
if hdc_screen == 0 {
|
||||||
|
return Err("GetDC 失败".into());
|
||||||
|
}
|
||||||
|
let hdc_mem = CreateCompatibleDC(hdc_screen);
|
||||||
|
let hbm = CreateCompatibleBitmap(hdc_screen, w, h);
|
||||||
|
let old = SelectObject(hdc_mem, hbm);
|
||||||
|
|
||||||
|
// 先用黑色填充(PrintWindow 对部分窗口不绘制透明区域)
|
||||||
|
PatBlt(hdc_mem, 0, 0, w, h, BLACKNESS);
|
||||||
|
|
||||||
|
let ok = PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT);
|
||||||
|
|
||||||
|
let result = if ok == 0 {
|
||||||
|
Err("PrintWindow 失败(可能窗口无响应或权限不足)".into())
|
||||||
|
} else {
|
||||||
|
extract_pixels(hdc_mem, hbm, w, h).and_then(|bgra| {
|
||||||
|
bgra_to_png(&bgra, w, h).map(|png| CapturedImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
png,
|
||||||
|
bgra,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
SelectObject(hdc_mem, old);
|
||||||
|
DeleteObject(hbm);
|
||||||
|
DeleteDC(hdc_mem);
|
||||||
|
ReleaseDC(0, hdc_screen);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定屏幕坐标下的顶层窗口(窗口拾取)
|
||||||
|
///
|
||||||
|
/// 入参 x/y 为物理屏幕坐标(前端需按显示器 scaleFactor 从逻辑坐标换算)。
|
||||||
|
pub fn window_from_point(x: i32, y: i32) -> Option<WindowInfo> {
|
||||||
|
unsafe {
|
||||||
|
let pt = POINT { x, y };
|
||||||
|
let mut hwnd = WindowFromPoint(pt);
|
||||||
|
if hwnd == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// 取顶层父窗口(WindowFromPoint 可能返回子窗口)
|
||||||
|
let root = GetAncestor(hwnd, GA_ROOT);
|
||||||
|
if root != 0 {
|
||||||
|
hwnd = root;
|
||||||
|
}
|
||||||
|
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut rect: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let srect = ScreenRect::from(rect);
|
||||||
|
if srect.width <= 0 || srect.height <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(WindowInfo {
|
||||||
|
hwnd,
|
||||||
|
title: get_window_title(hwnd),
|
||||||
|
rect: srect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 枚举所有可见、有标题的顶层窗口(供窗口列表选择)
|
||||||
|
pub fn enum_visible_windows() -> Vec<WindowInfo> {
|
||||||
|
extern "system" fn enum_proc(hwnd: HWND, lparam: isize) -> i32 {
|
||||||
|
unsafe {
|
||||||
|
let vec = &mut *(lparam as *mut Vec<WindowInfo>);
|
||||||
|
if windows_sys::Win32::UI::WindowsAndMessaging::IsWindowVisible(hwnd) == 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let mut rect: RECT = std::mem::zeroed();
|
||||||
|
if GetWindowRect(hwnd, &mut rect) == 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let srect = ScreenRect::from(rect);
|
||||||
|
if srect.width < 20 || srect.height < 20 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// 跳过工具窗口(本应用 OSD / 托盘菜单 / 截图覆盖层)
|
||||||
|
let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
||||||
|
if ex & (WS_EX_TOOLWINDOW as isize) != 0 {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let title = get_window_title(hwnd);
|
||||||
|
if title.is_empty() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
vec.push(WindowInfo {
|
||||||
|
hwnd,
|
||||||
|
title,
|
||||||
|
rect: srect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<WindowInfo> = Vec::new();
|
||||||
|
unsafe {
|
||||||
|
EnumWindows(Some(enum_proc), &mut out as *mut _ as isize);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn get_window_title(hwnd: HWND) -> String {
|
||||||
|
let len = GetWindowTextLengthW(hwnd);
|
||||||
|
if len <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u16; (len as usize) + 1];
|
||||||
|
let got = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
|
||||||
|
if got <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
String::from_utf16_lossy(&buf[..got as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 裁剪已存储的全屏捕获(按物理像素坐标)
|
||||||
|
pub fn crop_stored(x: i32, y: i32, w: i32, h: i32) -> Result<CaptureData, String> {
|
||||||
|
let guard = FULLSCREEN_CAPTURE.lock().map_err(|e| e.to_string())?;
|
||||||
|
let img = guard.as_ref().ok_or("无已存储的全屏捕获")?;
|
||||||
|
if x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > img.width || y + h > img.height {
|
||||||
|
return Err("裁剪区域越界".into());
|
||||||
|
}
|
||||||
|
let sw = img.width as usize;
|
||||||
|
let sub = crop_bgra(&img.bgra, sw, x, y, w, h)?;
|
||||||
|
let png = bgra_to_png(&sub, w, h)?;
|
||||||
|
Ok(CaptureData {
|
||||||
|
png_base64: base64_encode(&png),
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn crop_bgra(
|
||||||
|
src: &[u8],
|
||||||
|
src_width: usize,
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||||
|
for row in 0..(h as usize) {
|
||||||
|
let src_off = ((y as usize) + row) * src_width * 4 + (x as usize) * 4;
|
||||||
|
let dst_off = row * (w as usize) * 4;
|
||||||
|
out[dst_off..dst_off + (w as usize) * 4]
|
||||||
|
.copy_from_slice(&src[src_off..src_off + (w as usize) * 4]);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存储全屏捕获(覆盖层选区裁剪时取用)
|
||||||
|
pub fn store_fullscreen(img: CapturedImage) -> CaptureData {
|
||||||
|
let data = CaptureData {
|
||||||
|
png_base64: base64_encode(&img.png),
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
};
|
||||||
|
if let Ok(mut g) = FULLSCREEN_CAPTURE.lock() {
|
||||||
|
*g = Some(img);
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出并清除全屏捕获
|
||||||
|
pub fn take_fullscreen() -> Option<CaptureData> {
|
||||||
|
let mut g = FULLSCREEN_CAPTURE.lock().ok()?;
|
||||||
|
let img = g.take()?;
|
||||||
|
Some(CaptureData {
|
||||||
|
png_base64: base64_encode(&img.png),
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 输出:剪贴板 / 文件 =====
|
||||||
|
|
||||||
|
/// 将 PNG base64 解码为像素,构造 CF_DIB 并写入剪贴板
|
||||||
|
pub fn copy_png_to_clipboard(png_base64: &str) -> Result<(), String> {
|
||||||
|
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
|
||||||
|
let img = image::load_from_memory(&png)
|
||||||
|
.map_err(|e| format!("PNG 解码失败: {}", e))?
|
||||||
|
.to_rgba8();
|
||||||
|
let (w, h) = (img.width() as i32, img.height() as i32);
|
||||||
|
let dib = rgba_to_dib(&img, w, h);
|
||||||
|
write_dib_to_clipboard(&dib)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA → CF_DIB(BITMAPINFOHEADER + BGRA bottom-up,alpha 强制 255)
|
||||||
|
fn rgba_to_dib(rgba: &image::RgbaImage, width: i32, height: i32) -> Vec<u8> {
|
||||||
|
let header_size = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||||
|
let row_size = (width as usize) * 4;
|
||||||
|
let mut dib = Vec::with_capacity(header_size as usize + row_size * height as usize);
|
||||||
|
let header = BITMAPINFOHEADER {
|
||||||
|
biSize: header_size,
|
||||||
|
biWidth: width,
|
||||||
|
biHeight: height, // 正值 = bottom-up
|
||||||
|
biPlanes: 1,
|
||||||
|
biBitCount: 32,
|
||||||
|
biCompression: BI_RGB,
|
||||||
|
biSizeImage: (row_size * height as usize) as u32,
|
||||||
|
biXPelsPerMeter: 0,
|
||||||
|
biYPelsPerMeter: 0,
|
||||||
|
biClrUsed: 0,
|
||||||
|
biClrImportant: 0,
|
||||||
|
};
|
||||||
|
let raw = unsafe {
|
||||||
|
std::slice::from_raw_parts(
|
||||||
|
&header as *const _ as *const u8,
|
||||||
|
std::mem::size_of::<BITMAPINFOHEADER>(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
dib.extend_from_slice(raw);
|
||||||
|
// bottom-up 行序,BGRA 像素,alpha 强制 255
|
||||||
|
for y in (0..height as usize).rev() {
|
||||||
|
for x in 0..width as usize {
|
||||||
|
let p = rgba.get_pixel(x as u32, y as u32).0;
|
||||||
|
dib.push(p[2]); // B
|
||||||
|
dib.push(p[1]); // G
|
||||||
|
dib.push(p[0]); // R
|
||||||
|
dib.push(255); // A
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dib
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_dib_to_clipboard(dib: &[u8]) -> Result<(), String> {
|
||||||
|
unsafe {
|
||||||
|
if OpenClipboard(0) == 0 {
|
||||||
|
return Err("OpenClipboard 失败".into());
|
||||||
|
}
|
||||||
|
let r = (|| {
|
||||||
|
if EmptyClipboard() == 0 {
|
||||||
|
return Err("EmptyClipboard 失败".to_string());
|
||||||
|
}
|
||||||
|
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
|
||||||
|
if hglob.is_null() {
|
||||||
|
return Err("GlobalAlloc 失败".to_string());
|
||||||
|
}
|
||||||
|
let ptr = GlobalLock(hglob) as *mut u8;
|
||||||
|
if ptr.is_null() {
|
||||||
|
return Err("GlobalLock 失败".to_string());
|
||||||
|
}
|
||||||
|
std::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
|
||||||
|
GlobalUnlock(hglob);
|
||||||
|
if SetClipboardData(CF_DIB as u32, hglob as isize) == 0 {
|
||||||
|
return Err("SetClipboardData 失败".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
CloseClipboard();
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 PNG base64 写入文件
|
||||||
|
pub fn save_png_to_file(png_base64: &str, path: &str) -> Result<(), String> {
|
||||||
|
let png = base64_decode(png_base64).ok_or("base64 解码失败")?;
|
||||||
|
std::fs::write(path, &png).map_err(|e| format!("写入文件失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base64_encode(b: &[u8]) -> String {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
STANDARD.encode(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
STANDARD.decode(s).ok()
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Tauri 命令:截图模块
|
||||||
|
//!
|
||||||
|
//! 命令清单:
|
||||||
|
//! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态,返回元数据 + base64
|
||||||
|
//! - screenshot_take_fullscreen:取出并清除静态存储的全屏捕获
|
||||||
|
//! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获
|
||||||
|
//! - screenshot_window_from_point:拾取指定屏幕坐标下的顶层窗口
|
||||||
|
//! - screenshot_enum_windows:枚举可见顶层窗口
|
||||||
|
//! - screenshot_capture_window:按 hwnd 捕获指定窗口
|
||||||
|
//! - screenshot_set_editor_image / screenshot_get_editor_image:编辑器图片传递
|
||||||
|
//! - screenshot_copy_image:写入剪贴板(CF_DIB)
|
||||||
|
//! - screenshot_save_png:写入文件
|
||||||
|
|
||||||
|
use super::{CaptureData, WindowInfo};
|
||||||
|
|
||||||
|
/// 捕获整个虚拟屏(多显示器拼接),存入静态供后续裁剪,并返回 base64
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_capture_fullscreen() -> Result<CaptureData, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度
|
||||||
|
let img = tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
super::capture::capture_virtual_screen()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("捕获任务失败: {}", e))??;
|
||||||
|
Ok(super::capture::store_fullscreen(img))
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出并清除静态存储的全屏捕获(覆盖层取消时清理用)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_take_fullscreen() -> Result<Option<CaptureData>, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
Ok(super::capture::take_fullscreen())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按物理像素坐标裁剪已存储的全屏捕获
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_crop_stored(
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
) -> Result<CaptureData, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
super::capture::crop_stored(x, y, w, h)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("裁剪任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (x, y, w, h);
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拾取指定物理屏幕坐标下的顶层窗口
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_window_from_point(
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
) -> Result<Option<WindowInfo>, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
Ok(super::capture::window_from_point(x, y))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("查询失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (x, y);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 枚举所有可见顶层窗口
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_enum_windows() -> Result<Vec<WindowInfo>, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("枚举失败: {}", e))
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 hwnd 捕获指定窗口
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_capture_window(hwnd: isize) -> Result<CaptureData, String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let img = super::capture::capture_window(hwnd)?;
|
||||||
|
Ok(super::CaptureData {
|
||||||
|
png_base64: super::capture::base64_encode(&img.png),
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("捕获任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = hwnd;
|
||||||
|
Err("截图仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存入编辑器图片(base64 PNG)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_set_editor_image(png_base64: String) -> Result<(), String> {
|
||||||
|
super::set_editor_image(png_base64);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出编辑器图片(编辑器窗口加载时调用,取出即清除)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_get_editor_image() -> Result<Option<String>, String> {
|
||||||
|
Ok(super::take_editor_image())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 PNG base64 写入系统剪贴板(转 CF_DIB)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
super::capture::copy_png_to_clipboard(&png_base64)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("剪贴板任务失败: {}", e))?
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = png_base64;
|
||||||
|
Err("剪贴板仅支持 Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 PNG base64 写入文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
super::capture::save_png_to_file(&png_base64, &path)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (png_base64, path);
|
||||||
|
Err("文件写入仅支持 Windows".to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("保存任务失败: {}", e))?
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 截图模块
|
||||||
|
//!
|
||||||
|
//! 跨平台数据结构定义 + Windows 捕获实现(capture.rs)。
|
||||||
|
//! 捕获引擎选型见 capture.rs 顶部说明:BitBlt + PrintWindow,不使用 WGC。
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod capture;
|
||||||
|
pub mod commands;
|
||||||
|
|
||||||
|
/// 前端可见的捕获数据
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CaptureData {
|
||||||
|
pub png_base64: String,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 窗口信息(窗口拾取 / 枚举)
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WindowInfo {
|
||||||
|
pub hwnd: isize,
|
||||||
|
pub title: String,
|
||||||
|
pub rect: ScreenRect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScreenRect {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑器图片静态存储(覆盖层裁剪后存入 → 编辑器窗口加载取出)
|
||||||
|
static EDITOR_IMAGE: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// 存储编辑器图片(base64 PNG)
|
||||||
|
pub fn set_editor_image(png_base64: String) {
|
||||||
|
if let Ok(mut g) = EDITOR_IMAGE.lock() {
|
||||||
|
*g = Some(png_base64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取出并清除编辑器图片
|
||||||
|
pub fn take_editor_image() -> Option<String> {
|
||||||
|
EDITOR_IMAGE.lock().ok()?.take()
|
||||||
|
}
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { emit } from '@tauri-apps/api/event'
|
||||||
|
import { save } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import {
|
||||||
|
Square, MoveUpRight, Pencil, Type, Grid3x3, Highlighter,
|
||||||
|
Eraser, Undo2, Redo2, Trash2, Copy, Save, X, Image as ImageIcon,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// ===== 标注数据结构 =====
|
||||||
|
type ToolType = 'rect' | 'arrow' | 'pen' | 'text' | 'mosaic' | 'highlight'
|
||||||
|
|
||||||
|
interface Point { x: number; y: number }
|
||||||
|
|
||||||
|
interface RectAnno { type: 'rect'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
|
||||||
|
interface ArrowAnno { type: 'arrow'; x1: number; y1: number; x2: number; y2: number; color: string; lineWidth: number }
|
||||||
|
interface PenAnno { type: 'pen'; points: Point[]; color: string; lineWidth: number }
|
||||||
|
interface TextAnno { type: 'text'; x: number; y: number; text: string; color: string; fontSize: number }
|
||||||
|
interface MosaicAnno { type: 'mosaic'; x1: number; y1: number; x2: number; y2: number; blockSize: number }
|
||||||
|
interface HighlightAnno { type: 'highlight'; x1: number; y1: number; x2: number; y2: number; color: string; alpha: number }
|
||||||
|
|
||||||
|
type Annotation = RectAnno | ArrowAnno | PenAnno | TextAnno | MosaicAnno | HighlightAnno
|
||||||
|
|
||||||
|
/** 可拖拽绘制的标注(不含文字,文字通过独立输入框提交) */
|
||||||
|
type DrawableAnnotation = RectAnno | ArrowAnno | PenAnno | MosaicAnno | HighlightAnno
|
||||||
|
|
||||||
|
// ===== 工具与选项 =====
|
||||||
|
const TOOLS: { value: ToolType; icon: Component; label: string }[] = [
|
||||||
|
{ value: 'rect', icon: Square, label: '矩形' },
|
||||||
|
{ value: 'arrow', icon: MoveUpRight, label: '箭头' },
|
||||||
|
{ value: 'pen', icon: Pencil, label: '画笔' },
|
||||||
|
{ value: 'text', icon: Type, label: '文字' },
|
||||||
|
{ value: 'mosaic', icon: Grid3x3, label: '马赛克' },
|
||||||
|
{ value: 'highlight', icon: Highlighter, label: '高亮' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLORS = ['#ef4444', '#facc15', '#22c55e', '#3b82f6', '#000000', '#ffffff'] as const
|
||||||
|
const WIDTHS = [2, 4, 6] as const
|
||||||
|
const BLOCK_SIZES = [8, 10, 14] as const
|
||||||
|
const ALPHAS = [0.2, 0.4, 0.6] as const
|
||||||
|
|
||||||
|
// ===== 状态 =====
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const baseImage = ref<HTMLImageElement | null>(null)
|
||||||
|
const annotations = ref<Annotation[]>([])
|
||||||
|
const redoStack = ref<Annotation[]>([])
|
||||||
|
const draft = ref<DrawableAnnotation | null>(null)
|
||||||
|
const isDrawing = ref(false)
|
||||||
|
|
||||||
|
const currentTool = ref<ToolType>('rect')
|
||||||
|
const currentColor = ref<string>('#ef4444')
|
||||||
|
const currentLineWidth = ref<number>(4)
|
||||||
|
const blockSize = ref<number>(10)
|
||||||
|
const highlightAlpha = ref<number>(0.4)
|
||||||
|
|
||||||
|
const loaded = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
|
||||||
|
// 文字输入浮层
|
||||||
|
const textInputPos = ref<Point | null>(null)
|
||||||
|
const textInputValue = ref('')
|
||||||
|
const textInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const fontSizePx = computed(() => currentLineWidth.value * 3 + 14)
|
||||||
|
const canUndo = computed(() => annotations.value.length > 0)
|
||||||
|
const canRedo = computed(() => redoStack.value.length > 0)
|
||||||
|
|
||||||
|
// ===== 画布重绘 =====
|
||||||
|
function redraw() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
const img = baseImage.value
|
||||||
|
if (!canvas || !img) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
// 白色背景填充透明区
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
// 底图
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
// 已提交标注
|
||||||
|
for (const anno of annotations.value) {
|
||||||
|
drawAnnotation(ctx, anno)
|
||||||
|
}
|
||||||
|
// 进行中的草稿
|
||||||
|
if (draft.value) {
|
||||||
|
if (draft.value.type === 'mosaic') {
|
||||||
|
drawMosaicDraft(ctx, draft.value)
|
||||||
|
} else {
|
||||||
|
drawAnnotation(ctx, draft.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAnnotation(ctx: CanvasRenderingContext2D, anno: Annotation) {
|
||||||
|
switch (anno.type) {
|
||||||
|
case 'rect': drawRect(ctx, anno); break
|
||||||
|
case 'arrow': drawArrow(ctx, anno); break
|
||||||
|
case 'pen': drawPen(ctx, anno); break
|
||||||
|
case 'text': drawText(ctx, anno); break
|
||||||
|
case 'highlight': drawHighlight(ctx, anno); break
|
||||||
|
case 'mosaic': applyMosaic(ctx, anno); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRect(ctx: CanvasRenderingContext2D, a: RectAnno) {
|
||||||
|
ctx.strokeStyle = a.color
|
||||||
|
ctx.lineWidth = a.lineWidth
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.strokeRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArrow(ctx: CanvasRenderingContext2D, a: ArrowAnno) {
|
||||||
|
const { x1, y1, x2, y2, color, lineWidth } = a
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.lineWidth = lineWidth
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x1, y1)
|
||||||
|
ctx.lineTo(x2, y2)
|
||||||
|
ctx.stroke()
|
||||||
|
// 箭头头部
|
||||||
|
const headLen = Math.max(10, lineWidth * 3.5)
|
||||||
|
const angle = Math.atan2(y2 - y1, x2 - x1)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x2, y2)
|
||||||
|
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6))
|
||||||
|
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6))
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPen(ctx: CanvasRenderingContext2D, a: PenAnno) {
|
||||||
|
if (a.points.length === 0) return
|
||||||
|
ctx.strokeStyle = a.color
|
||||||
|
ctx.lineWidth = a.lineWidth
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(a.points[0].x, a.points[0].y)
|
||||||
|
for (let i = 1; i < a.points.length; i++) {
|
||||||
|
ctx.lineTo(a.points[i].x, a.points[i].y)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawText(ctx: CanvasRenderingContext2D, a: TextAnno) {
|
||||||
|
ctx.font = `${a.fontSize}px sans-serif`
|
||||||
|
ctx.fillStyle = a.color
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
|
ctx.fillText(a.text, a.x, a.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHighlight(ctx: CanvasRenderingContext2D, a: HighlightAnno) {
|
||||||
|
ctx.globalAlpha = a.alpha
|
||||||
|
ctx.fillStyle = a.color
|
||||||
|
ctx.fillRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对区域做像素化(马赛克):取块平均色填回 */
|
||||||
|
function applyMosaic(ctx: CanvasRenderingContext2D, a: MosaicAnno) {
|
||||||
|
const canvas = ctx.canvas
|
||||||
|
const block = Math.max(1, a.blockSize)
|
||||||
|
const sx = Math.max(0, Math.floor(Math.min(a.x1, a.x2)))
|
||||||
|
const sy = Math.max(0, Math.floor(Math.min(a.y1, a.y2)))
|
||||||
|
const sw = Math.min(canvas.width - sx, Math.floor(Math.abs(a.x2 - a.x1)))
|
||||||
|
const sh = Math.min(canvas.height - sy, Math.floor(Math.abs(a.y2 - a.y1)))
|
||||||
|
if (sw <= 0 || sh <= 0) return
|
||||||
|
const imageData = ctx.getImageData(sx, sy, sw, sh)
|
||||||
|
const data = imageData.data
|
||||||
|
for (let by = 0; by < sh; by += block) {
|
||||||
|
for (let bx = 0; bx < sw; bx += block) {
|
||||||
|
let r = 0, g = 0, b = 0, alpha = 0, count = 0
|
||||||
|
const maxJ = Math.min(by + block, sh)
|
||||||
|
const maxI = Math.min(bx + block, sw)
|
||||||
|
for (let j = by; j < maxJ; j++) {
|
||||||
|
for (let i = bx; i < maxI; i++) {
|
||||||
|
const idx = (j * sw + i) * 4
|
||||||
|
r += data[idx]
|
||||||
|
g += data[idx + 1]
|
||||||
|
b += data[idx + 2]
|
||||||
|
alpha += data[idx + 3]
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count === 0) continue
|
||||||
|
r = Math.round(r / count)
|
||||||
|
g = Math.round(g / count)
|
||||||
|
b = Math.round(b / count)
|
||||||
|
alpha = Math.round(alpha / count)
|
||||||
|
for (let j = by; j < maxJ; j++) {
|
||||||
|
for (let i = bx; i < maxI; i++) {
|
||||||
|
const idx = (j * sw + i) * 4
|
||||||
|
data[idx] = r
|
||||||
|
data[idx + 1] = g
|
||||||
|
data[idx + 2] = b
|
||||||
|
data[idx + 3] = alpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(imageData, sx, sy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 马赛克拖拽中的虚线框预览(避免每帧像素化开销) */
|
||||||
|
function drawMosaicDraft(ctx: CanvasRenderingContext2D, a: MosaicAnno) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.8)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.setLineDash([4, 4])
|
||||||
|
ctx.strokeRect(Math.min(a.x1, a.x2), Math.min(a.y1, a.y2), Math.abs(a.x2 - a.x1), Math.abs(a.y2 - a.y1))
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 鼠标交互 =====
|
||||||
|
function getPoint(e: MouseEvent): Point {
|
||||||
|
const canvas = canvasRef.value!
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const scaleX = canvas.width / rect.width
|
||||||
|
const scaleY = canvas.height / rect.height
|
||||||
|
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if (!baseImage.value || !loaded.value) return
|
||||||
|
const p = getPoint(e)
|
||||||
|
if (currentTool.value === 'text') {
|
||||||
|
startTextInput(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDrawing.value = true
|
||||||
|
switch (currentTool.value) {
|
||||||
|
case 'rect':
|
||||||
|
draft.value = { type: 'rect', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, lineWidth: currentLineWidth.value }
|
||||||
|
break
|
||||||
|
case 'arrow':
|
||||||
|
draft.value = { type: 'arrow', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, lineWidth: currentLineWidth.value }
|
||||||
|
break
|
||||||
|
case 'pen':
|
||||||
|
draft.value = { type: 'pen', points: [p], color: currentColor.value, lineWidth: currentLineWidth.value }
|
||||||
|
break
|
||||||
|
case 'mosaic':
|
||||||
|
draft.value = { type: 'mosaic', x1: p.x, y1: p.y, x2: p.x, y2: p.y, blockSize: blockSize.value }
|
||||||
|
break
|
||||||
|
case 'highlight':
|
||||||
|
draft.value = { type: 'highlight', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color: currentColor.value, alpha: highlightAlpha.value }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDrawing.value || !draft.value) return
|
||||||
|
const p = getPoint(e)
|
||||||
|
const d = draft.value
|
||||||
|
if (d.type === 'pen') {
|
||||||
|
d.points.push(p)
|
||||||
|
} else {
|
||||||
|
d.x2 = p.x
|
||||||
|
d.y2 = p.y
|
||||||
|
}
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (!isDrawing.value || !draft.value) return
|
||||||
|
const d = draft.value
|
||||||
|
// 过滤无效(空)标注
|
||||||
|
let valid: boolean
|
||||||
|
if (d.type === 'pen') {
|
||||||
|
valid = d.points.length > 1
|
||||||
|
} else {
|
||||||
|
valid = Math.abs(d.x2 - d.x1) > 1 || Math.abs(d.y2 - d.y1) > 1
|
||||||
|
}
|
||||||
|
if (valid) {
|
||||||
|
annotations.value.push(d)
|
||||||
|
redoStack.value = []
|
||||||
|
}
|
||||||
|
draft.value = null
|
||||||
|
isDrawing.value = false
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 文字输入 =====
|
||||||
|
function startTextInput(p: Point) {
|
||||||
|
textInputPos.value = { x: p.x, y: p.y }
|
||||||
|
textInputValue.value = ''
|
||||||
|
nextTick(() => textInputEl.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitText() {
|
||||||
|
const pos = textInputPos.value
|
||||||
|
if (!pos) return
|
||||||
|
textInputPos.value = null
|
||||||
|
const value = textInputValue.value.trim()
|
||||||
|
textInputValue.value = ''
|
||||||
|
if (value) {
|
||||||
|
annotations.value.push({
|
||||||
|
type: 'text',
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
text: value,
|
||||||
|
color: currentColor.value,
|
||||||
|
fontSize: fontSizePx.value,
|
||||||
|
})
|
||||||
|
redoStack.value = []
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelText() {
|
||||||
|
textInputPos.value = null
|
||||||
|
textInputValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 撤销 / 重做 / 清空 =====
|
||||||
|
function undo() {
|
||||||
|
if (annotations.value.length === 0) return
|
||||||
|
const last = annotations.value.pop()!
|
||||||
|
redoStack.value.push(last)
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function redo() {
|
||||||
|
if (redoStack.value.length === 0) return
|
||||||
|
const a = redoStack.value.pop()!
|
||||||
|
annotations.value.push(a)
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
annotations.value = []
|
||||||
|
redoStack.value = []
|
||||||
|
redraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 导出 =====
|
||||||
|
function getPngBase64(): string | null {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return null
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
return dataUrl.substring('data:image/png;base64,'.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
const pngBase64 = getPngBase64()
|
||||||
|
if (!canvas || !pngBase64) return
|
||||||
|
try {
|
||||||
|
await invoke('screenshot_copy_image', { pngBase64 })
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
console.error('[screenshot-editor] 复制失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToFile() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
const pngBase64 = getPngBase64()
|
||||||
|
if (!canvas || !pngBase64) return
|
||||||
|
try {
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `screenshot_${Date.now()}.png`,
|
||||||
|
filters: [{ name: 'PNG', extensions: ['png'] }],
|
||||||
|
})
|
||||||
|
if (!path) return
|
||||||
|
await invoke('screenshot_save_png', { pngBase64, path })
|
||||||
|
toast.success('已保存')
|
||||||
|
await emit('screenshot-exported', { pngBase64, width: canvas.width, height: canvas.height })
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('保存失败')
|
||||||
|
console.error('[screenshot-editor] 保存失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 窗口控制 =====
|
||||||
|
async function closeWindow() {
|
||||||
|
try {
|
||||||
|
await getCurrentWindow().close()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[screenshot-editor] 关闭失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 生命周期 =====
|
||||||
|
onMounted(() => {
|
||||||
|
// 绑定到 window 以便鼠标移出 canvas 仍能继续绘制 / 释放
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const b64 = await invoke<string | null>('screenshot_get_editor_image')
|
||||||
|
if (!b64) {
|
||||||
|
loadError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
baseImage.value = img
|
||||||
|
loaded.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = img.naturalWidth
|
||||||
|
canvas.height = img.naturalHeight
|
||||||
|
}
|
||||||
|
redraw()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
loadError.value = true
|
||||||
|
}
|
||||||
|
img.src = `data:image/png;base64,${b64}`
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[screenshot-editor] 加载图片失败:', e)
|
||||||
|
loadError.value = true
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-screen w-screen flex flex-col bg-zinc-950 text-zinc-100 overflow-hidden">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div
|
||||||
|
class="h-9 flex items-center justify-between px-3 bg-zinc-900 border-b border-zinc-800 shrink-0 select-none"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">截图编辑器</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||||
|
title="关闭"
|
||||||
|
@click="closeWindow"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 border-b border-zinc-800 shrink-0 flex-wrap">
|
||||||
|
<!-- 工具组 -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-for="tool in TOOLS"
|
||||||
|
:key="tool.value"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
:class="currentTool === tool.value
|
||||||
|
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
|
||||||
|
:title="tool.label"
|
||||||
|
@click="currentTool = tool.value"
|
||||||
|
>
|
||||||
|
<component :is="tool.icon" class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6 w-px bg-zinc-700" />
|
||||||
|
|
||||||
|
<!-- 颜色 -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-zinc-500">颜色</span>
|
||||||
|
<button
|
||||||
|
v-for="c in COLORS"
|
||||||
|
:key="c"
|
||||||
|
class="size-6 rounded-full border border-zinc-500 transition-transform hover:scale-110"
|
||||||
|
:class="currentColor === c ? 'ring-2 ring-blue-400 ring-offset-1 ring-offset-zinc-900 scale-110' : ''"
|
||||||
|
:style="{ backgroundColor: c }"
|
||||||
|
:title="c"
|
||||||
|
@click="currentColor = c"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6 w-px bg-zinc-700" />
|
||||||
|
|
||||||
|
<!-- 线宽 -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-zinc-500">线宽</span>
|
||||||
|
<Button
|
||||||
|
v-for="w in WIDTHS"
|
||||||
|
:key="w"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2"
|
||||||
|
:class="currentLineWidth === w
|
||||||
|
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
|
||||||
|
@click="currentLineWidth = w"
|
||||||
|
>{{ w }}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 马赛克块大小 -->
|
||||||
|
<div v-if="currentTool === 'mosaic'" class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-zinc-500">块大小</span>
|
||||||
|
<Button
|
||||||
|
v-for="b in BLOCK_SIZES"
|
||||||
|
:key="b"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2"
|
||||||
|
:class="blockSize === b
|
||||||
|
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
|
||||||
|
@click="blockSize = b"
|
||||||
|
>{{ b }}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 高亮透明度 -->
|
||||||
|
<div v-if="currentTool === 'highlight'" class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-zinc-500">透明度</span>
|
||||||
|
<Button
|
||||||
|
v-for="a in ALPHAS"
|
||||||
|
:key="a"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2"
|
||||||
|
:class="highlightAlpha === a
|
||||||
|
? 'bg-zinc-700 text-white hover:bg-zinc-700 hover:text-white'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'"
|
||||||
|
@click="highlightAlpha = a"
|
||||||
|
>{{ Math.round(a * 100) }}%</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-6 w-px bg-zinc-700" />
|
||||||
|
|
||||||
|
<!-- 历史 / 清空 -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
:disabled="!canUndo"
|
||||||
|
title="橡皮(撤销最近标注)"
|
||||||
|
@click="undo"
|
||||||
|
>
|
||||||
|
<Eraser class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
:disabled="!canUndo"
|
||||||
|
title="撤销"
|
||||||
|
@click="undo"
|
||||||
|
>
|
||||||
|
<Undo2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-zinc-400 hover:bg-zinc-800 hover:text-white"
|
||||||
|
:disabled="!canRedo"
|
||||||
|
title="重做"
|
||||||
|
@click="redo"
|
||||||
|
>
|
||||||
|
<Redo2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-zinc-400 hover:bg-red-900 hover:text-white"
|
||||||
|
:disabled="!canUndo"
|
||||||
|
title="清空"
|
||||||
|
@click="clearAll"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 画布区 -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-auto bg-zinc-950 p-4">
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div
|
||||||
|
v-if="loadError"
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-3 text-zinc-400"
|
||||||
|
>
|
||||||
|
<ImageIcon class="h-12 w-12 opacity-40" />
|
||||||
|
<p>未找到待编辑的截图</p>
|
||||||
|
<Button variant="ghost" class="text-zinc-200 hover:bg-zinc-800 hover:text-white" @click="closeWindow">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-else-if="!loaded" class="flex items-center justify-center h-full text-zinc-500">
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
<!-- 画布 -->
|
||||||
|
<div v-else class="canvas-wrap relative inline-block shadow-2xl">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="block max-w-none select-none"
|
||||||
|
:style="{ cursor: currentTool === 'text' ? 'text' : 'crosshair' }"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-if="textInputPos"
|
||||||
|
ref="textInputEl"
|
||||||
|
v-model="textInputValue"
|
||||||
|
class="absolute z-10 bg-transparent outline-none"
|
||||||
|
:style="{
|
||||||
|
left: textInputPos.x + 'px',
|
||||||
|
top: textInputPos.y + 'px',
|
||||||
|
color: currentColor,
|
||||||
|
fontSize: fontSizePx + 'px',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
lineHeight: '1',
|
||||||
|
padding: '0 2px',
|
||||||
|
border: '1px dashed ' + currentColor,
|
||||||
|
}"
|
||||||
|
placeholder="输入文字"
|
||||||
|
@keydown.enter.prevent="commitText"
|
||||||
|
@keydown.esc.prevent="cancelText"
|
||||||
|
@blur="commitText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作栏 -->
|
||||||
|
<div class="flex items-center justify-end gap-2 px-3 py-2 bg-zinc-900 border-t border-zinc-800 shrink-0">
|
||||||
|
<Button variant="ghost" class="text-zinc-300 hover:bg-zinc-800 hover:text-white" @click="closeWindow">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="text-zinc-200 hover:bg-zinc-800 hover:text-white" @click="saveToFile">
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
保存到文件
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="bg-blue-600 text-white hover:bg-blue-500" @click="copyToClipboard">
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
复制到剪贴板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
|
|
||||||
|
interface CaptureData {
|
||||||
|
pngBase64: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
interface WindowInfo {
|
||||||
|
hwnd: number
|
||||||
|
title: string
|
||||||
|
rect: { x: number; y: number; width: number; height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = getCurrentWindow()
|
||||||
|
const hash = window.location.hash
|
||||||
|
const query = hash.split('?')[1] || ''
|
||||||
|
const params = new URLSearchParams(query)
|
||||||
|
const mode = (params.get('mode') || 'region') as 'region' | 'window'
|
||||||
|
|
||||||
|
const imgEl = ref<HTMLImageElement | null>(null)
|
||||||
|
const imgSrc = ref('')
|
||||||
|
const imgWidth = ref(0) // 自然(物理)像素
|
||||||
|
const imgHeight = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
// 选区状态(CSS 逻辑像素,相对窗口左上角)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const startX = ref(0)
|
||||||
|
const startY = ref(0)
|
||||||
|
const curX = ref(0)
|
||||||
|
const curY = ref(0)
|
||||||
|
const hasSelection = ref(false)
|
||||||
|
|
||||||
|
// 窗口拾取高亮(CSS 逻辑像素)
|
||||||
|
const winHighlight = ref<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
title: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// 物理坐标换算所需
|
||||||
|
let winOuterX = 0
|
||||||
|
let winOuterY = 0
|
||||||
|
let dpr = 1
|
||||||
|
let currentHwnd = 0
|
||||||
|
let pickRaf = 0
|
||||||
|
let lastPickX = -1
|
||||||
|
let lastPickY = -1
|
||||||
|
|
||||||
|
const sel = computed(() => {
|
||||||
|
const x = Math.min(startX.value, curX.value)
|
||||||
|
const y = Math.min(startY.value, curY.value)
|
||||||
|
const w = Math.abs(curX.value - startX.value)
|
||||||
|
const h = Math.abs(curY.value - startY.value)
|
||||||
|
return { x, y, w, h }
|
||||||
|
})
|
||||||
|
|
||||||
|
const selSize = computed(() => {
|
||||||
|
if (!imgWidth.value || !imgHeight.value) return null
|
||||||
|
const sx = imgWidth.value / window.innerWidth
|
||||||
|
const sy = imgHeight.value / window.innerHeight
|
||||||
|
return { w: Math.round(sel.value.w * sx), h: Math.round(sel.value.h * sy) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具栏位置(选区右下方,溢出时翻到上方/左方)
|
||||||
|
const toolbarPos = computed(() => {
|
||||||
|
if (!hasSelection.value) return null
|
||||||
|
const s = sel.value
|
||||||
|
let left = s.x + s.w + 8
|
||||||
|
let top = s.y + s.h + 8
|
||||||
|
if (left + 280 > window.innerWidth) left = s.x + s.w - 280
|
||||||
|
if (top + 40 > window.innerHeight) top = s.y - 48
|
||||||
|
return { left: Math.max(8, left), top: Math.max(8, top) }
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const pos = await win.outerPosition()
|
||||||
|
winOuterX = pos.x
|
||||||
|
winOuterY = pos.y
|
||||||
|
dpr = window.devicePixelRatio || 1
|
||||||
|
|
||||||
|
const data = await invoke<CaptureData | null>('screenshot_take_fullscreen')
|
||||||
|
if (!data) {
|
||||||
|
errorMsg.value = '未找到屏幕捕获数据'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imgSrc.value = 'data:image/png;base64,' + data.pngBase64
|
||||||
|
imgWidth.value = data.width
|
||||||
|
imgHeight.value = data.height
|
||||||
|
loading.value = false
|
||||||
|
await win.show()
|
||||||
|
await win.setFocus()
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
if (mode === 'window') {
|
||||||
|
if (currentHwnd) finishWindowCapture(currentHwnd)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 区域模式:开始拖拽,清除上次选区
|
||||||
|
dragging.value = true
|
||||||
|
hasSelection.value = false
|
||||||
|
startX.value = e.clientX
|
||||||
|
startY.value = e.clientY
|
||||||
|
curX.value = e.clientX
|
||||||
|
curY.value = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (mode === 'window') {
|
||||||
|
scheduleWindowPick(e.clientX, e.clientY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dragging.value) {
|
||||||
|
curX.value = e.clientX
|
||||||
|
curY.value = e.clientY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (mode !== 'region' || !dragging.value) return
|
||||||
|
dragging.value = false
|
||||||
|
if (sel.value.w < 4 || sel.value.h < 4) {
|
||||||
|
hasSelection.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasSelection.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancel()
|
||||||
|
} else if (e.key === 'Enter' && mode === 'region' && hasSelection.value) {
|
||||||
|
void doEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowPick(cssX: number, cssY: number) {
|
||||||
|
const physX = Math.round(winOuterX + cssX * dpr)
|
||||||
|
const physY = Math.round(winOuterY + cssY * dpr)
|
||||||
|
if (physX === lastPickX && physY === lastPickY) return
|
||||||
|
lastPickX = physX
|
||||||
|
lastPickY = physY
|
||||||
|
if (pickRaf) return
|
||||||
|
pickRaf = requestAnimationFrame(async () => {
|
||||||
|
pickRaf = 0
|
||||||
|
try {
|
||||||
|
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
|
||||||
|
x: physX,
|
||||||
|
y: physY,
|
||||||
|
})
|
||||||
|
if (info) {
|
||||||
|
winHighlight.value = {
|
||||||
|
x: (info.rect.x - winOuterX) / dpr,
|
||||||
|
y: (info.rect.y - winOuterY) / dpr,
|
||||||
|
w: info.rect.width / dpr,
|
||||||
|
h: info.rect.height / dpr,
|
||||||
|
title: info.title,
|
||||||
|
}
|
||||||
|
currentHwnd = info.hwnd
|
||||||
|
} else {
|
||||||
|
winHighlight.value = null
|
||||||
|
currentHwnd = 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略拾取错误
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 区域裁剪:从底图自然像素裁剪,返回 base64(无 data: 前缀) */
|
||||||
|
function cropSelection(): string | null {
|
||||||
|
const img = imgEl.value
|
||||||
|
if (!img || !imgWidth.value) return null
|
||||||
|
const scaleX = imgWidth.value / window.innerWidth
|
||||||
|
const scaleY = imgHeight.value / window.innerHeight
|
||||||
|
const sx = Math.round(sel.value.x * scaleX)
|
||||||
|
const sy = Math.round(sel.value.y * scaleY)
|
||||||
|
const sw = Math.round(sel.value.w * scaleX)
|
||||||
|
const sh = Math.round(sel.value.h * scaleY)
|
||||||
|
if (sw < 2 || sh < 2) return null
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = sw
|
||||||
|
canvas.height = sh
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return null
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
|
||||||
|
const url = canvas.toDataURL('image/png')
|
||||||
|
return url.slice(url.indexOf(',') + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doEdit() {
|
||||||
|
const base64 = cropSelection()
|
||||||
|
if (!base64) return
|
||||||
|
await invoke('screenshot_set_editor_image', { pngBase64: base64 })
|
||||||
|
await openEditorAndClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCopy() {
|
||||||
|
const base64 = cropSelection()
|
||||||
|
if (!base64) return
|
||||||
|
try {
|
||||||
|
await invoke('screenshot_copy_image', { pngBase64: base64 })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
await win.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave() {
|
||||||
|
const base64 = cropSelection()
|
||||||
|
if (!base64) return
|
||||||
|
try {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog')
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `screenshot_${ts}.png`,
|
||||||
|
filters: [{ name: 'PNG', extensions: ['png'] }],
|
||||||
|
})
|
||||||
|
if (path) await invoke('screenshot_save_png', { pngBase64: base64, path })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
await win.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishWindowCapture(hwnd: number) {
|
||||||
|
if (!hwnd) return
|
||||||
|
try {
|
||||||
|
const data = await invoke<CaptureData>('screenshot_capture_window', { hwnd })
|
||||||
|
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
|
||||||
|
await openEditorAndClose()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[screenshot] 窗口捕获失败', e)
|
||||||
|
// 失败则留在覆盖层,用户可重试或取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditorAndClose() {
|
||||||
|
const label = 'screenshot-editor'
|
||||||
|
try {
|
||||||
|
const existing = await WebviewWindow.getByLabel(label)
|
||||||
|
if (!existing) {
|
||||||
|
new WebviewWindow(label, {
|
||||||
|
url: 'index.html#screenshot-editor',
|
||||||
|
title: '截图编辑器',
|
||||||
|
width: 960,
|
||||||
|
height: 720,
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: true,
|
||||||
|
shadow: true,
|
||||||
|
focus: true,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await existing.show()
|
||||||
|
await existing.setFocus()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
await win.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
void win.close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="overlay-root"
|
||||||
|
:class="mode === 'window' ? 'cursor-crosshair' : 'cursor-crosshair'"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
>
|
||||||
|
<!-- 冻结的屏幕底图 -->
|
||||||
|
<img
|
||||||
|
v-if="imgSrc"
|
||||||
|
ref="imgEl"
|
||||||
|
:src="imgSrc"
|
||||||
|
class="bg-img"
|
||||||
|
draggable="false"
|
||||||
|
@load="() => {}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading" class="hint">正在准备截图…</div>
|
||||||
|
<div v-else-if="errorMsg" class="hint error">
|
||||||
|
{{ errorMsg }}
|
||||||
|
<button class="btn" @click="cancel">关闭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 区域模式:选区遮罩 + 选框 -->
|
||||||
|
<template v-if="mode === 'region' && !loading && !errorMsg">
|
||||||
|
<div
|
||||||
|
v-if="dragging || hasSelection"
|
||||||
|
class="selection"
|
||||||
|
:style="{
|
||||||
|
left: sel.x + 'px',
|
||||||
|
top: sel.y + 'px',
|
||||||
|
width: sel.w + 'px',
|
||||||
|
height: sel.h + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span v-if="selSize && (dragging || hasSelection)" class="size-tag">
|
||||||
|
{{ selSize.w }} × {{ selSize.h }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选区工具栏 -->
|
||||||
|
<div
|
||||||
|
v-if="hasSelection && toolbarPos"
|
||||||
|
class="toolbar"
|
||||||
|
:style="{ left: toolbarPos.left + 'px', top: toolbarPos.top + 'px' }"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<button class="tb-btn primary" title="编辑" @click="doEdit">编辑</button>
|
||||||
|
<button class="tb-btn" title="复制到剪贴板" @click="doCopy">复制</button>
|
||||||
|
<button class="tb-btn" title="保存到文件" @click="doSave">保存</button>
|
||||||
|
<button class="tb-btn icon" title="取消 (Esc)" @click="cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 窗口模式:高亮框 -->
|
||||||
|
<template v-if="mode === 'window' && !loading && !errorMsg && winHighlight">
|
||||||
|
<div
|
||||||
|
class="win-highlight"
|
||||||
|
:style="{
|
||||||
|
left: winHighlight.x + 'px',
|
||||||
|
top: winHighlight.y + 'px',
|
||||||
|
width: winHighlight.w + 'px',
|
||||||
|
height: winHighlight.h + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="win-title">{{ winHighlight.title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 底部提示 -->
|
||||||
|
<div v-if="!loading && !errorMsg" class="bottom-hint">
|
||||||
|
<template v-if="mode === 'region'">
|
||||||
|
拖动选择区域 · Enter 编辑 · Esc 取消
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
点击窗口捕获 · Esc 取消
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overlay-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.bg-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cursor-crosshair {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.hint.error {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
/* 选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
|
||||||
|
.selection {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.size-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: -24px;
|
||||||
|
left: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: rgba(24, 24, 27, 0.95);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.tb-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tb-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.tb-btn.primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tb-btn.primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.tb-btn.icon {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.win-highlight {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.win-title {
|
||||||
|
position: absolute;
|
||||||
|
top: -22px;
|
||||||
|
left: -2px;
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.bottom-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(24, 24, 27, 0.8);
|
||||||
|
color: #d4d4d8;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #3b82f6;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||||
|
import { availableMonitors } from '@tauri-apps/api/window'
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
interface CaptureData {
|
||||||
|
pngBase64: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentCapture {
|
||||||
|
id: string
|
||||||
|
pngBase64: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
time: number
|
||||||
|
mode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CaptureMode = 'region' | 'window' | 'fullscreen'
|
||||||
|
|
||||||
|
const OVERLAY_LABEL = 'screenshot-overlay'
|
||||||
|
const EDITOR_LABEL = 'screenshot-editor'
|
||||||
|
|
||||||
|
export const useScreenshotStore = defineStore('screenshot', () => {
|
||||||
|
const capturing = ref(false)
|
||||||
|
const recent = ref<RecentCapture[]>([])
|
||||||
|
let exportUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
|
/** 计算所有显示器的逻辑像素联合矩形(用于覆盖层窗口定位/尺寸) */
|
||||||
|
async function computeVirtualLogicalRect() {
|
||||||
|
const monitors = await availableMonitors()
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
for (const m of monitors) {
|
||||||
|
const s = m.scaleFactor || 1
|
||||||
|
const lx = m.position.x / s
|
||||||
|
const ly = m.position.y / s
|
||||||
|
const lw = m.size.width / s
|
||||||
|
const lh = m.size.height / s
|
||||||
|
if (lx < minX) minX = lx
|
||||||
|
if (ly < minY) minY = ly
|
||||||
|
if (lx + lw > maxX) maxX = lx + lw
|
||||||
|
if (ly + lh > maxY) maxY = ly + lh
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX)) {
|
||||||
|
minX = 0
|
||||||
|
minY = 0
|
||||||
|
maxX = 800
|
||||||
|
maxY = 600
|
||||||
|
}
|
||||||
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动截图:region=区域选择,window=窗口拾取,fullscreen=直接进编辑器 */
|
||||||
|
async function startCapture(mode: CaptureMode) {
|
||||||
|
if (capturing.value) return
|
||||||
|
capturing.value = true
|
||||||
|
try {
|
||||||
|
// 1. 捕获虚拟屏(覆盖层尚未创建 → 不会出现在截图中)
|
||||||
|
const data = await invoke<CaptureData>('screenshot_capture_fullscreen')
|
||||||
|
|
||||||
|
if (mode === 'fullscreen') {
|
||||||
|
// 全屏截图直接送入编辑器
|
||||||
|
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
|
||||||
|
// 清掉静态全屏缓存(编辑器用 cropped/全图,不再需要原始 BGRA)
|
||||||
|
await invoke('screenshot_take_fullscreen').catch(() => {})
|
||||||
|
await openEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 区域/窗口模式:创建覆盖层选区窗口
|
||||||
|
await openOverlay(mode)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[screenshot] 捕获失败', e)
|
||||||
|
toast.error('截图启动失败:' + (e as Error).message)
|
||||||
|
// 失败时清理静态缓存
|
||||||
|
await invoke('screenshot_take_fullscreen').catch(() => {})
|
||||||
|
} finally {
|
||||||
|
capturing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openOverlay(mode: CaptureMode) {
|
||||||
|
const existing = await WebviewWindow.getByLabel(OVERLAY_LABEL)
|
||||||
|
if (existing) await existing.close()
|
||||||
|
|
||||||
|
const rect = await computeVirtualLogicalRect()
|
||||||
|
const url = `index.html#screenshot-overlay?mode=${mode}`
|
||||||
|
new WebviewWindow(OVERLAY_LABEL, {
|
||||||
|
url,
|
||||||
|
title: '截图',
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
shadow: false,
|
||||||
|
focus: true,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
const existing = await WebviewWindow.getByLabel(EDITOR_LABEL)
|
||||||
|
if (existing) {
|
||||||
|
await existing.show()
|
||||||
|
await existing.setFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
new WebviewWindow(EDITOR_LABEL, {
|
||||||
|
url: 'index.html#screenshot-editor',
|
||||||
|
title: '截图编辑器',
|
||||||
|
width: 960,
|
||||||
|
height: 720,
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
alwaysOnTop: false,
|
||||||
|
skipTaskbar: false,
|
||||||
|
resizable: true,
|
||||||
|
shadow: true,
|
||||||
|
focus: true,
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecent(pngBase64: string, width: number, height: number, mode: string) {
|
||||||
|
recent.value.unshift({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
pngBase64,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
time: Date.now(),
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
if (recent.value.length > 12) recent.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyImage(pngBase64: string) {
|
||||||
|
await invoke('screenshot_copy_image', { pngBase64 })
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveImage(pngBase64: string) {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog')
|
||||||
|
const ts = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, '-')
|
||||||
|
.slice(0, 19)
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `screenshot_${ts}.png`,
|
||||||
|
filters: [{ name: 'PNG', extensions: ['png'] }],
|
||||||
|
})
|
||||||
|
if (!path) return
|
||||||
|
await invoke('screenshot_save_png', { pngBase64, path })
|
||||||
|
toast.success('已保存到文件')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听编辑器导出事件(主窗口记录历史 + 提示) */
|
||||||
|
async function initExportListener() {
|
||||||
|
if (exportUnlisten) return
|
||||||
|
exportUnlisten = await listen<{
|
||||||
|
pngBase64: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>('screenshot-exported', (e) => {
|
||||||
|
addRecent(e.payload.pngBase64, e.payload.width, e.payload.height, 'edited')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyExportListener() {
|
||||||
|
if (exportUnlisten) {
|
||||||
|
exportUnlisten()
|
||||||
|
exportUnlisten = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capturing,
|
||||||
|
recent,
|
||||||
|
startCapture,
|
||||||
|
openEditor,
|
||||||
|
addRecent,
|
||||||
|
copyImage,
|
||||||
|
saveImage,
|
||||||
|
initExportListener,
|
||||||
|
destroyExportListener,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user