截图模块初始化

This commit is contained in:
zhongluofeng
2026-07-31 18:31:13 +08:00
parent 66575c6166
commit 89e5b7bed5
7 changed files with 2082 additions and 0 deletions
+653
View File
@@ -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>