BlockNote 焦点管理机制解析:谁拿焦点、为什么拿、三种皮肤如何落实同一套契约
2026/9/24 15:35:23 网站建设 项目流程
  • 前端
  • 富文本
  • UI组件
  • AI 应用

【免费下载链接】BlockNote

A React Rich Text Editor that's block-based (Notion style) and extensible. Built on top of Prosemirror and Tiptap.

项目地址:https://gitcode.com/gh_mirrors/bl/BlockNote
点击查看免费下载

本指南以 BlockNote React 包内部分析文档 focus-management.md 为骨架,结合核心与三种 UI 皮肤(Ariakit / Mantine / shadcn)的源码实现与端到端测试,系统讲解 BlockNote 编辑器在桌面端与移动端如何管理焦点。读完你将理解"编辑器保持焦点、只有autoFocus输入可以抢焦点"这条规则背后的移动端原因,掌握preventFocusOnTappreventFocusOnOpen两个关键机制的触发条件与实现细节,并能复述出 BlockNote 对 popover、菜单、选择器、工具栏按钮四类界面的焦点契约。

核心规则:编辑器保持焦点,只有主动请求焦点的输入才能拿走它

BlockNote 的焦点管理有一条总规则:用户在编辑器周围的 UI 中操作时,编辑器始终保持焦点;只有自己请求焦点的输入(autoFocus)才能把焦点拿走。

这条规则首先是为移动端服务的。在手机上,焦点一旦离开可编辑元素,屏幕软键盘就会立刻关闭;而移动端格式化工具栏是锚定在软键盘上方的(见 MobileFormattingToolbarController.tsx)。任何一次"游离"的焦点移动都会同时关闭键盘、收起工具栏,以及工具栏里正打开着的面板。

桌面端则宽松得多:鼠标点击会把焦点移向按钮,按钮在应用完样式后把焦点交还给编辑器(例如editor.focus()),所以用户点击"加粗"后可以继续打字。文档明确列出的例外只有三类:添加评论(评论编辑器获得焦点)、文件预览、表格单元格合并。

这条"桌面宽松、移动端严格"的差异,正是后面两套不同信号(isTouchDevice()与 UI 模式)并存的原因。

各类界面取焦点的时机:一张总览表

原文档给出了一张覆盖桌面端与移动端工具栏的行为矩阵,这里完整保留并逐项展开:

界面类型桌面端移动端工具栏
Popover(链接表单、文件面板、表情选择)autoFocus的输入相同
Menu(颜色、拖拽手柄、表格)打开时菜单获得焦点(库的默认行为)不获得焦点
Select(块类型选择)打开时列表获得焦点(库的默认行为)不获得焦点
工具栏按钮、Select 触发器点击时按钮获得焦点,多数随后交还点击时完全不获得焦点

下面分别讲解每一类界面在这张表背后对应的代码契约。

Popover:任何设备上都不从 UI 库获得焦点

Popover 中的内容由 BlockNote 自己掌控焦点:URL 输入框或标题输入框通过autoFocus主动请求焦点——这在手机上也没问题,因为焦点落在输入框内时键盘保持打开状态。而没有这类输入框的 popover(例如文件面板)则完全不获得焦点。

从源码看,autoFocus正是 BlockNote 内部各 popover 表单采用的统一手段:

  • 链接工具栏的编辑表单:EditLinkMenuItems.tsx 中 URL 输入autoFocus={true}
  • 文件标题输入:FileCaptionButton.tsx 与 FileRenameButton.tsx 均autoFocus={true}
  • 评论浮动编辑器(对应"添加评论"例外):FloatingComposer.tsx 的Editor组件autoFocus={true}

三种皮肤(Ariakit、Mantine、shadcn)在此处的行为完全一致,因为 popover 本身不参与焦点接管,焦点决策全部落在autoFocus上。

Menu 与 Select:保留库的"打开即聚焦",唯独移动端工具栏除外

打开菜单/列表时把焦点移入其中,是无障碍的默认行为,键盘用户依赖它用方向键导航菜单项。因此桌面端保留这一行为。但在移动端工具栏内部,这会导致键盘与整个工具栏一起关闭,所以在这里被关闭。

