Prosemirror 高级篇(源码):深入浅出 Proseimrror 中的位置映射

1. Prosemirror 中的位置映射

我们知道在 prosemirror 中想要提交文档的更改,必须通过 view.dispatch(tr) 提交一个 tr,其中 trTransaction 的实例,Transaction 又是继承自 Transform 的子类,在上一篇探索 Step 的过程中,我们知道了 prosemirror 中修改文档的原子操作其实是 Step,在 Transform 类中定义了 steps 来保存这些修改,同理 tr 中也有这些 steps,那就说明在一个 tr 中我们是可以做很多操作的,在多个操作进行完之后,再提交 tr,这也是实际开发中经常会使用的方法。

在进行多个操作的过程中,文档的内容如果不断更新,文档中的位置就会不断变换,要基于最初获取的一些位置信息来继续更新文档就会有风险,你可能会获取不到希望得到的节点信息,造成更新错误不符合预期,此时 tr.mapping 就可以发挥作用,通过 tr.mapping.map(oldPos) 可以获取到老的位置在新文档中的新位置,这样就能继续操作了。

在之前探索 Step 的过程中,我们知道通过 step.getMap() 可以获取到某一次操作后的位置 map 信息,但在 tr.mapping 中,我们可以获取到当前 tr 中一系列 steps 应用后对应的位置映射信息,相当于将所有 stepsMappable 对象合并了。在之前我们没有详细探索 Mappable 对象,本文们就深入看看它的实现。

2. Prosemirror 中的 Mappable 对象实现方案

2.1 Mappable 的定义

Prosemirror 中,Mappable 是个 interface,其中仅仅定义了它应该有的两个属性 mapmapResult:

ts 复制代码
export interface Mappable {
  // 给定旧的位置,输出新的位置,assoc 值为 `-1 或 1`,代表了当一段内容被插入到 map 的位置后,这个位置应当向那一侧移动,-1表示左侧,1 表示右侧(默认为1);
  map: (pos: number, assoc?: number) => number

  // 与 map 作用相同,但返回的内容有更多详细信息
  mapResult: (pos: number, assoc?: number) => MapResult
}

Prosemirror 高级篇(源码):文档修改的原子操作 Step 中看过 map 与 mapResult 的效果,这里就不演示了,我们主要来看一下实现了 Mappable 的对象的源码,看看他们是如何将旧文档位置映射到新文档中的。

2.2 StepMap:获取一个原子操作后的位置映射信息

StepMap 的数据结构定义

ts 复制代码
export class StepMap implements Mappable {
  // StepMap 有两个只读属性,ranges 是一个元组,分别表示 [start, oldSize, newSize] -> [开始位置,就内容大小,新内容大小]
  constructor(
    readonly ranges: readonly number[],
    readonly inverted = false
  ) {
    if (!ranges.length && StepMap.empty) return StepMap.empty
  }
  
  // 上面使用到的 StepMap.empty 是 StepMap 的一个静态属性,它存放了一个 ranges 为空的 StepMap
  static empty = new StepMap([])
  
  // 根据偏移量创建 StepMap 对象
  // 偏移量为负,则代表旧内容大小,此时心内容大小为 0
  // 偏移量为正,则代表新内容大小,此时就内容大小为 0
  static offset(n: number) {
    return n == 0 ? StepMap.empty : new StepMap(n < 0 ? [0, -n, 0] : [0, 0, n])
  }
}

StepMap 中是如何实现位置映射的?

