Tiptap BubbleMenu 扩展 v3 演进全解析:从 tippy.js 迁移到 Floating UI 的 API 与内部实现
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
导读:
@tiptap/extension-bubble-menu是 Tiptap 编辑器中随选区浮起的经典工具条扩展,它的版本变更史记录了 Tiptap v3 在浮层技术栈、定位管线、显示控制与多实例隔离上的完整演进。本文以 packages/extension-bubble-menu/CHANGELOG.md 为主线,结合同目录源码与测试,梳理该扩展从 v2 到 v3.30.3 的关键变化,帮助读者理解options对象、事务元数据(transaction meta)控制协议以及菜单定位的底层原理。
一、CHANGELOG 说明了什么:一个包的演进骨架
该 CHANGELOG 覆盖了从 2021 年2.0.0-beta.1到当前3.30.3的全部发布记录。去掉大量纯依赖同步(@tiptap/core、@tiptap/pm的版本跟随)条目后,剩下的核心信息可分为五类:
- 破坏性架构迁移:v3 起始(
3.0.0-next.0/3.0.1)用 Floating UI 替换 tippy.js; - API 面扩展:
appendTo、scrollTarget、shouldShow、options、生命周期回调等的引入与完善; - 程序化控制协议:通过事务元数据控制菜单显示/隐藏/更新定位;
- 多实例隔离:以
pluginKey作为元数据键解决实例间互相干扰; - 定位正确性修复:覆盖文本选区、节点选区、表格单元格选区、滚动与 resize、销毁等边界场景。
这些类别恰好对应着扩展的三个源文件:src/bubble-menu.ts(扩展本体)、src/bubble-menu-plugin.ts(ProseMirror 插件与视图类)、src/index.ts(出口),以及tests/bubble-menu-plugin.spec.ts 中的行为测试。下文将逐条展开。
二、v3 起点:移除 tippy.js,迁移到 Floating UI
CHANGELOG 在3.0.0-next.0、3.0.0-next.6与3.0.1中连续记录了同一项Major Change:构建工具改用 tsup(不再支持 UMD 构建),同时移除了 tippy.js,改用 Floating UI(当时被描述为"更新、更轻量、更可定制的浮层库")。
这是一次破坏性变更,波及的包包括:
@tiptap/extension-floating-menu@tiptap/extension-bubble-menu@tiptap/extension-mention@tiptap/suggestion@tiptap/react@tiptap/vue-2@tiptap/vue-3
迁移要求如下(原文档以代码块给出,此处原样继承):
npm install @floating-ui/dom@^1.6.0同时必须从FloatingMenu/BubbleMenu组件中删除旧的tippyOptions,改用新的options对象。这一点在源码中得到印证:BubbleMenuView内部维护floatingUIOptions,并通过computePosition完成定位,package.json 的依赖中已将@floating-ui/dom@^1.0.0列为核心依赖。
对于需要手动迁移的既有实现,核心动作是:将原来传给 tippy 的placement、offset等配置,映射到新的options对象——其键与 Floating UI 的 middleware 一一对应,详见下文第五节。
三、扩展结构:从配置到浮层定位的调用链
结合源码可以将 BubbleMenu 的运行链路还原为三层:
扩展层(bubble-menu.ts):
BubbleMenu = Extension.create<BubbleMenuOptions>,默认选项为:element: null(菜单 DOM 元素)pluginKey: 'bubbleMenu'updateDelay: undefinedappendTo: undefinedshouldShow: null
当且仅当传入的
element存在时,扩展才通过addProseMirrorPlugins()注册BubbleMenuPlugin,否则不产生任何副作用。插件层:
BubbleMenuPlugin把element、editor与各选项包装为一个 ProseMirrorPlugin,其view回调实例化BubbleMenuView;字符串形式的pluginKey会被转换为new PluginKey(...)实例。视图层(bubble-menu-plugin.ts 中的
BubbleMenuView):负责"何时显示、定位到哪、如何更新",这是整个扩展最核心的类。
关键默认值与节流策略
BubbleMenuView构造函数给出了 CHANGELOG 之外的实现细节:
pluginKey默认'bubbleMenu';updateDelay默认250毫秒——当存在有效选区(selection.from !== selection.to)时,菜单更新会被防抖,避免高频输入或协同光标造成的性能问题;resizeDelay默认60毫秒——窗口resize与滚动监听走独立防抖;this.element.tabIndex = 0使菜单可聚焦(对应 CHANGELOG 中"Addedtab-index="0"to menu wrappers")。
update()内部逻辑为:若存在有效选区且updateDelay > 0,走handleDebouncedUpdate,且只有 selection 或 doc 真正变化时才触发;否则即时updateHandler。updateHandler中还会跳过输入法组合输入(view.composing)与无变化的事务。
四、显示判定:shouldShow 与默认过滤逻辑
CHANGELOG 在3.0.5记录"Make shouldShow optional on bubbleMenu and floatingMenu options"(将shouldShow变为可选)。若未提供,源码中的默认实现如下:
- 计算选区所有 range 的起止,取最小
from与最大to(兼容表格CellSelection); - 过滤空文本块:
!doc.textBetween(from, to).length && isTextSelection,并注释说明"有时仅判断empty不够——双击空段落会得到节点尺寸 2",对应 v2 时代"空段落起点选中不显示菜单"等修复; - 过滤失焦:
hasEditorFocus = view.hasFocus() || isChildOfMenu,其中isChildOfMenu判断document.activeElement是否位于菜单元素内部——这保证用户点击菜单按钮时编辑器"blur"不会立刻收起菜单; - 过滤不可编辑:
!this.editor.isEditable时不显示。
开发者传入自定义shouldShow即可覆盖上述默认行为,其回调签名包含editor/element/view/state/oldState/from/to上下文(见 bubble-menu-plugin.ts 的类型定义)。
五、options 对象:透传 Floating UI 的完整定位配置
这是 tippy 迁移后最重要的 API。BubbleMenuPluginProps.options同时支持两类键:
1. 定位与策略
strategy: 'absolute' | 'fixed'placement:'top'、'bottom'、'left'、'right'及其-start/-end组合共 12 个取值
2. Floating UI middleware 开关与参数
flip、shift、offset、arrow、size、autoPlacement、hide、inline。每个键可传false关闭,或传对应 middleware 的选项对象。源码get middlewares()会按固定顺序组装:
[flip, shift, offset, arrow, size, autoPlacement, hide, inline]默认值集中在floatingUIOptions字段(bubble-menu-plugin.ts):
{ strategy: 'absolute', placement: 'top', offset: 8, flip: {}, shift: {}, arrow: false, size: false, autoPlacement: false, hide: false, inline: false, onShow / onHide / onUpdate / onDestroy: undefined, }其中inline的加入与 CHANGELOG3.0.7的修复相关——该版本修复了 inline 选项与"缺少getClientRects的虚拟元素"的兼容问题;从源码看,虚拟元素会同时实现getBoundingClientRect与getClientRects,正是为支持 Floating UI 的 inline middleware。
3. 生命周期回调
CHANGELOG 在3.0.0-beta.11记录"Added missingonShow,onUpdate,onHideandonDestroyoptions",这四个回调保留至今并纳入类型:show()触发onShow,hide()触发onHide,每次定位计算成功后触发onUpdate,destroy()触发onDestroy。
六、定位虚拟元素:文本、节点与表格单元格选区
BubbleMenuView.virtualElement的取值优先级为:
- 若配置了
getReferencedVirtualElement,则直接使用其返回值——这在菜单需要相对某个特定 DOM 元素定位时非常有用(3.0.0-beta期间引入的getReferencedVirtualElementAPI); - 默认基于选区:
posToDOMRect(view, from, to)生成虚拟元素; - 若是
NodeSelection,优先使用节点的[data-node-view-wrapper]包裹层做锚点,修复"节点选区下菜单位置非法"(3.1.0)并支持无文本内容的原子节点(v2 的#1446修复); - 若是表格
CellSelection,跨单元格时用combineDOMRects合并首尾单元格的包围盒(对应 v2#6472d2c与3.0.1中"修复表格单元格选区不会正确定位气泡菜单"的记录)。
定位本身由 Floating UI 的computePosition(virtualElement, this.element, { placement, strategy, middleware })完成,成功后写入element的内联样式:position、left、top,并保持width: max-content。
七、程序化控制:事务元数据协议与多实例隔离
这是 v3 后期版本的核心增强,也是源码注释中明确建议的用法。
1. 首次出现的 updateBubbleMenuPosition 命令(3.5.0)
3.5.0增加了updateBubbleMenuPosition命令,用于在菜单自身尺寸变化等事件后程序化刷新定位。但3.6.0随即将其移除——原因记录得很直白:该命令在 React、Vue 组件版 BubbleMenu 中不可用,只在原生扩展中生效,容易造成困惑。同一版本还要求把transactionHandler写成箭头函数,保证this始终指向BubbleMenuView实例。
2. 事务元数据成为统一控制通道(3.20.0、3.22.2)
3.20.0修复了BubbleMenu/FloatingMenu的事务元数据键问题——改用pluginKey作为元数据键,使多实例可独立更新互不干扰。源码transactionHandler中通过tr.getMeta(this.pluginKey)读取控制指令,支持四种值:
'updatePosition':立即重算位置;{ type: 'updateOptions', options }:运行时更新选项(调用updateOptions);'hide':隐藏菜单;'show':刷新位置并显示。
3.22.2正式将其作为公共 API 记录:可通过transaction.setMeta('menuKey', 'show')与transaction.setMeta('menuKey', 'hide')程序化显隐气泡/浮层菜单。标准调用形如:
// 显示默认 pluginKey 的菜单 editor.view.dispatch(editor.state.tr.setMeta('bubbleMenu', 'show')) // 隐藏 editor.view.dispatch(editor.state.tr.setMeta('bubbleMenu', 'hide')) // 仅刷新位置(例如外部改变了菜单宽度) editor.view.dispatch(editor.state.tr.setMeta('bubbleMenu', 'updatePosition')) // 运行时更新配置 editor.view.dispatch( editor.state.tr.setMeta('bubbleMenu', { type: 'updateOptions', options: { updateDelay: 500 }, }), )3. 多实例互不污染的测试证据
tests/bubble-menu-plugin.spec.ts 中专门设计了 "cross-contamination"(实例间串扰)测试组,覆盖:
- 字符串
pluginKey(bubbleMenu1vsbubbleMenu2)下,只有目标实例收到updateOptions; PluginKey实例(customBubbleA/customBubbleB)同样被正确隔离;updatePosition、show、hide均只作用于自身实例;- 两个实例
updateDelay被分别改写为 999/777 互不影响; - 未显式指定
pluginKey时兼容默认'bubbleMenu'键(向后兼容性回归测试)。
4. 运行时可更新 props(3.18.0)
3.18.0修复了"BubbleMenu 与 FloatingMenu 初始化后 props 不更新"的问题。对应实现即上文updateOptions:updateDelay、resizeDelay、appendTo、getReferencedVirtualElement、shouldShow与整个options都可在运行时替换;其中scrollTarget变化时会先移除旧监听再绑定新监听。
八、滚动、隐藏与销毁:边界情况的修复脉络
这类修复贯穿 v2 至 v3,可直接指导真实项目中的疑难排查:
| 版本 | 变更要点 | 对应源码机制 |
|---|---|---|
| 2.0.0-beta.16 | 修复插件全局 resize 处理器在销毁时未注销 | destroy()中对称removeEventListener |
| 2.1.0-rc.1 / 2.0.3 | 修复 debounce 在协同/协作光标下失效 | 基于selectionChanged/docChanged判断后再 debounce |
| 3.4.2 | 新增可选scrollTarget,替代默认window监听滚动并保证清理 | 构造时监听this.scrollTarget,销毁时移除 |
| 3.6.2 | 销毁编辑器时插件清理过程抛错 | blurHandler中先判断editor.isDestroyed再destroy() |
| 3.6.6 | 创建时shouldShow为真但菜单位置未更新 | 构造函数末尾getShouldShow()通过则show()+updatePosition() |
| 3.17.0 | 防止销毁时Cannot read properties of null (reading 'domFromPos') | 销毁防护 |
| 3.17.1 | 正确消费 hide middleware 数据,引用元素滚出视口时隐藏菜单 | updatePosition()检查middlewareData.hide.referenceHidden / escaped |
| 3.22.0 | 修复隐藏菜单在滚动/resize 期间"复活" | 定位仅对已显示菜单执行,且迟到的定位回调被丢弃 |
| 3.22.4 | 修复依赖安装后 peer 依赖解析冲突 | package.json 同步 |
其中3.22.0与"隐藏后是否复活"的行为,在测试中有两条直接验证:
- 已隐藏的菜单调用
updatePosition()后仍保持visibility: hidden、left/top为空(should not make a hidden menu visible); - 先
show()再updatePosition()再hide(),迟到的computePositionresolve 不会把已隐藏的菜单重新点亮(should ignore late position updates)。
3.17.1对应的机制位于updatePosition()的回调里:一旦 Floating UI 的hidemiddleware 报告referenceHidden或escaped,就置visibility: hidden并直接返回,而不是继续写入坐标。
九、appendTo:控制菜单挂载的父容器
3.0.9引入appendTo选项(v2 时代add appendTo option的延续),默认值为编辑器父元素(this.view.dom.parentElement)。其典型动机是:出于无障碍、裁剪(overflow clipping)或 z-index 层级问题,菜单需要挂到编辑器之外的 DOM 上下文。
3.6.3进一步允许appendTo传回调函数,要求同步返回一个元素,从而支持动态创建的目标容器。源码在show()中统一处理两者:
const appendToElement = typeof this.appendTo === 'function' ? this.appendTo() : this.appendTo ;(appendToElement ?? this.view.dom.parentElement)?.appendChild(this.element)值得注意的是:show()/hide()采用"插入/移除节点 + 切换visibility/opacity"而非 display 切换,配合element.isConnected判断,避免对已脱离文档的节点写入坐标。
十、交互细节与版本节奏
- 拖拽隐藏:
dragstartHandler会在用户拖拽选中内容时隐藏菜单(对应 v2#1443"hide bubble menu on drag")。 - 焦点守卫:
mousedownHandler以捕获阶段监听菜单内按下事件并置preventHide = true,配合blurHandler中对relatedTarget落在菜单父级或编辑器 DOM 内的放行,形成完整的失焦不隐藏逻辑。 - 当前版本:截至 CHANGELOG 顶部,包版本为
3.30.3,与@tiptap/core、@tiptap/pm完全同步发布;构建入口为 ESM + CJS 双格式(见 package.json 的exports字段)。
结语
透过这份 CHANGELOG 可以清晰看到 Tiptap BubbleMenu 扩展的两条主线:一是技术栈收敛——从 tippy.js 到 Floating UI,把定位能力开放为与 middleware 一一对应的options,并补齐生命周期回调;二是控制协议稳定化——以pluginKey为命名空间的事务元数据成为唯一的程序化控制通道,配合updateOptions实现运行时热更新,同时用完整的测试(bubble-menu-plugin.spec.ts)锁住多实例隔离与隐藏态不复活等关键行为。对于正在迁移到 v3 或希望深度定制浮层菜单的开发者,将 CHANGELOG 条目与 bubble-menu-plugin.ts 的BubbleMenuView对照阅读,是最快的上手路径。
【免费下载链接】tiptapThe headless rich text editor framework for web artisans.项目地址: https://gitcode.com/GitHub_Trending/ti/tiptap
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考