上一篇看了 Step 抽象和几个简单的 step 实现。这篇读 transform 包里分量最重的两个文件:src/replace_step.ts 的 ReplaceStep 与 ReplaceAroundStep,src/replace.ts 里负责给 slice 找落点的 Fitter。第 8 篇留过一个话头:model 层的 replace 要求 slice 严丝合缝,合不上就抛 ReplaceError。实际编辑里粘贴、lift、wrap 产生的 slice 形状各不相同,事先并不知道目标位置长什么样,总得有东西负责把它修剪、补全到合得上。这个负责的东西就是 Fitter。参考代码是 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,最复杂的一步(本篇) |
ReplaceStep:三要素加一面 structure 旗
ReplaceStep 的字段是 from、to、slice 三个要素加一个 internal 的 structure 旗标。apply 的流程:structure 为真时先用 contentBetween 检查 from 到 to 之间是不是纯粹的关开标签序列,中间夹着真实内容就返回失败 “Structure replace would overwrite content”。这个检查是给 rebase 场景预备的:step 被映射到别人的文档版本上之后,原本空着的位置可能已经被别的 step 填了内容,直接覆盖会丢数据,structure 步宁可失败。检查通过后交给 StepResult.fromReplace(doc, from, to, slice):try 里调 doc.replace,ReplaceError 被接住转成 StepResult.fail,其余异常继续抛。上一篇说过 step 用失败结果而不是异常表达「应用不上」,fromReplace 就是 model 层异常到 step 层失败值的转换点。
contentBetween(doc, from, to) 的实现值得看一遍。resolve from 之后先向上走:只要当前深度上 from 落在最后一个子节点之后(indexAfter(depth) == childCount),说明从 from 到 to 跨过的是一个关标签,depth 和剩余距离各减一。向上走完还有剩余距离,就沿 firstChild 链向下走,途中遇到叶节点或者该有子节点的位置没有子节点,说明中间真有内容,返回 true。除了一次 resolve,整个函数一次遍历完成「关标签序列接开标签序列」的形状判断,不构造任何临时对象。
getMap 返回 StepMap([from, to - from, slice.size]),一段区间,旧尺寸换新尺寸。invert 用 doc.slice(from, to) 把被替换掉的内容切回来做成反向 step,新区间是 from 到 from + slice.size。merge 处理两个相邻 replace 的合并,两个方向:本步插入内容的末尾正好贴着下一步的 from(this.from + this.slice.size == other.from),且接缝两侧都没有打开边界(this.slice.openEnd 与 other.slice.openStart 都是 0)时,两个 slice 的 content 拼接,合成一个 ReplaceStep;下一步的 to 贴着本步的 from 时对称处理。连续输入产生的逐字符 replace 在这里被不断并成大步,撤销时按合并后的单位回退。
类上还有个静态字段 MAP_BIAS,默认 1:插入型 step 映射过同一位置的另一次插入时,默认排到插入内容之后。注释写明这个开关为协作场景准备,redo 的插入落点可能因为默认行为跑到意外位置,设成 -1 可以改成保持在插入之前。
ReplaceAroundStep:把中间一段留下来
ReplaceAroundStep 在 from、to 之外多三个字段:gapFrom、gapTo 限定被替换区间里要保留的子区间,insert 是 slice 内部的一个位置,保留内容要搬到那里。apply 的流程:structure 为真时对 gap 两侧(from 到 gapFrom、gapTo 到 to)分别跑 contentBetween;doc.slice(gapFrom, gapTo) 切出 gap,要求它是扁平区间,openStart、openEnd 都必须为 0,否则失败 “Gap is not a flat range”;然后 slice.insertAt(insert, gap.content) 把保留内容塞进 slice,塞不进去(insertAt 内部有 canReplace 校验)失败 “Content does not fit in gap”;最后照旧 fromReplace。insertAt 是第 8 篇提过的 Slice internal 方法,参考代码 prosemirror-model 的 6264de0。
getMap 返回一个两段区间的 StepMap:[from, gapFrom - from, insert, gapTo, to - gapTo, slice.size - insert]。gap 里的内容没有动,不参与映射;gap 之前的旧内容换成 slice 前 insert 大小的部分,gap 之后的旧内容换成 slice 剩下的部分。位置映射的形状和文档变化的形状完全对应。
invert 的构造稍绕,对照着读:新 gap 是本步应用之后位于 from + insert 处、长度为 gapTo - gapFrom 的那段内容,也就是刚被搬进去的保留内容;新 slice 用 doc.slice(from, to).removeBetween(gapFrom - from, gapTo - from) 得到,即被替换掉的旧内容挖掉 gap 之后的部分;新 insert 是 gapFrom - from。removeBetween 同样是 Slice 的 internal 方法。整个 invert 就是把「搬进去」倒放成「搬出来」,撤销一次 lift 时靠它把内容塞回原壳。
为什么需要这个 step:有一类结构操作是「外壳换掉,内容留下」。src/structure.ts 里四处用到它:
- lift:把选区从几层父节点里提上来。gap 是要提升的内容,slice 是被切开的父节点链的前半截与后半截(before、after 两个 Fragment),openStart、openEnd 记录各打开几层,insert 指向 before 的末尾,提升的内容正好落回前后两个半截之间。
- wrap:给选区包一层新节点。gap 就是整个选区(gapFrom == from,gapTo == to),slice 是 findWrapping 算出的包裹节点壳,insert 是壳的层数,内容被搬进壳的最里层。
- setBlockType 与 setNodeMarkup:换节点类型、属性或 marks。gap 是节点的内容(pos + 1 到 pos + nodeSize - 1),slice 只有新外壳一个节点,insert 是 1,旧内容整个搬进新壳。
这四个场景共用同一个形状:新结构包住旧内容,旧内容在文档里的相对位置不变。用普通 ReplaceStep 表达也能做到,但要把内容先切出来再拼进 slice,「这块内容还是那块内容」的信息就丢了。ReplaceAroundStep 把 gap 留在原地,映射时它不增不减,别的 step 打在 gap 里的位置照常落在内容上。这是它独立存在的理由。
replaceStep 与 trivial 快路径
src/replace.ts 的 replaceStep(doc, from, to, slice) 是 Transform.replace 背后的入口(transform.ts 里 replace 方法直接调它)。空 slice 加空区间先返回 null,不产生 no-op step。然后 fitsTrivially 做一次廉价判断:slice 两侧都没打开,to 在同一个父节点的同一段内容里(to.start()),且父节点 canReplace 通过。三条都满足说明这次替换是一个父节点内部的平级替换,直接 new ReplaceStep(from, to, slice) 收工。段落里输入文字、平级替换几个块都走这条路,不碰 Fitter。
不满足才 new Fitter(to, slice).fit()。典型触发场景是粘贴:剪贴板解析出的 slice 带着 openStart、openEnd,目标位置的形状事先不知道,必须现算。
Fitter:frontier、placed、unplaced
Fitter 把「slice 放进文档缺口」建模成一个搬运过程。文件头部的注释给了心智模型:slice 左边缘打开的每一层节点,其内容都可以独立放置。<p(“foo”), p(“bar”)> 左边缘打开时,“foo” 可以单独放到缺口左侧某处,去向和 p(“bar”) 互不绑定。搬运进度用三块状态记录:
- frontier:{type, match} 的栈,表示替换缺口左缘当前打开的节点。构造时初始化为 from.indexAfter(i)),即「这个节点在 $from 处已经匹配到哪儿」。
- placed:已放置内容的 Fragment。构造时初始化为 from 里,右缘隐含在 frontier 里。
- unplaced:还没放下的 slice,初始就是传入的 slice。
fit() 的主循环很短:
while (this.unplaced.size) {
let fit = this.findFittable()
if (fit) this.placeNodes(fit)
else this.openMore() || this.dropNode()
}有东西没放完就找一对可放的深度,找到就搬;找不到就把 slice 再打开一层(openMore),连打开都不行就丢弃打不开的节点(dropNode)。循环保证终止:每轮要么 unplaced 缩小,要么打开深度加一,打开深度受 slice 内容限制,到顶只能 dropNode。
findFittable:两趟扫描
findFittable 在 unplaced 的左缘链和 frontier 之间找一对深度 {sliceDepth, frontierDepth}。扫描前先做一轮修剪:沿 slice.content 的 firstChild 链往下走 openStart 层,途中某层 childCount 大于 1 就把局部的 openEnd 清零(该层有多个子节点,右边缘实际没有打开到那层);遇到 isolating 节点且 openEnd 不超过当前深度时,把可放置的起始深度截断到它外面,不放任何内容进 isolating 边界。
之后分两趟。第一趟从修剪后的 sliceDepth 往下到 0,对每个 sliceDepth 取出该层的 fragment(sliceDepth 为 0 就是整个 slice.content,否则取左缘链上对应节点的内容),再从 frontier 栈顶向栈底逐层试:fragment 第一个子节点的类型能被该层 match.matchType 匹配,或者 match.fillBefore(Fragment.from(first), false) 能算出需要补的前置节点(记为 inject),都算找到;fragment 为空时退化为父节点类型比较 type.compatibleContent(parent.type)。第二趟才允许包节点:match.findWrapping(first.type) 给 first 找一串包裹类型,让它在语法上能进这一层。先不包、后包的顺序有实际意义:能不引入新节点就不引入,粘贴结果尽量保持浅。另有一条早退规则:某层 frontier 的 match 连 parent 的类型都能匹配时,说明 parent 本身适合落在更浅的位置,当前 sliceDepth 的 frontier 扫描提前结束,换更浅的 sliceDepth 再试。
placeNodes:搬一次发生什么
placeNodes 拿到 {sliceDepth, frontierDepth, parent, inject, wrap} 之后的动作序列:
- closeFrontierNode 把 frontier 里比 frontierDepth 深的层全部关闭。关闭就是 pop 出栈,并用该层 match.fillBefore(Fragment.empty, true) 算出这个节点还缺什么结尾内容,缺的部分补进 placed,补什么由内容表达式决定。
- wrap 非空时,openFrontierNode 按 findWrapping 给的类型逐层在 frontier 上打开新节点:栈顶的 match 先 matchType 掉这个新类型,placed 里挂上 type.create(attrs, content) 的壳,栈里压入新层的初始 contentMatch。
- inject 非空时,先把要补的前置节点加进 placed,match 相应 matchFragment 推进。
- 主循环扫描 fragment 的子节点,能被 matchType 匹配的就取走。取走的第一个节点继承 slice 左缘剩余的打开层数(slice.openStart 减去 sliceDepth),最后一个继承 openEndCount 标记的打开层数。每个取走的节点过 closeNodeStart 处理:递归地把打开的左缘用 fillBefore 补齐开头,右缘没打开(openEnd 不大于 0)时再补结尾,并顺手用 next.mark(type.allowedMarks(next.marks)) 过滤掉目标节点不允许的 marks。空的打开节点(第一个取走且没有内容)直接丢弃。
- 更新 placed(addToFragment 在 frontierDepth 深度追加)和 frontier 顶层的 match。parent 整个搬完、右缘没打开、且 parent 类型与 frontier 顶层一致时,立刻 closeFrontierNode 把它关上。
- fragment 末尾有 openEndCount 层打开的节点时,为它们压入新的 frontier 层,match 取各节点 contentMatchAt(node.childCount),即现有内容已匹配完、等后续内容的状态。
- 更新 unplaced:fragment 没取完就用 dropFromFragment 把取走的部分从 slice 里挖掉;取完了就把 parent 那一层整个摘掉,sliceDepth 相应减一。
openEndCount 的计算是 (fragment.size + sliceDepth) - (slice.content.size - slice.openEnd):fragment 末尾超出 slice 右边缘打开边界的层数,就是这次放置在右缘新打开了几层。
close:frontier 与 $to 对齐
unplaced 清空之后,placed 的右缘(由 frontier 表示)必须和 to) 收尾。close 先用 findCloseLevel 找 frontier 里能关到 to 同深度节点剩下的内容(fillBefore 算出缺口可补齐,invalidMarks 确认剩余内容的 marks 被该层允许),并且更浅的每层也必须接得上、且不需要补任何内容。找不到就返回 null,fit 整体失败,replaceStep 跟着返回 null。
找到之后:比该层深的 frontier 节点逐个 closeFrontierNode 关闭;该层算出的缺口内容加进 placed;该层到 to 一侧的节点类型 openFrontierNode 补开,携带 fillBefore(node.content, true, to 完全对齐。
fit() 最后做一步归一化:openStart、openEnd 都大于 0 且 placed 只有一个子节点时,剥掉一层,两边各减一,重复到不能剥,避免产出两侧都打开的壳套壳 slice。然后 new Slice(placed, openStart, openEnd),生成 ReplaceStep(to.pos, slice)。slice 为空且区间也没变化时返回 null,不生成 no-op step。
mustMoveInline:什么时候改产 ReplaceAroundStep
fit() 里还有一条支线。to 之后的行内内容能接进 frontier 顶层的文本块时(mustMoveInline 的检查),直接把缺口关到 to 后面那段行内内容留在新结构外面,语义不对。这时 close 的目标改成 from.pos, moveInline, this.to.end(), slice, placedSize):gap 是 $to 到其文本块末尾的那段行内内容,它被保留并搬进新文本块,insert 用 placedSize,即放置内容的净尺寸(placed.size 扣掉两侧打开边界占的 token)。粘贴一段文字到段落中间、slice 右缘带着打开的文本块时走的就是这条路。用户看到的结果是粘贴内容接进当前段落,段落剩余文字跟在后面,而不是被顶出新结构。
replaceRange 与 deleteRange:意图层面的入口
replace.ts 还导出 replaceRange、replaceRangeWith、deleteRange 三个函数,Transform 上的同名方法直接委托过去,处理的是「用户意图层面」的替换与删除。
replaceRange 对应粘贴这类操作。先走 fitsTrivially 快路径;不行就用 coveredDepths 算出 from 到 to 完整覆盖了哪些层,作为候选目标深度,再把「首选目标深度」(负值编码,表示只从 to.pos)插进候选列表,defining、definingAsContext、isolating 节点会截断向上扩展。然后拿 slice 左缘链上的节点(leftNodes),逐个打开深度、逐个目标深度地试 canReplaceWith,试中了就用 closeFragment 把 slice 的打开深度调整到目标形态再 tr.replace。closeFragment 的思路和 Fitter 里的 closeNodeStart 一致:打开深度变化时,用 contentMatch 的 fillBefore 在开头和结尾补齐缺的节点。全部候选试完还不行,回落到逐层扩大替换区间的循环。
deleteRange 对应框选一段按退格。两端都在文本块开头且跨块时,先把起止位置各自向外扩到块边界(路径上有 isolating 节点时不扩),再按 coveredDepths 找能合法删除的最外层:要么某层的 contentMatch 本身 validEnd,要么父节点 canReplace 通过。逐层都删不干净时还有一段不对称区间的兜底判断,最后才是原样 tr.delete(from, to)。这两个函数的共同点是先猜用户的意图(尽量动外层结构、保持结果浅),猜不中再逐层降级,Fitter 是它们最后的兜底。
openStart 与 openEnd 的消费总结
到这里可以把第 8 篇留下的两个字段的下游用途收拢(Slice 与 replace:切一块文档出来再塞回去)。openStart、openEnd 在 transform 层有四处消费点:fitsTrivially 用「都为 0」判断能不能走快路径;ReplaceStep.merge 用「相邻一侧都为 0」判断两步能不能合并;ReplaceAroundStep.apply 用 gap slice 的「都为 0」保证 gap 是扁平区间;Fitter 把它们当作放置算法的输入,openStart 决定左缘链有几层可拆分放置,openEnd 参与 openEndCount 和新 frontier 层的推导,fit 收尾时再从 to 的深度反算出产出 slice 的新打开深度。第 8 篇说 Slice 只描述「一块内容加两侧接缝」,这篇看到的是接缝信息的全部下游:它一路参与合法性判断、合并策略和放置搜索,最后在产出物上被重新写回。
下一篇看 StepMap:getMap 里那几段 [start, oldSize, newSize] 数组具体怎么映射位置,删除与恢复的语义怎么表达。