在不看代码的情况下,我们不妨猜测自己来想想如果给自己一个 range 信息[start, oldSize, newSize],该如何根据旧的位置计算新的位置?
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> F ( p o s ) = { p o s , p o s < = s t a r t s t a r t , s t a r t < p o s < = s t a r t + o l d S i z e p o s − ( s t a r t + o l d S i z e ) + s t a r t + n e w S i z e p o s > s t a r t + o l d S i z e F(pos) = \begin{cases} pos, & pos <= start \\ start, & start < pos <= start + oldSize \\ pos - (start + oldSize) + start + newSize& pos > start + oldSize \\ \end{cases} </math>F(pos)=⎩ ⎨ ⎧pos,start,pos−(start+oldSize)+start+newSizepos<=startstart<pos<=start+oldSizepos>start+oldSize

上述公式第三段化简后就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( p o s ) = p o s − o l d S i z e + n e w S i z e = p o s + ( n e w S i z e − o l d S i z e ) F(pos) = pos - oldSize + newSize = pos + (newSize - oldSize) </math>F(pos)=pos−oldSize+newSize=pos+(newSize−oldSize)

大概就是上面的公式,即一个分段函数,pos 小于等于 start 时,也就是发生变化前面的位置对应的新位置应该是不变的;pos 大于 start + oldSize,也就是发生变化的后面的位置,应该就是它们距离文档中发生变化结尾位置的偏移量,重新加上变化后的结尾位置;这两部分是比较好算的,比较麻烦的是中间的部分,我们假设原始部分都被删了,那新的位置其实全部对应到 start 也是合理的。但具体位置还是得分情况讨论,我们来看看 StepMap 中是如何处理的

ts 复制代码
export class StepMap implements Mappable {
  // _map 是 StepMap 中真正计算位置映射的内部方法,在 map 与 mapResult 中都是用 _map 计算的,
  // 区别就是 simple 参数的传值,map 为 true,此时返回的内容就简单,为一个数字,
  // mapReulst 中 simple 传参为 false, 此时返回内容就比较完善,为一个 MapResult 对象
  // assoc 在讲解 Step 一节的时候已经演示过了,就代表方向,会影响计算结果
	_map(pos: number, assoc: number, simple: boolean) {
    let diff = 0, oldIndex = this.inverted ? 2 : 1, newIndex = this.inverted ? 1 : 2
    
    // 这里的 for 循环一般情况意义不大,可以忽略,i += 3 就代表着 ranges 其实也可以是超出三个值的数组,此时它就会被以三个三个一组被划分,分别表示 [start, oldSize, newSize]
    for (let i = 0; i < this.ranges.length; i += 3) {
      // 先不看 inverted 的情况,start 就是 this.ranges[i]
      let start = this.ranges[i] - (this.inverted ? diff : 0)
      // start > pos 也就是我们上面 F(pos) 分段函数中的第一段,此时新的 pos 位置不变,这里直接就 break, 到最后返回
      // 最后返回的是 pos + diff,其实这里 diff 是 0,如果 ranges 只有一段,+diff 就是没必要的
      // 但是加入 ranges 是多段,就不能直接返回 pos 了,这里的 diff 就是 newSize - oldSize 的差值
      if (start > pos) break
      
      // 这里才能明白上面定义的 oldIndex 与 newIndex 是什么意思,就是在 invert 的时候,range 信息就是 [start, newStart, oldStart]
      // 这里也是为了处理多段 ranges 的情况,来重新计算 oldSize 与 newSize
      // end 就是我们上面 F(pos) 中第三段的 start + oldSize,即旧文档中发生变化的位置结尾
      let oldSize = this.ranges[i + oldIndex], newSize = this.ranges[i + newIndex], end = start + oldSize
      
      // 这里 pos <= end 对应到上面分段函数的第二段,但它的计算就相比我们略微复杂了,它考虑了 assoc 方向
      if (pos <= end) {
        let side = !oldSize ? assoc : pos == start ? -1 : pos == end ? 1 : assoc
        // 这里 result 与我们 分段函数第二段一致,
        // 如果不考虑多段 range,这里其实就是 result = start + (side < 0 ? 0 : newSize)
        // 此时再看 side: 它是根据 assoc 计算的真是方向,如果 oldSize 为 0,即没有旧内容,则 assoc 就是 side
        // 如果 oldSize 存在,即别替换的部分的大小,此时旧文档中发生更改的这一部分内容的前后位置,分别对应 -1 和 1,中间的部分则由 assoc 决定
        // side 方向影响的是最后位置的计算结果 result。side 为 -1,则旧文档中发生更改的部分都对应到 start,也就是我们分段函数中描述的 start,即旧文档中发生变化的那部分内容位置对应到新文档中的位置全都在新插入内容的左侧
        // 如果 side 为 1,则旧文档中发生更改的部分都对应到 start + newSize,即旧文档中发生变化的那部分内容位置对应到新文档中的位置全都在新插入内容的右侧
        let result = start + diff + (side < 0 ? 0 : newSize)
        
        // 如果是 simple 模式就直接返回结果,否则需要返回一个 MapResult 对象
        if (simple) return result
        
        // 这里的 recover 是一个可恢复的数据编码,后面会看看它的实现,
        // 通过 recover,我们可以恢复出来 i / 3 以及当前的 pos - start,
        // 这里 i / 3 表示第几个 range, pos - start 表示当前位置距离内容修改的开始位置的距离
        // 不过这里如果 assoc 是 -1,就不会记录发生变更的左侧的那个位置,也就是 start,如果 assoc 是 1,就不会记录发生变更的右侧的位置,即 end (start + oldSize)
        let recover = pos == (assoc < 0 ? start : end) ? null : makeRecover(i / 3, pos - start)
        
        // del 有四种值 const DEL_BEFORE = 1, DEL_AFTER = 2, DEL_ACROSS = 4, DEL_SIDE = 8,二进制分别为 1,10,100,1000
        // 分别代表当前位置左侧有内容被删除,右侧有内容被删除,被删除的内容包含当前位置,当前位置与旁边内容都被删除
        // pos == start 时,del 为 DEL_AFTER,即旧文档从 start 开始,它后面有内容被删除
        // pos == end 时, del 为 DEL_BEFORE,即旧文档被删除内容结尾处,这个位置前面有内容被删除
        // 如果删除内容处于 start -> start + oldSize 之间,也就是上面分段函数第二段区间,就是 DEL_ACROSS,表示删除内容中包含当前位置
        let del = pos == start ? DEL_AFTER : pos == end ? DEL_BEFORE : DEL_ACROSS
        
        // 不过还得根据 assoc 方向,再让 del 与 DEL_SIDE 进行或运算,一般是 DEL_ACROSS | DEL_SIDE,结果为 1100,即上面分段函数第二段中的位置被删除,旁边也被删除。不过如果 assoc 为 -1,这个区间左侧端点不会与 DEL_SIDE 进行或运算,如果assoc 为 1,右侧端点位置不会与 DEL_SIDE 进行或运算。
        if (assoc < 0 ? pos != start : pos != end) del |= DEL_SIDE
        
        // 最后返回 MapResult 对象
        return new MapResult(result, del, recover)
      }
      diff += newSize - oldSize
    }
    
    // 这里的返回值包含了分段函数第一段与第三段,巧妙使用了 diff,如果是分段中第一段(且 ranges 只有一段),diff 为 0,如果是第三段,diff 为上面的 newDize - oldSize,也是我们推导出来的结果
    return simple ? pos + diff : new MapResult(pos + diff, 0, null)
  }
  
  // 调用 _map,simple 为 true,只返回映射后的新位置
  map(pos: number, assoc = 1): number { return this._map(pos, assoc, true) as number }
  // 也是调用 _map, simple 为 false,返回映射后的详细位置信息
  mapResult(pos: number, assoc = 1): MapResult { return this._map(pos, assoc, false) as MapResult }
}

再来看一下上面用到的 makeRecover 创建一个恢复值是什么意思:

ts 复制代码
const lower16 = 0xffff
const factor16 = Math.pow(2, 16)

function makeRecover(index: number, offset: number) { return index + offset * factor16 }
function recoverIndex(value: number) { return value & lower16 }
function recoverOffset(value: number) { return (value - (value & lower16)) / factor16 }

这里 lower16 转为二进制为 1111 1111 1111 1111, factor16 转为二进制为 1 0000 0000 0000 0000makeRecover 做的事情是创建一个二进制位数为 32 位的数字,前 16 为(高 16 位)保存 offset 偏移量,后 16 位 (低16位) 保存 index. 因为 offset * factor16 就是将 offset 向左移动 16 位,然后加上 index。所以低 16 为保存的是 index,高 16 位保存 offset.

recoverIndex(recover) 就是将一个被转为 recover 的数值通过与 lower16 进行按位与,即将高 16 位全部归 0,低 16 为由于 lower16二进制为 1111 1111 1111 1111,低位跟他按位与后还是原值,就恢复除了 index。

recoverOffset是先恢复出 index,再用 recover - index获取到高 16 位的内容,再将其 /factor16 也就是向右移 16 位,就恢复除了 offset

最后再来看看上面返回的 MapResult 是个怎样的结构:

ts 复制代码
export class MapResult {
  constructor(
    // pos 也就是一个位置信息,也就是映射到新文档的新位置
    readonly pos: number,
    // delInfo 就是上面的 DEL_BEFORE = 1, DEL_AFTER = 10, DEL_ACROSS = 100, DEL_SIDE = 1000
    // 代表当前位置所对应的删除信息,是左边被删除了,还是右边被删除了,还是当前位置都被删除了等等
    readonly delInfo: number,
    // recover 上面了解了 makeRecover 之后也就懂了,这里记录的是 range index 与 pos - start 的一个可恢复的值。
    readonly recover: number | null
  ) {}

  // 是否被删除:当前的 delInfo 跟 DEL_SIDE 按位与之后的结果,只有在 delInfo 为 null 的时候才是 false
  // 只要当前位置附近有内容被删除,不管是左侧还是右侧,或者这个位置都被删除,它都是 true
  get deleted() { return (this.delInfo & DEL_SIDE) > 0 }

  // 前面内容被删除,delInfo 为 DEL_BEFORE | DEL_ACROSS | DEL_SIDE 的情况
  get deletedBefore() { return (this.delInfo & (DEL_BEFORE | DEL_ACROSS)) > 0 }

  // 后面的内容被删除,delInfo 为 DEL_AFTER | DEL_ACROSS | DEL_SIDE 的情况
  get deletedAfter() { return (this.delInfo & (DEL_AFTER | DEL_ACROSS)) > 0 }

  // 当前的位置被删除,delInfo 为 DEL_ACROSS 或 DEL_SIDE
  get deletedAcross() { return (this.delInfo & DEL_ACROSS) > 0 }
}

怎样创建一个反转的 (inverted) map 对象

ts 复制代码
export class StepMap implements Mappable {
  //...
  invert() {
    return new StepMap(this.ranges, !this.inverted)
  }
}

创建一个反转的 map 对象,其实就是将 inverted 变为当前 inverted 相反的值。在 _map 中我们知道,在具体的 map 过程中,会根据 inverted 来调整一个 range 中到第二位与第三位到底谁是 oldSize 谁是 newSize,默认是 [start, oldSize, newSize],inverted 模式下位 [start, newSize, oldSize]

StepMap 还提供了哪些方法?

recover(value) 根据一个 recover 值获取其在新文档中的位置

ts 复制代码
export class StepMap implements Mappable {
  //...
  
  // @internal 内部方法
  // 根据 recover 值,恢复对应在新文档中的位置信息
	recover(value: number) {
    // 根据 recoverIndex 恢复出 value 中对应的 index, 即 ranges 中对应的第几组,一般都是 0
    let diff = 0, index = recoverIndex(value)
    
   // 如果当前 stepMap 对象不是反转的 map,则遍历 index 前面组的 range,计算 diff,diff 为每组自己的 diff 值相加,即每组的 (newSize - oldSize) 之和
    if (!this.inverted) for (let i = 0; i < index; i++)
      diff += this.ranges[i * 3 + 2] - this.ranges[i * 3 + 1]
    
    // 计算真正的 pos 位置
    return this.ranges[index * 3] + diff + recoverOffset(value)
  }
}

touches(pos: number, recover: number) 判断一个 pos 是否处于旧文档中被删除的部分

ts 复制代码
export class StepMap implements Mappable {
  //...
  
  // @internal 内部方法
  // 判断 pos 是否处于旧文档中被删除的部分
	touches(pos: number, recover: number) {
    // 根据 recover 恢复 index, 即 ranges 中对应的第几组,一般都是 0
    let diff = 0, index = recoverIndex(recover)
    
    // 设置 oldSize 与 newSize 获取的 idnex,inverted 时,oldSize 与 newSize 与之前位置相反
    let 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)
      // 开始位置在当前位置之前,结束循环返回 false
      if (start > pos) break
      
      // 计算一下 oldSize 与 end
      let oldSize = this.ranges[i + oldIndex], end = start + oldSize
      // 如果当前位置在 end 之前,其实此时的范围就是之前我们分析的分段函数的第二段 pos 所处的位置。
      // 此时返回 true
      if (pos <= end && i == index * 3) return true
      // 计算 diff 值
      diff += this.ranges[i + newIndex] - oldSize
    }
    return false
  }
}

