背景:为什么 Esc 会变得"不可控"
在 Coco 里,Esc 同时承担了很多"退出/关闭"的职责:
- 退出输入(Input/Textarea)编辑态
- 关闭弹层(Popover / 菜单)
- 关闭历史面板(History Panel)
- 最后隐藏窗口(Tauri
hideWindow)
问题在于 :这些层级的 UI 往往同时存在。如果不做事件隔离,Esc 可能 "一键关闭所有",或者被某一层吞掉导致 "该关的不关"。
本次改动的核心,就是把 Esc 做成可预期的优先级链路 ,并通过 stopPropagation + DOM 状态判定让各层各司其职。
设计目标:Esc 的层级优先级(从近到远)
我们把 Esc 定义为 "从用户当前操作点开始逐层退出",优先级如下:
- 如果正在输入:先 blur(退出输入态),不做其它关闭
- 如果在 Popover 里 :关闭当前 Popover(但第一下
Esc若在输入框里,仍先 blur) - 如果上下文菜单打开:关闭上下文菜单
- 如果 History Panel 打开:关闭 History Panel
- 如果 Extension View 打开:不隐藏窗口(交给 Extension 自己处理或保持现状)
- 否则 :隐藏窗口(
hideWindow())
这条链路的关键点是:"内层 UI 必须能拦截并消费 Esc,避免冒泡到全局导致直接 hideWindow"。
全局 Esc:统一入口与"最后兜底"
全局 Esc 由 LayoutOutlet 初始化(src/routes/outlet.tsx 调用 useEscape()),核心逻辑在 src/hooks/useEscape.ts:
- 先做
event.preventDefault()+event.stopPropagation()(src/hooks/useEscape.ts) - 再按优先级处理:
- 输入 blur(
src/hooks/useEscape.ts) - 关闭右键菜单(
src/hooks/useEscape.ts) - 关闭 History Panel(
src/hooks/useEscape.ts) - Extension 打开时直接
return,避免误隐藏(src/hooks/useEscape.ts) - 最后隐藏窗口(
src/hooks/useEscape.ts)
- 输入 blur(
这里有个重要的小优化:回调里用 useSearchStore.getState() 取最新状态(src/hooks/useEscape.ts),避免快捷键回调捕获旧状态导致 "按了没反应"。
Popover 的 Esc:把"关闭弹层"从全局剥离出来
真正决定 "Esc 是否一层层退出" 的关键,是 Popover 对 Esc 的消费。
在 Radix Popover 中,PopoverContent 提供了 onEscapeKeyDown。本次在组件封装层统一加入:
- 文件:
src/components/ui/popover.tsx - 逻辑:
src/components/ui/popover.tsx
行为是:
stopPropagation+preventDefault(避免 Esc 冒泡到全局useEscape,也避免浏览器默认行为)- 如果焦点在输入框/文本域:只 blur(
src/components/ui/popover.tsx)- 这保证了"第一下 Esc 退出输入",而不是直接关掉 popover
- 否则:找到当前打开的 popover trigger 并
click(),从而走 Radix 的正常关闭流程(src/components/ui/popover.tsx)
为了找到 "当前打开的 trigger",新增了选择器常量:
OPENED_POPOVER_TRIGGER_SELECTOR:src/constants/index.ts
为什么要改选择器:从"自定义标记"到 Radix 的真实 DOM
这次对 Popover 的识别方式也做了统一调整:
POPOVER_PANEL_SELECTOR改为 Radix wrapper:src/constants/index.ts- 变成
"[data-radix-popper-content-wrapper]",用于更可靠地判断"现在是否存在 popover"
- 变成
HISTORY_PANEL_ID/CONTEXT_MENU_PANEL_ID从headlessui-...命名迁移到popover-panel:...(src/constants/index.ts)- 配合当前实际渲染结构,避免 ID 不一致导致关闭逻辑失效
对应的 History 关闭动作走的是点击 trigger:
closeHistoryPanel():src/utils/index.ts- 它通过
[aria-controls="${HISTORY_PANEL_ID}"]找到按钮并点击(src/utils/index.ts) useEscape用document.getElementById(HISTORY_PANEL_ID)判断历史面板是否存在(src/hooks/useEscape.ts)
VisibleKey 与 "在 Popover 里显示快捷键提示"
VisibleKey 需要知道 "当前快捷键提示是否应该显示",尤其是在 popover 打开时,只在 popover 内的元素上显示。
它通过:
POPOVER_PANEL_SELECTOR获取 popover 面板 wrapper(src/components/Common/VisibleKey.tsx)OPENED_POPOVER_TRIGGER_SELECTOR获取打开的 trigger(src/components/Common/VisibleKey.tsx)- 判断当前组件是否在 panel 或 trigger 内(
src/components/Common/VisibleKey.tsx)
这样一来,"打开 popover 后,快捷键提示只在当前 popover 的交互区域出现",不会污染外层 UI。
输入框组件收敛:删除 PopoverInput,统一用 shadcn Input
src/components/Common/PopoverInput.tsx 被删除,相关位置改为直接使用 src/components/ui/input:
SearchPopover:src/components/Search/SearchPopover.tsxMCPPopover:src/components/Search/MCPPopover.tsxAssistantList:src/components/Assistant/AssistantList.tsx
同时统一加了 autoCorrect="off",减少输入联想带来的干扰(例如英文关键字/ID 搜索场景)。
小结
本次改动将 Esc 从"不可控的一键退出"重构为有明确优先级的逐层退出机制:
输入态 → Popover → 菜单 / History → Extension → 窗口隐藏
核心做法是:
- 全局
useEscape只作为最后兜底,按优先级处理关闭逻辑 - Popover 内部消费
Esc(优先 blur 输入,其次关闭弹层),防止事件冒泡到全局 - 基于 Radix 实际 DOM 统一判断 popover / history 是否打开
VisibleKey只在当前 popover 交互区域内显示- 统一输入组件,减少干扰
结果是:Esc 行为稳定、可预期,不再误关、不再漏关。
开源
如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:
- GitHub : github.com/infinilabs/...
- Website : coco.rs