example-setup:官方起手式是怎么装配的

3 分钟阅读
·

表格专题收尾后,进入这个系列的最后一个模块:工程与测试。第一篇回到整个系列最开始用过的那个包,prosemirror-example-setup。第 3 篇跑 demo 时靠它几十行代码搭起一个能用的编辑器,当时只把它当黑盒;前五十多篇已经把它依赖的每个包都拆过了,现在回头拆这个组装层本身。参考代码是 prosemirror-example-setup 的 b6fcf7a。

整个包 src 下只有五个文件:index.ts、keymap.ts、inputrules.ts、menu.ts、prompt.ts,加起来六百五十来行。它没有任何新机制,全部工作是把前面读过的基础扩展按一个固定顺序拼成一个插件数组。使用方式在 demo 里见过:exampleSetup({schema}) 返回的数组直接放进 EditorState.create 的 plugins 字段,一个能输入、能撤销、带菜单的编辑器就起来了。值得看的就两点:这个顺序为什么是这样,以及三个 build 函数怎么按 schema 按需装配。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理
08-08 collab(下):receiveTransaction 与整个收发循环
08-15 changeset:变更集的计算与展示
09-05 markdown:文档与 Markdown 的双向转换
09-19 search:查找替换插件
10-03 表格专题(上):表格 schema 与 TableMap
10-10 表格专题(中):CellSelection,矩形的选区
10-17 表格专题(下):addColumn/mergeCells 等编辑命令
10-24 columnresizing:列宽拖拽的实现
11-07 example-setup:官方起手式是怎么装配的(本篇)

exampleSetup 的组装清单

入口只有 exampleSetup 一个函数(src/index.ts),必传参数只有 schema。函数体分三段:先固定拼五个插件,再按开关追加两个,最后无条件补一个样式插件:

let plugins = [
  buildInputRules(options.schema),
  keymap(buildKeymap(options.schema, options.mapKeys)),
  keymap(baseKeymap),
  dropCursor(),
  gapCursor()
]
if (options.menuBar !== false)
  plugins.push(menuBar({floating: options.floatingMenu !== false,
                        content: options.menuContent || buildMenuItems(options.schema).fullMenu}))
if (options.history !== false)
  plugins.push(history())

return plugins.concat(new Plugin({
  props: {
    attributes: {class: "ProseMirror-example-setup-style"}
  }
}))

逐个看每个位置的角色。buildInputRules 是输入规则插件,「# 空格」变标题、「> 」变引用、智能引号这类行为都在它身上,机制见第 41 篇。接下来两个 keymap 插件:第一个绑的是 buildKeymap 按 schema 生成的快捷键,第二个绑 commands 包的 baseKeymap,也就是 Enter、Backspace 这些通用编辑按键,两包分别见第 38 篇第 39 篇。dropCursor 和 gapCursor 只管两件具体的事:拖拽时画插入位置指示线、在块节点之间给光标一个落脚点(第 45 篇第 44 篇)。

menuBar 和 history 是两个开关项。menuBar 用 pluginView 在编辑器上方挂一条菜单,floating 默认开,关掉则菜单固定在顶部不跟随滚动;菜单内容默认取 buildMenuItems(schema).fullMenu,可以用 menuContent 整体换掉。history 是撤销栈(第 40 篇),传 false 整个不装。两个开关都只控制装不装,装了就固定在数组末尾,想把 history 挪到别的位置只能放弃 exampleSetup 自己拼。最后那个匿名插件只做一件事:通过 attributes prop 给编辑器根节点加一个 class,挂上包内 style/style.css 里的示例样式。

另外注意 index.ts 顶部还有一行 export {buildMenuItems, buildKeymap, buildInputRules}。三个 build 函数是独立导出的,不想要整个预设、只想借用其中一层(比如只要这套快捷键表,菜单自己写)时可以直接 import 单个函数。这个导出策略和整个包的定位一致:它假定你会很快长到需要自己组装的那一天,提前把零件都摆在外面。