toString() 将当前 step 转为字符串

ts 复制代码
export class StepMap implements Mappable {
  //...
	toString() {
    return (this.inverted ? "-" : "") + JSON.stringify(this.ranges)
  }
}

在实际案例中感受一下这些 api 的风采吧

我们来看看什么时候会产生 recover 信息,这里通过 tr.replace(8, 13, schema.text('小王')) 将 "hello world" 中的 "world" 替换为 "小王",但并没有提交这个 tr,所以页面没有发生变化,但我们可以通过它获取到对应的 step,然后看看映射关系:

上图中分别打印了从旧文档中 8 -> 13 位置到新文档中的位置映射,pos 分别表示旧文档中对应的位置在接下来新文档中的位置,recover 保存了原始的文档中 pos - start 的信息,即 pos 距离 start 的偏移量。根据这个 recover 值我们可以还原出当前这个位置在原始文档中位置信息,因为我们目前拥有 ranges,其中保存了多个 range ([start1, oldSize1, newSize1, start2, oldSize2, newSize2, ...]),不过一般只有一个。通过 recover 可以还原出这个更改处于哪一组 range 中,以及对应的 pos-start 的值,而我们又可以根据对应的 range 中的 start 信息,还原出原始的 pos (已知 δ = pos -start 以及 start,则 pos = start + δ ),在 stepMap 中也提供了 .recover方法,可以帮我们直接还原:

