StepMap:一步修改怎么映射每个位置

3 分钟阅读
·

这是 transform 阶段的第三篇。前两篇看了 Step 的抽象和 ReplaceStep 的实现:每一步 apply 之后文档变了,文档里的位置也就全变了,旧的选区、旧的装饰、别的并发修改里记录的位置,全都对不上新文档。ProseMirror 里以位置为锚的东西很多:选区是 anchor 和 head 两个位置,装饰是锚定在区间上的标注,step 自己的 from/to 也是位置。文档一改,这些记录全部需要一套确定的换算规则跟过去。Step 接口里的 getMap 就是用来回答「旧位置对应新文档的哪里」的,它返回的 StepMap 和配套的 MapResult 都在 src/map.ts 里。这个文件还定义了做多步映射的 Mapping 类,内容够单独一篇,这篇只看单步的部分。参考代码是 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:一步修改怎么映射每个位置(本篇)

谁需要映射

每次按键输入,Transform.addStep 把 step 应用到文档的同时,会把 step.getMap() 追加进内部的 mapping(src/transform.ts)。之后 state 层要把选区带进新文档,view 层要把装饰的位置带进新文档,协作场景还要把别人并发提交的 step 映射到自己当前的文档上(Step.map 方法,上一篇 ReplaceStep.map 里已经见过)。这些场景共用同一个接口,文件开头定义成 Mappable:

export interface Mappable {
  map: (pos: number, assoc?: number) => number
  mapResult: (pos: number, assoc?: number) => MapResult
}

map 只返回新位置;mapResult 多返回一个 MapResult,带出删除信息和恢复值。assoc 是方向偏好,取 1 或 -1,默认 1,含义后面展开。

StepMap:三个数描述一段修改

StepMap 的构造参数是一个数字数组 ranges,每三个数一组 [start, oldSize, newSize],表示「从 start 开始,oldSize 长度的旧内容被换成 newSize 长度的新内容」。数组按 start 升序,区间互不重叠。这个约定没有专门的校验代码,靠构造方自然满足:ReplaceStep 只有一段,ReplaceAroundStep 的两段本来就是 from 在前 gapTo 在后。_map 循环里的提前退出靠的就是这个序。

各 Step 的 getMap 就是把自己做的修改编码成这个数组:

  • ReplaceStep(src/replace_step.ts)只有一段修改:new StepMap([this.from, this.to - this.from, this.slice.size])。删除是 slice.size 为 0 的特例,插入是 to == from 的特例,编码统一。
  • ReplaceAroundStep 有两段:gap 之前的部分被 slice 前 insert 长度的内容替换,gap 之后的部分被剩下的替换,ranges 有六项。gap 里的内容被保留并移动,不出现在 ranges 里。
  • AddMarkStep、RemoveMarkStep 不改动文档结构,位置不变,走基类 Step 的实现返回 StepMap.empty;AttrStep 和 DocAttrStep 自己覆写了 getMap,返回的也是 StepMap.empty。empty 是共享单例,构造函数对空 ranges 做了拦截,空映射不会产生新对象。

ReplaceAroundStep 的编码拿数字看更清楚。设 from=5、to=20、gapFrom=10、gapTo=15、insert=3、slice.size=8,getMap 返回 [5, 5, 3, 15, 5, 5]:5 到 10 的旧内容换成 slice 前 3 个位置,15 到 20 的旧内容换成后 5 个位置。gap 里的位置(10 到 15)不在任何区间里,映射时只吃 diff。比如 pos 12,扫过第一段累计 diff = 3 - 5 = -2,第二段 start=15 > 12 退出,结果 10。新文档里 slice 放在 5 处、长 8,gap 内容被挪到 insert 之后,即 8 到 13,pos 12 在 gap 里偏移 2,正好落在 8 + 2 = 10。区间编码和「移动保留内容」的语义对得上。

还有一个静态方法 StepMap.offset(n),生成一个把位置整体平移 n 的映射,实现是在 0 处构造一段 oldSize 与 newSize 相差 n 的区间。注释里写的用途是把针对子文档的 step 套到大文档上,或者反过来。

_map:一遍扫描加方向偏好

map 和 mapResult 都转调内部的 _map(pos, assoc, simple),区别只在返回值。核心是一个循环:

let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2
for (let i = 0; i < this.ranges.length; i += 3) {
  let start = this.ranges[i] - (this.inverted ? diff : 0)
  if (start > pos) break
  let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize
  if (pos <= end) {
    let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc
    let result = start + diff + (side < 0 ? 0 : newSize)
    ...
  }
  diff += newSize - oldSize
}
return simple ? pos + diff : new MapResult(pos + diff, 0, null)

inverted 先不管,后面 invert 一节再说,先按 oldIndex=1、newIndex=2 读。循环从头到尾扫 ranges,diff 累计前面所有区间的长度变化。start > pos 就提前退出:ranges 按 start 升序,后面的区间只会更远,位置落在所有已见修改之后,加上 diff 返回。pos 落在两段之间的空隙时不会命中任何 pos <= end,循环一路扫到 start > pos 为止,结果同样是 pos + diff,只是 diff 里累计了空隙之前所有区间的净变化。pos 落在某个区间内时,结果分两步定。