关闭动作由preventFocusOnOpen属性驱动,它同时作用于Menu.RootToolbarSelect两类组件。该属性的语义在 ComponentsContext.tsx 中有明确注释:置为true时 UI 库不得在界面打开时把焦点移入;但其中通过autoFocus主动请求焦点的输入框仍然获得焦点(对应链接表单在移动端仍可聚焦的场景)。

在具体组件上,preventFocusOnOpen的取值统一由 UI 模式决定。例如:

  • 块类型选择器 BlockTypeSelect.tsx:preventFocusOnOpen={uiMode === "mobile"}
  • 颜色菜单 ColorStyleButton.tsx:同样preventFocusOnOpen={uiMode === "mobile"}

与此同时,菜单项的 hover 聚焦(Ariakit 的focusOnHover)也会以同样的方式移动焦点,因此一并关闭:Ariakit 皮肤中菜单项与选项的focusOnHover={!preventFocusOnOpen}(见 Menu.tsx 与 ToolbarSelect.tsx)。

此外,点击菜单项或选项本身在任何设备上都不会让该项获得焦点,这与工具栏按钮的规则一致,统一由preventFocusOnTap保证(onMouseDown={preventFocusOnTap})。

工具栏按钮:点击(tap)从不拿焦点

一次点击是指针手势。通过取消浏览器在mousedown时的默认聚焦行为,可以保证编辑器保持聚焦,同时 click 事件照常触发——这就是preventFocusOnTap做的事。桌面端鼠标点击保留浏览器默认行为(焦点移到按钮),而 Safari 需要特殊处理:它在mousedown时本身不会聚焦按钮,所以代码里显式调用focus(),让 Safari 与其他浏览器行为一致。

完整的实现位于 mouseDownFocus.ts:

export function preventFocusOnTap(event: MouseEvent<HTMLElement>) { if (isTouchDevice()) { event.preventDefault(); return; } if (isSafari()) { event.currentTarget.focus(); } }

值得注意的工程细节(源码注释中说明):mousedown是移动焦点的兼容事件;如果改为取消pointerdown,会抑制 iOS WebKit 上由浏览器合成的 click 事件,导致"打开 popover 的按钮永远无法切换开关"。另外,当 UI 库向触发器注入了自己的onMouseDown(例如 Base UI 的菜单触发器靠它打开菜单)时,必须先展开库的 props,再无条件转发到库的 handler——取消默认行为只取消焦点移动,不影响事件本身。

为什么是两套不同的信号

原文档的核心洞察是:无副作用的覆盖用宽信号,有代价的覆盖用精确信号。

工具栏按钮由isTouchDevice()决定。为一次点击取消焦点几乎不付出代价:键盘用户照样可以用 Tab 聚焦按钮,也没有任何逻辑依赖"按钮已聚焦"。所以守卫越宽越好——宽是有意的:手机上的每一个工具栏(包括链接工具栏,而不仅是设置 UI 模式的移动格式化工具栏)都可能有点击操作。isTouchDevice的实现在 browser.ts:它综合navigator.maxTouchPoints > 0matchMedia("(pointer: coarse)"),并且只在浏览器环境首次调用时惰性求值并缓存(SSR 期间不会冻结一个假的false)。

菜单与选择器由 UI 模式决定。取消"打开即聚焦"对键盘用户是有代价的,因此只应在"焦点移动会破坏东西"的场景应用。若用isTouchDevice()判断就太宽了:平板连接了实体键盘时,isTouchDevice()返回true,但界面上显示的是桌面工具栏——此时若取消菜单聚焦,键盘用户会平白失去方向键导航能力。

UI 模式的来源是 UIModeContext.ts,默认值是"desktop"。判断逻辑在 FormattingToolbarController.tsx:只有isTouchDevice() && keyboardOpen(虚拟键盘打开)时才渲染移动端控制器——因为手机和平板也可能外接键盘鼠标。虚拟键盘的判定由 useVirtualKeyboard.ts 完成:它比较缩放不变的布局等效视口高度与历史最大值,高度下降超过 150px 视为键盘打开(低于真实键盘的约 250px+,高于地址栏显隐的约 60-100px),并对横竖屏切换重置基线。

