Prosemirror 高级篇(源码):文档修改的原子操作 Step

1. 高级篇介绍

在了解了 Prosemirror 的基本操作后,如果要深入学习 Prosemirror,则离不开对它进行各种操作,但通过 API 文档,我们很难理解各种 api 的实际应用场景,更无法将其融汇贯通,做到运用自如。说实话,Prosemirror 的作者技术深不见底,Codemirror 也是他的一个产品,并被应用在 Chrome 浏览器的控制台,因此,大佬对一些概念的理解与抽象,不是简单看 api 文档就能玩转的。想要彻底理解 Prosemirror,并能够在项目中解决一些复杂的问题,对它的底层 api 需要有比较深入的理解,并且要知道在实际场景中应该如何应用。

要做到深入理解,其中少不了的就是对 api 进行全面细致探索,文档是一个渠道,源码也是一个渠道;要做到能够融汇贯通,还得多看看这些 api 在实际场景中都是如何应用、如何解决实际问题的。由于官方文档实在抽象,并且 api 文档只是用来查阅 api 的,也不适合学习,所以另外一个渠道就是从源码入手。

其实打算通过源码学习的初衷是想要通过 prosemirror-history 来了解一下核心包 prosemirror-transform 的一些常用 api 的用法,但最终实践下来,如果对核心概念没有深入了解,仅仅看 api,是很难完全理解 prosemirror-history 的,什么是完全理解?就是让你自己写一个 history 如果能写出来就算完全理解了。不过这有什么意义呢,如果换一个场景,让你实现一个基于 Prosemirror 的增量保存方案,你该怎么做?如果没有思路,或者实现不出来,那就是对 Prosemirror 没有完全理解。实现一个 prosemirror 的增量保存方案,必须对 prosemirror-transform 包有足够的理解,同时官方提供的 history 也是以操作文档为主,并且它是作者实现的官方案例,作者自己对 prosemirror 的理解肯定比任何人都深,所以它源码是深入理解 prosemirror 的一个很好的渠道。

我们可以通过 prosemirror-history 用到的最核心的内容来先了解最核心的概念,一些边缘的内容,后续再看。本文将会介绍 Prosemirror 中关于文档操作的最核心的概念 "Step",并从源码的角度分析 Step。

2. Prosmirror 中的 Step

2.1 Prosemirror 中文档变更的原子操作:Step

在我们之前了解 Prosemirror 基础内容的时候,经常用到操作文档的概念都是 Transcation 事务,无论是修改文档,还是移动光标,都会有对应的 tr 被生成,一旦通过 view.dispatch(tr),就可以将 tr 中的文档变更操作应用到文档上,但其实在 Prosemirror 中,文档操作最核心的部分并不是 tr,而是 StepsTransaction 是继承了 prosemirror-transformTransform 类的有一个子类,Transform 主要是实现文档操作的部分,也就是 Prosemirror MVC 架构中负责 C (Controller) 的部分,在 Transform 中,还定义了 steps 属性,里面存放了很多 Step,这些 Step 才是真正操作文档的原子部分,例如插入内容、删除内容、应用 mark、移除 mark 等操作,一个 transform 里面是可以保存一些列操作的,最终通过 dispatch(tr) ,可以将这一系列操作进行提交,真正应用在视图中。

2.2 通过源码了解抽象类 Step 的定义

在 Prosemirror 中,关于文档的修改,对应的最小概念就是 Step,每个 Step 对象都记录着文档的某个变化,代表的是对文档的原子修改,不能再拆分了;通过应用一个 Step,我们可以得到一个新的文档对象 doc,也就是 view.state.doc 中的 doc 对象,代表着整个文档节点,由于 Prosemirror 采用的是不可变数据,所以每次产生 doc 都是跟之前不同的新 doc。

Step 这个类是个抽象类,它的定义如下:

ts 复制代码
export abstract class Step {
  // 应用当前 step 到给定的文档中,返回一个 StepResult 对象用来表示是否应用成功
  abstract apply(doc: Node): StepResult

  // step 带来的改动会造成文档内容的变化,StepMap 记录了文档变化前的位置到文档变化后的位置的映射关系,根据它就可以计算文档变化前一个节点的 pos 在变化后应该处于的新的 pos 是多少。
  getMap(): StepMap { return StepMap.empty }

