feat-bootstrap-Snaplet-Windows-screenshot-tool
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
src-tauri/target
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
@@ -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 适配
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]}}
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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 |
|
After Width: | Height: | Size: 796 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
snaplet_lib::run();
|
||||||
|
}
|
||||||
@@ -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" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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; } }
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
});
|
||||||