到这篇为止,扩展阶段拆的包都围绕编辑行为:keymap 管按键,commands 管命令,history 管撤销,inputrules 管输入规则,schema 两个包管文档结构,gapcursor 和 dropcursor 管光标落点。它们的共同点是不管外观,最多画一条指示线。这篇拆 prosemirror-menu,系列里第一个纯 UI 包,回答的问题很具体:commands 体系攒下的一批命令,怎么摆成一条可用的工具栏。包不大,src 下三个实现文件:menu.ts 定义菜单元素体系,menubar.ts 负责插件装配,icons.ts 处理图标,另有一个只做导出的 index.ts。参考代码是 prosemirror-menu 的 4f015c6;顺带引用的 prosemirror-commands、prosemirror-history 分别是 52a84a8、445409b。
系列目录
MenuElement:render 一次,update 每次
menu.ts 开头的 MenuElement 接口是整个包的地基:
export interface MenuElement {
render(pm: EditorView): {dom: HTMLElement, update: (state: EditorState) => boolean, focusable?: HTMLElement}
}协议分两段。render 在编辑器挂载时调一次,产出 DOM 和一个 update 函数;之后每次 state 更新,工具栏拿着新 state 逐个调 update。update 返回 false 表示这个元素在当前状态下整个隐藏,返回 true 表示仍然可见。接口注释里特意写明:能放进菜单结构的不止包内几个类,任何满足这个接口的对象都行。focusable 可选,元素希望焦点落在内部某个节点上时单独给出,默认用 dom。
这个两段式和编辑器自身的更新周期对齐。第 25 篇讲 EditorView 时说过,每次 dispatch 产生新 state,view 更新 DOM,插件的 view 方法返回的对象跟着收到一次 update 调用。菜单把「这次 state 下我该长什么样」的计算全部收进 update,render 只跑一次,避免每次状态变化都重建 DOM。
MenuItem:run、select、enable、active 四件套
最常用的元素是 MenuItem,一个点击后执行命令的按钮。spec 里和状态打交道的字段有四个,分工明确:
- run(state, dispatch, view, event):点击时执行,签名就是命令签名(第 39 篇),多数情况直接把 commands 包的命令填进来。
- select(state):这项当前是否适用,返回 false 时整个按钮隐藏。joinUpItem 的 select 是 state => joinUp(state),光标上方没有可合并的块时按钮直接不出现。
- enable(state):是否可用,返回 false 时按钮保留但置灰。undoItem 用 enable:state => undo(state),没有可撤销历史时按钮灰着但不消失,位置保持稳定。
- active(state):是否处于激活态,典型用法是加粗按钮在光标位于加粗文本内时高亮。
select 和 enable 的分工值得记一下:select 管「这项和当前选区有没有关系」,enable 管「有关系但此刻能不能做」。隐藏和置灰是两种 UI 语义,前者减少干扰,后者告诉用户功能存在。
select 与 enable 的实现都是把同一个命令再调一遍。命令不带 dispatch 调用时不产生任何修改,只返回可行性布尔值,这是 commands 篇讲过的 dry run 约定。菜单层没有自己重写「能不能撤销」「能不能合并」的判断,全部复用命令本身。
spec 里其余字段管外观。MenuItemSpec 上 run 是唯一必填的函数;render、icon、label 三个 DOM 来源至少给一个,render 完全自定义,icon 走 icons.ts,label 生成一个纯文本按钮,主要给 Dropdown 里的条目用。title 出现在鼠标悬停提示上,可以是字符串或函数,函数版拿当前 state 算,可以随状态改提示文案。class 和 css 原样附加到 DOM 上,留给应用层微调样式。
render(view) 里做的事(menu.ts MenuItem.render):DOM 来源按优先级取 spec.render、spec.icon、spec.label,三者都没有就抛 RangeError。事件处理有两个细节。一是 mousedown 上 preventDefault,按下按钮时不触发浏览器的焦点转移,编辑器焦点不会被抢走。二是 click 里的一段焦点记账:
let setFocus = document.activeElement == dom || document.activeElement == view.dom
spec.run(view.state, view.dispatch, view, e)
if (setFocus && document.activeElement == dom) view.focus()点击前焦点在按钮或编辑器上,命令执行完焦点还停在按钮上,就把焦点还给编辑器,用户能接着打字。disabled 的按钮在 click 开头直接拦掉。
update(state) 是四件套的另一半,每次 state 更新时跑:select 为假就 display: none 并返回 false;enable 为假加 ProseMirror-menu-disabled 类和 aria-disabled;active 为真(且当前可用)加 ProseMirror-menu-active 类和 aria-pressed。状态同步只动 class 和属性,不碰 DOM 结构。
翻译也在这里接。title 和 label 都过一遍 translate 函数,它读 view 的 translate prop((view as any)._props.translate),应用层挂了翻译函数,整份菜单文本一起生效。
预置 item 与命令的复用
menu.ts 末尾给了一批现成的 item:joinUpItem、liftItem、selectParentNodeItem 走 select,undoItem、redoItem 走 enable,图标取自同文件的 icons 表。两个工厂函数值得看。
wrapItem(nodeType, options) 构造「把选区包进某类节点」的按钮,run 和 select 都来自 wrapIn(nodeType, options.attrs) 生成的命令。
blockTypeItem(nodeType, options) 构造「切换文本块类型」的按钮,段落和标题互切走的就是它。先造好 setBlockType 命令,run 直接用,enable 是 command(state) 的 dry run,active 需要额外判断当前块是不是已经是目标类型:
active(state) {
let {$from, to, node} = state.selection as NodeSelection
if (node) return node.hasMarkup(nodeType, options.attrs)
return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
}NodeSelection 选中了节点整体就看节点本身的 markup,否则看光标所在父块。to <= $from.end() 排除了选区跨块的情况,跨块时不算激活。hasMarkup 同时比较类型和 attrs,标题 level 不同不算同一个状态,所以 H1 按钮在光标位于 H2 内时不会高亮。
Dropdown 与 renderGrouped:组合规则
Dropdown 把一组元素收进一个下拉按钮。render 时 renderDropdownItems 把子项逐个渲染,每项包成 li(role: menuitem),整体放进一个 ul(role: menu),同时把各子项的 update 收进一个数组。这份子菜单 DOM 在 render 阶段就建好,点按钮时 expand 把它包进一个 div.ProseMirror-menu-dropdown-menu 挂到按钮旁边,按钮上同步维护 aria-haspopup 和 aria-expanded。聚合 update 的返回值是「至少有一项可见」:每项 update 返回 false 时对应的 li 一起隐藏,全部隐藏时整个下拉返回 false。
关闭逻辑有三路:点按钮自己切换;展开后往 window 上挂 click 监听,收到菜单外的事件就关,markMenuEvent 与 isMenuEvent 用 100 毫秒时间窗加事件目标的包含关系判断「这个点击是不是菜单自己产生的」,避免打开菜单的那次点击冒泡到 window 又把它关掉;子项 focusout 后延迟 20 毫秒检查焦点是否还在菜单内,不在就关。20 毫秒的延迟是给焦点转移留的窗口,焦点从一项移到另一项会先触发一次 focusout。
键盘支持跟着展开走。e.detail === 0 判定这次点击是键盘触发的(Enter 激活按钮没有坐标,detail 为 0),展开后焦点移到第一个可见子项。方向键用 keyboardMoveFocus 在可见项间循环,findFocusableIndex 跳过 display: none 的项;Escape 关闭并把焦点还给按钮。DropdownSubmenu 是向右展开的子菜单变体,ArrowRight 打开,Escape 或 ArrowLeft 收起,结构相同,不展开讲。
renderGrouped(view, content) 负责把嵌套数组拍平成工具栏内容:内层每个数组是一组,组内每项外面包 span.ProseMirror-menuitem,组与组之间插一个 separator。它同样返回 {dom, update, focusables},update 的组合规则分三层:
- 单项 update 返回 false,对应的 span 隐藏;
- 一组全部隐藏,这一组的 combineUpdates 返回 false;
- separator 只在左右两侧都有可见组时显示,组空了分隔符跟着消失,不会留下两条挨着的竖线。
Dropdown 自己的 update 也遵守同一条规则:子项全隐藏时整个下拉按钮隐藏。false 的语义从单个 item 一路传播到工具栏顶层,任何一级「没东西可显示」都会正确收缩。
应用层组织菜单时面对的是同一个嵌套数组:每个内层数组对应工具栏上的一段,段间分隔符由 renderGrouped 自动补上;Dropdown 和 DropdownSubmenu 可以当普通元素放进任何一层数组,菜单结构因此是纯数据,组合规则全部由这两个函数承担。
menubar 插件:包裹、floating 与焦点管理
menubar.ts 的 menuBar(options) 是这个包对外的主要入口,返回一个普通插件,全部逻辑在插件的 view 方法里 new 一个 MenuBarView。这回答了「一条工具栏怎么挂到编辑器上」:不修改 EditorView 本身,用插件 view 的生命周期接管。
构造函数先做 DOM 包裹:建一个 div.ProseMirror-menubar-wrapper,里面放 div.ProseMirror-menubar(role: toolbar),菜单条的 ariaControlsElements 指向 editorView.dom,声明它对编辑区域的控制关系;然后用 replaceChild 把 editorView.dom 从原父节点里换出来塞进 wrapper,options.position 决定菜单条在编辑器之前还是之后。renderGrouped 返回的 focusables 也在这里收集下来,供后面的键盘导航使用。插件销毁时 destroy 做反向操作,把 editorView.dom 换回原地,DOM 结构恢复原样。
update() 在每次 state 变化时被调,做三件事。一是根节点检查:editorView.root 变了(比如编辑器被挪进 shadow root),用 renderGrouped 重渲染整份内容。二是调 contentUpdate(state) 走上一节那条更新链,同时留意焦点:当前聚焦的项这次被隐藏了,用 findFocusableIndex 把焦点挪到下一个可见项。三是高度记账:非 floating 模式下记录菜单条出现过的最大高度并设成 minHeight,按钮随状态显隐导致菜单高度变化时,下面的编辑器不跟着上下跳;宽度变化时清零重新记账。
floating: true 开启浮动模式:编辑器被部分滚出视口时,菜单条切到 position: fixed 钉在视口顶部。updateFloat 拿 wrapper 的 getBoundingClientRect 判断:editorRect.top 小于参考 top 且 editorRect.bottom 还够放菜单(offsetHeight 加 10 像素余量)就进入浮动,否则复原。进入浮动时菜单条脱离文档流,原位置会塌,所以插一个等高的 spacer div 占位。滚动监听挂在 getAllWrapping 返回的所有祖先加 window 上,编辑器放进任何一层可滚动容器都能感知;wrapper 从文档里摘掉后监听器自行移除。isIOS() 用 userAgent 把 iOS 排除在 floating 之外,注释没写原因,iOS Safari 对 fixed 定位的滚动处理一直不稳是公开的问题。
浮动状态下 update 改走 updateScrollCursor:取当前 DOM 选区的矩形,选区被钉在顶部的菜单条盖住时,找最近的可滚动祖先把内容往下滚一段,让光标露出来。这是浮动工具栏特有的问题:菜单钉住不动,输入位置可能滚到它底下。
键盘方面菜单条整体是一个 toolbar:所有可聚焦项用 roving tabindex,只有当前项 tabindex 为 0,其余为 -1,Tab 把焦点带进工具栏一次,左右方向键在项间移动,与 Dropdown 内部的垂直导航共用 keyboardMoveFocus。
icons:三种形态与 symbol 复用
getIcon(root, icon) 的 icon 参数是三选一的联合类型(menu.ts 导出为 IconSpec):{path, width, height} 给 SVG 路径,{text, css} 给一段文本,{dom} 直接给一个 DOM 节点;返回值统一是 button.ProseMirror-icon。
SVG 分支做了去重。hashPath 对 path 字符串算一个 32 位哈希,图标 id 是 pm-icon- 加哈希的十六进制。buildSVG 在文档里维护一个隐藏的 svg#ProseMirror-icon-collection,第一次用到某个 path 时在里面建一个 symbol(viewBox 取自 width/height),按钮里只放一个 svg 加 use 指过去。同一个图标出现多次,路径数据只存一份。use 的 href 有个细节:引用值先用 /([^#]*)/ 从 location 里剥掉 hash 再拼上 #id,拼成完整的文档内地址。页面里有 base 标签时,裸的 fragment 引用会相对 base 解析,指不到当前文档的 symbol,这里提前把基址补全。
文本分支最简单:span 里放 textContent,css 直接附加。menu.ts 的 icons 表里 selectParentNode 就用的文本形态,字符是 ⬚(U+2B1A)加粗。dom 分支 cloneNode(true) 一份塞进按钮。
icons 表本身是十二个常用图标的集合(join、lift、selectParentNode、undo、redo、strong、em、code、link、bulletList、orderedList、blockquote),供预置 item 和应用层取用,不是必须使用的约定。
这套 UI 层为什么不进核心
读完整包可以回答这个问题。从依赖方向看,menu 站在核心四层之外:它 import view 和 state,还 import commands、history 这两个本身就是扩展的包,是命令体系的消费者。核心的 model、transform、state、view 对「编辑器上面有没有一条工具栏」一无所知,也不需要知道。
从扩展点看,menu 用到的全是公开接口:挂 DOM 用插件的 view 方法,状态同步用 update 回调,执行动作用命令签名,翻译走 view 的 props。换成任何一套 UI 方案,React 组件也好,浮动气泡也好,面对的扩展点是同一批。MenuElement 的 render/update 协议把编辑器「一次渲染、逐次更新」的周期压缩进一个接口,谁都可以实现。不想用这个包的成本也低:命令的 dry run 约定让任何框架都能自己算 enable 和 active,这个包提供的是一份现成的 DOM 实现和一组交互细节(焦点记账、键盘导航、浮动定位),后者恰恰是手写时最容易漏的部分。
工具栏长什么样是产品决策,命令能不能执行是文档状态问题。prosemirror-menu 只负责把后者翻译成前者,翻译规则(select 隐藏、enable 置灰、active 高亮、分组分隔符)又是通用度最高的那部分。再往上,菜单摆哪些项、什么顺序、什么图标,全部留给应用层,example-setup 给的是一份默认答案。
下一篇进入高级扩展阶段,看 collab:协作编辑怎么在第 16 篇 Mapping 的基础上做 rebase。