	// 反转一个 step 的应用,也就是创建相反操作,将文档恢复到应用这个 step 之前的状态,history 中的 undo 就是依靠它
	// 这个操作与 git 中的 revert 相同的概念
  abstract invert(doc: Node): Step

  // 通过一个 Mappable 对象,获取一个调整过位置的 Step,但如果这个 step 在 mapping 中被删除了,就会返回 null
  // 后面我们通过看看具体 ReplaceStep 等,看看它怎么实现,是个什么意思
  abstract map(mapping: Mappable): Step | null

	// 将当前 step 与其他的 step 进行合并,返回一个新的 step, 如果无法合并就会返回 null,即合并一些改动。
  merge(other: Step): Step | null { return null }

	// 返回当前 step 的 json 序列化格式,通过下面的 fromJSON 可以将当前生成的这种 json 格式,反序列化生成一个 step
  abstract toJSON(): any

  static fromJSON(schema: Schema, json: any): Step {
    if (!json || !json.stepType) throw new RangeError("Invalid input for Step.fromJSON")
    let type = stepsByID[json.stepType]
    if (!type) throw new RangeError(`No step type ${json.stepType} defined`)
    return type.fromJSON(schema, json)
  }

	// 为了实现 step 能够序列化为 JSON,每个 Step 都需要一个 id,来标注当前 step 是哪种类型的 Step,使用 jsonID 可以为一个 Step 的具体类注册一个 id,也就是这个具体类的唯一标识,比如 ReplaceStep 的 id (在 step 实例中也有一个 jsonID 的属性,区别与这里的 jsonID 静态方法,它代表的就是这个 id,为了避免混淆,我这里就成为 id) 就是 'replace'。
	// stepsByID 是在 step 定义文件中存储的一个常量,它的结构是:{[id: string]: {fromJSON(schema: Schema, json: any): Step}},这里的注册方法就会把 id -> fromJSON 缓存到 stepsByID 中,
  static jsonID(id: string, stepClass: {fromJSON(schema: Schema, json: any): Step}) {
    if (id in stepsByID) throw new RangeError("Duplicate use of step JSON ID " + id)
    stepsByID[id] = stepClass
    ;(stepClass as any).prototype.jsonID = id
    return stepClass
  }
}

由于 Step 是个抽象类,所以我们能够见到的 Step 一般都是实现了 Step 的具体类,如修改了文档内容后,会产生 ReplaceStep,应用了 Mark 后,会产生AddMarkStep,删除 Mark 会产生RemoveMarkStep等。

来看个具体的示例:我们增加一个段落后,提交了一个 tr,tr 中具体对文档的改动操作就保存在 steps 中,这里会产生一个 ReplaceStep,它就是 Step 的一个具体类,其中 jsonID 属性表示当前 step 的 id 是 replace,以后见到 id 为 replace 的 step 它就是 ReplaceStep,id 与 Step 的具体类之间是一对一的关系。

3 剖析 ReplaceStep 具体类的实现

3.1 准备篇:先了解 Slice 切片概念

在对 Step 有了一个初始印象后,我们需要仔细看看它的具体类是如何实现的,首先分析 ReplaceStep,因为他是文档内容修改会触发的原子操作,相比与设置 Mark 之类的 Step,它可谓是最重要的一个了,并且在文档修改中随处可见。

不过说到 ReplaceStep 就不得不先讲讲 Slice 的概念,因为 ReplaceStep 主要就是替换文档中的内容,要替换涉及到的最主要的就是替换插入的新内容长什么样,这部分内容其实就是以切片 Slice 的方式存在的。

Slice 其实就是很普通的文档切割后的片段,假如有如下结构的文档,slice(start, end) 代表的就是从 startend 的切片,对于标签是否闭合,slice 并不关心,它只关心从开始到结束位置中间的内容。

我们可以再浏览器控制台分别获取一下上述切片,通过 editorView.state.doc.slice(start, end) 可以获取到对应的切片:

上面讲获取到的切片转为了 JSON 格式,方便查看,虽然切片可以按照我们上面讲的那样随便切,但是对于例如没有闭合标签的切片来说,切片内部的 content 是会帮忙补充闭合标签的。如上面有 openEnd,就代表当前结束标签是没有闭合的(即 open 状态),数值代表当前没有闭合的标签的深度 depth。如果完全闭合了,就像最后一个 slice(0, 9),就没有 openEnd。

