上一篇讲了 ResolvedPos,一个数字位置怎么解析成带深度的路径。这篇读 src/replace.ts 和 src/node.ts 里的 copy、cut、slice、replace 几个方法,回答两个接续的问题:从文档里切一块出来得到什么对象,把这块塞回去时 ProseMirror 怎么保证结果仍然符合 schema。这两个操作是复制、粘贴、删除、替换的共同底层,后面 transform 的 ReplaceStep 也直接建立在它们上面。参考代码是 prosemirror-model 的 6264de0。
系列目录
| 日期 | 标题 |
|---|---|
| 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:切一块文档出来再塞回去(本篇) |
Slice 的三个字段
src/replace.ts 里 Slice 类的定义很短:content、openStart、openEnd 三个只读字段。content 是切出来的 Fragment,openStart 和 openEnd 是两个整数,记录切片左右两端各有几层节点是打开的。
打开的意思是:切片的边界把某个节点切成了两半,切片里只带着这个节点的一半,节点的开始标签或结束标签缺在外面。拿文档 doc(blockquote(p(“foo”), p(“bar”)), p(“baz”)) 举例,选区从 “foo” 的中间划到 “baz” 的中间,切出来的 fragment 是 blockquote(p(“o”), p(“bar”)) 加上 p(“b”)。左边缘的 blockquote 和它的第一个段落各缺了前半段,这两层都算打开,openStart 是 2;右边缘只有最后一个段落缺后半段,openEnd 是 1。
构造器注释里有两条约定。第一,声明非零的打开深度时,fragment 对应一侧必须真的有那么多层节点可打开,一个空段落最多允许打开 1 层。第二,打开节点的内容不要求满足 schema 的内容约束,它只需要构成该节点合法的开头、结尾或中段,具体算哪种取决于哪一侧打开。比如 list_item 的内容表达式是 “paragraph block*“,切片里半个 list_item 只带着后半的 block 部分时不算非法,因为前半还留在文档里。
为什么要记录打开深度:如果切片只存 fragment,把上面这个 slice 插回文档时,没法区分「插一个完整的 blockquote 加一个段落」和「把文档侧的半个 blockquote 与切片侧的半个 blockquote 拼成一个」。打开深度记录的就是接缝信息,插入时位置两侧的打开节点和切片两侧的打开节点逐层对上,拼成完整节点。剪贴板直接依赖这个行为:复制一段跨段落的选区再粘贴进另一个段落,期望的结果是新内容接进当前段落,几层嵌套都不凭空多出来。
size getter 也值得看一眼:content.size - openStart - openEnd。按 pos 编号约定,每个开标签、闭标签各占一个位置,而切片两侧打开节点的边界 token 在文档里并不真实出现,所以要从 fragment 尺寸里扣掉,得到的是这个 slice 插进文档后净增加的长度。
maxOpen(fragment, openIsolating = true) 是个静态工厂:沿 fragment 的 firstChild 链一直往下数得到 openStart,沿 lastChild 链数得到 openEnd,遇到叶节点停止;openIsolating 传 false 时遇到 isolating 节点也停(第 6 篇说过 isolating 是编辑操作的边界,切片不越过它)。从外部解析出一段内容、想让它尽量接进现有结构时,用它得到一个打开程度最大的 slice。
其余成员:eq 逐字段比较;toJSON 空 slice 返回 null,openStart、openEnd 为 0 时不写进 JSON;fromJSON 遇到非数字的打开深度抛 RangeError;Slice.empty 是空 fragment 加零深度的单例。
切片内部的增删:insertInto 与 removeRange
Slice 上还有两个 internal 方法,insertAt 和 removeBetween,分别往切片里再插一段、从切片里再删一段,transform 的 ReplaceStep 在处理 gap 结构时消费它们。两者的坐标换算都统一加上 openStart,因为 fragment 的坐标系从打开的边界起算,对外暴露的坐标要把打开的深度补回去。
底层是 removeRange 和 insertInto 两个递归函数,思路一致:findIndex 定位到目标位置落在哪个子节点上。落在节点边界或文本中间时直接处理,removeRange 用 content.cut(0, from).append(content.cut(to)) 把两端拼起来,insertInto 用 cut 加 append 把新片段夹进去;落在某个子节点内部时递归进这个子节点,结果用 replaceChild 换回。两个限制照实记录:removeRange 要求起止落在同一个子节点内部,跨子节点的非扁平删除抛 RangeError(“Removing non-flat range”);insertInto 在已知父节点时先跑 canReplace 校验,插不进去返回 null 让调用方换方案,这里的失败通过返回值表达,不抛异常。
Node 上的入口:copy、cut、slice
回到 src/node.ts。copy(content) 用同一套 type、attrs、marks 换上新内容,内容引用没变时直接返回 this,不可变节点的结构共享靠的就是这类短路。cut(from, to) 取内容坐标下的一段,委托给 content.cut,整段全取时同样返回 this。文本节点的 cut 是 withText(this.text.slice(from, to))。
node.slice(from, to, includeParents) 是产生 Slice 的主入口:
let $from = this.resolve(from), $to = this.resolve(to)
let depth = includeParents ? 0 : $from.sharedDepth(to)
let start = $from.start(depth), node = $from.node(depth)
let content = node.content.cut($from.pos - start, $to.pos - start)
return new Slice(content, $from.depth - depth, $to.depth - depth)先 resolve 出两个位置,sharedDepth 找出它们的公共祖先深度,从那一层节点的内容里 cut 出 fragment。打开深度不用额外推算:位置解析出的 depth 减去公共深度,就是两侧各自被切开的层数。上面 blockquote 的例子里 to 深度 1、公共深度 0,2 和 1 直接写进 Slice。includeParents 传 true 时公共深度取 0,切片从顶层开始,两侧打开深度等于位置本身的深度。
node.replace(from, to, slice) 把 from 到 to 的内容替换成 slice,实现只有一行:resolve 两个位置后交给 replace.ts 的 replace 函数。文档注释写明了约束:slice 必须合得上,打开侧要能和周围内容接上,内容节点要能成为目标位置的合法子节点,违反任何一条都抛 ReplaceError。ReplaceError 是这个文件定义的独立错误类型,调用方可以按类型捕获。
replace 的入口校验
replace(to, slice) 先做两项检查:
if (slice.openStart > $from.depth)
throw new ReplaceError("Inserted content deeper than insertion position")
if ($from.depth - slice.openStart != $to.depth - slice.openEnd)
throw new ReplaceError("Inconsistent open depths")第一条,切片的打开深度不能超过插入位置的深度。打开几层意味着要接管文档侧的几层节点,位置没有那么多层可给就接不上。用前面的例子具体化:切出的 slice openStart 是 2,粘贴位置如果在一个顶层段落里(深度 1),2 大于 1,直接抛错;粘贴位置在另一个 blockquote 的段落里(深度 2),才谈得上继续。第二条,两侧各自「关上」之后剩下的深度必须一致:$from.depth - openStart 是左接缝拼合完成后的落点深度,右侧同理,两边落点深度不同说明这个 slice 和这对位置形状不匹配。检查通过就进 replaceOuter,从深度 0 开始递归。
replaceOuter 的递归与四个分支
replaceOuter(to, slice, depth) 在当前深度处理替换,按条件分四种情况:
- 两侧在该深度的 index 相同,且还没到达切片开始打开的深度(depth < $from.depth - slice.openStart):替换完全发生在同一个子节点内部,递归进 depth + 1,结果用 content.replaceChild 换回去。
- slice 为空,即纯删除:replaceTwoWay 把左右两侧接起来。
- 扁平快路径:openStart、openEnd 都是 0 且两侧位置都在当前深度,直接
content.cut(0, $from.parentOffset).append(slice.content).append(content.cut($to.parentOffset))三段拼接,不递归。这是编辑器里最常见的替换形态,输入一个字符、替换同段落内的一段文字、平级换掉几个块,全都走这里,三段 Fragment 操作就完成,不构造任何临时节点。 - 一般情况:prepareSliceForReplace 把 slice 包装成带位置的形态,然后 replaceThreeWay 三路合并。
每个分支的出口都是 close(node, content):先 node.type.checkContent(content) 校验,通过了才 node.copy(content)。checkContent 就是第 6 篇 validContent 那两道工序,内容表达式自动机走完整段并要求停在合法结束,再逐个子节点过 allowsMarks。所以 replace 的产出一定过 schema 校验,接不上的结构在 close 这一步抛 RangeError,不会生成非法文档。校验分散在每一层 close 里,拼接哪一层就验哪一层,验不过的替换在原地失败,文档对象本身始终是不可变的旧值。
addRange、addNode 与两路合并
拼接用的工具函数先过一遍。addRange(end, depth, target) 把两个位置之间、位于指定深度的节点收集进数组:start 落在文本中间(textOffset 非零)时把 nodeAfter 里剩下的文本节点收进来,起始下标相应加一;结束一侧对称处理,$end 深度等于当前深度且有 textOffset 时把 nodeBefore 收进来。addNode 负责逐个追加,相邻两个 markup 相同的文本节点合并成一个,接缝处不会留下两个挨着的 text 节点。
replaceTwoWay(to, depth) 处理切片为空或只有一侧重合的情况:先 addRange(null, to, null) 收右边外面的节点。
拿一个常见操作对照着看:选中从某个段落中部到后面另一个段落中部的区间按退格,slice 为空,顶层两个位置的 index 不同,replaceOuter 直接走 replaceTwoWay:addRange 收左边外面的完整节点,两侧都比当前深度深,joinable 确认两个段落类型兼容后递归进段落层,把左段选中点之前的文本和右段选中点之后的文本合成一个段落,逐层 close 回来。用户感知是「两段的剩余部分接成了一段」,代码里就是这两条半边链的逐层合并。如果两侧是段落和代码块这类内容表达式不兼容的类型,checkJoin 在这一步抛错,删除命令那一层会改用别的策略,model 层只负责如实报告接不上。
replaceThreeWay 与 prepareSliceForReplace
一般情况有四个位置参与:文档侧的 to,切片侧的 end。切片没有文档树的身份,位置上无从解析,prepareSliceForReplace 给它造一个临时的:
let extra = $along.depth - slice.openStart, parent = $along.node(extra)
let node = parent.copy(slice.content)
for (let i = extra - 1; i >= 0; i--)
node = $along.node(i).copy(Fragment.from(node))
return {start: node.resolveNoCache(slice.openStart + extra),
end: node.resolveNoCache(node.content.size - slice.openEnd - extra)}思路是拿插入位置的父节点链当骨架:从 start、$end 用 resolveNoCache 在这棵树上解析,坐标里加 extra 补偿外面包的层数。这棵树只为位置解析服务,不进位置缓存,用完即弃。
replaceThreeWay(start, to, depth) 的合并顺序:
- addRange(null, $from, depth) 收文档左侧、接缝之外的节点。
- 处理 openStart 一侧:start、start, $end) 收切片中间的完整节点。
- openEnd 一侧对称处理,能 join 就两路合并 close 收进。
- addRange($to, null, depth) 收文档右侧、接缝之外的节点。
整个过程中所有节点都经过 addNode 汇入,接缝两侧如果各剩下半截文本,且 marks 一致,会在这里合并成一个文本节点,读者看到的文档里不存在「拼接缝」这种痕迹。返回的 Fragment 交给上层 close 校验。打开语义到这里落地成具体算法:文档侧从 start 往上同样留一条链,joinable 保证对应层类型兼容,递归到底之后逐层 close 回来,两个半边合成完整节点。
与 transform 的衔接
到这里 model 层给出的能力齐了:slice 切出带打开深度的片段,replace 把它接回去并强制校验。回头看这组设计的分工:Slice 只描述「一块内容加两侧接缝」,不绑定任何文档;replace 只处理「一对位置加一个 slice」的一次性拼合,不涉及撤销,也不涉及选区。更上层的语义都被推给了调用方。
transform 的 ReplaceStep 就是 from、to、slice 三个要素加一层 step 协议,apply 时调的正是这里的 replace;openStart、openEnd 在 step 的结构校验里继续被消费,Fitter 靠它们为 slice 寻找闭合节点序列,合不上时自动补节点。那部分留到 transform 阶段再拆。下一篇换方向,看文档怎么序列化成 DOM 和 HTML,DOMSerializer。

