commands:命令的签名约定与组合器

2 分钟阅读
·

上一篇 keymap 解决了按键怎么查到命令,查到的值是 Command,这篇看命令本身。参考代码是 prosemirror-commands 的 52a84a8,整个包只有一个 src/commands.ts,八百行上下。文件内容分四块:删除与光标类命令、结构类命令、参数化的命令工厂(wrapIn、setBlockType、toggleMark、autoJoin),最后拼出一张 baseKeymap。这个包本身不是插件,导出的全是普通函数,由 keymap、菜单或者你自己的代码决定在哪里调用。

系列目录

日期 标题
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:命令的签名约定与组合器(本篇)

Command 签名与 dry-run 惯例

Command 类型定义在 prosemirror-state 的 src/transaction.ts(参考代码是它的 ffad5d9):

export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean

三个参数里后两个都可缺省,返回值是 boolean。这个签名承载两条惯例。

第一条是 dry-run。dispatch 传了,命令就把构造好的 transaction 交出去执行;不传,命令只做适用性探测,直接返回 boolean。所以 someCommand(state) 的含义是「这个命令现在能不能用」,someCommand(state, dispatch) 才是「执行它」。菜单类 UI 靠这个决定按钮置灰,后面要讲的 chainCommands 靠这个短路。文件里所有命令都严格遵守:每个 dispatch 调用都包在 if (dispatch) 里,探测路径上不构造任何 transaction。setBlockType 把这个惯例执行得最彻底,它的探测阶段单独写了一遍 nodesBetween 加 canReplaceWith 的扫描,确认可行之后才进 dispatch 分支建 tr,dry-run 一次 transaction 都不碰。

第二条是返回值语义。true 表示命令适用且(在给了 dispatch 时)已执行,调用方可以停止后续处理;false 表示当前状态下这个命令无事可做,调用方继续问下一个。keymap 那一侧收到 true 会 preventDefault,收到 false 让事件继续传播,上一篇讲过这条链。

第三个参数 view 大多数命令用不到,用到的地方也很集中:atBlockStartatBlockEnd 两个内部函数。判断光标是否在文本块开头时,有 view 就走 view.endOfTextblock("backward", state)(prosemirror-view,参考代码是它的 ca4c78e),这是视觉维度的判断,对双向文本友好;没有 view 就退回 $cursor.parentOffset > 0 的纯文档位置判断。编辑器里 keymap 调命令时 view 一定在,所以实际运行走的是视觉判断。

另外注意一个动作上的统一:几乎每个命令 dispatch 前都在 transaction 上挂了 scrollIntoView(),执行完把光标滚进可视区。这个约定散落在每个命令里而不是集中在 dispatch 入口,自己写命令时不跟这条,长文档里按快捷键改结构会原地不动,视口不跟着走。

chainCommands:按顺序短路

组合器的全部实现:

export function chainCommands(...commands: readonly Command[]): Command {
  return function(state, dispatch, view) {
    for (let i = 0; i < commands.length; i++)
      if (commands[i](state, dispatch, view)) return true
    return false
  }
}

按数组顺序逐个调用,第一个返回 true 的命令终止整条链。注意它把 dispatch 原样传给了每个子命令,所以排在前面且适用的命令会直接执行,后面的连探测都不会发生。这里的「短路」有两层:执行短路(只有第一个适用的命令生效)和求值短路(后面的命令根本不被调用)。顺序因此就是优先级,写法上的约定是特殊场景在前、通用兜底在后。

文件里用 chainCommands 预组了两个常量:backspace 是 chainCommands(deleteSelection, joinBackward, selectNodeBackward),del 是把 joinBackward、selectNodeBackward 换成 forward 版本的镜像。baseKeymap 的 Enter 键则是四级链。

baseKeymap 逐条拆

pcBaseKeymap 绑了八条,全部与具体 schema 无关:

Enter:chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock)。一条回车键为什么需要四个命令,因为「按回车想干什么」取决于光标上下文,四个命令按场景从特殊到一般排列:

Enter 键命令链的短路求值

  • newlineInCode:光标在 spec.code 为真的节点(代码块)里时,插入 "\n" 文本。代码块里回车只换行,不分块。
  • createParagraphNear:选区两端不在 inline 内容里时(光标在普通段落里这条直接 false,典型的触发场景是选中一个块节点),在旁边建一个空段落。插在块前还是块后由位置决定:光标在父节点开头且后面还有兄弟,插前面,否则插后面。AllSelection 或者父级是 inline 内容时直接 false。
  • liftEmptyBlock:光标在空文本块里时,优先尝试 split(光标不在父容器末尾且 canSplit 通过时,把空块从父容器里分出去),分不了再用 liftTarget 提升一层。引用块里空段落按回车跳出引用,走的就是这条。
  • splitBlock:兜底,分裂当前块。它是 splitBlockAs() 的无参实例,splitBlockAs 是工厂,可以传回调定制分裂后新块的类型。默认实现的类型推导值得看:沿深度找到所在的块,光标在块尾(atEnd)时,新块类型取 defaultBlockAt,也就是父级内容表达式里第一个无必填属性的文本块。这就是标题末尾回车出来的是段落而不是新标题的原因。canSplit 带着类型数组先试一次,不行就把第一个类型换成默认类型再试,两次都失败才返回 false。还有一个对称处理:光标在块首(atStart)且当前块不是默认类型时,分裂后把留在原位置的那块 setNodeMarkup 回默认类型,效果是在标题开头回车,上面多出空段落,标题原样留在下面。

Mod-Enter:exitCode。在代码块里想出去时用它:找到代码块后面位置的默认块类型,canReplaceWith 通过后 replaceWith 插入新块并把光标挪过去。普通段落里这条返回 false。

Backspace、Mod-Backspace、Shift-Backspace:都绑到上面那个 backspace 常量。三个键同一行为,文件顶部的文档注释只列了前两个,Shift-Backspace 那条在代码里补的。链上三个命令的分工:

  • deleteSelection:选区非空就删掉选区内容,空选区返回 false。Backspace 按下时如果框选了一段内容,到这一级就结束了。
  • joinBackward:处理空选区且光标在文本块开头的场景,负责消除当前块和前一个块之间的距离,后面单独讲。
  • selectNodeBackward:前两步都处理不了时的兜底,把光标前面的节点整个选中(比如一张图片)。效果是删除被拆成两步:第一次 Backspace 选中,第二次由 deleteSelection 删掉。文档注释里写的用途是 schema 不允许在该点删除时的退路。

Delete、Mod-Delete:del 常量,joinBackward 换成 joinForward,selectNodeBackward 换成 selectNodeForward,逻辑完全镜像。

Mod-a:selectAll,把选区设为 AllSelection。

macBaseKeymap 在 pc 的基础上加了一组 emacs 风格键位:Ctrl-h 等同 Backspace,Ctrl-d 等同 Delete,Alt-Backspace 等同 Mod-Backspace,Ctrl-Alt-Backspace、Alt-Delete、Alt-d 等同 Mod-Delete,Ctrl-a 和 Ctrl-e 绑到 selectTextblockStart、selectTextblockEnd,即光标移到当前文本块首或尾。加完再把 pcBaseKeymap 全表拷进去。对外导出的 baseKeymap 按平台二选一,探测方式和 keymap 包同款:navigator.platform 匹配 Mac 或 iOS 设备,非浏览器环境退回 os.platform()

joinBackward 与 deleteBarrier

Backspace 链上分支最多的是 joinBackward,它处理的场景是「光标在块首,前面没有可删的字符」。先看它的骨架:

atBlockStart 确认光标在块首,findCutBefore 向上找切点:从光标的深度逐层向上,找第一个「该层索引大于 0」的位置,也就是前面还有兄弟节点的层,返回兄弟边界处的解析位置。任何一层节点的 spec 标了 isolating 就停止上爬,隔离节点内部的删除不许越界。

找不到切点,说明当前块在某个容器的第一位,前面没有兄弟,这时退化成 lift:blockRange 加 liftTarget 算出提升目标,把当前块从父容器里抬出去。找到切点,就交给 deleteBarrier 这个内部函数,它按四种情况依次尝试:

  1. joinMaybeClear:切点前后两个节点类型兼容(compatibleContent)时,前节点为空就删前节点,否则直接 join 合并。
  2. 把后节点的内容包进前节点:对前节点的末尾算 contentMatchAtfindWrapping,能匹配就用 ReplaceAroundStep 把后节点内容塞进前节点尾部。列表项里按 Backspace 把段落并入上一项,走的是这条。
  3. 提升后节点:后节点可以 lift 且目标深度不小于切点深度时,把它 lift 上来一层。
  4. 两个文本块隔着壳的情况:前节点的最深层是文本块、后节点沿首个子节点下钻也是文本块,且前者的尾部装得下后者的内容,就用 ReplaceAroundStep 把后者的文本内容挪进前者,同时保留前者外面的壳。

四条都不适用返回 false,joinBackward 还有自己的后续分支:当前块是空文本块且前面是文本块或可选节点时,删掉空块、把光标或选区放到前面;前面是 atom 节点时直接删掉它。整个函数体现的思路是逐级降级,每一级都先做可达性判断再动手,任何一级 schema 不允许就落到下一级。

另外几个值得一读的命令