顺序为什么重要

第 22 篇讲过,view 查 handleKeyDown、handleTextInput 这类 prop 时是按插件数组顺序逐个问的,第一个返回真值的插件截获事件。keymap 包的文档把这点写得很直白:数组靠前的 keymap 先派发。exampleSetup 的顺序正是按这个语义排的。

最典型的是 Backspace。buildKeymap 把 Backspace 绑给 undoInputRule:如果用户上一个动作刚触发了一条输入规则(比如「1. 」刚变成有序列表),按 Backspace 应该撤销那次转换,而不是删字符。baseKeymap 里的 Backspace 是 deleteSelection、joinBackward、selectNodeBackward 串起来的命令链,管常规删除。两个插件都想处理 Backspace,谁在前谁先说。buildKeymap 的 keymap 排在 baseKeymap 前面,undoInputRule 先执行;它发现无可撤销的规则就返回 false,按键继续下传给 baseKeymap。顺序反过来装,输入规则的撤销就永远轮不到。

Enter 同理。schema 里有 list_item 时,buildKeymap 把 Enter 绑给 splitListItem,在列表项里按 Enter 是拆列表项;不在列表里时它返回 false,落到 baseKeymap 的 newlineInCode、createParagraphNear、liftEmptyBlock、splitBlock 命令链。一个键的行为由「当前上下文里第一个声称能处理它的命令」决定,插件顺序就是这个声明顺序。

exampleSetup 插件数组与两次事件分发

inputrules 插件排在整个数组的第一位,因为它挂的是 handleTextInput,在文本真正进文档之前拦一道。输入规则命中后它自己 dispatch 一个 transaction 并返回 true,这次输入就此结束,后面的插件和浏览器默认行为都不参与。除了 handleTextInput,这个插件还挂了一个 handleDOMEvents.compositionend:输入法组合结束后延迟一拍,用空字符串在光标处重跑一遍规则匹配,把 IME 期间漏掉的规则补上。把它放在任何 keymap 后面不影响正确性,因为两条路处理的本来就不是同一类事件;放最前表达的是「文本转换优先于一切」的意图。

剩下的位置相对随意。这里要分清插件系统的两类挂载点:事件类 prop(handleKeyDown、handleTextInput、handleDOMEvents 等)按数组顺序短路,排前面有优先权;而 StateField 的 apply、appendTransaction 这类钩子对所有插件都会各跑一遍,顺序只影响拿到 transaction 的先后,不影响能不能拿到。dropCursor、gapCursor 处理的是拖拽和选区类事件,不和前面的处理器冲突。menuBar 是 pluginView,根本不收按键。history 是 StateField 加 appendTransaction,undo、redo 命令的绑定放在 buildKeymap 里(Mod-z、Shift-Mod-z),命令本身靠 getMeta 和 history 插件的 key 取状态,也不要求 history 插件排在哪。所以这个数组里真正讲究的就是前两位:inputrules 最先,schema 快捷键在通用快捷键之前。

三个 build 函数:按 schema 装配

buildInputRules、buildKeymap、buildMenuItems 都以 schema 为唯一输入,包内 README 解释了为什么绕不开它:这些辅助函数需要拿到节点和 mark 类型的实例才能构造规则与命令,同时也需要知道自己认识的那些类型在当前 schema 里到底存在不存在。三个函数的写法因此完全一致:逐个检查 schema 里有没有叫某个名字的节点或 mark,有才生成对应的规则、按键或菜单项。以 buildInputRules(src/inputrules.ts)为例:

export function buildInputRules(schema: Schema) {
  let rules = smartQuotes.concat(ellipsis, emDash), type
  if (type = schema.nodes.blockquote) rules.push(blockQuoteRule(type))
  if (type = schema.nodes.ordered_list) rules.push(orderedListRule(type))
  if (type = schema.nodes.bullet_list) rules.push(bulletListRule(type))
  if (type = schema.nodes.code_block) rules.push(codeBlockRule(type))
  if (type = schema.nodes.heading) rules.push(headingRule(type, 6))
  return inputRules({rules})
}