我们还可以看到 mapResult 中的 delInfo 也挺有意思,它的设计与 linux 权限设置有着异曲同工之妙,前面的源码中演示了他们都是通过按位与运算得出的结果,对应到二进制其实就是这样的:

ts 复制代码
const DEL_BEFORE = 1, DEL_AFTER = 10, DEL_ACROSS = 100, DEL_SIDE = 1000
DEL_BEFORE & DEL_AFTER === 11
DEL_BEFORE & DEL_ACROSS === 101
DEL_BEFORE & DEL_SIDE === 1001
DEL_AFTER & DEL_ACROSS === 110
//....

因为对于不同的标志位,他们在二进制表示中都是错位的,所以讲他们按位与之后,可以得到这些标志的组合信息,通过一个值可以保存多个信息。

其实到这里我有点好奇前面提到的 touches 的返回结果了,我们来看看推测的他的作用是否准确(判断 pos 是否处于旧文档中被删除的部分):

可以看到,这个 api 确实能够帮我们确定某个位置是否处于源文档中被删除的部分,证明前面的推测没错。不过如果你给个错误 recover,得到的结果也可能会有问题,这也是证明传递正确的 recover 是很重要的。

2.3 Mapping 的实现

Mapping 常见于 Transform 实例的 mapping 对象,通常用来获取旧 doc 上的位置对应到 tr 对象上的新 doc 中的新位置。它也是一个实现了 Mappable 的类,我们来看看它的底层实现,然后再看看它的实际应用场景。

