Update plugin ZTools 提供商 v1.0.1#304
Conversation
- feat: ZTools OCR + 翻译提供商插件(截图识别 / 代码翻译 / manage) - chore: 调整插件命名空间 - chore: 调整资源url - feat: 微信 OCR 原生模块支持 macOS(libwxocr.dylib) - feat: 截图识别结果改为独立窗口展示(左图右文 + 拖动缩放)
There was a problem hiding this comment.
Code Review
This pull request refactors the screenshot OCR feature by moving the result display into a dedicated, borderless sub-window (screen-ocr-result.html / ScreenOcrResult.vue), which is opened and populated dynamically by the main orchestrator (ScreenOcr.vue). Feedback focuses on improving robustness and code cleanliness: checking if the sub-window is destroyed before injecting data, properly handling asynchronous clipboard API errors, removing unused references and redundant functions (such as stageRef and decodePngSize in the result view), and binding pointer drag events to the global window object with proper cleanup to prevent stuck drag states and memory leaks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function injectData( | ||
| win: BrowserWindow.WindowInstance, | ||
| data: { image: string; lines: OcrLine[]; isDark: boolean; logo?: string } | ||
| ): void { | ||
| try { | ||
| // 序列化为安全的 JSON,避免引号/换行破坏 JS 字符串 | ||
| const payload = JSON.stringify(data) | ||
| const code = `window.__loadScreenOcrResult && window.__loadScreenOcrResult(${payload});` | ||
| win.webContents.executeJavaScript(code) | ||
| } catch (_) { | ||
| /* ignore:兜底注入会再试 */ | ||
| } | ||
| } |
There was a problem hiding this comment.
在 Electron 中,如果用户在 setTimeout 的 800ms 延迟内关闭了子窗口,win 实例可能已被销毁。此时直接访问 win.webContents 或调用 executeJavaScript 会抛出异常。此外,executeJavaScript 返回一个 Promise,如果执行失败或窗口在执行期间被销毁,会产生未捕获的 Promise 拒绝(Unhandled Promise Rejection)。
建议在调用前增加 win.isDestroyed() 检查,并为 executeJavaScript 链式调用 .catch() 以优雅地捕获任何异步错误。
function injectData(
win: BrowserWindow.WindowInstance,
data: { image: string; lines: OcrLine[]; isDark: boolean; logo?: string }
): void {
if (!win || win.isDestroyed()) return
try {
// 序列化为安全的 JSON,避免引号/换行破坏 JS 字符串
const payload = JSON.stringify(data)
const code = 'window.__loadScreenOcrResult && window.__loadScreenOcrResult(' + payload + ');'
win.webContents.executeJavaScript(code).catch(() => {
/* ignore */
})
} catch (_) {
/* ignore:兜底注入会再试 */
}
}
| function copyText(text: string) { | ||
| if (!text) return | ||
| try { | ||
| navigator.clipboard?.writeText(text) | ||
| } catch (_) { | ||
| /* 子窗口可能无 clipboard 权限,静默失败 */ | ||
| } | ||
| copied.value = text | ||
| if (copiedTimer) window.clearTimeout(copiedTimer) | ||
| copiedTimer = window.setTimeout(() => (copied.value = ''), 1200) | ||
| } |
There was a problem hiding this comment.
navigator.clipboard.writeText 是一个异步操作,返回一个 Promise。如果直接使用 try...catch 包裹它,由于它是异步执行的,任何 Promise 拒绝(例如用户未授权剪贴板权限或窗口失去焦点)都无法被同步的 try...catch 捕获,从而导致未捕获的 Promise 拒绝(Unhandled Promise Rejection)。
另外,如果 navigator.clipboard 未定义,使用 navigator.clipboard?.writeText(text).catch(...) 会因为 undefined 没有 catch 方法而抛出 TypeError。
建议先显式检查 navigator.clipboard 是否存在,然后使用 .catch() 捕获异步错误。
function copyText(text: string) {
if (!text) return
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {
/* 子窗口可能无 clipboard 权限,静默失败 */
})
}
copied.value = text
if (copiedTimer) window.clearTimeout(copiedTimer)
copiedTimer = window.setTimeout(() => (copied.value = ''), 1200)
}
| const viewportRef = ref<HTMLDivElement | null>(null) | ||
| const naturalWidth = ref(0) | ||
| const naturalHeight = ref(0) | ||
| const stageRef = ref<HTMLDivElement | null>(null) |
| const el = list.children[i] as HTMLElement | undefined | ||
| if (el) { | ||
| el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 点击图上文字:若是拖动结束(didDrag)则不触发,避免拖图误复制; | ||
| * 否则复制该行文字并闪烁高亮反馈。 | ||
| */ | ||
| function onOverlayClick(i: number): void { | ||
| if (didDrag) { | ||
| didDrag = false | ||
| return | ||
| } | ||
| flashLine(i) | ||
| copyText(lines.value[i]?.text ?? '') | ||
| } | ||
|
|
||
| /** 触发第 i 行的闪烁高亮(图上 + 列表),1.5s 后移除。 */ | ||
| function flashLine(i: number): void { | ||
| activeIndex.value = i | ||
| const id = (flashedIds.value[i] ?? 0) + 1 | ||
| flashedIds.value[i] = id | ||
| window.setTimeout(() => { | ||
| if (flashedIds.value[i] === id) flashedIds.value[i] = 0 |
| function onPointerDown(e: PointerEvent) { | ||
| if (e.button !== 0) return | ||
| pointerActive = true | ||
| didDrag = false | ||
| dragStart = { x: e.clientX, y: e.clientY } | ||
| dragOrigin = { x: offsetX.value, y: offsetY.value } | ||
| } | ||
| function onPointerMove(e: PointerEvent) { | ||
| if (!pointerActive) return | ||
| const dx = e.clientX - dragStart.x | ||
| const dy = e.clientY - dragStart.y | ||
| if (!dragging.value && Math.hypot(dx, dy) < DRAG_THRESHOLD) return | ||
| if (!dragging.value) { | ||
| dragging.value = true | ||
| didDrag = true | ||
| ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId) | ||
| } | ||
| offsetX.value = dragOrigin.x + dx | ||
| offsetY.value = dragOrigin.y + dy | ||
| } | ||
| function onPointerUp(e: PointerEvent) { | ||
| pointerActive = false | ||
| if (dragging.value) { | ||
| dragging.value = false | ||
| ;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId) | ||
| } | ||
| } |
There was a problem hiding this comment.
当前的拖拽逻辑将 pointermove、pointerup 和 pointercancel 事件直接绑定在 .stage 元素上。如果用户在未触发拖拽(未超过 DRAG_THRESHOLD)前快速将鼠标移出元素并释放,或者在拖拽过程中由于卡顿导致鼠标移出窗口释放,元素将无法接收到 pointerup 事件。这会导致 pointerActive 状态一直保持为 true,当鼠标再次滑入元素时,即使没有按下鼠标,也会触发非预期的拖拽行为。
建议在 pointerdown 时将 pointermove 和 pointerup 事件动态绑定到 window 上,并在拖拽结束时移除,这样可以保证拖拽交互的绝对可靠性。
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
pointerActive = true
didDrag = false
dragStart = { x: e.clientX, y: e.clientY }
dragOrigin = { x: offsetX.value, y: offsetY.value }
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (!pointerActive) return
const dx = e.clientX - dragStart.x
const dy = e.clientY - dragStart.y
if (!dragging.value && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
if (!dragging.value) {
dragging.value = true
didDrag = true
}
offsetX.value = dragOrigin.x + dx
offsetY.value = dragOrigin.y + dy
}
function onPointerUp() {
pointerActive = false
dragging.value = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
}
| onUnmounted(() => { | ||
| const vp = viewportRef.value | ||
| if (vp) vp.removeEventListener('wheel', onWheel) | ||
| window.removeEventListener('resize', onWinResize) | ||
| if (copiedTimer) window.clearTimeout(copiedTimer) | ||
| }) |
There was a problem hiding this comment.
由于拖拽事件监听器已改为动态绑定到 window,我们需要在组件卸载时(onUnmounted)一并清理这些全局监听器,以防止潜在的内存泄漏或在组件销毁后触发非预期的全局事件。
onUnmounted(() => {
const vp = viewportRef.value
if (vp) vp.removeEventListener('wheel', onWheel)
window.removeEventListener('resize', onWinResize)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
if (copiedTimer) window.clearTimeout(copiedTimer)
})
插件信息
本次变更
截图 / 演示
自检清单
plugins/f-provider/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。