智能引号、省略号、破折号转换是无条件的,其余五条规则各认一个节点名。schema 里没有 heading,headingRule 就不存在,输入「# 空格」不会有任何反应,也不报错。orderedListRule 值得多看一眼:wrappingInputRule 的第二个回调参数 (match, node) => node.childCount + node.attrs.order == +match[1] 决定已有列表是否接着编号,输入「3. 」时如果当前列表正好排到 3,就并入现有列表而不是新开一个。

buildKeymap(src/keymap.ts)的模式一样,只是多了一层 mapKeys 改写。内部所有绑定走一个 bind 函数:mapKeys[key] 为 false 就跳过这条绑定,是字符串就把按键名换掉。调用方想禁掉 Mod-b 或者把加粗改成 Mod-Shift-b,不用动函数本身。文件开头还有一个 mac 判定(navigator.platform 匹配 Mac|iP(hone|[oa]d)),影响两条绑定:非 Mac 才给 redo 补一个 Mod-y,Mac 才给 hard_break 补一个 Ctrl-Enter。绑定内容的安排能看出主次:undo/redo/undoInputRule 和 joinUp、joinDown、lift、selectParentNode 这些结构性命令无条件绑,mark 和节点的快捷键全部按 schema 条件绑。

条件绑定里有两处写法值得学。hard_break 的 Mod-Enter 绑的是 chainCommands(exitCode, 插换行) 两条命令的组合:在代码块里 Mod-Enter 先尝试 exitCode 跳出代码块,跳不出去才插 hard_break,一个键在两种上下文里做两件事。horizontal_rule 的 Mod-_ 绑定是一行内联命令,replaceSelectionWith(hr.create()) 之后链一个 scrollIntoView(),保证插入后视口滚到分割线位置。这两处都是 commands 包组合器的标准用法,buildKeymap 相当于给出了一份搭配示例。

buildMenuItems(src/menu.ts)是三个函数里最啰嗦的,因为每个菜单项都是 new MenuItem 加 enable、active、run 的组合。两个辅助函数承担了大部分重复劳动。cmdItem 把一条命令包成菜单项,调用方没给 enable 和 select 时自动补一个 state => cmd(state):利用命令的 dry-run 惯例(dispatch 不传时只检查可行性)决定按钮亮不亮,菜单项的可用性判断和 keymap 的按键拦截用的是同一份逻辑。markItem 在 cmdItem 之上再补 active,active 的判定分两种情况:光标态查 storedMarks 或 $from.marks(),范围选区查 doc.rangeHasMark,加粗按钮的高亮状态就是这么来的。

buildMenuItems 的返回值是按用途命名的对象:toggleStrong、insertImage、makeHead1 到 makeHead10、wrapBulletList 等等,外加组装好的 insertMenu、typeMenu、inlineMenu、blockMenu 和 fullMenu。装配时用 cut = arr => arr.filter(x => x) 把 undefined 项滤掉,schema 里缺的节点对应的菜单位置直接消失,不会留灰按钮。fullMenu 的分组顺序是行内 mark 一组,Insert 和 Type 两个下拉一组,undo/redo 一组,块级操作(列表、引用、joinUp、lift、selectParentNode)一组。一个细节:heading 的循环跑到 10 级,makeHead7 以上也生成了,但 typeMenu 的 Heading 子菜单只收前六级。

prompt.ts:一个不走插件体系的弹层