Mapping 的数据结构定义

ts 复制代码
export class Mapping implements Mappable {
  constructor(
    // tr 中可能存在多个 Step,所以也会有对应多个 StepMap,他们都保存在 tr.mapping.maps 中
    readonly maps: StepMap[] = [],
    /// @internal
    public mirror?: number[],
    // 进行 map 获取映射的新位置时,需要对 maps 进行依次遍历获取新的位置信息,这里的 from 与 to 规定了遍历 maps 的开始结束位置
    public from = 0,
    public to = maps.length
  ) {}
}

先看 Mapping 中的 map 方法是如何实现的

一个 Mapping 其实就是一堆 StepMap 的集合,所以在 Mapping 的 map 映射方法实现中,必然会调用 stepMap 的 map 方法。在 Mapping 中,实现映射同样是_map方法,具体的 mapmapResult 都是调用 _map 的,我们来看看它是如何实现的:

ts 复制代码
export class Mapping implements Mappable {
  // ...
  
  // _map 的参数含义与 MapStep 中一致,这里就不多说了
  _map(pos: number, assoc: number, simple: boolean) {
    // delInfo 默认为 0,即不存在删除的内容
    let delInfo = 0

    // 遍历 maps,即所有 stepMaps,依次从旧位置映射到新位置,获取到最终的新位置
    // 不过遍历的范围是从 from 到 to,这也是在创建 Mapping 对象时进行设置的
    for (let i = this.from; i < this.to; i++) {
      // 遍历获取到对应的 stepMap,通过 mapResult 方法获取到详细的新位置信息
      let map = this.maps[i], result = map.mapResult(pos, assoc)
      
      // 这里遇到一个新概念,即 mirror 镜像映射,这里判断了当前 result 的 recover 是否有值 (非 null)
      // 此时证明当前位置在当前 maoStep 中应该处于被删除的位置,因为上面一小节结束的时候我们刚看到,只有那些被删除的部分 recover 才有值
      if (result.recover != null) {
        // 此时需要得到当前 mapStep 对应的 mirror 镜像操作
        let corr = this.getMirror(i)
        
        // 如果这个镜像操作不存在则作罢
        // 但它如果存在,并且位于当前位置 i 后面,以及结束位置 to 前面
        // 证明后面又会有一个与当前操作反转的操作,将会把当前的操作反转回去,所以这里再进行 map 就没有什么意义了
        // 不过后续在遍历到对应的反转操作时,也要跳过那个操作
        if (corr != null && corr > i && corr < this.to) {
          // 这里就直接将 i 更新到 coor, 即跳转到它的反转那里,再继续向后遍历,中间如果还有 mapStep 也会给丢掉
          // 这样也能避免中间一些内容乱改 doc,最终导致后面 stepMap 映射出错。
          i = corr
          // 调整 pos, 这里的调整使用了反转操作对应的 stepMap 进行位置恢复
          pos = this.maps[corr].recover(result.recover)
          continue
        }
      }

      // delInfo 与 result.delInfo 做按位或
      delInfo |= result.delInfo
      // 更新 pos 位置
      pos = result.pos
    }

    return simple ? pos : new MapResult(pos, delInfo, null)
  }
  
  // map 就是调用 _map,simple 为 true,只返回映射后的新位置
  // 不过这里判断了 mirror,如果存在镜像操作,才使用 _map
  // 否则直接遍历所有的 maps,计算 pos
  // 这里只用 this._map 行不行?当然行!不过 _map 里面会分情况处理,还有一些 delInfo 的计算,所以如果是比较简单的情况,
  // 是没必要做那么复杂的事的,没必要浪费性能。
  map(pos: number, assoc = 1) {
    if (this.mirror) return this._map(pos, assoc, true) as number
    for (let i = this.from; i < this.to; i++)
      pos = this.maps[i].map(pos, assoc)
    return pos
  }
  