上面演示的是没有闭合标签,如果没有开始标签呢?slice 也是会在 content 中补充的,但随之而来的是会带来 openStart 属性,表示缺少开始标签,数值代表缺少的深度:

通过上面的示例,可以了解到,实际上的 slice content 会自动帮我们补充标签,但 slice 中仍然记录了缺失的开始标签深度与结束标签深度。

最后来看一下源码中 Slice 数据结构的定义吧:

ts 复制代码
export class Slice {
  
  constructor(
  	// slice 的 content:会帮我们自动补全标签,它的数据结构是个 Fragment
    readonly content: Fragment,
    // 缺失的开始标签的深度
    readonly openStart: number,
    // 缺失的结束标签的深度
    readonly openEnd: number
  ) {}

  // slice 的大小,由于 this.content 帮我们补充了缺失的开始与结束标签,这里计算大小的时候,就要减去对应的 openStart 与 openEnd,其实缺失标签的深度是多少就会补充多少个对应的开始与结束标签,他们每个大小都是 1,所以这里他们可以直接参与计算
  get size(): number {
    return this.content.size - this.openStart - this.openEnd
  }

  // 内部方法: 向 content 中添加一个新的 fragment,一般不使用
  insertAt(pos: number, fragment: Fragment) {
    let content = insertInto(this.content, pos + this.openStart, fragment)
    return content && new Slice(content, this.openStart, this.openEnd)
  }

  // 内部方法:从 content 中移除 from -> to 的内容,一般不使用
  removeBetween(from: number, to: number) {
    return new Slice(removeRange(this.content, from + this.openStart, to + this.openStart), this.openStart, this.openEnd)
  }

  // 检测两个 Slice 是否相等,首先调用 fragment.eq 比较两个 slice 的 content 是不是相等,然后还要比较缺失的开始标签与结束标签的深度是否相等,都相等两个 Slice 才相等
  eq(other: Slice): boolean {
    return this.content.eq(other.content) && this.openStart == other.openStart && this.openEnd == other.openEnd
  }

  // 内部方法,转为 string
  toString() {
    return this.content + "(" + this.openStart + "," + this.openEnd + ")"
  }

  // 转为 json,就是我们上面使用的
  toJSON(): any {
    if (!this.content.size) return null
    let json: any = {content: this.content.toJSON()}
    if (this.openStart > 0) json.openStart = this.openStart
    if (this.openEnd > 0) json.openEnd = this.openEnd
    return json
  }

  // 将一个 josn 转为 Slice 对象
  static fromJSON(schema: Schema, json: any): Slice {
    if (!json) return Slice.empty
    let openStart = json.openStart || 0, openEnd = json.openEnd || 0
    if (typeof openStart != "number" || typeof openEnd != "number")
      throw new RangeError("Invalid input for Slice.fromJSON")
    return new Slice(Fragment.fromJSON(schema, json.content), openStart, openEnd)
  }

  // 根据一个 fragment 创建一个 openStart 与 openEnd 都是最大的 slice
  /**
  * 例如下面这样的结构:
  * <block_tile><paragraph>hello</paragraph></block_tile>
  * <block_tile><paragraph>world</paragraph></block_tile>
  * 最终会创建一个 helo</paragraph></block_tile><block_tile><paragraph>world 的 slice,openStart 与 openEnd 均为2
  **/
  static maxOpen(fragment: Fragment, openIsolating = true) {
    let openStart = 0, openEnd = 0
    for (let n = fragment.firstChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.firstChild) openStart++
    for (let n = fragment.lastChild; n && !n.isLeaf && (openIsolating || !n.type.spec.isolating); n = n.lastChild) openEnd++
    return new Slice(fragment, openStart, openEnd)
  }

  // 静态属性,获取一个空的 slice 对象
  static empty = new Slice(Fragment.empty, 0, 0)
}

这就是 Slice 的定义,其主要数据结构还是上面讲的 content,openStart 与 openEnd,其余都是一些辅助的操作方法。

3.2 ReplaceStep 的构造函数

在展示源码之前,先思考一下,替换的场景如果是自己来实现应该需要怎样的数据结构,要被替换掉的内容的开始与结束位置,其次要有要替换进去的内容,也就是上面的 slice,大家可以思考下插入的内容为什么是 Slice 而不是 Fragmenet 呢?然后再看 ReplaceStep 数据结构怎么设计:

