这是 transform 阶段的第四篇。前几篇看了 Step 的抽象、ReplaceStep 的内部和位置映射,回答的是一步修改怎么表示、怎么应用、怎么换算位置。还有一组问题没碰:回车能不能在这里分段、两个块能不能合并、选中的块能不能提出去、能不能包进引用块。这些「能不能」的判断集中在 src/structure.ts,同文件还放着 split、join、lift、wrap 几个执行函数,Transform 类上的同名方法就是一行转调。参考代码是 prosemirror-transform 的 662b7a9。
系列目录
| 日期 | 标题 |
|---|---|
| 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 的可达性判断(本篇) |
判断函数与执行函数分放两堆
structure.ts 的导出可以分两堆。判断的一堆:canSplit、canJoin、joinPoint、liftTarget、findWrapping、insertPoint、dropPoint,全是纯函数,输入文档和位置,返回布尔值、深度、位置或者 null,不碰文档。执行的一堆:split、join、lift、wrap、setBlockType、setNodeMarkup,第一个参数都是 Transform,内部构造 ReplaceStep 或 ReplaceAroundStep 塞进 tr.step。src/transform.ts 里 Transform 类的 split、join、lift、wrap、setBlockType、setNodeMarkup 方法各自只有一行转调。
这个拆分的受益者是命令体系。prosemirror-commands 的每个命令先跑判断函数决定自己适不适用,不适用就返回 false 让 chainCommands 试下一个;判断通过才 dispatch 执行。判断和执行共用同一套 schema 校验原语:Node.canReplace、Node.canReplaceWith、ContentMatch,都来自 prosemirror-model(参考代码是 prosemirror-model 的 6264de0)。校验原语只有一份,判断说能做的事,执行做出来一定合法。
canSplit:每一层都合法才算能分
src/structure.ts 的 canSplit(doc, pos, depth, typesAfter) 回答「在 pos 处把 depth 层祖先各自切成前后两个节点,合法吗」。depth 默认 1,typesAfter 可选,指定分裂后新节点用什么类型,数组顺序由外到内。
先看最内层的四个拦截条件:
if (base < 0 || $pos.parent.type.spec.isolating ||
!$pos.parent.canReplace($pos.index(), $pos.parent.childCount) ||
!innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount)))
return false分裂把父节点切成两半,前半留在原节点里,后半要成为新节点的内容。后两个条件分别问两件事:把后半从原节点拿掉是否合法(canReplace(index, childCount),即把 index 到末尾替换成空);后半作为新节点的内容是否合法(validContent,新节点类型取 typesAfter 末项,没给就同类型)。isolating 节点不许分裂穿透,base 小于 0 说明 depth 超过了当前深度,直接否。
最内层过了之后循环向上,每层做同样的两半检查,isolating 也逐层再问一次,差别在于 typesAfter 会参与构造:这一层指定了新类型时,后半段 rest 的第一个孩子要先换成下层的新类型节点,再拿 after.type.validContent(rest) 验。最后落到 base 层,问 canReplaceWith(index, index, baseType),即分裂产生的两个并排节点能不能立在 base 层里。整条链任何一环不过都是 false。
typesAfter 的典型场景是回车:在 heading 末尾按回车,新分裂出来的块应该变回 paragraph 而不是再来一个 heading。splitBlock 会给 typesAfter[0] 填上默认块类型。
拿一个具体例子过一遍。文档是 doc(blockquote(p(“ab”), p(“cd”))),光标在 “cd” 中间,pos 指向段落内部,$pos.depth 为 2,调用 canSplit(doc, pos, 2)。最内层检查段落 p(“cd”):分裂点之后的内容能从原段落拿掉,作为新段落的内容也合法。注意这两个检查按 child 粒度进行,光标在 “cd” 中间时,被检查的是分裂点所在的整个文本节点而不是只有 “d”,文本节点可以整进整出,段落也允许变短,两关都过。循环向上一层到 blockquote:分裂点所在段落之后没有其他兄弟,canReplace(index + 1, childCount) 平凡成立;rest 是分裂点所在段落本身,作为分裂后新 blockquote 的内容也合法。最后落到 doc 层:canReplaceWith 确认两个并排的 blockquote 能立在 doc 里。三关都过,返回 true。如果 schema 里 blockquote 的内容表达式是「恰好一个段落」之类不允许截断的写法,第二层就会卡住,整个判断返回 false,回车键的命令链接着往下走。
执行的 split(tr, pos, depth, typesAfter) 做的事和检查对应:从 $pos.depth 往下走 depth 层,逐层复制 before 壳和 after 壳(after 侧用 typesAfter 指定的类型,没给就复制原类型),最后一步 ReplaceStep(pos, pos, Slice(before.append(after), depth, depth), true)。slice 的 openStart 和 openEnd 都等于 depth,意思是这条 slice 的两端各打开 depth 层,嵌进 pos 时正好把原节点撑成两半。
canJoin 与 joinPoint:合并的门槛
canJoin(doc, pos) 很短:
let $pos = doc.resolve(pos), index = $pos.index()
return joinable($pos.nodeBefore, $pos.nodeAfter) &&
$pos.parent.canReplace(index, index + 1)两个条件。parent.canReplace(index, index + 1) 问父节点是否允许把 after 这个节点拿掉,合并之后 after 的内容进了 before,after 本体消失,父节点的内容表达式要仍然成立。joinable 要求两侧节点都存在、before 一侧不是叶节点,并且内部函数 canAppendWithSubstitutedLinebreaks 通过:取 before 末尾的 ContentMatch,逐个匹配 after 的子节点,最后 match.validEnd 必须成立。匹配时把 linebreakReplacement 类型的孩子折算成 text 再匹配,因为 join 会在 pre 块和普通块之间合并时做 hard_break 与 \n 的互转,校验要按折算后的类型走;每个孩子的 marks 也要被 before 的类型允许,allowsMarks 不过就合并不了。
joinPoint(doc, pos, dir) 在 canJoin 之上加了一层搜索:从 pos 最深一层开始向上找可合并的边界。最深一层取 nodeBefore 和 nodeAfter;往上每层按 dir 取相邻的两个节点,dir 为 -1 找前面的,为 1 找后面的。命中条件里有一条值得注意:before.isTextblock 为真时跳过。文本块的合并不走 tr.join,那条路由 joinBackward 和 joinForward 里直接构造 ReplaceStep 的 joinTextblocksAround 处理,join 只负责块容器之间的合并。
执行的 join(tr, pos, depth) 本体很短:一步 ReplaceStep(start, mapping.map(pos + depth, -1), Slice.empty, true),删掉的就是两个节点之间 2 * depth 个边界位置,每层一对开闭标签。真正占篇幅的是删除前后的清理:目标块是普通文本块且支持 linebreakReplacement 时,合并完成后把并入内容里的 \n 换成 hard_break;目标是 pre 块且不支持 linebreak 时反过来,合并之前先把 after 里的 hard_break 换成 \n。marks 层面还要调 clearIncompatible(src/mark.ts),把 before 类型不允许的 mark 从即将并入的内容里清掉。清理本身也是 step,所以后面取位置时一律走 tr.mapping.slice(mapFrom) 映射,不直接用旧位置。这套「先清理、再映射、最后删边界」的顺序在 setBlockType 里又出现了一次。
同文件的 setBlockType 和 setNodeMarkup 不算结构移动,也走 ReplaceAroundStep:gap 取节点内容区间(pos + 1 到 pos + nodeSize - 1),slice 放一个只带新类型和 attrs 的空壳,内容保留、外壳换掉。setBlockType 多了两步防御:canChangeType 先确认父节点容得下新类型,clearIncompatible 清掉新类型容不下的 mark 和节点,换向 pre 类型时同样处理换行符折算。setNodeMarkup 在替换前用 type.validContent(node.content) 兜底,内容不合法直接抛 RangeError,叶节点则退化成普通的 replaceWith。
liftTarget:向上找能容纳的深度
lift 把 range 覆盖的内容从嵌套层里提出来,liftTarget(range) 负责算出能提到哪一层。代码不长,信息量集中在循环里:
let parent = range.parent
let content = parent.content.cutByIndex(range.startIndex, range.endIndex)
for (let depth = range.depth, contentBefore = 0, contentAfter = 0;; --depth) {
let node = range.$from.node(depth)
let index = range.$from.index(depth) + contentBefore, endIndex = range.$to.indexAfter(depth) - contentAfter
if (depth < range.depth && node.canReplace(index, endIndex, content))
return depth
if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break
if (index) contentBefore = 1
if (endIndex < node.childCount) contentAfter = 1
}
return nullcontent 是要往上提的那串兄弟节点。循环从 range.depth 逐层向上,每层问两个问题。注意第一轮 depth 等于 range.depth 时,返回条件 depth < range.depth 不成立,这一轮只做 canCut 检查和标志设置,命中最早发生在第二轮,也就是说 lift 至少要把内容提出一层。先问这一层之上容不容得下 content:node.canReplace(index, endIndex, content) 的意思是用 content 替换掉包住它的那一层节点,等价于问 content 能不能立在上一层的这个位置。容得下就返回当前 depth。容不下再问能不能把当前层切开继续向上:内部函数 canCut(node, index, endIndex) 检查切点前后各自剩下的内容对当前层类型仍然合法,切得动就把 contentBefore 或 contentAfter 置 1 再往上走。到顶(depth == 0)、撞上 isolating 节点、或者切不动,三条路都返回 null。
contentBefore 和 contentAfter 这对标志值得停一下。index 和 endIndex 描述的是当前层里「包住 content 的节点区间」。往上一层之后,如果当前层在切点前面还有内容,切开后当前层节点变成前后两个壳,content 前面多出一个壳的位置,上一层的 index 就要加 1;endIndex 对称地减 1。两个布尔值把上层区间的偏移压缩成了两行赋值。
执行的 lift(tr, range, target) 是这个判断的镜像。它从 depth 往 target 走两个循环:切点前面有内容的层,复制一份壳塞进 before 片段并把 openStart 加一;前面没有内容的层,把替换起点 start 往前挪一格,让空壳直接被删。还有一个 splitting 标志负责传递:只要某个内层进入过复制壳的分支,更外层即使切点前没有内容也得跟着复制壳,因为内层已经断开,外层的壳不可能再保持完整。after 一侧对称。最后构造 ReplaceAroundStep(start, end, gapStart, gapEnd, Slice(before.append(after), openStart, openEnd), before.size - openStart, true)。insert 取 before.size - openStart,因为 before 里有 openStart 层是打开的壳,剩下的才是插在 gap 前的实体内容长度。ReplaceAroundStep 的 gap 语义第 14 篇看过,lift 是它的标准用例。
findWrapping:外侧和内侧各问一次自动机
findWrapping(range, nodeType, attrs, innerRange) 回答「把 range 的内容包进 nodeType,需要哪些包裹节点」。返回值是一条节点链,由三段拼成:around 加目标类型加 inner。两段查找各问一次内容表达式自动机,用的是 model 层 ContentMatch 上的 findWrapping 方法(prosemirror-model/src/content.ts),第 6 篇讲的自动机在这里承担结构查询。
findWrappingOutside 问外侧:parent.contentMatchAt(startIndex).findWrapping(nodeType),从 range 所在位置的匹配状态出发,要到达「能放 nodeType」的状态,中间需要垫哪几层节点。这条链是 around。链的最外层还要过一道 canReplaceWith(startIndex, endIndex, outer),确认它能替换掉 range 覆盖的现有节点。
findWrappingInside 问内侧:nodeType.contentMatch.findWrapping(第一个孩子的类型),从 wrapper 的内容起点出发,到能放下 innerRange 的第一个子节点,中间需要垫哪几层。拿到链之后还不算完,用链上最后一层的 contentMatch 从 innerRange 的第一个子节点开始逐个匹配,全部匹配且 validEnd 成立才返回。
两个方向都通才拼出结果,任何一段找不到就是 null。
innerRange 参数默认等于 range,分开传的场景在 prosemirror-schema-list 的 wrapRangeInList:选区在一个列表的第一项开头时,目标是把内容并进前一个同类型列表,于是 outerRange 取前一个列表末尾的空 range(决定壳放在哪),innerRange 仍是真正的选区(决定壳里要容下什么)。外侧的放置位置和内侧的容纳对象分开算,一个函数就覆盖了「新建列表」和「并入现有列表」两种形态。
举例:把两个并排段落包进 blockquote,schema 里 doc 和 blockquote 的内容都是 block+,around 为空(当前位置直接能放 blockquote),inner 为空(blockquote 直接能放 p),结果就是单层 [{type: blockquote}]。包进列表类型时,list 要求内容是 list_item,inner 会给出 [list_item],结果是两层链。执行的 wrap(tr, range, wrappers) 把这条链从里向外逐层 create,造出一条嵌套壳,用 ReplaceAroundStep 套在 range 内容外面。注意 wrap 是整体套一层链,range 里的多个块一起进同一个壳;列表那种每一项各套一层的效果由 schema-list 的命令自己分组后再调 wrap,本篇不展开。
commands 怎么消费这些判断
prosemirror-commands 的 src/commands.ts(参考代码是 prosemirror-commands 的 52a84a8)第一行 import 就是 joinPoint、canJoin、findWrapping、liftTarget、canSplit 这一串。逐个看消费点:
- splitBlock 由 splitBlockAs 生成,Enter 键链的最后一棒。它先按当前块类型组装 types,末尾分裂时用 defaultBlockAt 找默认块类型填进 types,然后 canSplit(tr.doc, splitPos, types.length, types)。第一次不过会把 types[0] 换成默认类型再试一次,还不过才返回 false。canSplit 的 typesAfter 参数主要就是为它准备的。
- lift 命令和 liftEmptyBlock 共用 blockRange 加 liftTarget 的组合:to) 造出 NodeRange,liftTarget 算出目标深度,null 就返回 false。liftEmptyBlock 多一步:空块后面还有兄弟时先 canSplit(state.doc, before) 试着把父节点分裂开,分不动才走 lift。
- wrapIn 就是 findWrapping 加 tr.wrap,wrapping 为 null 返回 false。
- joinUp 和 joinDown 按选区类型分流。NodeSelection 选中整个块时直接 canJoin;文本选区用 joinPoint 向上搜可合并的边界,搜不到返回 false。
- joinBackward 里,光标在块首且 findCutBefore 找不到前序兄弟时,说明这个块是父节点的第一个孩子,后退一步的语义变成把它提出去,走 liftTarget。deleteBarrier 处理更复杂的合并:前一块末尾的 ContentMatch 能包住后一块时,用 ReplaceAroundStep 把后一块塞进去,然后 canJoin 检查塞进去的节点能不能顺手和邻居合并。ContentMatch.findWrapping 在这里又出现了一次,可见这个自动机查询是结构判断的通用零件。
- autoJoin 包装别的命令的 dispatch:扫描 transaction 改动区间里相邻的同类型节点,再过一个调用方传入的 isJoinable 谓词(或者直接给一个节点类型名数组),都通过且 canJoin 成立就补一个 tr.join。它的用途是把「改完文档后留下两个本该合并的相邻块」收尾成净结果。
文件里还有 insertPoint 和 dropPoint 两个找插入位置的判断。insertPoint 在 pos 处直接试 canReplaceWith,不行且光标在节点开头或结尾时逐层向上找能插入的边界,中途遇到前面还有兄弟就放弃。commands 没有消费它,留给外部命令用。dropPoint 针对 slice 找落点:先沿 slice 的 openStart 逐层下钻到实际内容,再从 mouse.pos, slice) 算的(参考代码是 prosemirror-view 的 ca4c78e)。
小结
structure.ts 的判断函数有一个共同形状:把「能不能做」翻译成一串 canReplace、canReplaceWith、validContent、ContentMatch 匹配的组合,从最深的层逐层向上验证,任何一环不过就否决。执行函数则是判断的镜像,lift 剥壳、wrap 造壳、split 一分为二、join 合二为一,全部落到 ReplaceStep 和 ReplaceAroundStep 两种 step 上。到这一篇,transform 包的零件基本看完了,下一篇看把这些零件装在一起的 Transform 类:docs 和 mapping 两个数组怎么随 step 增长,replace、delete、insert 这些便捷方法怎么归一到 replaceStep。