  // mapResult 就完全只调用 _map 了
  mapResult(pos: number, assoc = 1) { return this._map(pos, assoc, false) as MapResult }
}

所以一个 mapping 中进行 map 操作,其实就是把其中保存的 steps 根据 from,to 遍历一遍,逐步将原始的位置一步一步 map 到对应的新文档中的位置,最后就可以获取到映射到最新的文档中的位置。

Mapping 中的 mirror

在上面遇到了一个 getMirror 的概念,我们来看看它究竟是干嘛的,在 prosemirror 文档中有这样一句话可能与其有关

It has special provisions for losslessly handling mapping positions through a series of steps in which some steps are inverted versions of earlier steps. (This comes up when 'rebasing' steps for collaboration or history management.)

也就是,在处理一系列 mapSteps 的时候,有些 step 可能是之前 step 的 inverted (反转) 版本,对于这些反转版本的 step,在处理时候会有一些特殊规定。发生这种情况主要是在协同编辑或者管理 history 需要进行 rebase steps 时。

其实就是在一个位置遇到了一个 stepMap,后续如果有它的反转操作,他们两个 step 都应用的话相当于没应用,所以要用 mirror 保存那些出现反转的 step,与正常的 step 做映射,方便查找。在 _map 中我们看到如果一个 stepMap 能找到对应的反转操作,并且这个反转操作 index 位置位于 当前 stepMap 之后,位于 to 之前 (代表后面会应用它),此时就跳过当前 stepMap 映射,直接跳转到它的反转操作那里继续向后遍历。

我们先来看看跟 mirror 相关的一些操作吧:

ts 复制代码
export class Mapping implements Mappable {
  // setMirror 也就是进行 mirror 的设置
  // 我们可以看出 mirror 每次春初都是一对一对的存储,即 [n1, m1, n2, m2, ...]
  // 所以其中 n,m 之间必然有一些联系
  setMirror(n: number, m: number) {
    if (!this.mirror) this.mirror = []
    this.mirror.push(n, m)
  }

  // getMirror 用来获取 mirror,可以看到传入的是 n,所以返回的应该就是 m 了
  // 上面看到 n 都出现在奇数位置,对应到索引就是 0, 2, 4... 偶数索引,m 都在奇数索引上
  // 所以获取对应的 mirror 就是遍历 mirror 数组,找到 n, 后面一个就是 m 了,如果给的 n 为奇数,则 m 取前一个。
  // 这里其实 m,n 的顺序也不一定必须显示 n1, m1,有可能会存在 [m1,n1]的情况,不过确定的是必须是一对一对的出现。
  // 这里通过 getMirror 不管是传入 n 还是传入 m 都能获取到对应的镜像,这就是 i + (i % 2 ? -1 : 1) 的魔力。传入奇数 index,前一个一定是他的 mirror 操作,传入偶数 index, 后一个也一定是他的 mirror 操作
  getMirror(n: number): number | undefined {
    if (this.mirror) for (let i = 0; i < this.mirror.length; i++)
      if (this.mirror[i] == n) return this.mirror[i + (i % 2 ? -1 : 1)]
  }
}

关于 Mapping 的一些操作

mapping 毕竟在 transform 上,而一个 tr 是可以不断进行操作的,此间也会不断产生 step,这就要求 mapping 能动态修改其中的 maps,我们来看看这些操作都有哪些吧:

ts 复制代码
export class Mapping implements Mappable {
	// 向 maps 中追加 stepMap,此时如果 mirrors 不是 null,就会设置 mirrors
	appendMap(map: StepMap, mirrors?: number) {
    this.to = this.maps.push(map)
    if (mirrors != null) this.setMirror(this.maps.length - 1, mirrors)
  }
  
  // 将给定的 mapping 中的 stepMaps 合并到当前 mapping 中
  // 其实就是遍历给定的 mapping 中的 maps,将其添加到当前 mapping 中
  // startSize 记录了当前 maps 的大小,也是新的 mapping 中 maps 开始插入的索引位置,在追加 mapStep 的时候
  // 如果其对应有镜像操作,镜像位置需要加上 startSize
  appendMapping(mapping: Mapping) {
    for (let i = 0, startSize = this.maps.length; i < mapping.maps.length; i++) {
      let mirr = mapping.getMirror(i)
      // 不过这里 为什么是 mirr < i 呢?不应该 mirr > i 吗?
      // 个人认为这是为了安全操作,毕竟 mirror 如果 > i,如果在 append 过程中,for 循环意外中断或出现了什么意外,后续的 mapSetp 没有加到当前 mapping 的 steps 中,这不就出问题了。所以为了安全,这里只有遍历到后面 mirr 时,才将前一个 step 作为镜像操作加到 mirrors 中
      this.appendMap(mapping.maps[i], mirr != null && mirr < i ? startSize + mirr : undefined)
    }
  }
  