Slice 里面主要保存了 openStart 与 openEnd,在插入的时候,可以更好地确定插入的内容,而 Fragmenet 内容如果有标记,最后应该都是完善的开放与闭合标签,插入到文档中后,可能会与原始内容无法完美连接。

ts 复制代码
export class ReplaceStep extends Step {
   constructor(
    // 被替换的部分的开始位置
    readonly from: number,
    // 被替换的部分的结束位置
    readonly to: number,
    // 要插入进文档的内容
    readonly slice: Slice,
    // 内部使用的属性:structure,表示是否只进行结构替换,而不进行内容覆盖
    // 这主要是处理 rebase step 可能会覆盖原始内容的情况,在 ProseMirror 的多人协作中,当收到远程其他用户的更改时,collab 模块会将这些更改表示为一系列的步骤(steps)。在应用这些远程步骤时,会基于当前用户操作的文档来应用这些步骤,这个过程就是 rebase,在 rebase 过程中,如果远程步骤中的 from 和 to 与当前文档中的内容重叠,可能会导致新内容被覆盖。为了避免这种情况,使用 structure 参数来标记步骤,以指示是否需要进行结构检查。如果 structure 参数为 true,ProseMirror 会检查要替换的内容是否符合结构要求,以确保不会意外覆盖其他内容,因此,structure 参数的目的是确保在 rebase 过程中,不会不经意地覆盖其他用户或当前用户的修改内容。
    readonly structure = false
  ) {
    super()
  }
}
// 注册 ReplaceStep,将其与 "replace" 字符串对应
Step.jsonID("replace", ReplaceStep)

看起来数据结构与我们预想差不多,但增加了个 prosemirror 内部专用的 structure

3.3 实现 toJSON 与 fromJSON

实现一个 Step 的具体类,在定义好数据结构后,要实现的方法中最简单的一个就是 toJSON 了,只需要把数据结构中必要的内容映射到一个纯对象上即可。fromJSON 可以将一个普通的 json 对象还原出一个 ReplaceStep 实例对象。

ts 复制代码
export class ReplaceStep extends Step {
  //...
  toJSON(): any {
    // json 基本结构:包含 stepType,也是当前 ReplaceStep 的 jsonId, from 与 to 对应到上面 from 与 to 的值
    let json: any = {stepType: "replace", from: this.from, to: this.to}
    // slice 中如果存在内容,就把 slice 转为 json 添加到 json 对象中,否则就不添加,如果不添加,当前的 ReplaceStep 对应的操作就是删除操作
    if (this.slice.size) json.slice = this.slice.toJSON()
    // 记录 structure,可能会影响到后续内部的执行逻辑,因此需要保存在 json 中,方便后续根据 json 还原一个 ReplaceStep
    if (this.structure) json.structure = true
    return json
  }
  
  /// @internal
  static fromJSON(schema: Schema, json: any) {
    // from 与 to 有一个部位 number, 则报错,因为他们是被替换的部分的位置,如果没有,当前 Step 也就失去了它的意义
    if (typeof json.from != "number" || typeof json.to != "number")
      throw new RangeError("Invalid input for ReplaceStep.fromJSON")
    // 根据 from,to以及给定的 json 内容,new 一个 ReplaceStep 对象
    return new ReplaceStep(json.from, json.to, Slice.fromJSON(schema, json.slice), !!json.structure)
  }
}

3.4 实现 apply 方法

apply 方法会将当前 Step 应用到给定的 doc 文档上,生成一个新的 doc,我们之前看 Step 的 apply 方法应该要返回一个 StepResult,这里我们先来看看 StepResult 究竟是何方神圣:

ts 复制代码
export class StepResult {
  // constructor 中定义了 StepResult 的数据结构,internal 标记了它是个内部方法,因此 StepResult 不能通过 new 来初始化
  /// @internal
  constructor(
  	// doc 属性表示在应用 step 成功后,转换后的新文档对象,应用失败就是 null
    readonly doc: Node | null,
    // failed 属性表示在应用 step 失败后的错误信息,如果成功就是 null
    readonly failed: string | null
  ) {}

  // 创建一个应用 Step 成功后的 StepResult 实例
  static ok(doc: Node) { return new StepResult(doc, null) }

 // 创建一个应用 Step 失败后的 StepResult 实例
  static fail(message: string) { return new StepResult(null, message) }

