截图模块初始化
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user