joinBackward 还有两个受限变体 joinTextblockBackward 和 joinTextblockForward,注释里写明是 more limited form:不做 lift,不删 atom,只尝试把当前文本块和相邻的文本块合并。内部的 joinTextblocksAround 把切点两侧分别沿 lastChild、firstChild 下钻到文本块,路径上遇到 isolating 节点就放弃,然后用 replaceStep 计算删除步,还要求算出来的步起点和预期一致、插入内容小于被删范围,确认是真正的合并而不是改结构。schema-list 在列表里覆盖 Backspace 行为时用的就是这对变体,完整的 joinBackward 在列表内部动作太大。

splitBlockKeepMarks 是 splitBlock 的修饰版:包一层 dispatch,事务出来前把 storedMarks(或者光标处的 marks)用 ensureMarks 补回去。默认 splitBlock 分裂后新块不继承光标处的活跃标记,输入一个加粗中的换行会丢掉加粗;换用这个命令,新行继续带标记。代价只是 dispatch 被装饰了一次,命令本体完全复用。

selectParentNode 把选区扩大到包住当前选区的最近祖先块,用 $from.sharedDepth(to) 算公共深度,深度为 0 返回 false,不会选中文档节点本身。selectTextblockStart、selectTextblockEnd 由 selectTextblockSide 工厂生成,把光标挪到当前文本块的开头或结尾,上面 mac 键位的 Ctrl-a、Ctrl-e 用的就是它们。

结构类命令怎么消费 structure.ts

第 17 篇讲过 structure.ts 的可达性判断:canSplit、canJoin、joinPoint、liftTarget、findWrapping 这批函数只判断「能不能做」,不动文档(参考代码是 prosemirror-transform 的 662b7a9)。commands.ts 是它们最集中的调用方,几乎每个结构命令都是「structure.ts 判断 + Transform 执行」的两段式:

  • joinUp、joinDown:joinPoint 沿指定方向找到可合并的位置(NodeSelection 时直接用选区边界配 canJoin 验证),然后 tr.join(point)
  • lift:blockRange 拿到选区所在的块范围,liftTarget 算目标深度,tr.lift 执行。
  • wrapIn:工厂函数,传入节点类型返回命令。blockRange 加 findWrapping 算出包装序列,tr.wrap 执行。findWrapping 返回 null 就是包不进去,返回 false。
  • splitBlock:上面拆过了,探测靠 canSplit,执行靠 tr.split。
  • setBlockType:工厂函数,把选区里的文本块改成指定类型。它的 dry-run 探测是逐 range 扫描:nodesBetween 遍历,跳过非文本块和已经是目标标记(hasMarkup)的块,类型相同的直接算适用,类型不同的查 canReplaceWith。这个跳过逻辑带来一个推论:选区内所有文本块都已经是目标类型和属性时,命令返回 false,工具栏按钮因此自然置灰。执行阶段对每个 range 调 tr.setBlockType

标记类只有一个 toggleMark,同样是工厂。探测函数 markApplies 沿选区扫描,确认范围内有允许该标记的内联内容。空选区走 storedMarks:光标处已有这个标记就 removeStoredMark,没有就 addStoredMark,下一个输入的字符带上它。非空选区用 rangeHasMark 决定加还是删,默认行为是范围内已有就整体移除。两个细节:默认会把选区首尾的空白字符从加标记的范围里剥掉(dropSpace),加粗不会带上尾部空格;enterInlineAtoms 关掉时,removeInlineAtoms 会把被完整覆盖的内联 atom 节点从 ranges 里剔出去,标记不进 atom 内部。

最后提 autoJoin,它是命令的装饰器:包装 dispatch,在事务交给原 dispatch 之前扫描 mapping 覆盖过的范围,找出相邻且同类型、满足 isJoinable 谓词的节点对,从后往前逐个 join 进同一个事务。用途是某些结构操作(比如把列表项 lift 出来)会把一个同类型节点劈成相邻的两半,autoJoin 负责把它们再合并回去。isJoinable 传字符串数组时按节点类型名匹配。

小结

commands.ts 本身不发明新的编辑能力,它做两件事:用 (state, dispatch, view) 签名和 dry-run 惯例把「判断能不能做」和「做」统一进一个函数,让组合器和 UI 层可以低成本复用;把 model、transform、state 三层已有的判断函数组装成一组符合编辑器直觉的操作。baseKeymap 只绑与 schema 无关的键,列表、标题这类和具体文档结构绑定的命令在 schema-list 等包里,后面会读到。下一篇看 history,undo/redo 的栈怎么存,以及远程步骤进来时怎么 rebase。


927 字 · 54 段落
xi ming

Written by xi mingFollow onGitHub