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 行为稳定、可预期,不再误关、不再漏关。

开源

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

相关推荐
橙子家4 小时前
浏览器缓存之【身份与会话管理】:Cookies 和 Private state tokens
前端
最新资讯动态5 小时前
HDC 2026 | 对话鲸鸿动能:存量时代,品牌如何夺回营销“主动权”?
前端
最新资讯动态5 小时前
游戏出海,从产品走向体系
前端
最新资讯动态5 小时前
20人团队跑出百万DAU、大厂也来抢量:谁在鸿蒙生态跑出加速度
前端
最新资讯动态5 小时前
千万开发者背后,鸿蒙商业化的B面
前端
爱勇宝7 小时前
AI 时代:智商决定起点,情商决定走多远
前端·ai编程
kyriewen7 小时前
用了半年 Claude Code 后,我尝试关掉它写了一周代码——结果比想象中严重
前端·javascript·ai编程
IT_陈寒8 小时前
Vite的静态资源打包让我熬夜到三点,这坑千万别跳
前端·人工智能·后端
徐小夕9 小时前
万字拆解 JitWord:企业级实时协同文档底层架构 + 大模型 AI 融合完整实践
前端·vue.js·github
一份执念9 小时前
uni-app 小程序分包限制处理与主包体积优化实战
前端·微信小程序