Tauri (22)——让 `Esc` 快捷键一层层退出的分层关闭方案

背景:为什么 Esc 会变得"不可控"

在 Coco 里,Esc 同时承担了很多"退出/关闭"的职责:

  • 退出输入(Input/Textarea)编辑态
  • 关闭弹层(Popover / 菜单)
  • 关闭历史面板(History Panel)
  • 最后隐藏窗口(Tauri hideWindow

问题在于 :这些层级的 UI 往往同时存在。如果不做事件隔离,Esc 可能 "一键关闭所有",或者被某一层吞掉导致 "该关的不关"。

本次改动的核心,就是把 Esc 做成可预期的优先级链路 ,并通过 stopPropagation + DOM 状态判定让各层各司其职。


设计目标:Esc 的层级优先级(从近到远)

我们把 Esc 定义为 "从用户当前操作点开始逐层退出",优先级如下:

  1. 如果正在输入:先 blur(退出输入态),不做其它关闭
  2. 如果在 Popover 里 :关闭当前 Popover(但第一下 Esc 若在输入框里,仍先 blur)
  3. 如果上下文菜单打开:关闭上下文菜单
  4. 如果 History Panel 打开:关闭 History Panel
  5. 如果 Extension View 打开:不隐藏窗口(交给 Extension 自己处理或保持现状)
  6. 否则 :隐藏窗口(hideWindow()

这条链路的关键点是:"内层 UI 必须能拦截并消费 Esc,避免冒泡到全局导致直接 hideWindow"


全局 Esc:统一入口与"最后兜底"

全局 EscLayoutOutlet 初始化(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

这里有个重要的小优化:回调里用 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

行为是:

  1. stopPropagation + preventDefault(避免 Esc 冒泡到全局 useEscape,也避免浏览器默认行为)
  2. 如果焦点在输入框/文本域:只 blur(src/components/ui/popover.tsx
    • 这保证了"第一下 Esc 退出输入",而不是直接关掉 popover
  3. 否则:找到当前打开的 popover trigger 并 click(),从而走 Radix 的正常关闭流程(src/components/ui/popover.tsx

为了找到 "当前打开的 trigger",新增了选择器常量:

  • OPENED_POPOVER_TRIGGER_SELECTORsrc/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_IDheadlessui-... 命名迁移到 popover-panel:...src/constants/index.ts
    • 配合当前实际渲染结构,避免 ID 不一致导致关闭逻辑失效

对应的 History 关闭动作走的是点击 trigger:

  • closeHistoryPanel()src/utils/index.ts
  • 它通过 [aria-controls="${HISTORY_PANEL_ID}"] 找到按钮并点击(src/utils/index.ts
  • useEscapedocument.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

  • SearchPopoversrc/components/Search/SearchPopover.tsx
  • MCPPopoversrc/components/Search/MCPPopover.tsx
  • AssistantListsrc/components/Assistant/AssistantList.tsx

同时统一加了 autoCorrect="off",减少输入联想带来的干扰(例如英文关键字/ID 搜索场景)。


小结

本次改动将 Esc 从"不可控的一键退出"重构为有明确优先级的逐层退出机制

输入态 → Popover → 菜单 / History → Extension → 窗口隐藏

核心做法是:

  • 全局 useEscape 只作为最后兜底,按优先级处理关闭逻辑
  • Popover 内部消费 Esc(优先 blur 输入,其次关闭弹层),防止事件冒泡到全局
  • 基于 Radix 实际 DOM 统一判断 popover / history 是否打开
  • VisibleKey 只在当前 popover 交互区域内显示
  • 统一输入组件,减少干扰

结果是:Esc 行为稳定、可预期,不再误关、不再漏关。

开源

如果你对我们的技术细节感兴趣,或者想体验一下这款全能的生产力工具,欢迎访问我们的开源仓库和官网:

相关推荐
大猫会长2 小时前
react中用css加载背景图的2种情况
开发语言·前端·javascript
编程修仙2 小时前
第一篇 VUE3的介绍以及搭建自己的VUE项目
前端·javascript·vue.js
search72 小时前
前端学习13:存储器
前端·学习
星月心城2 小时前
八股文-JavaScript(第一天)
开发语言·前端·javascript
政采云技术2 小时前
深入理解 Webpack5:从打包到热更新原理
前端·webpack
T___T2 小时前
从入门到实践:React Hooks 之 useState 与 useEffect 核心解析
前端·react.js·面试
山有木兮木有枝_2 小时前
当你的leader问你0.1+0.2=?
前端
前端程序猿之路2 小时前
模型应用开发的基础工具与原理之Web 框架
前端·python·语言模型·学习方法·web·ai编程·改行学it
名字被你们想完了2 小时前
Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(八)
前端·flutter