|
| 1 | +/** |
| 2 | + * CaptureOverlayPanel — 系统级覆盖层截图面板(v0.12.0 M3,交互债)。 |
| 3 | + * |
| 4 | + * @ai-context: 应用内 letterbox 框选(4K 屏缩到 73% 丢细节 + 三步操作)替代—— |
| 5 | + * 覆盖层窗口全屏透明显示当前帧(1:1 原始像素,鼠标坐标经 scale |
| 6 | + * 换算回图像坐标),拖拽框选 → 确认 → overlay_submit_capture(后端 |
| 7 | + * 裁剪 PNG 回传主窗口);Esc → overlay_cancel。窗口不持久,截完即销毁。 |
| 8 | + * @ai-context: 自动边缘检测候选(微信截图对标)YAGNI 不预排——仅拖拽框选。 |
| 9 | + */ |
| 10 | +import { useEffect, useRef, useState } from "react"; |
| 11 | +import { invoke } from "@tauri-apps/api/core"; |
| 12 | +import { convertFileSrc } from "@tauri-apps/api/core"; |
| 13 | + |
| 14 | +interface Sel { |
| 15 | + x1: number; |
| 16 | + y1: number; |
| 17 | + x2: number; |
| 18 | + y2: number; |
| 19 | +} |
| 20 | + |
| 21 | +export default function CaptureOverlayPanel() { |
| 22 | + const [src, setSrc] = useState<string | null>(null); |
| 23 | + const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); |
| 24 | + const [sel, setSel] = useState<Sel | null>(null); |
| 25 | + const dragging = useRef(false); |
| 26 | + const imgRef = useRef<HTMLImageElement>(null); |
| 27 | + const scaleRef = useRef<{ x: number; y: number }>({ x: 1, y: 1 }); |
| 28 | + |
| 29 | + useEffect(() => { |
| 30 | + void invoke<string | null>("overlay_get_image") |
| 31 | + .then((p) => { |
| 32 | + if (p) setSrc(convertFileSrc(p)); |
| 33 | + }) |
| 34 | + .catch(() => undefined); |
| 35 | + const onKey = (e: KeyboardEvent) => { |
| 36 | + if (e.key === "Escape") void invoke("overlay_cancel").catch(() => undefined); |
| 37 | + if (e.key === "Enter" && sel) void submit(); |
| 38 | + }; |
| 39 | + window.addEventListener("keydown", onKey); |
| 40 | + return () => window.removeEventListener("keydown", onKey); |
| 41 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 42 | + }, [sel]); |
| 43 | + |
| 44 | + const onLoad = () => { |
| 45 | + const img = imgRef.current; |
| 46 | + if (!img) return; |
| 47 | + setNatural({ w: img.naturalWidth, h: img.naturalHeight }); |
| 48 | + // 1:1 换算:图像坐标 = 客户端坐标 ×(自然尺寸/显示尺寸)——DPI/多屏下依旧准确 |
| 49 | + scaleRef.current = { |
| 50 | + x: img.naturalWidth / Math.max(1, img.clientWidth), |
| 51 | + y: img.naturalHeight / Math.max(1, img.clientHeight), |
| 52 | + }; |
| 53 | + }; |
| 54 | + |
| 55 | + const toImageRect = (s: Sel) => { |
| 56 | + const img = imgRef.current; |
| 57 | + if (!img || !natural) return null; |
| 58 | + const box = img.getBoundingClientRect(); |
| 59 | + const sx = Math.min(s.x1, s.x2); |
| 60 | + const sy = Math.min(s.y1, s.y2); |
| 61 | + const ex = Math.max(s.x1, s.x2); |
| 62 | + const ey = Math.max(s.y1, s.y2); |
| 63 | + const x = Math.round((sx - box.left) * scaleRef.current.x); |
| 64 | + const y = Math.round((sy - box.top) * scaleRef.current.y); |
| 65 | + const w = Math.round((ex - sx) * scaleRef.current.x); |
| 66 | + const h = Math.round((ey - sy) * scaleRef.current.y); |
| 67 | + if (w < 4 || h < 4) return null; |
| 68 | + return { x: Math.max(0, x), y: Math.max(0, y), w: Math.min(w, natural.w - x), h: Math.min(h, natural.h - y) }; |
| 69 | + }; |
| 70 | + |
| 71 | + const submit = () => { |
| 72 | + if (!sel) return; |
| 73 | + const r = toImageRect(sel); |
| 74 | + if (!r) return; |
| 75 | + void invoke("overlay_submit_capture", { rect: r }).catch(() => undefined); |
| 76 | + }; |
| 77 | + |
| 78 | + const onMouseDown = (e: React.MouseEvent) => { |
| 79 | + dragging.current = true; |
| 80 | + setSel({ x1: e.clientX, y1: e.clientY, x2: e.clientX, y2: e.clientY }); |
| 81 | + }; |
| 82 | + const onMouseMove = (e: React.MouseEvent) => { |
| 83 | + if (!dragging.current || !sel) return; |
| 84 | + setSel({ ...sel, x2: e.clientX, y2: e.clientY }); |
| 85 | + }; |
| 86 | + const onMouseUp = () => { |
| 87 | + dragging.current = false; |
| 88 | + }; |
| 89 | + |
| 90 | + const selBox = |
| 91 | + sel && Math.abs(sel.x2 - sel.x1) > 3 && Math.abs(sel.y2 - sel.y1) > 3 |
| 92 | + ? { |
| 93 | + left: Math.min(sel.x1, sel.x2), |
| 94 | + top: Math.min(sel.y1, sel.y2), |
| 95 | + width: Math.abs(sel.x2 - sel.x1), |
| 96 | + height: Math.abs(sel.y2 - sel.y1), |
| 97 | + } |
| 98 | + : null; |
| 99 | + |
| 100 | + return ( |
| 101 | + <div |
| 102 | + style={{ position: "fixed", inset: 0, background: "#000", cursor: "crosshair", overflow: "hidden", userSelect: "none" }} |
| 103 | + onMouseDown={onMouseDown} |
| 104 | + onMouseMove={onMouseMove} |
| 105 | + onMouseUp={onMouseUp} |
| 106 | + > |
| 107 | + {src && ( |
| 108 | + <img |
| 109 | + ref={imgRef} |
| 110 | + src={src} |
| 111 | + onLoad={onLoad} |
| 112 | + alt="待框选截图" |
| 113 | + draggable={false} |
| 114 | + style={{ position: "absolute", left: 0, top: 0, width: "100vw", imageRendering: "pixelated" }} |
| 115 | + /> |
| 116 | + )} |
| 117 | + {/* 框选遮罩:选中区域高亮,其余压暗 */} |
| 118 | + {selBox && ( |
| 119 | + <div |
| 120 | + style={{ |
| 121 | + position: "absolute", |
| 122 | + left: selBox.left - 1, |
| 123 | + top: selBox.top - 1, |
| 124 | + width: selBox.width + 2, |
| 125 | + height: selBox.height + 2, |
| 126 | + border: "2px solid #14b8a6", |
| 127 | + background: "rgba(20,184,166,0.15)", |
| 128 | + boxShadow: "0 0 0 9999px rgba(0,0,0,0.45)", |
| 129 | + }} |
| 130 | + /> |
| 131 | + )} |
| 132 | + {/* 操作条 */} |
| 133 | + <div style={{ position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", display: "flex", gap: 8, zIndex: 10 }}> |
| 134 | + <button |
| 135 | + style={{ padding: "8px 18px", borderRadius: 6, border: "none", background: "#0d9488", color: "#fff", fontWeight: 600, cursor: "pointer" }} |
| 136 | + disabled={!selBox} |
| 137 | + onClick={submit} |
| 138 | + > |
| 139 | + 确认截图(Enter) |
| 140 | + </button> |
| 141 | + <button |
| 142 | + style={{ padding: "8px 18px", borderRadius: 6, border: "1px solid #d1d5db", background: "#fff", cursor: "pointer" }} |
| 143 | + onClick={() => void invoke("overlay_cancel").catch(() => undefined)} |
| 144 | + > |
| 145 | + 取消(Esc) |
| 146 | + </button> |
| 147 | + <span style={{ alignSelf: "center", fontSize: 12, color: "#e5e7eb" }}> |
| 148 | + {natural ? `${natural.w}×${natural.h} 像素 · 1:1` : "加载截图…"} |
| 149 | + </span> |
| 150 | + </div> |
| 151 | + </div> |
| 152 | + ); |
| 153 | +} |
0 commit comments