  // slice, 根据 from,to 生成一个新的 mapping,这里通过调整 from,to 对 mapping 进行了裁剪。但其实 maps 没变,只是便利的过程中会从给定的 from -> to 进行遍历
  slice(from = 0, to = this.maps.length) {
    return new Mapping(this.maps, this.mirror, from, to)
  }
  
  // coppy 会生成一个新的 Mapping 对象,maps 与 mirror 也是通过 slice() 生成的一个新对象数组,from 与 to 与原来一样
  copy() {
    return new Mapping(this.maps.slice(), this.mirror && this.mirror.slice(), this.from, this.to)
  }
}

Mapping 中的反转操作

在看源码之前先自己分析一下如果是自己实现,应该是什么思路?其实就是反向遍历当前的 maps, 将其反转后添加到新的 mapping 对象中。仅仅把 maps 的顺序做成相反的是不行的,还需要把每个 map 通过 map.ivnert() 转为反向操作。

来看看源码的实现:

ts 复制代码
export class Mapping implements Mappable {
  // ...
  
  // Mapping 中的 invert 操作创建了一个新的 Mapping 对象
  // 并通过 appendMappingInverted 将自身的 maps 反转添加到自身的 maps中
	invert() {
    let inverse = new Mapping
    inverse.appendMappingInverted(this)
    return inverse
  }
  
  // 反向遍历当前 maps, 并将这些 mapStep 通过 map.invert() 反转后添加到当前的 maps 中,同时也需要保存对应的镜像操作。
  appendMappingInverted(mapping: Mapping) {
    for (let i = mapping.maps.length - 1, totalSize = this.maps.length + mapping.maps.length; i >= 0; i--) {
      let mirr = mapping.getMirror(i)
      this.appendMap(mapping.maps[i].invert(), mirr != null && mirr > i ? totalSize - mirr - 1 : undefined)
    }
  }
}

其实跟我们的分析是相同的。

看一个 mapping 的实际应用场景

如果我们的编辑器增加了表格(使用 prosemirror-tables 加的),现在有个功能,将选中单元格的内容替换为 1

这里简单贴一下 prosemirror-tables 的使用,css 就不贴了

ts 复制代码
// shcema.ts
import { tableNodes } from 'prosemirror-tables';
// 根据 tableNodes 创建 tbale 相关节点的 spec,并加入到 shcema 中
const { table, table_cell, table_header, table_row} = tableNodes({
  tableGroup: 'block',
  cellContent: 'block+',
  cellAttributes: {
    background: {
      default: null,
      getFromDOM(dom) {
        return dom.style.backgroundColor || null;
      },
      setDOMAttr(value, attrs) {
        console.log("set dom attr", value)
        if (value)
          attrs.style = (attrs.style || '') + `background-color: ${value};`;
      },
    },
  }
})

export const schema = new Schema({
  nodes: {
    table,
    table_cell,
    table_header,
    table_row
  }
})
// 插入 row * col 的表格
export function createTable(view: EditorView, row: number, col: number) {
  const { state } = view;
  const schema = state.schema;
  const rows = Array
    .from({length: row})
    .map(() => schema.nodes.table_row.create({}, 
      Array.from({length: col})
        .map(() => schema.nodes.table_cell.create({}, schema.nodes.paragraph.create()))
    ))
  const tableNode = schema.nodes.table.create({}, rows);
  const blockTile = schema.nodes.block_tile.create({}, tableNode);
  
  const tr = state.tr.replaceSelectionWith(blockTile)

  view.dispatch(tr)
}

// view.ts
const editorState = EditorState.create({
    schema,
    plugins: [
      tableEditing(),
      columnResizing(),
      //...
      key: new PluginKey('toolbar'),
        view: (view) => new Toolbar(view, {
  				groups: [
            //...
            {
              name: '其他',
              menus: [
                {
                  label: '向已选单元格填充 "1"',
                  // MenuItem 需要小改造,增加 attrs
                  attrs: {
                    disabled: 'true'
                  },
                  handler() {
                  },
                  update(view, state, menuDom) {
                    const selection = state.selection;
                    if (selection instanceof CellSelection && menuDom.getAttribute('disabled')) {
                      menuDom.removeAttribute('disabled')
                    }
                    if (!(selection instanceof CellSelection) && !menuDom.getAttribute('disabled')) {
                      menuDom.setAttribute('disabled', 'true')
                    }
                  }
                }
              ]
            }
          ]
  			})
      //...
    ]
})

这样就有了上面的效果,现在缺少的就是这里的 handler 如何实现