prompt.ts 是这个包里唯一和编辑器内核没有关系的文件。openPrompt 直接往 document.body 上 append 一个绝对定位的 div,按 getBoundingClientRect 算出的尺寸居中,里面是一个原生 form:每个字段调 Field.render() 生成 DOM,提交时 getValues 逐字段处理,先 read 取值,再 validate,最后 clean 归一化,任何一个字段校验失败就调 reportInvalid 在字段旁边插一条 1.5 秒后自动消失的错误提示,整个提交作废;全部通过才执行 callback(attrs)。validate 的检查顺序也有讲究:先看 required 空值,再跑子类的 validateType,最后才轮到构造参数里传入的自定义 validate。Field 是个抽象类,规定 render/read/validateType/clean 四个口子,包内只实现了 TextField(单行 input)和 SelectField(原生 select)。关闭路径有四条:Esc、点取消、mousedown 落在弹层外,以及 Tab 把焦点移出弹层(500 毫秒后检查 document.activeElement 还在不在弹层里)。mousedown 那一条靠 setTimeout 延迟 50 毫秒才注册的监听器实现,避免打开弹层的那次点击顺手把它关掉。

menu.ts 里有两个消费者。insertImageItem 的 run 打开 openPrompt 收 src、title、alt,alt 的默认值取当前选区文本;linkItem 在选区已有 link 时直接 toggleMark 去掉,没有时开弹层收 href 和 title。两个 callback 里都先 dispatch 再 view.focus(),把焦点还给编辑器。reportInvalid 上方挂着一行 FIXME 注释:「this is awful and needs a lot more work」。这行注释基本给整个文件定了性:它是给 demo 用的最小可用弹层,够用即可,不追求成为组件。

值得留意的是这套东西完全绕开了 view 的 props 和 Decoration 体系,不走编辑器内部渲染,弹层出现时编辑器本身没有任何状态变化。示例代码在这里选择实用主义:弹层是 UI 层问题,不值得为它动编辑器状态。

从这里到生产级编辑器缺什么

exampleSetup 的 doc 注释自己写了结论:Probably only useful for quickly setting up a passable editor,真实场景大多需要更细的控制。对照着这份代码,「更细的控制」具体指几块。

先说一个前提。exampleSetup 能写得这么薄,靠的是插件系统的合并能力:每个扩展包各自产出一个普通插件,props 由 view 统一合并分发,StateField 由 state 统一调度,组装层不需要任何注册中心或生命周期管理,一个数组字面量加两次条件 push 就是全部胶水。这是第 22 篇第 23 篇那套设计在消费端的直接兑现。也正因为组装层薄,它的简化都暴露在明面上。

第一,三个 build 函数认死节点名。schema.nodes.blockquote、schema.marks.strong 这些名字来自 schema-basic 和 schema-list 的约定,自己的 schema 换了名字,绑定静默消失,没有任何提示。生产装配通常显式列出规则,不做这种按名探测。

第二,功能面只覆盖单人基础编辑。没有 collab,没有 placeholder,没有表格,没有搜索替换,菜单项的 enable 判断也是粗粒度的 canInsert 沿选区深度逐层向上查。这些在前面各篇里都是独立的包,exampleSetup 一个都没装。开关也只有 menuBar 和 history 两个,粒度到「整个插件装不装」为止,想保留菜单但换掉其中几项,就得走 menuContent 整体覆盖或者干脆自己调 buildMenuItems 重新分组。

第三,UI 是演示级。菜单栏的图标和样式、prompt 的裸 form、example-setup-style 那份 CSS,都是「能看出来是什么」的程度,样式也没有主题变量之类的定制口。

所以这个包适合当装配模板读,直接当依赖用的场景基本只剩 demo 和原型验证。它给出的最有价值的东西就是那一页数组:inputrules 在前,schema 快捷键在通用快捷键之前,StateField 类插件的位置随便放,UI 插件和样式插件垫后。自己装编辑器时把每一层换成自己的实现,顺序照搬,就踩不到分发优先级的坑。到这一步,从 model 到扩展的五十五篇积累可以完整解释一次按键从落到键盘到变成菜单高亮的每一环,exampleSetup 只是把这些环串起来的最短路径。下一篇看同阶段的另一个工程件:prosemirror-test-builder,各包测试里 doc(p(”…“)) 那种写法是怎么实现的。


1146 字 · 34 段落
xi ming

Written by xi mingFollow onGitHub