细节调整及优化(26.8.3)

This commit is contained in:
zhongluofeng
2026-08-21 17:25:47 +08:00
parent 0a19b4b38a
commit 4dd60f42a1
72 changed files with 4779 additions and 1395 deletions
+111 -66
View File
@@ -18,6 +18,7 @@ import {
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'
// ===== 窗口 / 底图 =====
@@ -65,6 +66,21 @@ 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)
@@ -655,51 +671,22 @@ function canvasPoint(e: MouseEvent): Point {
}
}
// ===== 窗口识别 =====
function scheduleWindowPick(cssX: number, cssY: number) {
const physX = Math.round(winOuterX + cssX * dpr)
const physY = Math.round(winOuterY + cssY * dpr)
if (physX === lastPickX && physY === lastPickY) return
lastPickX = physX
lastPickY = physY
if (pickRaf) return
pickRaf = requestAnimationFrame(async () => {
pickRaf = 0
try {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
x: physX,
y: physY,
})
if (phase.value !== 'pick') return
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
}
} catch {
// 忽略拾取错误
// ===== 窗口识别(本地命中测试,零 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
}
/** 按物理坐标拾取窗口(覆盖层打开时定位鼠标所在窗口) */
async function pickAt(physX: number, physY: number) {
try {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
x: physX,
y: physY,
})
if (phase.value !== 'pick' || !info) return
/** 应用窗口命中结果到高亮状态 */
function applyWindowInfo(info: WindowInfo | null) {
if (info) {
// 高亮框用 DWM 视觉边界,避免 GetWindowRect 包含隐形缩放边框导致大一圈
const r = info.visualRect ?? info.rect
winHighlight.value = {
x: (r.x - winOuterX) / dpr,
@@ -709,11 +696,34 @@ async function pickAt(physX: number, physY: number) {
title: info.title,
}
currentHwnd.value = info.hwnd
} catch {
// 忽略拾取错误
} 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 = []
@@ -739,6 +749,13 @@ function enterSelected() {
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 }
@@ -752,9 +769,9 @@ function onMouseDown(e: MouseEvent) {
dragTracking.value = true
dragStart.value = { x: e.clientX, y: e.clientY }
} else if (phase.value === 'selected') {
// 点击选区外 → 取消选中,回到窗口识别
// 点击选区外 → 取消选中,回到窗口识别(按点击位置恢复高亮)
resetAnnotations()
phase.value = 'pick'
backToPick()
} else if (phase.value === 'editing') {
// 点击选区外 → 提交未完成的文字、取消选中并退出标注,回到选区调整
commitText()
@@ -816,6 +833,9 @@ function onHandleMouseDown(dir: HandleDir, e: MouseEvent) {
}
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)
@@ -975,10 +995,9 @@ function onKeyDown(e: KeyboardEvent) {
}
} else if (phase.value === 'selected') {
resetAnnotations()
phase.value = 'pick'
backToPick()
} else if (phase.value === 'drawing') {
dragTracking.value = false
phase.value = 'pick'
backToPick()
} else {
cancel()
}
@@ -1901,15 +1920,17 @@ onMounted(async () => {
// 禁用窗口显示/隐藏过渡动画(消除进入/关闭时的缩放动画),失败静默
commands.screenshotDisableTransitions(WINDOWS.screenshotOverlay).catch(() => {})
// 先注册 begin 监听再通知 store 就绪,避免首轮事件丢失
beginUnlisten = await listen(EVENTS.screenshotBegin, () => {
void beginCapture()
beginUnlisten = await listen<ScreenshotBeginPayload>(EVENTS.screenshotBegin, (e) => {
void beginCapture(e.payload)
})
await emit(EVENTS.screenshotOverlayReady)
})
/** 响应 store 的 'screenshot-begin':先装载底图(隐藏中),解码完成后再一次性显示窗口 */
async function beginCapture() {
async function beginCapture(payload?: ScreenshotBeginPayload) {
try {
// 入场动画准备:整体置为透明起点(窗口 show 后从透明淡入,底图/遮罩/高亮同步出现)
resetEnterAnim()
// 每次截图开始时同步主界面主题(覆盖层是常驻窗口,主界面可能已切换主题)
applyTheme()
// 重置到"拾取"初始状态
@@ -1927,13 +1948,23 @@ async function beginCapture() {
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 = await invoke<ArrayBuffer>('screenshot_get_fullscreen_bmp')
// 并行: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)
@@ -1946,31 +1977,36 @@ async function beginCapture() {
])
loading.value = false
const pos = await win.outerPosition()
winOuterX = pos.x
winOuterY = pos.y
dpr = window.devicePixelRatio || 1
// 窗口显示前同步尺寸:store 的 setSize 可能在 webview ready 前就已调用,
// onResized 回调可能错过,此处主动刷新确保 winW/winH 正确
await refreshWinSize()
await win.show()
await win.setFocus()
// 显示前完成初始窗口命中(捕获时刻光标处):首帧即"窗口高亮",无"全屏遮罩→高亮"闪烁
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())
// 默认识别鼠标所在窗口(按下快捷键时鼠标仍在原位)
try {
const [cx, cy] = await invoke<[number, number]>('screenshot_cursor_pos')
await pickAt(cx, cy)
} catch {
// 忽略初始定位失败,移动鼠标后自动识别
}
} 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(() => {})
}
@@ -1986,6 +2022,7 @@ onUnmounted(() => {
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)
@@ -1997,7 +2034,7 @@ onUnmounted(() => {
<template>
<div
class="overlay-root"
:class="cursorClass"
:class="[cursorClass, enterState !== 'done' ? `overlay-enter-${enterState}` : '']"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@@ -2317,6 +2354,14 @@ onUnmounted(() => {
-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;