  // 这里会调用 doc.replace 方法,doc 也是个普通的 node, node 实例上有个 replace 方法,可以将一个 slice 应用到文档中,后续我们在增加一篇文章研究下 node 实例上的方法。
  static fromReplace(doc: Node, from: number, to: number, slice: Slice) {
    try {
      return StepResult.ok(doc.replace(from, to, slice))
    } catch (e) {
      // 如果 slice 应用失败,会抛出错误,并返回一个应用失败的 StepResult 实例,什么时候会失败呢?比如 slice 开头结尾缺少标签,与原来位置的内容连不上,就可能失败。
      if (e instanceof ReplaceError) return StepResult.fail(e.message)
      throw e
    }
  }
}

知道了 apply 应该返回一个 StepResult 之后再来看看 ReplaceStep 的 apply 应该如何实现

ts 复制代码
export class ReplaceStep extends Step {
  // ...
  // 这里主要就调用了 StepResult.fromReplace 来创建一个 StepResult 实例,具体的替换是在 fromReplace 里进行的,见上面的代码,要注意的是这里有个 structure 的判断,里面调用了 contentBetween,我们后面来看看它是干嘛的
  apply(doc: Node) {
    // 标记了 structure 后需要检测一下 from -> to 之间是否有内容,如果有,就不能应用当前的 step,避免将内容覆盖掉
    if (this.structure && contentBetween(doc, this.from, this.to))
      return StepResult.fail("Structure replace would overwrite content")
    return StepResult.fromReplace(doc, this.from, this.to, this.slice)
  }
  // ...
}

// 判断 from 到 to 是否包含内容:比如 from -> to 选中的刚好是 [</span></paragraph></tail_block>], 他就不包含内容
function contentBetween(doc: Node, from: number, to: number) {
  // 获取到当前的 from -> to 选区的长度 dist,以及开始位置的深度 depth
  let $from = doc.resolve(from), dist = to - from, depth = $from.depth
  
  // $from.indexAfter(depth) 的结果是:根据 depth 深度的祖先节点,获取到当前位置在对应祖先节点中的子节点后面一个节点的位置,$from.node(depth).childCount 获取的是depth 深度的祖先节点。这里的判断是当前选区开头的位置是不是父节点中的最后一个节点。
  // 如果是的话,并且 dist 选区有长度,深度就向上提一层,再看是否还是最后一个元素
  // 以:选区[</span></paragraph></tail_block>] 为例,from 在 </span> 前,depth 向上提一层后,$from.indexAfter(depth) 获取的位置就是 [</paragraph></tail_block>] 的开头,所以 dist 比之前少了 1
  while (dist > 0 && depth > 0 && $from.indexAfter(depth) == $from.node(depth).childCount) {
    depth--
    dist--
  }
  // 如果选区是 [</span></paragraph></tail_block>], 上面循环结束后 dist就是0,最后返回 false, 表明当前范围内不包含内容
  // 如果选区是 [</span></paragraph></tail_block><tail_block><paragraph>hello],上面循环结束后,depth 是0,dist 是 7,会进入 if 中
  if (dist > 0) {
    // 获取到指定 depth 祖先节点的下一个节点
    let next = $from.node(depth).maybeChild($from.indexAfter(depth))
    while (dist > 0) {
      // 这里获取 next 就是 [<tail_block><paragraph>hello] 对应的 tail_block
     	// 并且 next 不是个叶子节点,继续循环,最终 next 会走到 hello 上,此时 isLeaf 为 true,就会返回选区中有内容。
      if (!next || next.isLeaf) return true
      next = next.firstChild
      dist--
    }
  }
  return false
}

3.5 getMap 返回的 StepMap 是什么

在抽象类 Step 的定义中,还有个 getMap 方法,可以获取到一个 StepMap 对象,这个 StepMap 描述了应用 Step 之前以及之后文档中位置的映射关系:

ts 复制代码
export class ReplaceStep extends Step {
  //...
	getMap() {
    return new StepMap([this.from, this.to - this.from, this.slice.size])
  }
}

getMap 直接新建了一个 StepMap 对象,所以我需要详细了解 StepMap 才能知道它到底是什么。我们可以看到上面创建一个 StepMap 时,传递了一个元组,其中供三个元素,它们代表一个修改的区域,分别为 [开始位置,旧内容大小,新内容大小]。我们可以在控制台中打印看看中打印看看:

这里我将 <doc><block_tile><paragraph>hello world</paragraph></block_tile></doc> 中的 world 选中,通过复制粘贴,将其替换为 小王,这里产生的 ReplaceStep 要替换的位置就是 8 -> 13 , 原内容大小为 13 - 8 = 5,新内容大小为 2, 所以最终生成的 StepMap 中 ranges 就是 [8, 5, 2],其中 inversed 为 false,代表本次 step 应用不是反转 step 恢复文档,而是一次正常的操作。

StepMap 是个实现了 Mappable 接口的对象,Mappable 接口规定了两个公开的方法:

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

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

通过以下代码,在控制台中生成一个 tr,但是不提交:

这里再用简易图表示一下

上面我们看到,tr 替换了 world小王 之后,原本位置 8 还是在新内容 8 的位置,由于 world 全部被删除,所以 9,10,11,12 全都对应在了新内容 10 的位置上,这时候,我们通过 map 获取的位置,默认 assoc 为 1,他们都指向被替换内容的右侧,但如果 传入为 -1,控制台的输出 9,10,11,12 都是 8,为替换内容的左侧位置。

再来看一下 mapResult 方法的返回值,其中 delInfo 与 recover 不需要关心,他们是内部计算过程中使用的值,除此之外,pos 与上面 map 计算的是一致的,deleted 表示当前位置是否有删除内容,除此之外还有 deletedAfter deletedBefore 这两分别代表删除行为发生在前面还是后面,deletedAcross 代表当前位置是否被删除了。

3.6 如何实现 invert?

再看这个图,invert 一个 Step 会把新的内容恢复到旧的内容,invert 其实就是创建一个相反的操作,我们这里从新的 doc 恢复到旧的 doc 可以通过创建一个新的 ReplaceStep,从 8 -> 10,slice 为旧的 doc 的 从 8 -> 13 的内容。看源码其实就是这样的:

ts 复制代码
export class ReplaceStep extends Step {
  //...
  invert(doc: Node) {
    return new ReplaceStep(this.from, this.from + this.slice.size, doc.slice(this.from, this.to))
  }
}

可能有人会有问题,当前处于新的 doc,怎么能知道旧的 doc 是什么样呢?其实没必要担心,因为每次应用 step prosemirror 都会产生一个新的 doc,旧的 doc 我们完全可以保存起来。那为什么不直接用旧的 doc,直接返回不是更方便 吗?但你要知道,prosemirror 中的每一次操作都要产生一个新的 doc,这里返回旧的 doc 不太符合 prosemirror 的不可变数据规则;而且如果 invert 之后再次 insert 回去呢?目前这种方式并不关心你 invert 几次,每次操作文档都按规定生成一个 Step,所有的文档修改都要通过 Step 来也是 Prosemirror 的一个文档更新范式,对于一些操作的追踪管理等,通过这种范式,都可以很容易实现。

3.7 ReplaceStep 中的 map 是什么?

Step 中的 map 可以让我们传入一个实现了 Mappable 接口的对象,根据这个对象,可以将当前的 Step 转为一个新的 Step。应用场景是什么?比如针对一个 doc 创建了一个 Step 之后,在它被应用之前,又有另外一个 Step 被先应用了,那直接应用当前的 Step 位置可能就会是错误的,所以需要重新获取到正确的 from,to,更新当前的 Step。

来看看源码怎么实现:

ts 复制代码
export class ReplaceStep extends Step {
  //...
  map(mapping: Mappable) {
    // 根据 mapping 获取当前 step 中 from to 在新的 doc 中对应的新的位置
    let from = mapping.mapResult(this.from, 1), to = mapping.mapResult(this.to, -1)
    // 如果这个 Step 本应该被插入的部分都被删除了,那当前 Step 就不应该被应用了,直接返回 null
    if (from.deletedAcross && to.deletedAcross) return null
    // 否则就根据新的位置,新建一个 Step,内容还是之前的 slice,位置是新的位置
    return new ReplaceStep(from.pos, Math.max(from.pos, to.pos), this.slice)
  }
}

3.8 ReplaceStep 是如何实现 merge 的?

在 ReplaceStep 中,目前还剩一个 merge 接口没实现,它主要负责将当前 step 与其他 step 进行合并,返回一个 step,也就是将多个操作合并为一个操作。但在 ReplaceStep 中,merge 也不能乱 merge,如将一个 ReplaceStep 的 Step 与一个 AddMarkStep 合并,那合并后的 Step 应该是什么类型呢?其实对于这种需求,它们是不能合并的,只有 ReplaceStep 才能与 ReplaceStep 合并。其次,如果两个 ReplaceStep 无法连接在一起,也无法合成一个 Step,毕竟两处更改,无法对应到一个原子操作。再来看看 merge 源码是如何处理的:

ts 复制代码
export class ReplaceStep extends Step {
	merge(other: Step) {
    // 如何合并的另外一个 Step 不是 ReplaceStep 返回 null,不进行合并,structure 结构的 Step 也不可以合并。
    if (!(other instanceof ReplaceStep) || other.structure || this.structure) return null

    // otherStep 可以连接在当前 Step 后面的情况,并且当前闭合标签与新内容的开始标签都是完整未确实的,才能连接
    if (this.from + this.slice.size == other.from && !this.slice.openEnd && !other.slice.openStart) {
      // 构建新的 slice,即将其他的内容插入到当前内容后面,之后根据新的内容与位置生成新的 ReplaceStep
      let slice = this.slice.size + other.slice.size == 0 ? Slice.empty
          : new Slice(this.slice.content.append(other.slice.content), this.slice.openStart, other.slice.openEnd)
      return new ReplaceStep(this.from, this.to + (other.to - other.from), slice, this.structure)
      // otherSteo 可以连接在当前 Step 前面的情况,并且当前 slice 开始标签与新内容的结束标签完整,才能连接
    } else if (other.to == this.from && !this.slice.openStart && !other.slice.openEnd) {
      let slice = this.slice.size + other.slice.size == 0 ? Slice.empty
          : new Slice(other.slice.content.append(this.slice.content), other.slice.openStart, this.slice.openEnd)
      return new ReplaceStep(other.from, this.to, slice, this.structure)
    } else {
      // 其他情况视为不能合并
      return null
    }
  }
}

这里所说的开始标签与结束标签不是很标准,就代表在 prosemirror 文档结构中的一种结构,类比到HTML的概念。

4. 小结

到这里 ReplaceStep 就告一段落了,我们了解了 Step 是什么,也了解了如何实现一个 Step 具体类,这里来对 Step 与 ReplaceStep 做个简单总结:

每个 Step 具体类的都有它自己的 jsonID,它是一个唯一字符串,与这个 Step 具体类的唯一对应,通过 Step.jsonID(id, StepClass),可以将其注册到 Prosemirror 中,本质就是缓存到一个对象中,通过 key-value 方便取值;每个 Step 具体类都需要实现 toJSONfromJSON 方法,便于将自身对象与 JSON 对象进行互相转换;最重要的一个方法是 apply,它可以将当前 Step 应用到 doc 上,其实就是把当前 Step 中的 Slice 插入到 doc 中对应的位置,主要通过 StepResult 来实现,借助了 doc.replace(from,to,slice) 来将 Step 中的 slice 替换到 doc 中产生一个全新的 doc;invert 则根据 step 中记录的fromslice.size计算出了新内容在新文档中的位置,用旧的doc从之前的位置fromto来截取旧的内容,生成一个新的ReplaceStep 进行反转操作;getMap主要获取应用当前 Step 后的 map 对象,传入之前的 pos 获取新 doc 中对应的 pos;map则会根据一个新的Mappable对象,将当前 Step 中的 slice 根据映射后的位置,生成一个新的 ReplaceStep;最后是merge,只能合并 ReplaceStep,且有条件限制,即新的 Step 与旧的中的 Slice 必须首或尾相连;这就是 ReplaceStep 的全貌了。

在 Prosemirror 中,还有其他类型的 Step,就不在本文中详细阐述了。我们在复杂的业务场景中,特别是定制自己的编辑器时,也可以根据需求实现自己的 Step 具体类。

后续篇章会继续探索 Prosemirror 的源码,不过第一阶段的主要目标是完全理解 prosemirror-history,我们目前了解的内容都是他的前置内容,在了解了足够知识后我们再分析 prosemirror-history 源码,并自己重新实现一个。

See you next time.

相关推荐
四喜花露水11 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy20 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie1 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust1 小时前
css:基础
前端·css
帅帅哥的兜兜1 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
工业甲酰苯胺1 小时前
C# 单例模式的多种实现
javascript·单例模式·c#
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称1 小时前
购物车-多元素组合动画css
前端·css
编程一生2 小时前
回调数据丢了?
运维·服务器·前端
丶21362 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web