移动端控制器在 MobileFormattingToolbarController.tsx 中通过<UIModeContext.Provider value="mobile">把模式注入子树,并整体 portal 到document.body层级(规避 iOS 滚动容器的层叠上下文把固定定位工具栏画到页脚后面),同时用useEditorFocus({ includeEditorUI: true })检查"用户仍在与这个编辑器交互",避免多编辑器同页时键盘打开就同时弹出所有工具栏。

三种皮肤如何落实同一套契约

三套皮肤对preventFocusOnTappreventFocusOnOpen的落实路径不同,但对外行为一致:

  • Ariakit:菜单用autoFocusOnShow={preventFocusOnOpen ? () => false : true}focusOnHover={!preventFocusOnOpen}(Menu.tsx);工具栏按钮与选项在onMouseDown中调用preventFocusOnTap(ToolbarButton.tsx)。
  • Mantine:用trapFocus={preventFocusOnOpen ? false : undefined}关闭聚焦陷阱(Menu.tsx),按钮同样挂载onMouseDown={preventFocusOnTap}(ToolbarButton.tsx)。
  • shadcn:底层 Base UI 的 Menu 与 Select 没有关闭初始聚焦的开关,因此封装了一个专用工具 preventFocusOnOpen.ts,通过preventFocusOnOpenProps(enabled)注入等价于"不聚焦"的 props(Menu.tsx)。

端到端测试 skinFocus.test.tsx 同时覆盖三套皮肤(mantine / ariakit / shadcn),验证移动端工具栏的两类承诺:点击工具栏按钮不能把焦点移出编辑器(否则软键盘关闭);从它打开的表面(块类型选择器、颜色菜单)也不能把焦点移走(preventFocusOnOpen),除非是主动请求焦点的输入(链接表单)。测试还专门用trackFocusLeavingEditor区分"焦点真的离开"与"同一任务内交还":一次瞬间的 blur 会让键盘闪烁,而 shadcn 适配器在任务内交还焦点则不算离开。

可能的后续演进:指针永不聚焦工具栏按钮

原文档在结尾记录了一个未采纳的设计方向:把 tap 守卫同样应用到鼠标点击上,让指针在任何设备上都不聚焦工具栏按钮——这样设备检测和 Safari 特殊分支都可以删除。理由:大多数工具栏按钮在点击后立即把焦点交还编辑器,桌面工具栏也不依赖按钮聚焦。代价则是:Ariakit 工具栏的 roving tabindex 不再跟随鼠标点击,点击后按 Tab 会从编辑器(而不是刚点过的按钮)继续。在 mouseDownFocus.ts 的注释中同样标注了"possible follow-up",表明这是一个有意保留的开放问题,而非已实现的特性。

小结

BlockNote 的焦点管理可以概括为一条规则与两套信号:规则是编辑器保持焦点、只有autoFocus输入能抢走它;信号是"无代价的覆盖用isTouchDevice()(工具栏按钮)、有代价的覆盖用 UI 模式(菜单与选择器的preventFocusOnOpen)"。移动端软键盘的存续是这一切的锚点,桌面端无障碍(方向键导航、roving tabindex)则是另一端的约束。理解这层设计,对自定义格式化工具栏、接入新 UI 皮肤或排查"手机上点击按钮键盘消失"类问题时,都能直接定位到正确的决策点。

  • 前端
  • 富文本
  • UI组件
  • AI 应用

【免费下载链接】BlockNote

A React Rich Text Editor that's block-based (Notion style) and extensible. Built on top of Prosemirror and Tiptap.

项目地址:https://gitcode.com/gh_mirrors/bl/BlockNote
点击查看免费下载
上一篇:Qwen3.6-35B-A3B-Escha-W2高级特性:结构化输出与工具调用功能的使用技巧
下一篇:ens-contracts高级功能:NameWrapper如何实现域名所有权与权限管理

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询