背景
在上一篇文章中,我们分享了如何在 Coco AI 中实现丝滑的 NSPanel 窗口全屏体验。然而,全屏只是第一步,真正的挑战往往隐藏在细节之中。
Coco AI 的核心亮点之一是其日渐强大的插件生态 (Extensions)。用户可以通过安装插件,在 Coco 的悬浮窗中直接运行各种 Web 应用,甚至玩 Doom 这样的小游戏。
但我们在开发过程中遇到了一个非常影响心情的 Bug:
当用户正沉浸在游戏中,觉得窗口太小而点击"全屏"后,突然发现键盘失灵了------WASD 怎么按都没反应,手动点画面也不能继续操作...
对于一款追求极致体验的生产力工具来说,这种"断触"是不可接受的。今天我们就来深度复盘这个**焦点丢失(Focus Loss)**问题,以及我们在 Coco App 中的"组合拳"解决方案。
场景复现
- 用户在 Coco AI 中启动了一个游戏插件(通过
iframe加载)。 - 初始窗口较小,用户通过 WASD 控制角色移动,一切正常。
- 用户点击右上角的"全屏"按钮,希望获得沉浸体验。
- 窗口瞬间变大铺满屏幕,但此时按下 W 键,角色纹丝不动。
- 用户拿起鼠标不断的点击一下游戏画面,控制权也未能恢复。
为什么会失焦?
这个问题的根源在于 DOM 树的重建和窗口系统的焦点管理机制,特别是在 React + Tauri 的混合架构下:
- DOM 重绘/重排 :当窗口从悬浮模式切换到全屏模式时,React 组件可能会因为状态变化(如
scale缩放系数改变、layout模式切换)而重新渲染。如果iframe在这个过程中被卸载并重新挂载,它就是一个全新的iframe,之前的焦点自然荡然无存。 - Native 窗口焦点转移 :调用 Tauri 的
setWindowSize或setFullscreen等底层 API 时,操作系统可能会暂时把焦点从 WebView 内容区域移开,转移到窗口边框或系统层级。 - Iframe 的隔离性 :
iframe内部是一个独立的window上下文。主文档(Parent)获得焦点并不意味着iframe获得焦点。你需要显式地把焦点"传递"进去。
Coco AI 的解决方案:全方位焦点守护
为了确保用户体验的连贯性,我们在 ViewExtension.tsx 组件中实施了一套多层级的焦点管理策略。
第一招:组件挂载后的自动聚焦
在 React 中,利用 ref 和 onLoad 事件,确保插件加载完毕的那一刻,焦点就自动锁定在它身上。
typescript
<div
// 绑定 Ref
ref={iframeRef}
// 任何点击外层容器的操作,都把焦点送给 iframe
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
// Iframe 加载完毕瞬间聚焦
onLoad={(event) => {
event.currentTarget.focus();
try {
// 尝试深入聚焦到 iframe 内部的 window
iframeRef.current?.contentWindow?.focus();
} catch (e) {
console.warn("Failed to focus iframe content:", e);
}
}}
// 允许必要的权限:全屏、鼠标锁定(FPS游戏必备)、手柄
allow="fullscreen; pointer-lock; gamepad"
tabIndex={-1}
/>
</div>
第二招:状态变化后的延迟聚焦
当你执行全屏或缩放操作后,Native 层的窗口调整是异步的,React 的渲染也是异步的。如果你立即调用 focus(),可能 DOM 还没稳,或者窗口还在动画中,导致聚焦失败。
我们的秘诀是 setTimeout,等待一轮事件循环:
typescript
const applyFullscreen = useCallback(async (next: boolean) => {
// ... 执行窗口大小调整逻辑 ...
// 等待系统窗口调整完成且 React 渲染完毕
setTimeout(() => {
// 1. 聚焦 iframe 元素本身
iframeRef.current?.focus();
try {
// 2. 尝试聚焦 iframe 内部内容(处理跨域限制时可能报错,加 try-catch)
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}, []);
第三招:显式的"焦点救生圈"
为了应对浏览器安全策略限制脚本自动聚焦等极端情况,我们在 UI 上设计了一个显式的 Focus 按钮。这不仅是一个功能补救,也是一个视觉提示。
typescript
{/* Focus helper button */}
<button
aria-label="Focus Game"
className="absolute top-2 right-12 z-10 p-2 bg-black/50 hover:bg-black/70 rounded text-white transition-colors"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<FocusIcon className="size-4"/>
</button>
当用户发现控制失灵时,潜意识会寻找界面上的交互点,点击这个按钮就能瞬间找回焦点。
第四招:事件捕获(Capture Phase)
有时候用户点击了窗口边缘的空白区域(padding 或 margin),焦点会跑回主文档 body。我们可以通过在容器上监听 onMouseDownCapture 来拦截这些点击,强行把焦点按回 iframe。
typescript
<div
className="w-full h-full flex flex-col items-center justify-center"
// 捕获阶段拦截,比冒泡更早
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
>
<iframe ... />
</div>
小结
焦点管理看似简单,但在构建像 Coco AI 这样复杂的桌面+Web 混合应用时,它直接关系到用户的沉浸感。通过这套 "主动出击 + 纵深防御 + 异步等待 + 兜底方案" 的组合拳,我们成功解决了跨平台、跨窗口尺寸下的焦点丢失问题。
现在,无论是在写代码时快速查阅文档,还是在休息时玩一把小游戏,Coco AI 都能提供无缝、流畅的交互体验。
如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:
- GitHub : github.com/infinilabs/...
- Website : coco.rs