上一篇说明 keymap 如何按按键定位 Command,本文分析命令本身。参考代码为 prosemirror-commands 的 52a84a8,整个包只有 src/commands.ts,约八百行。文件包含删除与光标命令、结构命令、参数化命令工厂(wrapIn、setBlockType、toggleMark、autoJoin)以及 baseKeymap。该包不提供插件,导出的函数可由 keymap、菜单或应用代码调用。
系列目录
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;未传入 dispatch 时,命令只判断是否适用并返回 boolean。因此,someCommand(state) 用于探测当前是否可用,someCommand(state, dispatch) 用于执行。菜单 UI 可据此决定按钮是否禁用,chainCommands 也依赖该返回值短路。文件中的 dispatch 调用都置于 if (dispatch) 内,探测路径不构造 transaction。setBlockType 在探测阶段通过 nodesBetween 和 canReplaceWith 扫描可行性,只有执行分支才创建 transaction。
第二条是返回值语义。true 表示命令适用且(在给了 dispatch 时)已执行,调用方可以停止后续处理;false 表示当前状态下这个命令无事可做,调用方继续问下一个。keymap 那一侧收到 true 会 preventDefault,收到 false 让事件继续传播,上一篇讲过这条链。
第三个参数 view 大多数命令用不到,用到的地方也很集中:atBlockStart 和 atBlockEnd 两个内部函数。判断光标是否在文本块开头时,有 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
}
}chainCommands 按数组顺序调用命令,第一个返回 true 的命令终止整条链。它将 dispatch 原样传入每个子命令,因此排在前面且适用的命令会直接执行,后续命令不会被探测。顺序同时定义执行优先级,通常将特殊场景置前、通用处理置后。
文件里用 chainCommands 预组了两个常量:backspace 是 chainCommands(deleteSelection, joinBackward, selectNodeBackward),del 是把 joinBackward、selectNodeBackward 换成 forward 版本的镜像。baseKeymap 的 Enter 键则是四级链。
baseKeymap 逐条拆
pcBaseKeymap 绑了八条,全部与具体 schema 无关:
Enter:chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock)。一条回车键为什么需要四个命令,因为「按回车想干什么」取决于光标上下文,四个命令按场景从特殊到一般排列:
- 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 这个内部函数,它按四种情况依次尝试:
- joinMaybeClear:切点前后两个节点类型兼容(compatibleContent)时,前节点为空就删前节点,否则直接 join 合并。
- 把后节点的内容包进前节点:对前节点的末尾算
contentMatchAt再findWrapping,能匹配就用 ReplaceAroundStep 把后节点内容塞进前节点尾部。列表项里按 Backspace 把段落并入上一项,走的是这条。 - 提升后节点:后节点可以 lift 且目标深度不小于切点深度时,把它 lift 上来一层。
- 两个文本块隔着壳的情况:前节点的最深层是文本块、后节点沿首个子节点下钻也是文本块,且前者的尾部装得下后者的内容,就用 ReplaceAroundStep 把后者的文本内容挪进前者,同时保留前者外面的壳。
四条尝试均不适用时,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 处理。
