Files
Thing/src/modules/screenshot/ScreenshotOverlay.vue
T

2736 lines
87 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts
import { commands } from '@/lib/bindings'
import {
Undo2, Redo2, Eraser, Copy, Save,
} from '@lucide/vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Slider } from '@/components/ui/slider'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
TOOLS, COLORS, BLOCK_SIZES, ALPHAS, HANDLES, HANDLE_HIT, DRAG_THRESHOLD,
type Phase, type ToolType, type Annotation, type DrawableAnnotation,
type Point, type Sel, type CaptureData, type WindowInfo, type HandleDir,
type RectAnno, type EllipseAnno, type ArrowAnno, type PenAnno, type TextAnno,
type MosaicAnno, type HighlightAnno, type NumberAnno,
type ScreenshotBeginPayload,
} from './types'
// ===== 窗口 / 底图 =====
const win = getCurrentWindow()
const imgEl = ref<HTMLImageElement | null>(null)
const regionEl = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const imgSrc = ref('')
const imgWidth = ref(0)
const imgHeight = ref(0)
const loading = ref(true)
const errorMsg = ref('')
let objectUrl = ''
let winOuterX = 0
let winOuterY = 0
let dpr = 1
// 响应式窗口尺寸:覆盖层从初始 800×600 被 setSize 到虚拟屏后,window.innerWidth 才更新。
// 直接在 computed 里读 window.innerWidth 不是响应式的,会导致工具栏定位用旧值(被 clamp 到左上角)。
const winW = ref(window.innerWidth)
const winH = ref(window.innerHeight)
async function refreshWinSize() {
// 多屏混合 DPI 下 Tauri 的 innerSize()/scaleFactor() 不可靠(scaleFactor 仅返回
// 主屏或窗口中心所在屏的缩放,跨屏时与实际 CSS 像素不匹配,导致工具栏被 clamp 到左上角)。
// 直接使用浏览器维护的 innerWidth/innerHeightWebView2 在 per-monitor DPI aware 下
// 正确反映 CSS 尺寸)。refreshWinSize 在 beginCapture 中于 BMP 解码后被调用,
// 此时 setSize 早已生效,innerWidth 已更新。
if (window.innerWidth > 0 && window.innerHeight > 0) {
winW.value = window.innerWidth
winH.value = window.innerHeight
}
toolbarTick.value++
}
// ===== 阶段状态 =====
const phase = ref<Phase>('pick')
const sel = ref<Sel>({ x: 0, y: 0, w: 0, h: 0 })
const dragStart = ref<Point>({ x: 0, y: 0 })
const dragTracking = ref(false)
// 窗口识别
const winHighlight = ref<{ x: number; y: number; w: number; h: number; title: string } | null>(null)
const currentHwnd = ref(0)
let pickRaf = 0
let lastPickX = -1
let lastPickY = -1
/** 拾取窗口列表(Z 序顶→底,begin 事件携带,与冻结底图同一时刻),JS 本地命中测试零 IPC */
let pickWindows: WindowInfo[] = []
/** 最近一次鼠标物理坐标(ESC 等回到 pick 阶段时按当前位置恢复高亮,无需移动鼠标) */
const lastMousePhys = { x: 0, y: 0 }
// 入场淡入(快门定格感):idle=整体透明(窗口 show 前的起点)→ fade=淡入中 → done=常态
const enterState = ref<'idle' | 'fade' | 'done'>('done')
let enterTimer: number | null = null
function resetEnterAnim() {
if (enterTimer !== null) {
clearTimeout(enterTimer)
enterTimer = null
}
enterState.value = 'idle'
}
// 移动 / 缩放
const moving = ref(false)
const moveStart = ref({ x: 0, y: 0, sx: 0, sy: 0 })
const resizeDir = ref<HandleDir | null>(null)
const resizeStart = ref({ mx: 0, my: 0, x: 0, y: 0, w: 0, h: 0 })
// ===== 标注 =====
const annotations = ref<Annotation[]>([])
const redoStack = ref<Annotation[]>([])
const draft = ref<DrawableAnnotation | null>(null)
const drawing = ref(false)
const currentTool = ref<ToolType>('rect')
const currentColor = ref('#ef4444')
const customColor = ref('#ef4444')
const colorPickerOpen = ref(false)
const lineWidthPickerOpen = ref(false)
const currentLineWidth = ref(2)
const blockSize = ref(10)
const highlightAlpha = ref(0.4)
/** 序号标注:下一个序号值(清空标注时重置为 1) */
const numberSeq = ref(1)
const textInputPos = ref<Point | null>(null)
const textInputValue = ref('')
const textInputEl = ref<HTMLTextAreaElement | null>(null)
/** 编辑已有文字标注时的索引(-1 = 新建) */
const editingTextAnnoIdx = ref(-1)
const exporting = ref(false)
let mosaicTmp: HTMLCanvasElement | null = null
/** 马赛克结果缓存:拖拽/移动其他标注时,已有马赛克标注参数不变则跳过重复像素化 */
let mosaicCache: { key: string; canvas: HTMLCanvasElement } | null = null
// 标注选中 / 拖拽 / 调整大小状态
const selectedAnnoIdx = ref(-1)
const draggingAnno = ref(false)
const resizingAnno = ref(false)
const annoResizeDir = ref<HandleDir | null>(null)
const annoDragStart = ref<{ p: Point; orig: Annotation } | null>(null)
const annoResizeStart = ref<{ p: Point; orig: Annotation } | null>(null)
let measureCtx: CanvasRenderingContext2D | null = null
/** Canvas 分层:静态层缓存已提交标注,动态层只画 draft + 选中框。
* 绘制 draft(画笔/矩形等)时 annotations 不变,只需 drawImage(静态层) + draftO(1) 合成。 */
let staticCanvas: HTMLCanvasElement | null = null
let staticDirty = true
// 工具栏实际尺寸测量(多行折叠/工具切换后尺寸变化,用于定位)
const toolbarRef = ref<HTMLDivElement | null>(null)
const toolbarTick = ref(0)
// ===== 取色器 / 放大镜 =====
type ColorFormat = 'hex' | 'rgb' | 'hsl'
const MAG_W = 160
const MAG_H = 100
const MAG_ZOOM = 10
const magCanvasRef = ref<HTMLCanvasElement | null>(null)
const magVisible = ref(false)
const magX = ref(0)
const magY = ref(0)
const cursorPhysX = ref(0)
const cursorPhysY = ref(0)
const currentPixelColor = ref({ r: 0, g: 0, b: 0 })
const colorFormat = ref<ColorFormat>('hex')
let pixelCanvas: HTMLCanvasElement | null = null
let pixelCtx: CanvasRenderingContext2D | null = null
let magRaf = 0
let lastMagEvent: MouseEvent | null = null
/** 放大镜像素网格预渲染:固定网格线只需绘制一次,每帧 drawImage 复用 */
let magGridCanvas: HTMLCanvasElement | null = null
// 常驻窗口:'screenshot-begin' 事件监听(store 捕获并定位窗口后触发)
let beginUnlisten: UnlistenFn | null = null
const beginUnlistenCleanups: Array<() => void> = []
// 底图解码完成信号:新一轮底图 decode 完成后再显示窗口(避免"旧图→loading→新图"割裂)
let imgReadyResolve: (() => void) | null = null
let imgReadyPromise: Promise<void> | null = null
function resetImgReady() {
imgReadyPromise = new Promise<void>((r) => {
imgReadyResolve = r
})
}
// ===== 派生 =====
const scale = computed(() => ({
x: imgWidth.value ? imgWidth.value / winW.value : 1,
y: imgHeight.value ? imgHeight.value / winH.value : 1,
}))
/** 选区在画布上的物理像素尺寸(标注画布大小) */
const physW = computed(() => Math.max(1, Math.round(sel.value.w * scale.value.x)))
const physH = computed(() => Math.max(1, Math.round(sel.value.h * scale.value.y)))
/** 选区对应的全屏图像物理坐标(裁剪/导出用),越界裁剪 */
const selPhys = computed(() => {
const s = sel.value
const iw = imgWidth.value || winW.value
const ih = imgHeight.value || winH.value
let x = Math.round(s.x * scale.value.x)
let y = Math.round(s.y * scale.value.y)
let w = Math.round(s.w * scale.value.x)
let h = Math.round(s.h * scale.value.y)
x = Math.max(0, Math.min(x, iw - 1))
y = Math.max(0, Math.min(y, ih - 1))
w = Math.max(1, Math.min(w, iw - x))
h = Math.max(1, Math.min(h, ih - y))
return { x, y, w, h }
})
/** 画布物理坐标 → 区域 CSS 坐标 */
const cssScale = computed(() => ({ x: sel.value.w / physW.value, y: sel.value.h / physH.value }))
const hasAnnotations = computed(() => annotations.value.length > 0)
const canUndo = computed(() => annotations.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
const fontSizePx = computed(() => currentLineWidth.value * 3 + 14)
/** 选中标注是否有可变颜色属性 */
function annoHasColor(a: Annotation): boolean {
return a.type !== 'mosaic'
}
/** 选中标注是否有粗细概念(lineWidth) */
function annoHasLineWidth(a: Annotation): boolean {
return a.type === 'rect' || a.type === 'ellipse' || a.type === 'arrow' || a.type === 'pen'
}
/**
* 颜色控件:双用途——选中标注时反映并修改其颜色,否则设置新标注默认色
*/
const effectiveColor = computed<string>({
get() {
const i = selectedAnnoIdx.value
if (i >= 0 && i < annotations.value.length) {
const a = annotations.value[i]
if (annoHasColor(a)) return (a as { color: string }).color
}
return currentColor.value
},
set(v: string) {
currentColor.value = v
const i = selectedAnnoIdx.value
if (i >= 0 && i < annotations.value.length) {
const a = annotations.value[i]
if (annoHasColor(a)) {
(a as { color: string }).color = v
markDirtyRedraw()
}
}
},
})
/**
* 粗细控件:双用途——选中标注时反映并修改其 lineWidth,否则设置新标注默认粗细
*/
const effectiveLineWidth = computed<number>({
get() {
const i = selectedAnnoIdx.value
if (i >= 0 && i < annotations.value.length) {
const a = annotations.value[i]
if (annoHasLineWidth(a)) return (a as { lineWidth: number }).lineWidth
}
return currentLineWidth.value
},
set(v: number) {
currentLineWidth.value = v
const i = selectedAnnoIdx.value
if (i >= 0 && i < annotations.value.length) {
const a = annotations.value[i]
if (annoHasLineWidth(a)) {
(a as { lineWidth: number }).lineWidth = v
markDirtyRedraw()
}
}
},
})
/** 控件是否可用(颜色/粗细),选中不支持该属性的标注时禁用 */
const colorEnabled = computed(() => {
const i = selectedAnnoIdx.value
if (i < 0 || i >= annotations.value.length) return true
return annoHasColor(annotations.value[i])
})
const lineWidthEnabled = computed(() => {
const i = selectedAnnoIdx.value
if (i < 0 || i >= annotations.value.length) return true
return annoHasLineWidth(annotations.value[i])
})
const showToolbar = computed(() => phase.value === 'selected' || phase.value === 'editing')
/** 选中标注的 bounding box(物理像素,用于手柄定位与边框绘制) */
const selectedAnnoBBox = computed(() => {
const i = selectedAnnoIdx.value
if (i < 0 || i >= annotations.value.length) return null
return annoBBox(annotations.value[i])
})
/** 选中标注是否可调整大小(pen/text/number 仅支持移动) */
const selectedAnnoResizable = computed(() => {
const i = selectedAnnoIdx.value
if (i < 0 || i >= annotations.value.length) return false
return isAnnoResizable(annotations.value[i])
})
const cursorClass = computed(() => {
if (phase.value === 'editing') return currentTool.value === 'text' ? 'cursor-text' : 'cursor-crosshair'
if (phase.value === 'pick' || phase.value === 'drawing') return 'cursor-crosshair'
return ''
})
const regionCursor = computed(() => {
if (phase.value === 'editing') {
// 拖拽/调整标注大小时显示移动光标
if (draggingAnno.value || resizingAnno.value) return 'cursor-move'
return currentTool.value === 'text' ? 'cursor-text' : 'cursor-crosshair'
}
return 'cursor-move'
})
const hint = computed(() => {
if (loading.value) return '正在准备截图…'
if (errorMsg.value) return errorMsg.value
switch (phase.value) {
case 'pick': return '移动鼠标识别窗口 · 点击选中窗口 · 长按拖动自由选区 · Shift 切换色值格式 · C 复制色值退出 · Esc 取消'
case 'drawing': return '松开鼠标完成选区'
case 'selected': return '拖动移动选区 · 拖动手柄调整大小 · 选择工具开始标注 · Enter 完成 · Esc 取消'
case 'editing': return '在选区内绘制标注 · 文字工具点击已有文字可编辑 · Ctrl+Enter 完成 · Esc 退出标注'
}
})
/** 当前颜色值字符串(按 colorFormat 切换格式) */
const colorText = computed(() => {
const { r, g, b } = currentPixelColor.value
if (colorFormat.value === 'hex') {
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`.toUpperCase()
}
if (colorFormat.value === 'rgb') {
return `rgb(${r}, ${g}, ${b})`
}
// hsl
const [h, s, l] = rgbToHsl(r, g, b)
return `hsl(${h}, ${s}%, ${l}%)`
})
const cursorText = computed(() => `X:${cursorPhysX.value} Y:${cursorPhysY.value}`)
/**
* 工具栏位置:锚定选区右下角(与所有截图软件一致)。
* - 用 sel 坐标(选区 div 即按 left:sel.x 定位)作为锚点,避免 getBoundingClientRect
* 在多屏混合 DPI 下与逻辑坐标不一致导致工具栏跑到左上角。
* - 右侧/下方放不下时翻转到选区左侧/上方;仍放不下再收缩到视口内。
*/
const toolbarPos = computed(() => {
void toolbarTick.value // 依赖测量刷新
const el = toolbarRef.value
const tw = el?.offsetWidth ?? 560
const th = el?.offsetHeight ?? 44
const gap = 8
const vw = winW.value
const vh = winH.value
const s = sel.value
const right = s.x + s.w
const bottom = s.y + s.h
// 默认工具栏右边框与选区右边框对齐,放在选区下方
let left = right - tw
let top = bottom + gap
// 左侧越出选区 → 至少留 gap
if (left < s.x + gap) {
left = s.x + gap
}
// 下方放不下 → 翻到选区上方
if (top + th > vh - gap) {
top = s.y - gap - th
}
// 最终收缩到视口内
left = Math.max(gap, Math.min(left, vw - tw - gap))
top = Math.max(gap, Math.min(top, vh - th - gap))
return { left: left + 'px', top: top + 'px' }
})
// 工具栏显示/尺寸变化后重新测量定位(两行折叠、马赛克/高亮参数按钮增减等)
watch([showToolbar, currentTool, phase], async () => {
await nextTick()
toolbarTick.value++
})
// 选区移动/缩放后重新测量工具栏(折叠换行时尺寸变化,保证始终锚定选区右下角)
watch(
() => sel.value,
async () => {
await nextTick()
toolbarTick.value++
}
)
// 文字输入内容变化时自动调整 textarea 高度
watch(textInputValue, () => {
nextTick(() => autoResizeTextarea())
})
// ===== 取色器:放大镜 / 坐标 / 颜色值 =====
/** RGB → HSL(返回整数 h/s/l */
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rr = r / 255, gg = g / 255, bb = b / 255
const max = Math.max(rr, gg, bb), min = Math.min(rr, gg, bb)
let h = 0, s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case rr: h = (gg - bb) / d + (gg < bb ? 6 : 0); break
case gg: h = (bb - rr) / d + 2; break
case bb: h = (rr - gg) / d + 4; break
}
h /= 6
}
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)]
}
/** 确保像素读取 canvas 已从底图初始化(图片加载后创建一次,底图变更时重建) */
function ensurePixelCanvas() {
const img = imgEl.value
if (!img || !img.complete || !img.naturalWidth) return
if (!pixelCanvas) {
pixelCanvas = document.createElement('canvas')
pixelCtx = pixelCanvas.getContext('2d', { willReadFrequently: true })
}
if (pixelCanvas.width !== img.naturalWidth || pixelCanvas.height !== img.naturalHeight) {
pixelCanvas.width = img.naturalWidth
pixelCanvas.height = img.naturalHeight
pixelCtx?.drawImage(img, 0, 0)
}
}
/** 调度放大镜更新(rAF 节流,每帧最多一次) */
function scheduleMagnifier(e: MouseEvent) {
if (loading.value || errorMsg.value) {
magVisible.value = false
return
}
// editing 阶段(绘制标注)不显示放大镜,避免干扰
if (phase.value === 'editing') {
magVisible.value = false
return
}
lastMagEvent = e
if (magRaf) return
magRaf = requestAnimationFrame(() => {
magRaf = 0
if (lastMagEvent) updateMagnifier(lastMagEvent)
})
}
/** 更新放大镜:读取像素颜色 + 绘制放大视图 + 定位 */
function updateMagnifier(e: MouseEvent) {
const img = imgEl.value
if (!img || !img.complete || !img.naturalWidth) {
magVisible.value = false
return
}
ensurePixelCanvas()
if (!pixelCtx) {
magVisible.value = false
return
}
// CSS 坐标 → 物理像素坐标(裁剪到图像范围)
const px = Math.max(0, Math.min(Math.round(e.clientX * scale.value.x), img.naturalWidth - 1))
const py = Math.max(0, Math.min(Math.round(e.clientY * scale.value.y), img.naturalHeight - 1))
cursorPhysX.value = px
cursorPhysY.value = py
// 读取像素颜色
try {
const data = pixelCtx.getImageData(px, py, 1, 1).data
currentPixelColor.value = { r: data[0], g: data[1], b: data[2] }
} catch {
// 跨域或越界,忽略
}
drawMagnifier(img, px, py)
// 定位放大镜(偏移在鼠标右下方,出界则翻转)
const offset = 18
// 估算放大镜总尺寸:canvas + info(padding ~4px + 各行 ~16px*3行) + padding
const magTotalW = MAG_W + 8
const magTotalH = MAG_H + 70
let mx = e.clientX + offset
let my = e.clientY + offset
if (mx + magTotalW > winW.value) mx = e.clientX - offset - magTotalW
if (my + magTotalH > winH.value) my = e.clientY - offset - magTotalH
mx = Math.max(4, mx)
my = Math.max(4, my)
magX.value = mx
magY.value = my
magVisible.value = true
}
/** 绘制放大镜内容:放大像素 + 中心色块(填充实际颜色,非交叉点) */
function drawMagnifier(img: HTMLImageElement, physX: number, physY: number) {
const canvas = magCanvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = MAG_W
const h = MAG_H
const zoom = MAG_ZOOM
const cx = w / 2
const cy = h / 2
ctx.imageSmoothingEnabled = false
ctx.clearRect(0, 0, w, h)
// 放大绘制(以鼠标所在像素为中心)
const srcW = w / zoom
const srcH = h / zoom
const srcX = physX - srcW / 2
const srcY = physY - srcH / 2
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, w, h)
// 像素网格线(预渲染到离屏 canvas,每帧 drawImage 复用,避免重复 stroke
if (!magGridCanvas) {
magGridCanvas = document.createElement('canvas')
magGridCanvas.width = w
magGridCanvas.height = h
const gctx = magGridCanvas.getContext('2d')
if (gctx) {
gctx.strokeStyle = 'rgba(255,255,255,0.1)'
gctx.lineWidth = 1
for (let i = 0; i <= w; i += zoom) {
gctx.beginPath()
gctx.moveTo(i + 0.5, 0)
gctx.lineTo(i + 0.5, h)
gctx.stroke()
}
for (let j = 0; j <= h; j += zoom) {
gctx.beginPath()
gctx.moveTo(0, j + 0.5)
gctx.lineTo(w, j + 0.5)
gctx.stroke()
}
}
}
ctx.drawImage(magGridCanvas, 0, 0)
// 中心色块:填充实际像素颜色(大色块,非交叉点)
// 色块覆盖中心 2×2 个放大像素区域,让用户明确看到"对准的颜色"
const blockPixels = 2 // 覆盖 2×2 个源像素(放大后 20×20 CSS 像素)
const blockSize = blockPixels * zoom
const blockX = cx - blockSize / 2
const blockY = cy - blockSize / 2
const { r, g, b } = currentPixelColor.value
ctx.fillStyle = `rgb(${r},${g},${b})`
ctx.fillRect(blockX, blockY, blockSize, blockSize)
// 黑白双层边框确保在任何背景上可见
ctx.strokeStyle = '#000'
ctx.lineWidth = 2
ctx.strokeRect(blockX - 1, blockY - 1, blockSize + 2, blockSize + 2)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 1
ctx.strokeRect(blockX - 2, blockY - 2, blockSize + 4, blockSize + 4)
}
/** 复制颜色值并退出截图 */
async function copyColorAndExit() {
const colorStr = colorText.value
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(colorStr)
}
} catch { /* 忽略剪贴板错误 */ }
cancel()
}
/** 区域底图:全屏图按负偏移定位,形成"窗口"裁剪效果,随选区移动/缩放即时同步 */
const regionBgStyle = computed(() => ({
backgroundImage: `url(${imgSrc.value})`,
backgroundSize: `${winW.value}px ${winH.value}px`,
backgroundPosition: `-${sel.value.x}px -${sel.value.y}px`,
}))
const textInputStyle = computed(() => {
const p = textInputPos.value
if (!p) return {}
// 编辑已有文字时使用原标注的颜色和字号,保持视觉一致
const ei = editingTextAnnoIdx.value
const anno = ei >= 0 ? annotations.value[ei] : null
const color = anno && anno.type === 'text' ? anno.color : currentColor.value
const fs = anno && anno.type === 'text' ? anno.fontSize : fontSizePx.value
return {
left: p.x * cssScale.value.x + 'px',
top: p.y * cssScale.value.y + 'px',
color,
fontSize: Math.max(10, fs * cssScale.value.x) + 'px',
}
})
// ===== 选区几何 =====
function normRect(a: Point, b: Point): Sel {
return {
x: Math.min(a.x, b.x),
y: Math.min(a.y, b.y),
w: Math.abs(b.x - a.x),
h: Math.abs(b.y - a.y),
}
}
function clampSel() {
const s = sel.value
const min = 8
let w = Math.min(Math.max(min, s.w), winW.value)
let h = Math.min(Math.max(min, s.h), winH.value)
const x = Math.min(Math.max(0, s.x), winW.value - w)
const y = Math.min(Math.max(0, s.y), winH.value - h)
sel.value = { x, y, w, h }
}
function handleAnchor(dir: HandleDir, s: Sel): Point {
switch (dir) {
case 'nw': return { x: 0, y: 0 }
case 'n': return { x: s.w / 2, y: 0 }
case 'ne': return { x: s.w, y: 0 }
case 'e': return { x: s.w, y: s.h / 2 }
case 'se': return { x: s.w, y: s.h }
case 's': return { x: s.w / 2, y: s.h }
case 'sw': return { x: 0, y: s.h }
case 'w': return { x: 0, y: s.h / 2 }
}
}
function handleStyle(dir: HandleDir) {
const s = sel.value
const a = handleAnchor(dir, s)
return { left: s.x + a.x + 'px', top: s.y + a.y + 'px' }
}
function hitHandle(e: MouseEvent): HandleDir | null {
const r = regionEl.value?.getBoundingClientRect()
if (!r) return null
const lx = e.clientX - r.left
const ly = e.clientY - r.top
for (const dir of HANDLES) {
const a = handleAnchor(dir, sel.value)
if (Math.abs(lx - a.x) <= HANDLE_HIT && Math.abs(ly - a.y) <= HANDLE_HIT) return dir
}
return null
}
/** 标注调整手柄位置(CSS 坐标,在 region-view 内定位) */
function annoHandleStyle(dir: HandleDir) {
const bb = selectedAnnoBBox.value
if (!bb) return { display: 'none' }
const sx = cssScale.value.x
const sy = cssScale.value.y
let cx = 0
let cy = 0
switch (dir) {
case 'nw': cx = bb.x; cy = bb.y; break
case 'n': cx = bb.x + bb.w / 2; cy = bb.y; break
case 'ne': cx = bb.x + bb.w; cy = bb.y; break
case 'e': cx = bb.x + bb.w; cy = bb.y + bb.h / 2; break
case 'se': cx = bb.x + bb.w; cy = bb.y + bb.h; break
case 's': cx = bb.x + bb.w / 2; cy = bb.y + bb.h; break
case 'sw': cx = bb.x; cy = bb.y + bb.h; break
case 'w': cx = bb.x; cy = bb.y + bb.h / 2; break
}
return { left: cx * sx + 'px', top: cy * sy + 'px' }
}
/** 标注手柄按下:进入调整大小模式 */
function onAnnoHandleMouseDown(dir: HandleDir, e: MouseEvent) {
if (phase.value !== 'editing' || selectedAnnoIdx.value < 0) return
e.preventDefault()
const p = canvasPoint(e)
const cur = annotations.value[selectedAnnoIdx.value]
if (!cur) return
resizingAnno.value = true
annoResizeDir.value = dir
annoResizeStart.value = { p: { ...p }, orig: cloneAnno(cur) }
}
/** 事件坐标 → 标注画布(物理)坐标 */
function canvasPoint(e: MouseEvent): Point {
const r = regionEl.value?.getBoundingClientRect()
if (!r) return { x: 0, y: 0 }
const x = (e.clientX - r.left) * (physW.value / Math.max(1, r.width))
const y = (e.clientY - r.top) * (physH.value / Math.max(1, r.height))
return {
x: Math.min(physW.value, Math.max(0, x)),
y: Math.min(physH.value, Math.max(0, y)),
}
}
// ===== 窗口识别(本地命中测试,零 IPC) =====
/** 在缓存列表中命中测试(与 Rust 拾取同语义:rect 包含点,取 Z 序最顶的第一个命中) */
function hitTestWindow(physX: number, physY: number): WindowInfo | null {
for (const info of pickWindows) {
const r = info.rect
if (physX >= r.x && physX < r.x + r.width && physY >= r.y && physY < r.y + r.height) {
return info
}
}
return null
}
/** 应用窗口命中结果到高亮状态 */
function applyWindowInfo(info: WindowInfo | null) {
if (info) {
// 高亮框用 DWM 视觉边界,避免 GetWindowRect 包含隐形缩放边框导致大一圈
const r = info.visualRect ?? info.rect
winHighlight.value = {
x: (r.x - winOuterX) / dpr,
y: (r.y - winOuterY) / dpr,
w: r.width / dpr,
h: r.height / dpr,
title: info.title,
}
currentHwnd.value = info.hwnd
} else {
winHighlight.value = null
currentHwnd.value = 0
}
}
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(() => {
pickRaf = 0
if (phase.value !== 'pick') return
// 用最新位置命中(一帧内多次移动时取最后一次,不丢帧)
applyWindowInfo(hitTestWindow(lastPickX, lastPickY))
})
}
/** 按物理坐标拾取窗口并立即应用高亮(同步,无 IPC) */
function pickWindowAt(physX: number, physY: number) {
lastPickX = physX
lastPickY = physY
applyWindowInfo(hitTestWindow(physX, physY))
}
// ===== 选区流转 =====
function resetAnnotations() {
annotations.value = []
redoStack.value = []
draft.value = null
drawing.value = false
textInputPos.value = null
editingTextAnnoIdx.value = -1
numberSeq.value = 1
selectedAnnoIdx.value = -1
draggingAnno.value = false
resizingAnno.value = false
annoDragStart.value = null
annoResizeStart.value = null
mosaicCache = null
staticDirty = true
}
function enterSelected() {
resetAnnotations()
winHighlight.value = null
clampSel()
phase.value = 'selected'
}
/** 回到窗口拾取阶段,并按当前鼠标位置立即恢复高亮(ESC/点击选区外时无需移动鼠标) */
function backToPick() {
dragTracking.value = false
phase.value = 'pick'
pickWindowAt(lastMousePhys.x, lastMousePhys.y)
}
/** 点击窗口 → 以窗口矩形为选区 */
function selectWindow(w: { x: number; y: number; w: number; h: number }) {
sel.value = { x: w.x, y: w.y, w: w.w, h: w.h }
enterSelected()
}
// ===== 鼠标交互 =====
function onMouseDown(e: MouseEvent) {
if (e.button !== 0 || loading.value || errorMsg.value) return
if (phase.value === 'pick') {
dragTracking.value = true
dragStart.value = { x: e.clientX, y: e.clientY }
} else if (phase.value === 'selected') {
// 点击选区外 → 取消选中,回到窗口识别(按点击位置恢复高亮)
resetAnnotations()
backToPick()
} else if (phase.value === 'editing') {
// 点击选区外 → 提交未完成的文字、取消选中并退出标注,回到选区调整
commitText()
selectedAnnoIdx.value = -1
phase.value = 'selected'
}
}
function onRegionMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (phase.value === 'selected') {
if (!hasAnnotations.value) {
const dir = hitHandle(e)
if (dir) {
resizeDir.value = dir
resizeStart.value = { mx: e.clientX, my: e.clientY, ...sel.value }
e.preventDefault()
return
}
}
moving.value = true
moveStart.value = { x: e.clientX, y: e.clientY, sx: sel.value.x, sy: sel.value.y }
} else if (phase.value === 'editing') {
// 文字工具:阻止 mousedown 默认行为,防止浏览器抢占焦点导致 input 立即失焦
if (currentTool.value === 'text') {
e.preventDefault()
}
const p = canvasPoint(e)
// 点击已有标注 → 选中并进入拖拽模式
const idx = hitTestAnno(p)
if (idx >= 0) {
// 先提交未完成的文字输入(点击别处放置/选中时,旧输入框提交)
if (textInputPos.value) commitText()
// 文字工具 + 点击文字标注:内容区→编辑,边框区→移动
if (currentTool.value === 'text' && annotations.value[idx].type === 'text') {
const zone = hitTestTextZone(annotations.value[idx] as TextAnno, p)
if (zone === 'core') {
openTextInput(p, idx)
return
}
// border → 选中并拖拽(移动),继续往下走
}
selectedAnnoIdx.value = idx
draggingAnno.value = true
annoDragStart.value = { p: { ...p }, orig: cloneAnno(annotations.value[idx]) }
redraw()
return
}
// 点击空白处 → 取消选中,开始绘制新标注
selectedAnnoIdx.value = -1
beginDraft(e)
}
}
function onHandleMouseDown(dir: HandleDir, e: MouseEvent) {
if (phase.value !== 'selected') return
resizeDir.value = dir
resizeStart.value = { mx: e.clientX, my: e.clientY, ...sel.value }
}
function onMouseMove(e: MouseEvent) {
// 追踪鼠标物理坐标(ESC 等回到 pick 阶段时按当前位置恢复窗口高亮)
lastMousePhys.x = Math.round(winOuterX + e.clientX * dpr)
lastMousePhys.y = Math.round(winOuterY + e.clientY * dpr)
// 取色器放大镜更新(rAF 节流)
scheduleMagnifier(e)
if (phase.value === 'pick') {
if (dragTracking.value) {
const dx = e.clientX - dragStart.value.x
const dy = e.clientY - dragStart.value.y
if (dx * dx + dy * dy > DRAG_THRESHOLD * DRAG_THRESHOLD) {
// 长按拖动 → 自由选区
phase.value = 'drawing'
sel.value = normRect(dragStart.value, e)
return
}
}
scheduleWindowPick(e.clientX, e.clientY)
} else if (phase.value === 'drawing') {
sel.value = normRect(dragStart.value, e)
} else if (phase.value === 'selected') {
if (moving.value) {
const dx = e.clientX - moveStart.value.x
const dy = e.clientY - moveStart.value.y
sel.value = { x: moveStart.value.sx + dx, y: moveStart.value.sy + dy, w: sel.value.w, h: sel.value.h }
} else if (resizeDir.value) {
const st = resizeStart.value
let x = st.x
let y = st.y
let w = st.w
let h = st.h
const dx = e.clientX - st.mx
const dy = e.clientY - st.my
const dir = resizeDir.value
if (dir.includes('e')) w = st.w + dx
if (dir.includes('s')) h = st.h + dy
if (dir.includes('w')) { w = st.w - dx; x = st.x + dx }
if (dir.includes('n')) { h = st.h - dy; y = st.y + dy }
w = Math.max(16, w)
h = Math.max(16, h)
if (dir.includes('w')) x = st.x + st.w - w
if (dir.includes('n')) y = st.y + st.h - h
sel.value = { x, y, w, h }
}
} else if (phase.value === 'editing') {
if (resizingAnno.value && annoResizeStart.value) {
// 调整选中标注大小
const p = canvasPoint(e)
const dx = p.x - annoResizeStart.value.p.x
const dy = p.y - annoResizeStart.value.p.y
const cur = annotations.value[selectedAnnoIdx.value]
if (cur && annoResizeDir.value) {
applyResize(cur, annoResizeStart.value.orig, annoResizeDir.value, dx, dy)
markDirtyRedraw()
}
} else if (draggingAnno.value && annoDragStart.value) {
// 移动选中标注
const p = canvasPoint(e)
const dx = p.x - annoDragStart.value.p.x
const dy = p.y - annoDragStart.value.p.y
const cur = annotations.value[selectedAnnoIdx.value]
if (cur) {
applyMove(cur, annoDragStart.value.orig, dx, dy)
markDirtyRedraw()
}
} else if (drawing.value && draft.value) {
const p = canvasPoint(e)
const d = draft.value
if (d.type === 'pen') {
d.points.push(p)
} else {
d.x2 = p.x
d.y2 = p.y
}
scheduleRedraw()
}
}
}
function onMouseUp(e: MouseEvent) {
if (e.button !== 0) return
if (phase.value === 'pick') {
if (dragTracking.value) {
dragTracking.value = false
// 点击(未拖动):选中悬停窗口
if (winHighlight.value) selectWindow(winHighlight.value)
}
} else if (phase.value === 'drawing') {
if (sel.value.w < 8 || sel.value.h < 8) {
phase.value = 'pick'
} else {
enterSelected()
}
} else if (phase.value === 'selected') {
moving.value = false
resizeDir.value = null
clampSel()
} else if (phase.value === 'editing') {
if (resizingAnno.value) {
// 调整大小结束
resizingAnno.value = false
annoResizeDir.value = null
annoResizeStart.value = null
} else if (draggingAnno.value) {
// 拖拽结束(点击未移动时保持选中)
draggingAnno.value = false
annoDragStart.value = null
} else {
// 绘制完成 → 提交 draft
commitDraft()
}
}
}
function onKeyDown(e: KeyboardEvent) {
if (textInputPos.value) {
// Ctrl+Enter → 提交文字;普通 Enter → 换行(textarea 默认行为)
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
commitText()
} else if (e.key === 'Escape') {
e.stopPropagation()
cancelText()
}
return
}
// Shift 切换颜色格式(hex → rgb → hsl → hex
if (e.key === 'Shift' && !e.repeat) {
const formats: ColorFormat[] = ['hex', 'rgb', 'hsl']
const idx = formats.indexOf(colorFormat.value)
colorFormat.value = formats[(idx + 1) % formats.length]
return
}
// C 复制当前像素颜色值并退出截图
if ((e.key === 'c' || e.key === 'C') && magVisible.value) {
e.preventDefault()
void copyColorAndExit()
return
}
// Delete/Backspace 删除选中的标注(editing 阶段)
if ((e.key === 'Delete' || e.key === 'Backspace') && phase.value === 'editing' && selectedAnnoIdx.value >= 0) {
e.preventDefault()
const a = annotations.value[selectedAnnoIdx.value]
if (a && a.type === 'number') numberSeq.value = Math.max(1, numberSeq.value - 1)
annotations.value.splice(selectedAnnoIdx.value, 1)
selectedAnnoIdx.value = -1
redoStack.value = []
markDirtyRedraw()
return
}
if (e.key === 'Escape') {
if (phase.value === 'editing') {
if (selectedAnnoIdx.value >= 0) {
// 选中状态下 Esc 先取消选中
selectedAnnoIdx.value = -1
redraw()
} else {
// 无选中时 Esc 直接退出截图(不退回框选界面)
cancel()
}
} else if (phase.value === 'selected') {
resetAnnotations()
backToPick()
} else if (phase.value === 'drawing') {
backToPick()
} else {
cancel()
}
} else if (e.key === 'Enter') {
if (showToolbar.value) void finish()
}
}
function onRegionDblClick() {
if (textInputPos.value) return
void finish()
}
// ===== 标注绘制 =====
function getMosaicTmp(): HTMLCanvasElement {
if (!mosaicTmp) mosaicTmp = document.createElement('canvas')
return mosaicTmp
}
/** rAF 节流重绘:鼠标移动事件频率远高于 60fps,合并到下一帧统一重绘 */
let redrawRaf = 0
function scheduleRedraw() {
if (!redrawRaf) {
redrawRaf = requestAnimationFrame(() => {
redrawRaf = 0
redraw()
})
}
}
/** 标记静态层脏 + rAF 节流重绘(annotations 增删/修改后调用) */
function markDirtyRedraw() {
staticDirty = true
scheduleRedraw()
}
/** 重绘静态层(已提交标注缓存)。annotations 增删/修改/选区变化时标记脏。 */
function redrawStatic() {
if (!staticCanvas) staticCanvas = document.createElement('canvas')
const pw = physW.value
const ph = physH.value
if (staticCanvas.width !== pw || staticCanvas.height !== ph) {
staticCanvas.width = pw
staticCanvas.height = ph
}
const sctx = staticCanvas.getContext('2d')
if (!sctx) return
sctx.clearRect(0, 0, pw, ph)
for (let i = 0; i < annotations.value.length; i++) {
// 正在编辑的文字标注由 textarea 显示,跳过绘制避免重影
if (i === editingTextAnnoIdx.value) continue
drawAnnotation(sctx, annotations.value[i])
}
staticDirty = false
}
function redraw() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
// 画布尺寸变化时(选区调整)静态层也要重绘
if (canvas.width !== physW.value || canvas.height !== physH.value) {
canvas.width = physW.value
canvas.height = physH.value
staticDirty = true
}
if (staticDirty) redrawStatic()
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 静态层合成(O(1) drawImage,跳过遍历所有标注)
if (staticCanvas) ctx.drawImage(staticCanvas, 0, 0)
// 动态层:draft
if (draft.value) {
if (draft.value.type === 'mosaic') {
drawMosaicDraft(ctx, draft.value)
} else {
drawAnnotation(ctx, draft.value)
}
}
// 选中标注:绘制虚线边框(手柄用 CSS DOM 定位,便于点击)
const si = selectedAnnoIdx.value
if (si >= 0 && si < annotations.value.length) {
drawSelectionBox(ctx, annoBBox(annotations.value[si]))
}
}
function drawAnnotation(ctx: CanvasRenderingContext2D, anno: Annotation) {
switch (anno.type) {
case 'rect': drawRect(ctx, anno); break
case 'ellipse': drawEllipse(ctx, anno); break
case 'arrow': drawArrow(ctx, anno); break
case 'pen': drawPen(ctx, anno); break
case 'text': drawText(ctx, anno); break
case 'number': drawNumber(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 drawEllipse(ctx: CanvasRenderingContext2D, a: EllipseAnno) {
const cx = (a.x1 + a.x2) / 2
const cy = (a.y1 + a.y2) / 2
const rx = Math.max(1, Math.abs(a.x2 - a.x1) / 2)
const ry = Math.max(1, Math.abs(a.y2 - a.y1) / 2)
ctx.strokeStyle = a.color
ctx.lineWidth = a.lineWidth
ctx.beginPath()
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
ctx.stroke()
}
/** 序号标注:实心圆 + 白色数字(步骤说明常用) */
function drawNumber(ctx: CanvasRenderingContext2D, a: NumberAnno) {
const r = a.fontSize / 2
ctx.beginPath()
ctx.arc(a.x, a.y, r, 0, Math.PI * 2)
ctx.fillStyle = a.color
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = `bold ${a.fontSize * 0.8}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(a.n), a.x, a.y + 1)
ctx.textAlign = 'start'
ctx.textBaseline = 'alphabetic'
}
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'
const lines = a.text.split('\n')
lines.forEach((line, i) => {
ctx.fillText(line, a.x, a.y + i * a.fontSize)
})
}
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
}
/**
* 马赛克:从全屏底图取区域像素做块平均。
* 缓存:标注参数+选区位置不变时直接 drawImage 缓存结果,避免拖拽其他标注时重复像素化。
*/
function applyMosaic(ctx: CanvasRenderingContext2D, a: MosaicAnno) {
const img = imgEl.value
if (!img) return
const block = Math.max(1, a.blockSize)
const x1 = Math.min(a.x1, a.x2)
const y1 = Math.min(a.y1, a.y2)
const w = Math.abs(a.x2 - a.x1)
const h = Math.abs(a.y2 - a.y1)
if (w < 1 || h < 1) return
const sp = selPhys.value
const srcX = sp.x + x1
const srcY = sp.y + y1
const cw = Math.min(Math.ceil(w), img.naturalWidth - srcX)
const ch = Math.min(Math.ceil(h), img.naturalHeight - srcY)
if (cw < 1 || ch < 1) return
// 缓存命中检查
const cacheKey = `${a.x1},${a.y1},${a.x2},${a.y2},${block},${sp.x},${sp.y}`
if (mosaicCache?.key === cacheKey) {
ctx.drawImage(mosaicCache.canvas, Math.floor(x1), Math.floor(y1))
return
}
const tmp = getMosaicTmp()
tmp.width = cw
tmp.height = ch
const tctx = tmp.getContext('2d')
if (!tctx) return
tctx.drawImage(img, srcX, srcY, cw, ch, 0, 0, cw, ch)
const id = tctx.getImageData(0, 0, cw, ch)
const data = id.data
for (let by = 0; by < ch; by += block) {
for (let bx = 0; bx < cw; bx += block) {
let r = 0
let g = 0
let b = 0
let count = 0
const maxJ = Math.min(by + block, ch)
const maxI = Math.min(bx + block, cw)
for (let j = by; j < maxJ; j++) {
for (let i = bx; i < maxI; i++) {
const idx = (j * cw + i) * 4
r += data[idx]
g += data[idx + 1]
b += data[idx + 2]
count++
}
}
if (count === 0) continue
r = Math.round(r / count)
g = Math.round(g / count)
b = Math.round(b / count)
for (let j = by; j < maxJ; j++) {
for (let i = bx; i < maxI; i++) {
const idx = (j * cw + i) * 4
data[idx] = r
data[idx + 1] = g
data[idx + 2] = b
}
}
}
}
tctx.putImageData(id, 0, 0)
// 缓存结果
mosaicCache = { key: cacheKey, canvas: tmp }
ctx.drawImage(tmp, Math.floor(x1), Math.floor(y1))
}
/** 马赛克拖拽中的虚线框预览(避免每帧像素化开销) */
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([])
}
// ===== 标注选中 / 编辑 =====
/** 获取文字测量用的 canvas 上下文(惰性创建一次复用) */
function getMeasureCtx(): CanvasRenderingContext2D | null {
if (!measureCtx) {
const c = document.createElement('canvas')
measureCtx = c.getContext('2d')
}
return measureCtx
}
/** 估算文字宽高(用 ctx.measureText 测宽,多行取最宽行,高 = 行数 × fontSize
* 结果缓存:相同 text+fontSize 的测量结果不变,避免 hitTest / annoBBox 重复调用开销 */
const measureCache = new Map<string, { w: number; h: number }>()
function measureText(text: string, fontSize: number): { w: number; h: number } {
const key = `${fontSize}\0${text}`
const cached = measureCache.get(key)
if (cached) return cached
const ctx = getMeasureCtx()
const lines = text.split('\n')
let result: { w: number; h: number }
if (!ctx) {
const maxLen = Math.max(1, ...lines.map(l => l.length))
result = { w: fontSize * maxLen * 0.6, h: fontSize * lines.length }
} else {
ctx.font = `${fontSize}px sans-serif`
let maxW = 0
for (const line of lines) {
const m = ctx.measureText(line)
if (m.width > maxW) maxW = m.width
}
result = { w: Math.ceil(maxW), h: fontSize * lines.length }
}
measureCache.set(key, result)
return result
}
/** 标注 bounding box(物理像素坐标) */
function annoBBox(anno: Annotation): { x: number; y: number; w: number; h: number } {
switch (anno.type) {
case 'rect':
case 'ellipse':
case 'mosaic':
case 'highlight':
case 'arrow': {
const x = Math.min(anno.x1, anno.x2)
const y = Math.min(anno.y1, anno.y2)
return { x, y, w: Math.abs(anno.x2 - anno.x1), h: Math.abs(anno.y2 - anno.y1) }
}
case 'pen': {
if (anno.points.length === 0) return { x: 0, y: 0, w: 0, h: 0 }
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const pt of anno.points) {
if (pt.x < minX) minX = pt.x
if (pt.y < minY) minY = pt.y
if (pt.x > maxX) maxX = pt.x
if (pt.y > maxY) maxY = pt.y
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
case 'text': {
const { w, h } = measureText(anno.text, anno.fontSize)
return { x: anno.x, y: anno.y, w, h }
}
case 'number': {
const r = anno.fontSize / 2
return { x: anno.x - r, y: anno.y - r, w: anno.fontSize, h: anno.fontSize }
}
}
}
/** 点到线段的距离 */
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x
const dy = b.y - a.y
const len2 = dx * dx + dy * dy
if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y)
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
t = Math.max(0, Math.min(1, t))
const cx = a.x + t * dx
const cy = a.y + t * dy
return Math.hypot(p.x - cx, p.y - cy)
}
/** 文字标注区域检测:core=文字内容区(编辑),border=边框区(移动)null=未命中 */
function hitTestTextZone(anno: TextAnno, p: Point): 'core' | 'border' | null {
const { w, h } = measureText(anno.text, anno.fontSize)
const x1 = anno.x, y1 = anno.y
const x2 = anno.x + w, y2 = anno.y + h
const pad = 8
if (p.x < x1 - pad || p.x > x2 + pad || p.y < y1 - pad || p.y > y2 + pad) return null
if (p.x >= x1 && p.x <= x2 && p.y >= y1 && p.y <= y2) return 'core'
return 'border'
}
/** 点击测试单个标注 */
function hitTestSingle(anno: Annotation, p: Point): boolean {
switch (anno.type) {
case 'rect':
case 'ellipse':
case 'mosaic':
case 'highlight': {
const x1 = Math.min(anno.x1, anno.x2)
const y1 = Math.min(anno.y1, anno.y2)
const x2 = Math.max(anno.x1, anno.x2)
const y2 = Math.max(anno.y1, anno.y2)
const pad = Math.max(4, anno.type === 'rect' || anno.type === 'ellipse' ? anno.lineWidth : 2)
return p.x >= x1 - pad && p.x <= x2 + pad && p.y >= y1 - pad && p.y <= y2 + pad
}
case 'arrow':
return distToSegment(p, { x: anno.x1, y: anno.y1 }, { x: anno.x2, y: anno.y2 }) <= Math.max(6, anno.lineWidth)
case 'pen': {
for (let i = 1; i < anno.points.length; i++) {
if (distToSegment(p, anno.points[i - 1], anno.points[i]) <= Math.max(6, anno.lineWidth)) return true
}
return false
}
case 'text': {
return hitTestTextZone(anno, p) !== null
}
case 'number': {
const r = anno.fontSize / 2
const dx = p.x - anno.x
const dy = p.y - anno.y
return dx * dx + dy * dy <= r * r
}
}
}
/** 点击测试:返回命中的标注索引(-1 未命中),从后往前测试(后画的在上层) */
function hitTestAnno(p: Point): number {
for (let i = annotations.value.length - 1; i >= 0; i--) {
if (hitTestSingle(annotations.value[i], p)) return i
}
return -1
}
/** 标注是否可调整大小(pen/text/number 仅支持移动) */
function isAnnoResizable(anno: Annotation): boolean {
return anno.type === 'rect' || anno.type === 'ellipse' || anno.type === 'arrow' || anno.type === 'mosaic' || anno.type === 'highlight'
}
/** 深拷贝标注(用于拖拽/调整大小时保存原始快照) */
function cloneAnno(a: Annotation): Annotation {
if (a.type === 'pen') return { ...a, points: a.points.map(pt => ({ ...pt })) }
return { ...a }
}
/** 移动标注:基于原始快照 + 偏移量更新目标标注坐标 */
function applyMove(target: Annotation, orig: Annotation, dx: number, dy: number) {
switch (target.type) {
case 'rect':
case 'ellipse':
case 'arrow':
case 'mosaic':
case 'highlight': {
const o = orig as typeof target
target.x1 = o.x1 + dx
target.y1 = o.y1 + dy
target.x2 = o.x2 + dx
target.y2 = o.y2 + dy
break
}
case 'pen': {
const o = orig as typeof target
target.points = o.points.map(pt => ({ x: pt.x + dx, y: pt.y + dy }))
break
}
case 'text':
case 'number': {
const o = orig as typeof target
target.x = o.x + dx
target.y = o.y + dy
break
}
}
}
/** 调整标注大小:根据手柄方向更新对应坐标(仅对可调整大小的标注有效) */
function applyResize(target: Annotation, orig: Annotation, dir: HandleDir, dx: number, dy: number) {
switch (target.type) {
case 'rect':
case 'ellipse':
case 'arrow':
case 'mosaic':
case 'highlight': {
const o = orig as typeof target
let x1 = o.x1
let y1 = o.y1
let x2 = o.x2
let y2 = o.y2
if (dir.includes('e')) x2 = o.x2 + dx
if (dir.includes('s')) y2 = o.y2 + dy
if (dir.includes('w')) x1 = o.x1 + dx
if (dir.includes('n')) y1 = o.y1 + dy
target.x1 = x1
target.y1 = y1
target.x2 = x2
target.y2 = y2
break
}
}
}
/** 绘制选中标注的虚线边框 */
function drawSelectionBox(ctx: CanvasRenderingContext2D, bb: { x: number; y: number; w: number; h: number }) {
const pad = 2
ctx.strokeStyle = '#3b82f6'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.strokeRect(bb.x - pad, bb.y - pad, bb.w + pad * 2, bb.h + pad * 2)
ctx.setLineDash([])
}
// ===== 标注交互 =====
function beginDraft(e: MouseEvent) {
if (phase.value !== 'editing') return
const p = canvasPoint(e)
if (currentTool.value === 'text') {
openTextInput(p)
return
}
if (currentTool.value === 'number') {
// 点击即放置序号(自增)
annotations.value.push({
type: 'number',
x: p.x,
y: p.y,
n: numberSeq.value,
color: currentColor.value,
fontSize: Math.max(18, currentLineWidth.value * 4 + 12),
})
numberSeq.value++
redoStack.value = []
// 自动选中新放置的序号
selectedAnnoIdx.value = annotations.value.length - 1
markDirtyRedraw()
return
}
drawing.value = true
const color = currentColor.value
const lw = currentLineWidth.value
switch (currentTool.value) {
case 'rect':
draft.value = { type: 'rect', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color, lineWidth: lw }
break
case 'ellipse':
draft.value = { type: 'ellipse', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color, lineWidth: lw }
break
case 'arrow':
draft.value = { type: 'arrow', x1: p.x, y1: p.y, x2: p.x, y2: p.y, color, lineWidth: lw }
break
case 'pen':
draft.value = { type: 'pen', points: [p], color, lineWidth: lw }
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, alpha: highlightAlpha.value }
break
}
redraw()
}
function commitDraft() {
if (!drawing.value || !draft.value) return
drawing.value = false
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 = []
// 自动选中新创建的标注,便于立即调整位置和大小
selectedAnnoIdx.value = annotations.value.length - 1
} else {
selectedAnnoIdx.value = -1
}
draft.value = null
markDirtyRedraw()
}
/** 自定义颜色选择:更新 customColor 并设为当前颜色/选中标注颜色 */
function onCustomColorPick(color: string) {
customColor.value = color
effectiveColor.value = color
}
function openTextInput(p: Point, editIdx: number = -1) {
// 先提交当前未完成的文字(点击别处放置新文字时,旧输入框会失焦)
commitText()
if (editIdx >= 0 && editIdx < annotations.value.length) {
const anno = annotations.value[editIdx]
if (anno && anno.type === 'text') {
editingTextAnnoIdx.value = editIdx
textInputPos.value = { x: anno.x, y: anno.y }
textInputValue.value = anno.text
markDirtyRedraw() // 隐藏原文字,由 textarea 显示
nextTick(() => {
textInputEl.value?.focus()
autoResizeTextarea()
})
return
}
}
textInputPos.value = p
textInputValue.value = ''
nextTick(() => {
textInputEl.value?.focus()
autoResizeTextarea()
})
}
function commitText() {
const pos = textInputPos.value
if (!pos) return
textInputPos.value = null
const value = textInputValue.value.trim()
textInputValue.value = ''
const editIdx = editingTextAnnoIdx.value
editingTextAnnoIdx.value = -1
// 编辑已有文字标注
if (editIdx >= 0 && editIdx < annotations.value.length) {
const anno = annotations.value[editIdx]
if (anno && anno.type === 'text') {
if (value) {
anno.text = value
selectedAnnoIdx.value = editIdx
} else {
// 空文字 → 删除
annotations.value.splice(editIdx, 1)
selectedAnnoIdx.value = -1
redoStack.value = []
}
markDirtyRedraw()
}
return
}
// 新建文字标注
if (value) {
annotations.value.push({
type: 'text',
x: pos.x,
y: pos.y,
text: value,
color: currentColor.value,
fontSize: fontSizePx.value,
})
redoStack.value = []
// 自动选中新创建的文字
selectedAnnoIdx.value = annotations.value.length - 1
markDirtyRedraw()
}
}
function cancelText() {
textInputPos.value = null
textInputValue.value = ''
editingTextAnnoIdx.value = -1
markDirtyRedraw() // 恢复显示原文字标注
}
/** 自动调整 textarea 高度以适应内容 */
function autoResizeTextarea() {
const el = textInputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
// ===== 撤销 / 重做 / 清空 =====
function undo() {
if (annotations.value.length === 0) return
const a = annotations.value.pop()!
redoStack.value.push(a)
if (a.type === 'number') numberSeq.value = Math.max(1, numberSeq.value - 1)
// 撤销后选中可能失效,重置
if (selectedAnnoIdx.value >= annotations.value.length) selectedAnnoIdx.value = -1
markDirtyRedraw()
}
function redo() {
if (redoStack.value.length === 0) return
const a = redoStack.value.pop()!
annotations.value.push(a)
if (a.type === 'number') numberSeq.value = a.n + 1
selectedAnnoIdx.value = -1
markDirtyRedraw()
}
function clearAnnotations() {
annotations.value = []
redoStack.value = []
numberSeq.value = 1
selectedAnnoIdx.value = -1
markDirtyRedraw()
}
// ===== 工具切换 =====
function onSelectTool(t: ToolType) {
if (phase.value === 'editing' && currentTool.value === t) {
// 再次点击当前工具 → 退出标注,回到选区调整
phase.value = 'selected'
// 清除选中状态并重绘,避免选中边框残留
selectedAnnoIdx.value = -1
redraw()
return
}
currentTool.value = t
phase.value = 'editing'
}
// ===== 导出 =====
interface ExportOut {
b64: string
w: number
h: number
}
/** 无标注:Rust 直接裁剪原始像素并编码 PNG */
async function cropFromStored(): Promise<ExportOut | null> {
const sp = selPhys.value
if (sp.w < 2 || sp.h < 2) return null
try {
const data = await invoke<CaptureData>('screenshot_crop_stored', {
x: sp.x,
y: sp.y,
w: sp.w,
h: sp.h,
})
return { b64: data.pngBase64, w: data.width, h: data.height }
} catch (e) {
console.error('[screenshot] 裁剪失败', e)
return null
}
}
/** 有标注:canvas 合成(底图裁剪 + 标注),返回合成后的 canvas */
function composeCanvas(): HTMLCanvasElement | null {
const sp = selPhys.value
if (sp.w < 2 || sp.h < 2) return null
const img = imgEl.value
if (!img) return null
const canvas = document.createElement('canvas')
canvas.width = sp.w
canvas.height = sp.h
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(img, sp.x, sp.y, sp.w, sp.h, 0, 0, sp.w, sp.h)
for (const anno of annotations.value) {
drawAnnotation(ctx, anno)
}
return canvas
}
/** 纯导出(保存用,无副作用):canvas → toDataURL base64 */
async function exportBase64(): Promise<ExportOut | null> {
if (annotations.value.length === 0) return cropFromStored()
const canvas = composeCanvas()
if (!canvas) return null
const sp = selPhys.value
return { b64: canvas.toDataURL('image/png').split(',')[1] ?? '', w: sp.w, h: sp.h }
}
async function finish() {
if (exporting.value) return
exporting.value = true
magVisible.value = false
// 先隐藏窗口,立即反馈;导出(裁剪+剪贴板)在后台进行
await win.hide().catch(() => {})
try {
let out: ExportOut | null = null
if (annotations.value.length === 0) {
// 快速路径:Rust 一次调用完成 裁剪+剪贴板(省一次大 base64 往返 + PNG 解码)
const sp = selPhys.value
if (sp.w >= 2 && sp.h >= 2) {
try {
const data = await invoke<CaptureData>('screenshot_crop_copy_stored', {
x: sp.x,
y: sp.y,
w: sp.w,
h: sp.h,
})
out = { b64: data.pngBase64, w: data.width, h: data.height }
} catch (e) {
console.error('[screenshot] 快速复制失败,回退普通路径', e)
}
}
if (!out) {
out = await cropFromStored()
if (out) {
await commands.screenshotCopyImage(out.b64).catch((e) =>
console.error('[screenshot] 复制失败', e)
)
}
}
} else {
// 有标注:canvas 合成 → getImageData 直传 raw RGBA → Rust 一次完成 剪贴板+PNG 编码
// 省去 toDataURL(PNG 编码+base64) + Rust base64 解码 + PNG 解码 三次往返
// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素
const canvas = composeCanvas()
if (canvas) {
const sp = selPhys.value
const ctx = canvas.getContext('2d')
if (ctx) {
const imageData = ctx.getImageData(0, 0, sp.w, sp.h)
const header = new Uint8Array(8)
new DataView(header.buffer).setInt32(0, sp.w, true)
new DataView(header.buffer).setInt32(4, sp.h, true)
const combined = new Uint8Array(8 + imageData.data.length)
combined.set(header, 0)
combined.set(imageData.data, 8)
try {
const data = await invoke<CaptureData>('screenshot_compose_copy', combined.buffer)
out = { b64: data.pngBase64, w: data.width, h: data.height }
} catch (e) {
console.error('[screenshot] raw 复制失败,回退 base64 路径', e)
out = await exportBase64()
if (out) {
await commands.screenshotCopyImage(out.b64).catch((e2) =>
console.error('[screenshot] 复制失败', e2)
)
}
}
}
}
}
if (!out) return
// 附带框选区域的屏幕逻辑坐标(DIP):窗口逻辑位置 + 选区图像坐标。
// 截图图像按虚拟屏 DIP 捕获,坐标按 DIP 传递,贴图窗口按 DIP 布局即可 1:1 还原
const spPos = selPhys.value
await emit(EVENTS.screenshotExported, {
pngBase64: out.b64,
width: out.w,
height: out.h,
posX: Math.round(winOuterX / dpr + spPos.x),
posY: Math.round(winOuterY / dpr + spPos.y),
})
} catch (e) {
console.error('[screenshot] 完成失败', e)
} finally {
exporting.value = false
}
}
async function doSave() {
if (exporting.value) return
exporting.value = true
try {
const out = await exportBase64()
if (!out) return
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) return
await commands.screenshotSavePng(out.b64, path)
const spPos = selPhys.value
await emit(EVENTS.screenshotExported, {
pngBase64: out.b64,
width: out.w,
height: out.h,
posX: Math.round(winOuterX / dpr + spPos.x),
posY: Math.round(winOuterY / dpr + spPos.y),
})
await win.hide().catch(() => {})
} catch (e) {
console.error('[screenshot] 保存失败', e)
} finally {
exporting.value = false
}
}
function cancel() {
// 常驻窗口:只隐藏不销毁;不清理静态捕获(下一轮 capture 覆盖写,避免与新捕获竞态)
magVisible.value = false
void win.hide().catch(() => {})
}
// ===== 生命周期 =====
function onImgLoad() {
if (imgEl.value) {
imgWidth.value = imgEl.value.naturalWidth
imgHeight.value = imgEl.value.naturalHeight
}
imgReadyResolve?.()
imgReadyResolve = null
}
/**
* 同步主界面主题:覆盖层是独立窗口(无 pinia),通过 localStorage 读取主应用设置,
* 切换 .dark 类使全局 CSS 变量(--card / --foreground 等)与主界面一致。
*/
function applyTheme() {
const root = document.documentElement
let theme = 'system'
try {
const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
if (raw) {
const s = JSON.parse(raw)
theme = s.theme ?? 'system'
}
} catch { /* 忽略 */ }
let isDark: boolean
if (theme === 'dark') isDark = true
else if (theme === 'light') isDark = false
else isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', isDark)
// 跟随系统时监听变化
return window.matchMedia('(prefers-color-scheme: dark)')
}
/** 主应用 localStorage 变化(主题切换)时同步主题 */
function onStorageChange(e: StorageEvent) {
if (e.key === STORAGE_KEYS.appSettings) {
applyTheme()
}
}
onMounted(async () => {
// mousemove/mouseup 由模板 @mousemove/@mouseup 绑定在 overlay-root 上(position:fixed inset:0 覆盖全屏),
// 不再在 window 上重复绑定,避免每次鼠标移动处理两次
window.addEventListener('keydown', onKeyDown)
// 应用主界面主题(工具栏等使用主题变量)
const mq = applyTheme()
const onThemeChange = () => applyTheme()
mq.addEventListener('change', onThemeChange)
beginUnlistenCleanups.push(() => mq.removeEventListener('change', onThemeChange))
// 监听主应用主题变化(覆盖层是常驻窗口,主界面切换主题后需同步)
window.addEventListener('storage', onStorageChange)
// 监听窗口尺寸变化(Tauri onResized 比 DOM resize 更可靠),
// 覆盖层从 800×600 被 setSize 到虚拟屏后需同步 winW/winH,否则工具栏定位用旧值
const onResize = () => void refreshWinSize()
window.addEventListener('resize', onResize)
let unlistenResized: UnlistenFn | null = null
try {
unlistenResized = await win.onResized(() => void refreshWinSize())
} catch { /* 忽略 */ }
beginUnlistenCleanups.push(() => {
window.removeEventListener('resize', onResize)
unlistenResized?.()
})
// 首次同步窗口尺寸
await refreshWinSize()
// 禁用窗口显示/隐藏过渡动画(消除进入/关闭时的缩放动画),失败静默
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// 先注册 begin 监听再通知 store 就绪,避免首轮事件丢失
beginUnlisten = await listen<ScreenshotBeginPayload>(EVENTS.screenshotBegin, (e) => {
void beginCapture(e.payload)
})
await emit(EVENTS.screenshotOverlayReady)
})
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
async function beginCapture(payload?: ScreenshotBeginPayload) {
try {
// 入场动画准备:整体置为透明起点(窗口 show 后从透明淡入,底图/遮罩/高亮同步出现)
resetEnterAnim()
// 每次截图开始时同步主界面主题(覆盖层是常驻窗口,主界面可能已切换主题)
applyTheme()
// 重置到"拾取"初始状态
phase.value = 'pick'
winHighlight.value = null
currentHwnd.value = 0
sel.value = { x: 0, y: 0, w: 0, h: 0 }
dragTracking.value = false
moving.value = false
resizeDir.value = null
resetAnnotations()
errorMsg.value = ''
magVisible.value = false
// 底图变更后像素 canvas 需重建
pixelCanvas = null
pixelCtx = null
// 缓存窗口拾取列表与捕获时刻光标(pick 阶段鼠标移动零 IPC 命中测试)
pickWindows = payload?.windows ?? []
lastMousePhys.x = payload?.cursorX ?? 0
lastMousePhys.y = payload?.cursorY ?? 0
lastPickX = -1
lastPickY = -1
// 先清除上一轮底图,避免"旧图一闪";窗口隐藏期间完成新底图的传输与解码
imgSrc.value = ''
resetImgReady()
loading.value = true
// 并行:BMP 传输(raw IPC → ArrayBufferBlob URL 免编码直接显示)+ 窗口外框位置
const [buf, pos] = await Promise.all([
invoke<ArrayBuffer>('screenshot_get_fullscreen_bmp'),
win.outerPosition(),
])
if (objectUrl) URL.revokeObjectURL(objectUrl)
const blob = new Blob([buf], { type: 'image/bmp' })
objectUrl = URL.createObjectURL(blob)
imgSrc.value = objectUrl
// 等底图解码完成后再显示窗口:打开即完整清晰,无暗屏穿透/旧图割裂(2s 超时兜底)
await Promise.race([
imgReadyPromise,
new Promise<void>((r) => setTimeout(r, 2000)),
])
loading.value = false
winOuterX = pos.x
winOuterY = pos.y
dpr = window.devicePixelRatio || 1
// 窗口显示前同步尺寸:store 的 setSize 可能在 webview ready 前就已调用,
// onResized 回调可能错过,此处主动刷新确保 winW/winH 正确
await refreshWinSize()
// 显示前完成初始窗口命中(捕获时刻光标处):首帧即"窗口高亮",无"全屏遮罩→高亮"闪烁
pickWindowAt(lastMousePhys.x, lastMousePhys.y)
// 一次 IPC 完成 show + setFocus(比两次 JS 调用少一次往返)
await commands.screenshotShowOverlay(WINDOWS.screenshotOverlay)
// 窗口已显示(首帧即透明起点):整体从透明淡入,定格画面+遮罩+高亮同步浮现,不突兀
enterState.value = 'fade'
enterTimer = window.setTimeout(() => {
enterState.value = 'done'
enterTimer = null
}, 220)
// 窗口显示后再次刷新尺寸:隐藏窗口的 innerWidth/innerHeight 可能仍是初始 800×600
// show() 后 WebView2 才更新 DOM 尺寸,需重新读取确保工具栏定位正确
await refreshWinSize()
// 再延迟一帧重读(WebView2 尺寸更新可能滞后一帧)
requestAnimationFrame(() => void refreshWinSize())
} catch (e) {
errorMsg.value = (e as Error).message
loading.value = false
// 错误提示需立即可见:跳过淡入(idle 全透明会看不到错误信息)
if (enterTimer !== null) {
clearTimeout(enterTimer)
enterTimer = null
}
enterState.value = 'done'
// 出错时也显示窗口,展示错误信息(带关闭按钮)
await win.show().catch(() => {})
}
}
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('storage', onStorageChange)
beginUnlisten?.()
beginUnlisten = null
beginUnlistenCleanups.forEach(fn => fn())
beginUnlistenCleanups.length = 0
if (pickRaf) cancelAnimationFrame(pickRaf)
if (magRaf) cancelAnimationFrame(magRaf)
if (redrawRaf) cancelAnimationFrame(redrawRaf)
if (enterTimer !== null) clearTimeout(enterTimer)
staticCanvas = null
magGridCanvas = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
// 覆盖层窗口真正销毁(应用退出)时释放 Rust 静态中的全屏原始像素
void commands.screenshotClearFullscreen().catch(() => {})
})
</script>
<template>
<div
class="overlay-root"
:class="[cursorClass, enterState !== 'done' ? `overlay-enter-${enterState}` : '']"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
>
<TooltipProvider>
<!-- 冻结的屏幕底图 -->
<img
v-if="imgSrc"
ref="imgEl"
:src="imgSrc"
class="bg-img"
draggable="false"
@load="onImgLoad"
/>
<!-- 加载中 / 错误 -->
<div v-if="loading" class="hint">正在准备截图</div>
<div v-else-if="errorMsg" class="hint error">
{{ errorMsg }}
<button class="btn" @click="cancel">关闭</button>
</div>
<!-- pick 阶段全屏淡遮罩无悬停窗口时有悬停窗口时由窗口框 box-shadow 暗化框外 -->
<div v-if="phase === 'pick' && !winHighlight" class="pick-mask" />
<!-- 窗口识别高亮 -->
<div
v-if="phase === 'pick' && winHighlight"
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>
<!-- 拖拽中的选区 -->
<div
v-if="phase === 'drawing'"
class="selection"
:style="{
left: sel.x + 'px',
top: sel.y + 'px',
width: sel.w + 'px',
height: sel.h + 'px',
}"
>
<span class="size-tag">{{ selPhys.w }} × {{ selPhys.h }}</span>
</div>
<!-- 选中区域底图窗口 + 标注画布 + 蓝色粗边框 -->
<div
v-if="showToolbar"
ref="regionEl"
class="region-view"
:class="regionCursor"
:style="{
left: sel.x + 'px',
top: sel.y + 'px',
width: sel.w + 'px',
height: sel.h + 'px',
}"
@mousedown.stop="onRegionMouseDown"
@dblclick.stop="onRegionDblClick"
>
<div class="region-bg" :style="regionBgStyle" />
<canvas ref="canvasRef" class="anno-canvas" :width="physW" :height="physH" />
<div class="region-border" />
<!-- 文字输入textarea 支持多行Enter 换行Ctrl+Enter 提交 -->
<textarea
v-if="textInputPos && phase === 'editing'"
id="screenshot-text-input"
ref="textInputEl"
v-model="textInputValue"
class="text-input"
:style="textInputStyle"
placeholder="输入文字 (Ctrl+Enter 完成)"
rows="1"
@keydown.enter.ctrl.prevent="commitText"
@keydown.esc.stop.prevent="cancelText"
@blur="commitText"
/>
<!-- 选中标注的调整手柄仅可调整大小的标注显示 -->
<template v-if="phase === 'editing' && selectedAnnoIdx >= 0 && selectedAnnoResizable">
<div
v-for="dir in HANDLES"
:key="'anno-h-' + dir"
class="handle anno-handle"
:class="'handle-' + dir"
:style="annoHandleStyle(dir)"
@mousedown.stop.prevent="onAnnoHandleMouseDown(dir, $event)"
/>
</template>
</div>
<!-- 缩放手柄已有标注时禁用缩放仅可移动 -->
<template v-if="phase === 'selected' && !hasAnnotations">
<div
v-for="dir in HANDLES"
:key="dir"
class="handle"
:class="'handle-' + dir"
:style="handleStyle(dir)"
@mousedown.stop.prevent="onHandleMouseDown(dir, $event)"
/>
</template>
<!-- 编辑栏单行布局工具栏右边框与选区右边框对齐复制/保存置于末尾 -->
<div v-if="showToolbar" ref="toolbarRef" class="toolbar" :style="toolbarPos" @mousedown.stop>
<!-- 标注工具 -->
<div class="tb-group">
<Tooltip v-for="t in TOOLS" :key="t.value">
<TooltipTrigger as-child>
<button
class="tb-icon"
:class="{ active: phase === 'editing' && currentTool === t.value }"
@click="onSelectTool(t.value)"
>
<component :is="t.icon" class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t.label }}</TooltipContent>
</Tooltip>
</div>
<div class="tb-sep" />
<!-- 颜色色卡显示当前颜色点击打开选色板 -->
<Popover v-model:open="colorPickerOpen">
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button
class="color-badge"
:class="{ disabled: !colorEnabled }"
:style="{ '--swatch-color': effectiveColor }"
:disabled="!colorEnabled"
/>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>颜色</TooltipContent>
</Tooltip>
<PopoverContent class="w-auto p-3" align="start">
<div class="color-popover">
<input
type="color"
:value="customColor"
class="color-picker-input"
@input="onCustomColorPick(($event.target as HTMLInputElement).value)"
/>
<div class="color-swatches">
<Tooltip v-for="c in COLORS" :key="c">
<TooltipTrigger as-child>
<button
class="color-swatch-mini"
:class="{ active: effectiveColor === c }"
:style="{ backgroundColor: c }"
@click="effectiveColor = c"
/>
</TooltipTrigger>
<TooltipContent>{{ c }}</TooltipContent>
</Tooltip>
</div>
</div>
</PopoverContent>
</Popover>
<!-- 粗细 badge点击打开 Slider -->
<Popover v-model:open="lineWidthPickerOpen">
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<button
class="width-badge"
:class="{ disabled: !lineWidthEnabled }"
:disabled="!lineWidthEnabled"
>{{ effectiveLineWidth }}</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>粗细</TooltipContent>
</Tooltip>
<PopoverContent class="w-auto p-3" align="start">
<div class="width-popover">
<div class="width-popover-header">
<span>粗细</span>
<span class="width-popover-value">{{ effectiveLineWidth }}</span>
</div>
<Slider
:model-value="[effectiveLineWidth]"
:min="1"
:max="16"
:step="1"
:disabled="!lineWidthEnabled"
class="width-slider"
@update:model-value="(v: number[] | undefined) => { if (v && v.length) effectiveLineWidth = v[0] }"
/>
</div>
</PopoverContent>
</Popover>
<template v-if="currentTool === 'mosaic'">
<div class="tb-sep" />
<div class="tb-group">
<Tooltip v-for="b in BLOCK_SIZES" :key="b">
<TooltipTrigger as-child>
<button
class="tb-num"
:class="{ active: blockSize === b }"
@click="blockSize = b"
>{{ b }}</button>
</TooltipTrigger>
<TooltipContent>马赛克块大小</TooltipContent>
</Tooltip>
</div>
</template>
<template v-if="currentTool === 'highlight'">
<div class="tb-sep" />
<div class="tb-group">
<Tooltip v-for="a in ALPHAS" :key="a">
<TooltipTrigger as-child>
<button
class="tb-num"
:class="{ active: highlightAlpha === a }"
@click="highlightAlpha = a"
>{{ Math.round(a * 100) }}%</button>
</TooltipTrigger>
<TooltipContent>高亮透明度</TooltipContent>
</Tooltip>
</div>
</template>
<div class="tb-sep" />
<div class="tb-group">
<Tooltip>
<TooltipTrigger as-child>
<button class="tb-icon" :disabled="!canUndo" @click="undo">
<Undo2 class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="tb-icon" :disabled="!canRedo" @click="redo">
<Redo2 class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>重做</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="tb-icon" :disabled="!canUndo" @click="clearAnnotations">
<Eraser class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>清空标注</TooltipContent>
</Tooltip>
</div>
<div class="tb-sep" />
<div class="tb-size">{{ selPhys.w }} × {{ selPhys.h }}</div>
<div class="tb-sep" />
<!-- 复制 / 保存(置末) -->
<div class="tb-group">
<Tooltip>
<TooltipTrigger as-child>
<button class="tb-icon" @click="doSave">
<Save class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>保存到文件</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="tb-icon primary" @click="finish">
<Copy class="tb-svg" />
</button>
</TooltipTrigger>
<TooltipContent>复制到剪贴板 (Enter)</TooltipContent>
</Tooltip>
</div>
</div>
<!-- 取色器放大镜:放大像素 + 中心色块 + 坐标 + 颜色值 + 提示 -->
<div v-show="magVisible" class="magnifier" :style="{ left: magX + 'px', top: magY + 'px' }">
<canvas ref="magCanvasRef" :width="MAG_W" :height="MAG_H" class="mag-canvas" />
<div class="mag-info">
<div class="mag-row">
<span class="mag-coord">{{ cursorText }}</span>
<span class="mag-format">{{ colorFormat.toUpperCase() }}</span>
</div>
<div class="mag-color-row">
<span class="mag-swatch" :style="{ backgroundColor: colorText }" />
<span class="mag-color-text">{{ colorText }}</span>
</div>
<div class="mag-hint">{{ hint.split(' · ').join('\n') }}</div>
</div>
</div>
</TooltipProvider>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
/* 入场淡入:idle=透明起点(窗口 show 前已就位),fade=180ms ease-out 浮现定格画面+遮罩 */
.overlay-enter-idle {
opacity: 0;
}
.overlay-enter-fade {
opacity: 1;
transition: opacity 180ms ease-out;
}
.bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.cursor-crosshair { cursor: crosshair; }
.cursor-move { cursor: move; }
.cursor-text { cursor: text; }
/* pick 阶段全屏淡透黑遮罩(无悬停窗口时兜底) */
.pick-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 4;
}
.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);
z-index: 20;
}
.hint.error { flex-direction: column; }
.btn {
padding: 4px 12px;
background: var(--primary);
border: none;
color: var(--primary-foreground);
border-radius: 4px;
cursor: pointer;
}
/* 窗口识别:框内全透明透出原画面,box-shadow 暗化框外(形成"框内透明框外变暗" */
.win-highlight {
position: absolute;
border: 2px solid #3b82f6;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
}
.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;
}
/* 拖拽中的选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
.selection {
position: absolute;
border: 2px solid #3b82f6;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
}
.size-tag {
position: absolute;
top: -24px;
left: 0;
padding: 2px 6px;
background: #3b82f6;
color: #fff;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
}
/* 选中区域 */
.region-view {
position: absolute;
overflow: hidden;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
z-index: 6;
}
.region-bg {
position: absolute;
inset: 0;
background-repeat: no-repeat;
}
.anno-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.region-border {
position: absolute;
inset: 0;
border: 2px solid #3b82f6;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
pointer-events: none;
}
/* 缩放手柄 */
.handle {
position: absolute;
width: 9px;
height: 9px;
background: #fff;
border: 2px solid #3b82f6;
border-radius: 2px;
transform: translate(-50%, -50%);
z-index: 8;
}
.handle-nw, .handle-se { cursor: nwse-resize; }
.handle-ne, .handle-sw { cursor: nesw-resize; }
.handle-n, .handle-s { cursor: ns-resize; }
.handle-e, .handle-w { cursor: ew-resize; }
/* 编辑栏:跟随主界面主题(card / foreground / border / accent 等主题变量) */
.toolbar {
position: absolute;
display: flex;
flex-wrap: nowrap; /* 单行:复制/保存为图标,分辨率置末 */
align-items: center;
gap: 2px;
padding: 5px 6px;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
z-index: 30;
}
.tb-group { display: flex; align-items: center; gap: 2px; }
.tb-sep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
flex: 0 0 auto;
}
.tb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
color: var(--card-foreground);
border-radius: 5px;
cursor: pointer;
}
.tb-icon:hover:not(:disabled):not(.active) { background: var(--accent); color: var(--accent-foreground); }
.tb-icon.active { background: var(--primary); color: var(--primary-foreground); }
.tb-icon.primary {
background: var(--primary);
color: var(--primary-foreground);
}
.tb-icon.primary:hover { filter: brightness(0.95); }
.tb-icon:disabled { opacity: 0.35; cursor: default; }
.tb-svg { width: 15px; height: 15px; }
/* 颜色 badge:显示当前颜色的小方块 */
.color-badge {
width: 22px;
height: 22px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0;
cursor: pointer;
background-color: var(--swatch-color, #ef4444);
transition: transform 0.1s;
}
.color-badge:hover:not(:disabled) { transform: scale(1.08); }
.color-badge.disabled,
.color-badge:disabled { opacity: 0.35; cursor: default; pointer-events: none; }
/* 颜色 Popover 内容 */
.color-popover {
display: flex;
flex-direction: column;
gap: 8px;
}
.color-picker-input {
width: 180px;
height: 120px;
border: none;
cursor: pointer;
padding: 0;
background: transparent;
}
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.color-swatch-mini {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid var(--border);
padding: 0;
cursor: pointer;
transition: transform 0.1s;
}
.color-swatch-mini:hover { transform: scale(1.15); }
.color-swatch-mini.active {
box-shadow: 0 0 0 2px var(--ring);
transform: scale(1.15);
}
/* 粗细 badge */
.width-badge {
min-width: 24px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--border);
background: var(--card);
color: var(--card-foreground);
font-size: 12px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.width-badge:hover:not(:disabled) { background: var(--accent); color: var(--accent-foreground); }
.width-badge.disabled,
.width-badge:disabled { opacity: 0.35; cursor: default; pointer-events: none; }
/* 粗细 Popover 内容 */
.width-popover {
display: flex;
flex-direction: column;
gap: 8px;
width: 200px;
}
.width-popover-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--muted-foreground);
}
.width-popover-value {
color: var(--card-foreground);
font-weight: 600;
}
.width-slider { width: 100%; }
.tb-num {
min-width: 24px;
height: 22px;
padding: 0 4px;
border: none;
background: transparent;
color: var(--muted-foreground);
font-size: 12px;
border-radius: 4px;
cursor: pointer;
}
.tb-num:hover:not(.active) { background: var(--accent); color: var(--accent-foreground); }
.tb-num.active { background: var(--primary); color: var(--primary-foreground); }
.tb-size {
font-size: 12px;
color: var(--muted-foreground);
padding: 0 4px;
white-space: nowrap;
}
/* 文字输入:textarea 支持多行,Enter 换行 */
.text-input {
position: absolute;
z-index: 10;
background: transparent;
outline: none;
border: 1px dashed currentColor;
font-family: sans-serif;
line-height: 1;
padding: 0 2px;
min-width: 20px;
resize: none;
overflow: hidden;
white-space: pre;
word-break: keep-all;
box-sizing: border-box;
height: auto;
}
/* 取色器放大镜:跟随主界面主题 */
.magnifier {
position: absolute;
z-index: 50;
pointer-events: none;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 3px;
}
.mag-canvas {
display: block;
border-radius: 4px;
image-rendering: pixelated;
image-rendering: crisp-edges;
width: 160px;
height: 100px;
}
.mag-info {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 6px 4px;
font-family: 'Consolas', 'Monaco', 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.35;
}
.mag-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.mag-coord {
color: var(--muted-foreground);
}
.mag-format {
font-size: 10px;
color: var(--muted-foreground);
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 2px;
}
.mag-color-row {
display: flex;
align-items: center;
gap: 6px;
}
.mag-swatch {
width: 14px;
height: 14px;
border: 1px solid var(--border);
border-radius: 2px;
flex-shrink: 0;
}
.mag-color-text {
font-weight: 600;
color: var(--card-foreground);
}
.mag-hint {
font-size: 11px;
color: var(--muted-foreground);
line-height: 1.5;
margin-top: 2px;
white-space: pre-line;
word-break: keep-all;
}
</style>