先定 side,决定映射后落在替换内容的哪一侧:

  • oldSize 为 0,纯插入,位置就是插入点,side 直接取 assoc。assoc=1 认为这个位置属于插入内容之后,映射到 start + newSize;assoc=-1 认为属于之前,留在 start。
  • pos == start,落在被删区间的左端,side 固定 -1,映射到 start。被删的是右边的内容,这个位置贴着左边界活下来。
  • pos == end,落在右端,side 固定 1,映射到 start + newSize。
  • 其余情况位置在被删区间内部,内容已经没了,往哪边靠由 assoc 决定。

再算结果:start + diff + (side < 0 ? 0 : newSize)。区间之后的位置不进这个分支,循环走完统一 pos + diff

拿一个具体例子过一遍。ReplaceStep from=10、to=16、slice.size=3,ranges 就是 [10, 6, 3],整段修改让文档缩短 3。

StepMap 区间映射

  • pos 8 在区间之前,循环第一轮就 break,结果 8。
  • pos 12 在区间内部,assoc=1 时 side=1,结果 10 + 3 = 13;assoc=-1 时结果 10。两种都算被删除的位置。
  • pos 16 正好在右端,side=1,结果 13,不算被删,因为删掉的只是它左边的内容。
  • pos 20 在区间之后,diff=-3,结果 17。

再看纯插入:ranges [10, 0, 4],pos 10 在 assoc=1 时映射到 14,assoc=-1 时映射到 10。这里能看到 assoc 的实际含义:插入发生时,边界位置属于哪一边没有客观答案,调用方用 assoc 表达自己的偏好。上一篇 ReplaceStep.map 里 mapping.mapResult(this.to, -1)mapping.mapResult(this.from, 1) 就是在用这个偏好:from 取 1,别人在 from 处插入的内容不并进本 step 的范围;to 取 -1 同理。两个配合起来,rebase 之后的 step 只覆盖自己原来要改的内容。同文件里还有个静态开关 ReplaceStep.MAP_BIAS,控制纯插入的 step 和别人在同一位置的插入谁排前面,默认 1,排后面。

MapResult:四个位记录删除情况

simple 为 false 时 _map 返回 MapResult,三个字段:映射后的 pos、删除信息 delInfo、恢复值 recover。delInfo 是位标记,常量四个:

  • DEL_BEFORE:位置左边的 token 被删了。
  • DEL_AFTER:右边的被删了。
  • DEL_ACROSS:左右两边都被删,位置被删除区间整个跨过。
  • DEL_SIDE:按本次查询的 assoc 方向看,位置自己被删了。

对外是四个 getter。deleted 只看 DEL_SIDE;deletedBefore 是 DEL_BEFORE 或 DEL_ACROSS;deletedAfter 同理;deletedAcross 只看 DEL_ACROSS。deleted 和 deletedAcross 不等价:pos == end 时 delInfo 只有 DEL_BEFORE,deletedAcross 为假,这个位置也确实没被跨过。

DEL_SIDE 的设置条件值得看一眼:

let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS
if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE

区间内部的位置无条件打 DEL_SIDE。边界上,pos == start 时删掉的是右侧内容,只有 assoc=1(偏好右侧)才算被删;pos == end 时反过来。Mappable 接口的注释把这条写成了规则:只有一侧内容被删时,assoc 指向被删内容,位置才算 deleted。同一个位置,assoc 不同,deleted 可以不同。

还用 [10, 6, 3] 那个例子,把几个位置的 delInfo 列全:

  • pos 10,assoc=1:DEL_AFTER | DEL_SIDE,deleted 为真(assoc 指向右边被删的内容),deletedAcross 为假。
  • pos 10,assoc=-1:只有 DEL_AFTER,deleted 为假。同一个位置换了个方向偏好,结论就反过来。
  • pos 12,assoc=1:DEL_ACROSS | DEL_SIDE,四个 getter 全真。
  • pos 16,assoc=1:只有 DEL_BEFORE,deleted 为假。
  • pos 16,assoc=-1:DEL_BEFORE | DEL_SIDE,deleted 为真(assoc 指向左边被删的内容)。

这个表也解释了为什么 mapResult 要求把 assoc 传进去,不允许事后判断:删除信息是在映射过程中算出来的,和方向偏好耦合,事后拿一个映射后的位置推不出这些结论。

另一个容易忽略的点:单步之内 pos 只会命中一个区间,所以 MapResult 的 delInfo 全部来自这一个区间,位之间不会互相污染。多步串联时各步的标记按位或累计,那是 Mapping 的事,下一篇会看到。

