2736 lines
87 KiB
Vue
2736 lines
87 KiB
Vue
<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/innerHeight(WebView2 在 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(静态层) + draft,O(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 → ArrayBuffer,Blob 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>
|