feat-bootstrap-Snaplet-Windows-screenshot-tool

This commit is contained in:
leefer
2026-08-25 21:04:14 +08:00
commit d8038d292d
78 changed files with 14835 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
src-tauri/target
*.log
.DS_Store
.idea
.vscode
+39
View File
@@ -0,0 +1,39 @@
# Snaplet
轻量、快速、顺手的桌面截图工具。当前优先支持 Windows,并为 macOS 保留相同的捕获接口。
## 第一版能力
- `Alt + Shift + A` 全局快捷键呼出截图
- 主显示器区域框选
- 复制截图到剪贴板或保存为 PNG
- 系统托盘驻留,关闭设置窗口后继续后台运行
- 可选开机自启动
- 自定义截图快捷键
## 本地开发
需要 Node.js、Rust stable 和 Windows WebView2。
```powershell
npm install
npm run tauri dev
```
在 Windows 上还需要 Visual Studio Build Tools 的“使用 C++ 的桌面开发”工作负载。当前开发机安装在 `C:\BuildTools`,可用以下方式生成发布包:
```cmd
call C:\BuildTools\Common7\Tools\VsDevCmd.bat -arch=x64
set PATH=C:\Users\vm15\.cargo\bin;%PATH%
npm run tauri build
```
生成结果位于 `src-tauri/target/release/bundle`
## 近期计划
- 多显示器虚拟画布与跨屏框选
- 标注工具:矩形、箭头、画笔、文字、马赛克
- 窗口识别和放大镜
- 截图历史与贴图
- macOS ScreenCaptureKit 适配
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f8fa" />
<title>Snaplet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2203
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "snaplet",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-autostart": "^2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.8.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.5"
}
}
+6781
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "snaplet"
version = "0.1.0"
description = "A lightweight cross-platform screenshot tool"
authors = ["Snaplet"]
edition = "2021"
[lib]
name = "snaplet_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-autostart = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
image = "0.25"
xcap = "0.7"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = ["Win32_UI_WindowsAndMessaging"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Snaplet desktop permissions",
"windows": ["main", "capture"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"autostart:default",
"clipboard-manager:allow-write-image",
"dialog:allow-save",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Snaplet desktop permissions","local":true,"windows":["main","capture"],"permissions":["core:default","core:window:allow-close","core:window:allow-hide","core:window:allow-show","autostart:default","clipboard-manager:allow-write-image","dialog:allow-save","global-shortcut:allow-register","global-shortcut:allow-unregister","global-shortcut:allow-is-registered"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="82" y1="62" x2="426" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#8377FF"/>
<stop offset="1" stop-color="#5849D4"/>
</linearGradient>
<filter id="shadow" x="80" y="114" width="352" height="302" filterUnits="userSpaceOnUse">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#2F268E" flood-opacity="0.35"/>
</filter>
</defs>
<rect x="28" y="28" width="456" height="456" rx="120" fill="url(#bg)"/>
<path d="M128 176H184L207 142H305L328 176H384C406.1 176 424 193.9 424 216V350C424 372.1 406.1 390 384 390H128C105.9 390 88 372.1 88 350V216C88 193.9 105.9 176 128 176Z" fill="white" fill-opacity="0.18" filter="url(#shadow)"/>
<path d="M128 176H184L207 142H305L328 176H384C406.1 176 424 193.9 424 216V350C424 372.1 406.1 390 384 390H128C105.9 390 88 372.1 88 350V216C88 193.9 105.9 176 128 176Z" stroke="white" stroke-width="24" stroke-linejoin="round"/>
<circle cx="256" cy="279" r="70" stroke="white" stroke-width="24"/>
<circle cx="256" cy="279" r="26" fill="white"/>
<circle cx="366" cy="226" r="13" fill="#BDB6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+202
View File
@@ -0,0 +1,202 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use image::{DynamicImage, ImageFormat, RgbaImage};
use serde::{Deserialize, Serialize};
use std::{io::Cursor, sync::Mutex};
use tauri::{
image::Image,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
AppHandle, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindowBuilder,
};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use xcap::Monitor;
struct RuntimeState {
shortcut: Mutex<String>,
last_capture: Mutex<Option<CaptureCache>>,
}
struct CaptureCache {
image: RgbaImage,
x: i32,
y: i32,
scale_factor: f64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CapturePayload {
data_url: String,
x: i32,
y: i32,
width: u32,
height: u32,
scale_factor: f64,
}
#[derive(Deserialize)]
struct Rect {
x: f64,
y: f64,
width: f64,
height: f64,
}
fn rgba_to_png(image: &RgbaImage) -> Result<Vec<u8>, String> {
let mut bytes = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image.clone())
.write_to(&mut bytes, ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(bytes.into_inner())
}
#[tauri::command]
fn capture_primary_screen(state: tauri::State<RuntimeState>) -> Result<CapturePayload, String> {
let guard = state.last_capture.lock().map_err(|_| "截图状态不可用")?;
let cached = guard.as_ref().ok_or_else(|| "没有准备好的截图".to_string())?;
let (width, height) = cached.image.dimensions();
let png = rgba_to_png(&cached.image)?;
Ok(CapturePayload {
data_url: format!("data:image/png;base64,{}", STANDARD.encode(png)),
x: cached.x,
y: cached.y,
width,
height,
scale_factor: cached.scale_factor,
})
}
fn prepare_primary_capture(app: &AppHandle) -> Result<(), String> {
let monitor = Monitor::all()
.map_err(|e| e.to_string())?
.into_iter()
.find(|m| m.is_primary().unwrap_or(false))
.ok_or_else(|| "未找到主显示器".to_string())?;
let image = monitor.capture_image().map_err(|e| e.to_string())?;
let cache = CaptureCache {
image,
x: monitor.x().unwrap_or(0),
y: monitor.y().unwrap_or(0),
scale_factor: monitor.scale_factor().unwrap_or(1.0) as f64,
};
let state = app.state::<RuntimeState>();
*state.last_capture.lock().map_err(|_| "截图状态不可用")? = Some(cache);
Ok(())
}
#[tauri::command]
fn start_capture(app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("capture") {
window.close().map_err(|e| e.to_string())?;
}
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
// Capture before creating the overlay. Capturing after the overlay appears
// records the white WebView surface and can leave an unusable topmost window.
std::thread::sleep(std::time::Duration::from_millis(80));
prepare_primary_capture(&app)?;
let monitor = app.primary_monitor().map_err(|e| e.to_string())?.ok_or("未找到主显示器")?;
let position = monitor.position();
let size = monitor.size();
let window = WebviewWindowBuilder::new(&app, "capture", WebviewUrl::App("index.html?mode=capture".into()))
.title("Snaplet Capture")
.decorations(false)
.transparent(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.position(position.x as f64, position.y as f64)
.inner_size(size.width as f64 / monitor.scale_factor(), size.height as f64 / monitor.scale_factor())
.build()
.map_err(|e| e.to_string())?;
window.set_position(PhysicalPosition::new(position.x, position.y)).map_err(|e| e.to_string())?;
window.set_size(PhysicalSize::new(size.width, size.height)).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
fn finish_capture(
app: AppHandle,
state: tauri::State<RuntimeState>,
rect: Rect,
action: String,
copy_after_capture: bool,
) -> Result<(), String> {
let guard = state.last_capture.lock().map_err(|_| "截图状态不可用")?;
let source = &guard.as_ref().ok_or("没有可用的截图")?.image;
let capture_window = app.get_webview_window("capture").ok_or("截图窗口已关闭")?;
let scale = capture_window.scale_factor().map_err(|e| e.to_string())?;
let x = (rect.x * scale).round().max(0.0) as u32;
let y = (rect.y * scale).round().max(0.0) as u32;
let width = (rect.width * scale).round().max(1.0) as u32;
let height = (rect.height * scale).round().max(1.0) as u32;
let width = width.min(source.width().saturating_sub(x));
let height = height.min(source.height().saturating_sub(y));
let cropped = image::imageops::crop_imm(source, x, y, width, height).to_image();
if action == "copy" || copy_after_capture {
app.clipboard().write_image(&Image::new_owned(cropped.as_raw().clone(), width, height)).map_err(|e| e.to_string())?;
}
if action == "save" {
let bytes = rgba_to_png(&cropped)?;
app.dialog().file().set_file_name("Snaplet.png").add_filter("PNG 图片", &["png"]).save_file(move |path| {
if let Some(path) = path { let _ = std::fs::write(path.as_path().unwrap(), bytes); }
});
}
Ok(())
}
#[tauri::command]
fn set_shortcut(app: AppHandle, state: tauri::State<RuntimeState>, shortcut: String) -> Result<(), String> {
let mut current = state.shortcut.lock().map_err(|_| "快捷键状态不可用")?;
if *current == shortcut && app.global_shortcut().is_registered(shortcut.as_str()) {
return Ok(());
}
let _ = app.global_shortcut().unregister(current.as_str());
app.global_shortcut().register(shortcut.as_str()).map_err(|e| e.to_string())?;
*current = shortcut;
Ok(())
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(|app, _, event| {
if event.state == ShortcutState::Pressed { let _ = start_capture(app.clone()); }
}).build())
.plugin(tauri_plugin_autostart::Builder::new().build())
.manage(RuntimeState { shortcut: Mutex::new("Alt+Shift+A".into()), last_capture: Mutex::new(None) })
.setup(|app| {
let show = MenuItem::with_id(app, "show", "打开设置", true, None::<&str>)?;
let capture = MenuItem::with_id(app, "capture", "开始截图", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出 Snaplet", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&capture, &show, &quit])?;
let mut tray = TrayIconBuilder::new().menu(&menu).tooltip("Snaplet 截图");
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.on_menu_event(|app, event| match event.id.as_ref() {
"capture" => { let _ = start_capture(app.clone()); }
"show" => { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } }
"quit" => app.exit(0),
_ => {}
}).build(app)?;
app.global_shortcut().register("Alt+Shift+A")?;
Ok(())
})
.on_window_event(|window, event| {
if window.label() == "main" {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); }
}
})
.invoke_handler(tauri::generate_handler![start_capture, capture_primary_screen, finish_capture, set_shortcut])
.run(tauri::generate_context!())
.expect("failed to run Snaplet");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
snaplet_lib::run();
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Snaplet",
"version": "0.1.0",
"identifier": "com.snaplet.capture",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{
"label": "main",
"title": "Snaplet",
"width": 980,
"height": 700,
"minWidth": 760,
"minHeight": 560,
"center": true,
"resizable": true
}],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "轻量、快速、顺手的截图工具",
"windows": { "webviewInstallMode": { "type": "skip" } }
}
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart";
import { Camera, Check, ChevronRight, Copy, FolderOpen, Keyboard, MonitorUp, Power, Settings2, Sparkles } from "lucide-react";
import CaptureOverlay from "./CaptureOverlay";
import type { AppSettings } from "./types";
const defaults: AppSettings = {
shortcut: "Alt+Shift+A",
launchAtLogin: false,
copyAfterCapture: true,
showCursor: false,
saveDirectory: "图片/Snaplet"
};
const isTauri = "__TAURI_INTERNALS__" in window;
export default function App() {
const overlay = new URLSearchParams(location.search).get("mode") === "capture";
const [settings, setSettings] = useState<AppSettings>(() => {
try { return { ...defaults, ...JSON.parse(localStorage.getItem("snaplet-settings") ?? "{}") }; }
catch { return defaults; }
});
const [saved, setSaved] = useState(false);
const [recordingShortcut, setRecordingShortcut] = useState(false);
const [shortcutCandidate, setShortcutCandidate] = useState("");
const [shortcutError, setShortcutError] = useState("");
useEffect(() => {
if (overlay || !isTauri) return;
isEnabled().then((value) => setSettings((s) => ({ ...s, launchAtLogin: value }))).catch(() => undefined);
invoke("set_shortcut", { shortcut: settings.shortcut }).catch(() => undefined);
}, [overlay, settings.shortcut]);
const persist = async (next: AppSettings) => {
if (isTauri && next.shortcut !== settings.shortcut) {
await invoke("set_shortcut", { shortcut: next.shortcut });
}
setSettings(next);
localStorage.setItem("snaplet-settings", JSON.stringify(next));
setSaved(true);
setTimeout(() => setSaved(false), 1200);
if (!isTauri) return;
if (next.launchAtLogin !== settings.launchAtLogin) {
await (next.launchAtLogin ? enable() : disable());
}
};
useEffect(() => {
if (!recordingShortcut) return;
const record = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") { setRecordingShortcut(false); return; }
if (["Control", "Alt", "Shift", "Meta"].includes(event.key)) return;
const modifiers = [event.ctrlKey && "Ctrl", event.altKey && "Alt", event.shiftKey && "Shift", event.metaKey && "Super"].filter(Boolean);
if (!modifiers.length) { setShortcutError("快捷键至少需要 Ctrl、Alt、Shift 或 Win 中的一个"); return; }
const special: Record<string, string> = { " ": "Space", ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right" };
const key = special[event.key] ?? (event.key.length === 1 ? event.key.toUpperCase() : event.key);
setShortcutCandidate([...modifiers, key].join("+"));
setShortcutError("");
};
addEventListener("keydown", record, true);
return () => removeEventListener("keydown", record, true);
}, [recordingShortcut]);
const confirmShortcut = async () => {
if (!shortcutCandidate) return;
try {
await persist({ ...settings, shortcut: shortcutCandidate });
setRecordingShortcut(false);
} catch (reason) {
setShortcutError(`无法注册:${String(reason)}`);
}
};
if (overlay) return <CaptureOverlay settings={settings} />;
const startCapture = () => isTauri ? invoke("start_capture") : alert("桌面版中会立即进入截图模式");
return (
<main className="settings-shell">
<aside className="sidebar">
<div className="brand"><span className="brand-mark"><Camera size={20} /></span><span>Snaplet</span></div>
<nav>
<button className="nav-item active"><Settings2 size={18} /></button>
<button className="nav-item"><Keyboard size={18} /></button>
<button className="nav-item"><Sparkles size={18} /></button>
</nav>
<div className="sidebar-foot"><span className="status-dot" /></div>
</aside>
<section className="settings-content">
<header><div><h1></h1><p></p></div><button className="capture-button" onClick={startCapture}><Camera size={17} /></button></header>
<section className="hero-card">
<div className="hero-icon"><MonitorUp /></div>
<div><h2></h2><p>使</p></div>
<kbd>{settings.shortcut.replaceAll("+", " + ")}</kbd>
</section>
<div className="section-label"></div>
<div className="card settings-list">
<SettingRow icon={<Power />} title="开机时自动启动" description="静默驻留系统托盘,不打扰工作">
<Toggle checked={settings.launchAtLogin} onChange={(v) => persist({ ...settings, launchAtLogin: v })} />
</SettingRow>
<SettingRow icon={<Copy />} title="截图后复制到剪贴板" description="完成框选后可以直接粘贴">
<Toggle checked={settings.copyAfterCapture} onChange={(v) => persist({ ...settings, copyAfterCapture: v })} />
</SettingRow>
</div>
<div className="section-label"></div>
<div className="card settings-list">
<SettingRow icon={<Keyboard />} title="截图快捷键" description="点击右侧组合键即可修改">
<button className="value-button" onClick={() => { setShortcutCandidate(""); setShortcutError(""); setRecordingShortcut(true); }}>{settings.shortcut}<ChevronRight size={15} /></button>
</SettingRow>
<SettingRow icon={<FolderOpen />} title="默认保存位置" description={settings.saveDirectory}>
<button className="value-button"><ChevronRight size={15} /></button>
</SettingRow>
</div>
<footer>{saved ? <><Check size={14} /></> : "Snaplet 0.1.0 · Windows Preview"}</footer>
</section>
{recordingShortcut && <div className="shortcut-modal-backdrop" onMouseDown={() => setRecordingShortcut(false)}>
<div className="shortcut-modal" onMouseDown={(e) => e.stopPropagation()}>
<h3></h3>
<p></p>
<div className="shortcut-recorder">{shortcutCandidate || "等待按键…"}</div>
<small className={shortcutError ? "error" : ""}>{shortcutError || "按 Esc 取消"}</small>
<div className="modal-actions"><button onClick={() => setRecordingShortcut(false)}></button><button className="confirm" disabled={!shortcutCandidate} onClick={confirmShortcut}></button></div>
</div>
</div>}
</main>
);
}
function SettingRow({ icon, title, description, children }: { icon: React.ReactNode; title: string; description: string; children: React.ReactNode }) {
return <div className="setting-row"><span className="row-icon">{icon}</span><div className="row-copy"><strong>{title}</strong><span>{description}</span></div>{children}</div>;
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (value: boolean) => void }) {
return <button aria-label="切换" className={`toggle ${checked ? "on" : ""}`} onClick={() => onChange(!checked)}><span /></button>;
}
+87
View File
@@ -0,0 +1,87 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Check, Copy, Download, MousePointer2, X } from "lucide-react";
import type { AppSettings, CapturePayload } from "./types";
type Point = { x: number; y: number };
type Rect = { x: number; y: number; width: number; height: number };
export default function CaptureOverlay({ settings }: { settings: AppSettings }) {
const [image, setImage] = useState<CapturePayload | null>(null);
const [start, setStart] = useState<Point | null>(null);
const [current, setCurrent] = useState<Point | null>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState("");
const canvas = useRef<HTMLCanvasElement>(null);
const close = useCallback(() => {
if ("__TAURI_INTERNALS__" in window) getCurrentWindow().close();
}, []);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); close(); }
};
addEventListener("keydown", key, true);
if ("__TAURI_INTERNALS__" in window) {
invoke<CapturePayload>("capture_primary_screen").then(setImage).catch((reason) => {
setError(String(reason));
setTimeout(close, 1200);
});
}
return () => removeEventListener("keydown", key, true);
}, [close]);
useEffect(() => {
if (!image || !canvas.current) return;
const img = new Image();
img.onload = () => {
const ctx = canvas.current!.getContext("2d")!;
canvas.current!.width = innerWidth * devicePixelRatio;
canvas.current!.height = innerHeight * devicePixelRatio;
ctx.drawImage(img, 0, 0, canvas.current!.width, canvas.current!.height);
requestAnimationFrame(() => {
getCurrentWindow().show().then(() => getCurrentWindow().setFocus()).catch(close);
});
};
img.onerror = () => { setError("截图画面加载失败"); setTimeout(close, 1200); };
img.src = image.dataUrl;
}, [image, close]);
const rect: Rect | null = start && current ? {
x: Math.min(start.x, current.x), y: Math.min(start.y, current.y),
width: Math.abs(current.x - start.x), height: Math.abs(current.y - start.y)
} : null;
const finish = async (action: "copy" | "save") => {
if (!rect || rect.width < 2 || rect.height < 2) return;
await invoke("finish_capture", { rect, action, copyAfterCapture: settings.copyAfterCapture });
close();
};
return (
<div className="capture-overlay"
tabIndex={-1}
onContextMenu={(e) => { e.preventDefault(); close(); }}
onPointerDown={(e) => { setStart({ x: e.clientX, y: e.clientY }); setCurrent({ x: e.clientX, y: e.clientY }); setDragging(true); }}
onPointerMove={(e) => dragging && setCurrent({ x: e.clientX, y: e.clientY })}
onPointerUp={(e) => { if (dragging) setCurrent({ x: e.clientX, y: e.clientY }); setDragging(false); }}>
<canvas ref={canvas} />
{!rect && <div className="dim-layer" />}
{rect && <>
<div className="selection" style={{ left: rect.x, top: rect.y, width: rect.width, height: rect.height }} />
<div className="size-badge" style={{ left: rect.x, top: Math.max(8, rect.y - 34) }}>{Math.round(rect.width)} × {Math.round(rect.height)}</div>
{!dragging && rect.width > 2 && <div className="capture-toolbar" style={{ left: Math.min(rect.x + rect.width, innerWidth - 196), top: Math.min(rect.y + rect.height + 10, innerHeight - 54) }} onPointerDown={(e) => e.stopPropagation()}>
<button title="取消" onClick={close}><X /></button>
<span />
<button title="保存" onClick={() => finish("save")}><Download /></button>
<button title="复制" onClick={() => finish("copy")}><Copy /></button>
<button className="primary" title="完成" onClick={() => finish("copy")}><Check /></button>
</div>}
</>}
{!rect && <div className="capture-tip"><MousePointer2 size={17} /> · Esc </div>}
{error && <div className="capture-error">{error}退</div>}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);
+56
View File
@@ -0,0 +1,56 @@
:root { font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #20232a; background: #f5f6f8; font-synthesis: none; }
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
button { font: inherit; }
.settings-shell { width: 100%; height: 100%; display: grid; grid-template-columns: 220px 1fr; background: #f7f8fa; }
.sidebar { display: flex; flex-direction: column; padding: 28px 18px 20px; background: rgba(255,255,255,.92); border-right: 1px solid #e9ebef; }
.brand { display: flex; align-items: center; gap: 11px; padding: 0 10px 30px; font-size: 18px; font-weight: 700; letter-spacing: -.3px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; color: white; border-radius: 11px; background: linear-gradient(145deg,#7667f4,#5d50db); box-shadow: 0 8px 18px #675ae244; }
nav { display: grid; gap: 7px; }
.nav-item { border: 0; border-radius: 10px; background: transparent; color: #737781; padding: 11px 12px; display: flex; align-items: center; gap: 11px; text-align: left; cursor: pointer; }
.nav-item.active { color: #5f52d9; background: #f0edff; font-weight: 600; }
.sidebar-foot { margin-top: auto; padding: 12px; font-size: 12px; color: #8a8f99; display: flex; align-items: center; gap: 8px; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #42c97a; box-shadow: 0 0 0 4px #42c97a18; }
.settings-content { overflow: auto; padding: 42px 52px 28px; }
.settings-content > header { display: flex; justify-content: space-between; align-items: center; max-width: 780px; }
h1 { margin: 0 0 8px; font-size: 27px; letter-spacing: -.7px; }
header p, .hero-card p { margin: 0; color: #8b909a; font-size: 14px; }
.capture-button { display: flex; align-items: center; gap: 8px; border: 0; color: white; background: #6558df; padding: 10px 16px; border-radius: 10px; cursor: pointer; box-shadow: 0 7px 18px #6558df35; }
.hero-card { max-width: 780px; margin-top: 32px; padding: 22px; border-radius: 16px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; color: #fff; background: linear-gradient(125deg,#6256da,#7b6be9); box-shadow: 0 14px 34px #6a5ee42c; }
.hero-card h2 { margin: 0 0 6px; font-size: 17px; }
.hero-card p { color: #e5e2ff; }
.hero-icon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 12px; background: #ffffff1d; }
kbd { padding: 10px 13px; border-radius: 9px; background: #4338a454; border: 1px solid #ffffff2b; font: 600 12px Inter, sans-serif; }
.section-label { max-width: 780px; margin: 28px 0 10px; color: #8e939d; font-size: 12px; font-weight: 600; letter-spacing: .4px; }
.card { max-width: 780px; background: white; border: 1px solid #e9ebef; border-radius: 14px; box-shadow: 0 4px 18px #30384d08; }
.setting-row { min-height: 76px; display: flex; align-items: center; padding: 15px 18px; gap: 14px; }
.setting-row + .setting-row { border-top: 1px solid #eef0f3; }
.row-icon { width: 36px; height: 36px; flex: none; display: grid; place-items: center; color: #6b5ee0; background: #f1efff; border-radius: 10px; }
.row-icon svg { width: 18px; }
.row-copy { display: grid; gap: 5px; flex: 1; }
.row-copy strong { font-size: 14px; }
.row-copy span { color: #979ba4; font-size: 12px; }
.toggle { width: 42px; height: 24px; padding: 2px; border: 0; border-radius: 20px; background: #d8dbe1; cursor: pointer; transition: .2s; }
.toggle span { display: block; width: 20px; height: 20px; border-radius: 50%; background: white; box-shadow: 0 2px 5px #0002; transition: .2s; }
.toggle.on { background: #6558df; }.toggle.on span { transform: translateX(18px); }
.value-button { display: flex; align-items: center; gap: 7px; border: 0; background: transparent; color: #626771; font-size: 12px; cursor: pointer; }
footer { max-width: 780px; min-height: 18px; display: flex; align-items: center; justify-content: center; gap: 5px; margin-top: 25px; color: #a1a5ad; font-size: 11px; }
.capture-overlay { position: fixed; inset: 0; cursor: crosshair; user-select: none; background: #191b20; }
.capture-overlay canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.dim-layer { position: absolute; inset: 0; background: rgba(4,6,10,.45); pointer-events: none; }
.selection { position: absolute; border: 1px solid #7d6dff; background: transparent; box-shadow: 0 0 0 9999px rgba(4,6,10,.42); outline: 1px solid #ffffff55; }
.selection::before { content: ""; position: absolute; inset: 0; background: inherit; }
.size-badge { position: absolute; padding: 5px 8px; border-radius: 6px; background: rgba(25,27,32,.86); color: white; font-size: 11px; }
.capture-toolbar { position: absolute; height: 44px; display: flex; align-items: center; gap: 3px; padding: 5px; border: 1px solid #ffffff20; border-radius: 11px; background: rgba(31,33,40,.94); box-shadow: 0 8px 30px #0006; backdrop-filter: blur(14px); }
.capture-toolbar button { width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 8px; color: #dfe1e7; background: transparent; cursor: pointer; }
.capture-toolbar button:hover { background: #ffffff15; }.capture-toolbar button.primary { color: white; background: #6b5de3; }
.capture-toolbar svg { width: 17px; }.capture-toolbar > span { height: 21px; width: 1px; margin: 0 3px; background: #ffffff1a; }
.capture-tip { position: absolute; left: 50%; top: 24px; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; padding: 9px 13px; color: #fff; border-radius: 8px; background: rgba(27,29,34,.82); font-size: 12px; }
.capture-error { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); padding: 12px 16px; color: #fff; border-radius: 9px; background: #c53d4e; font-size: 13px; box-shadow: 0 10px 30px #0005; }
.shortcut-modal-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; background: #17192355; backdrop-filter: blur(4px); }
.shortcut-modal { width: 380px; padding: 26px; border: 1px solid #e7e8ed; border-radius: 17px; background: #fff; box-shadow: 0 24px 70px #20243a2e; text-align: center; }
.shortcut-modal h3 { margin: 0 0 8px; font-size: 18px; }.shortcut-modal p { margin: 0 0 20px; color: #8d929c; font-size: 13px; }
.shortcut-recorder { height: 58px; display: grid; place-items: center; border: 2px solid #6b5de3; border-radius: 12px; color: #5d50d5; background: #f5f3ff; font-weight: 700; }
.shortcut-modal small { display: block; min-height: 18px; margin-top: 11px; color: #a0a4ad; }.shortcut-modal small.error { color: #c53d4e; }
.modal-actions { display: flex; justify-content: center; gap: 9px; margin-top: 18px; }.modal-actions button { border: 0; border-radius: 9px; padding: 9px 16px; cursor: pointer; }.modal-actions .confirm { color: white; background: #6558df; }.modal-actions .confirm:disabled { opacity: .45; cursor: default; }
@media (max-width: 720px) { .settings-shell { grid-template-columns: 72px 1fr; }.sidebar { padding-inline: 12px; }.brand > span:last-child,.nav-item { font-size: 0; }.nav-item { justify-content: center; }.sidebar-foot { display: none; }.settings-content { padding: 32px 25px; } }
+16
View File
@@ -0,0 +1,16 @@
export type AppSettings = {
shortcut: string;
launchAtLogin: boolean;
copyAfterCapture: boolean;
showCursor: boolean;
saveDirectory: string;
};
export type CapturePayload = {
dataUrl: string;
x: number;
y: number;
width: number;
height: number;
scaleFactor: number;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: { port: 1420, strictPort: true },
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: { target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13" }
});