调用方靠这些位决定自己要不要失效。AttrStep.map(src/attr_step.ts)用 assoc=1 映射自己作用的节点位置,pos.deletedAfter 为真就返回 null:节点本身被删了,改 attr 没有意义。ReplaceStep.map 用 from.deletedAcross && to.deletedAcross 判断整个替换区间被别的修改覆盖,覆盖了就返回 null,这个 step 在 rebase 后消失。step 的 map 返回 null 是约定好的「这步作废」信号。

recover:被删位置留个找回的口子

MapResult 的第三个字段 recover 解决另一个问题:位置被删之后,如果后面某一步恰好把这一步逆过来(undo、协作 rebase 里都有这种情况),这个位置还能不能找回来。要找回来,映射时就得记下它在第几个区间里、区间内偏移多少,这就是 recover 存的东西。

文件顶部的注释解释了编码选择。recover 是一个数字:低 16 位是区间下标,其余位是偏移,makeRecover(index, offset) = index + offset * 2^16。不用对象存,是因为批量映射时会创建海量 MapResult,注释里举的场景是映射大量 Decoration,对象开销太大。编码也没有用移位运算:JS 的位运算按 32 位截断,偏移占的是高位,移位会丢数据,所以偏移部分用乘除编解码;低 16 位的下标不受截断影响,recoverIndex 直接 & 0xffff 取。64 位浮点能精确表示 48 位整数,下标加偏移在这个范围内不会丢精度。

边界位置不给 recover:pos == (assoc < 0 ? start : end) 时是 null。边界位置的映射结果是确定的,直接能从映射后的位置推回去,不需要额外信息。只有区间内部的位置才需要 recover,因为多个内部位置被压到了同一个映射结果上。

配套的 StepMap.recover(value) 做反解:

recover(value: number) {
  let diff = 0, index = recoverIndex(value)
  if (!this.inverted) for (let i = 0; i < index; i++)
    diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1]
  return this.ranges[index * 3] + diff + recoverOffset(value)
}

取回区间起点,加上前面区间的净变化和区间内偏移。非 inverted 时映射方向是旧到新,目标位置在新文档坐标系里,要把前面区间的 newSize - oldSize 补上;inverted 时方向反过来,ranges 里的 start 本来就是旧文档坐标,直接用。

拿前面的例子走一遍往返。pos 12 在 ranges [10, 6, 3] 里被删,assoc=1,recover 是 makeRecover(0, 2),即 2 * 65536 = 131072。对这个值调 recover:recoverIndex 取出 0,第一个区间之前没有别的区间,diff 为 0,recoverOffset 取出 2,结果是 ranges[0] + 0 + 2 = 12,原位置找回来了。注意 recover 依赖的是生成它的那个 StepMap(或它的逆),换一张映射来解就失去意义,所以 recover 值只在同一条映射链内部流转,不适合持久化。

recover 的真正消费方在 Mapping 里:位置穿过一串 step 时,在中间某步被删了,但后面有这一步的镜像(逆)步,Mapping 会跳过中间这段,用 recover 值在镜像步里把位置找回来。这套机制是协作 rebase 和历史管理的基础,下一篇拆 Mapping 时展开。

invert 与 forEach

StepMap.invert() 不复制数据,用同一个 ranges 数组构造一个 inverted 标志取反的新映射。_map 里 oldIndex 与 newIndex 对调,start 的坐标换算也跟着反过来:正向映射时 ranges 的 start 是旧文档坐标,反向后输入是新文档坐标,要把累计 diff 减回去再比较。Step.invert 生成逆 step 用来 undo,StepMap.invert 生成逆映射用来把位置从 undo 后的文档映射回去,两者配套。

forEach(f) 按区间逐个回调 (oldStart, oldEnd, newStart, newEnd),新旧两套坐标都换算好。Transform.changedRange(src/transform.ts)用它把一次修改的所有区间汇总成新文档里的一个范围,返回给想知道「这次改了哪一段」的调用方。这个方法的注释里还有一句提醒:只增删 mark 的修改不会体现在结果里,因为这类 step 的 getMap 是 StepMap.empty,forEach 一个区间也回调不出来。

文件里另有一个 @internal 的 touches(pos, recover),判断 recover 值指向的那个区间是否覆盖 pos。翻了一遍包内和几个下游包的源码都没找到调用方,照实记录,不影响主线的理解。

小结

StepMap 用三元组数组描述一步修改,_map 一遍扫描完成映射,assoc 在插入点和删除边界上决定位置的归属。MapResult 用四个位标记删除情况,调用方据此决定 step 或位置记录是否失效。recover 给被删位置留了找回的口子,等逆步出现时还原位置。整个设计里没有什么花哨的算法,难点全在边界语义的穷举:插入点、删除区间的两端、区间内部,每种情况的落点和删除判定都要给出确定答案,assoc 是把「没有客观答案」的那部分选择权交还给调用方。单步映射的全貌就这些,但实际使用里位置很少只穿过一步,下一篇看 Mapping:多步映射怎么链起来,mirror 数组怎么配合 recover 实现跨逆步的位置还原。


1013 字 · 62 段落
xi ming

Written by xi mingFollow onGitHub