ts 复制代码
{
  handler({ state }) {
		// 根据 selectedRect 获取到 当前选中的 table 的信息
    const tableRect = selectedRect(state);
    if (!tableRect) return;

    const selectedCellsPos = tableRect.map.cellsInRect(tableRect).map(reltivePos => reltivePos + tableRect.tableStart)

    // 查看当前所选cell的位置
    console.log('state.doc.nodeAt(pos)',selectedCellsPos)
    // 查看当前 cell 节点
    selectedCellsPos.forEach((pos) => {
      const cell = state.doc.nodeAt(pos)

      console.log('cell',cell)
    })
  }
}

插入一个表格,选中几个单元格后点击填充1的按钮看看输出,这里根据 state.doc.nodeAt(pos) 获取到对应 pos 的节点可以看到确实是对应的 table_cell。

然后我们要尝试把 cell 里面的内容替换成 1

ts 复制代码
{
  handler({ state, view }) {
    const tableRect = selectedRect(state);
    if (!tableRect) return;

    const selectedCellsPos = tableRect.map.cellsInRect(tableRect).map(reltivePos => reltivePos + tableRect.tableStart)

    let tr = state.tr;
    selectedCellsPos.forEach((pos) => {
      // 获取到 cell
      const cell = tr.doc.nodeAt(pos)

      // 如果 cell 存在,且它真的是 table_cell ,就把里面内容替换成文本 1
      if (cell && cell.type.name === 'table_cell') {
        // pos + 2 是因为 pos 是 tbale_cell 的位置,需要跨过 <table_cell><paragraph> 才是真正的内容,这里就得 +2
        tr.replaceWith(pos + 2, pos + 2 + cell.textContent.length, state.schema.text('1'))
      }
			console.log('cell', cell)
    })
    view.dispatch(tr)
  }
}

应该很完美,来看看效果:

????? 为什么不符合预期啊,明明前面获取的都是对应选中的 table_cell,怎么填充的不对, 而且输出的 cell 只有第一次是 table_cell,后面三次都是乱七八糟的东西?不必惊慌,这是因为我们在每次通过 tr.replaceWith 操作文档的时候都会产生一个 replaceStep,每次操作后 tr.doc 都会变成最新的文档,而每次 tr 的操作都会基于 tr 中新文档的位置进行,所以后续每次操作,获取的 pos +2 都是旧的位置,而不是新的位置,这时候我们就需要用 tr.mapping.map 来将旧文档中的位置映射到新的文档中,获取新的位置后再替换内容:

上面还算好的,加了 if (cell && cell.type.name === 'table_cell') 判断没报错,如果连 if 语句都没加,产生的后果简直不敢想象。

ts 复制代码
selectedCellsPos.forEach((pos) => {
  // 根据 mapping 先获取新位置
  const newPos = tr.mapping.map(pos)
  // 再根据新位置获取对应的 cell 节点
  const cell = tr.doc.nodeAt(newPos)

  // 如果 cell 节点存在,就将其内部内容替换为 1
  if (cell && cell.type.name === 'table_cell') {
    tr.replaceWith(newPos + 2, newPos + 2 + cell.textContent.length, state.schema.text('1'))
  }
})

这次就正常了,并且打印出来的 cell 也都是 table_cell。所以在 tr 要进行多次操作时,pos 进行 mapping 是非常重要的,这要求使用者对 Prosemirror 的光标位置系统比较熟悉,同时文本更新以及本文描述的 mapping 熟悉才能准确无误地修改期望的内容。

3. 小结

本文主要探索了 Prosemirror 中坐标系统 map 相关内容的源码,位置坐标就像是 Prosemirror 的眼睛,一旦搞错,内容更新就是完全错乱的,一不小心就会出问题。其实很多技术不一定非得看源码才能深入理解,但放在 prosemirror 中,一些高级的内容,如果不看源码,api 文档会让你看的一脸懵逼,所以我们才深入到源码的层面,结合文档与实际案例,由深到浅进行学习,在了解了他的源码设计后,再去理解他的 api 以及一些操作现象,就会感觉这不是水到渠成、顺其自然的事嘛,否则一些 api 真得靠猜。

很多时候学习是要由浅入深,又有些时候也是要深入浅出的。

期待与你下次相见 !

See you next time!

相关推荐
ekskef_sef1 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6411 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻2 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云2 小时前
npm淘宝镜像
前端·npm·node.js
dz88i82 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr2 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook
程序员_三木2 小时前
Three.js入门-Raycaster鼠标拾取详解与应用
开发语言·javascript·计算机外设·webgl·three.js
顾平安3 小时前
Promise/A+ 规范 - 中文版本
前端
聚名网3 小时前
域名和服务器是什么?域名和服务器是什么关系?
服务器·前端
桃园码工3 小时前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染