slate operation 如何修改 model

146 阅读7分钟
  1. 什么是 operation?

假设用户A和用户B同时对一篇文档进行编辑,文档内容是 12345。

  • A 调用 insertText 指令在 4 后面插入了文本 a,生成了新的文档 1234a5

  • B 调用 removeText 指令删除了 5,生成了新的文档 1234

在多人协作的场景下,期望的结果是每个用户「彼时彼刻都看到同样的内容」。也就是说用户A需要知道用户B做的操作并将操作应用到本地修改Model,同理用户B需要知道用户A做的操作并将操作应用到本地修改Model。这样 A、B 才能看到一样结果的内容。(当然实际协同编辑并不是这么简单,协同过程中存在冲突。)

Operation 俗称 op,表示的就是你做了什么操作。Slate op 大体可以分为3种类型。分别是对 节点/选区/文本 的操作: NodeOperation | SelectionOperation | TextOperation

  1. slate operation 的设计

op 的设计上还需要特别注意 undo/redo,本质上 undo/redo 就是将之前的 op 取反并重新应用到文档模型上。举个例子:insertTextundo 就是 removeText

  • offset 位置 insertText 了一个 a 文本。

  • 与其对应的undo: 在 offset 位置 removeText 了一个 a 文本。

在 slate 中大部分操作都有与其对应的相反操作。而 setNode/setSelection/moveNode 取反则是在 op 携带的属性上做文章。

insertText    <=>   removeText
insertNode    <=>   removeNode
splitNode     <=>   mergeNode
setNode       <=>   setNode
setSelection  <=>   setSelection
moveNode      <=>   moveNode
  1. op 数据结构

  1. insertText Operation

export type BaseInsertTextOperation = {
  type: 'insert_text'
  path: Path
  offset: number
  text: string
}
  1. 通过 type: insert_text 表示是一个插入文本操作的 op

  2. 通过 path 找到对应的 slateNode 节点

  3. 通过 offset 找到在哪一个位置插入 text 文本

举个🌰:

下面 op 表示在[0,0,0]节点的第1个位置插入一个2文本

{
  type: 'insert_text'
  path: [0,0,0]
  offset: 1
  text: 2
}

  1. removeText Operation

export type BaseRemoveTextOperation = {
  type: 'remove_text'
  path: Path
  offset: number
  text: string
}
  1. 通过 type: insert_text 表示是一个删除文本操作的 op

  2. 通过 path 找到对应的 slateNode 节点

  3. 通过 offset 找到在哪一个位置删除 text 文本

举个🌰:

下面 op 表示在[0,0,0]节点的第1个位置删除一个长度的文本,也就是2这个文本。

{
  type: 'remove_text'
  path: [0,0,0]
  offset: 1
  text: 2
}

这里的 text 字段设计就是为了适应 undo ****。设想一下如果不考虑 undo 这里是不是设计成下面比较符合直觉:

export type BaseRemoveTextOperation = {
  type: 'remove_text'
  path: Path
  offset: number
  length: number
}
  1. 通过 type: insert_text 表示是一个插入操作的 op

  2. 通过 path 找到对应的 slateNode 节点

  3. 通过 offset 找到在哪一个位置删除 length 个文本

那用 length 会有什么问题呢?假设有一个文档内容是 12345,用户执行一个 op 删除了5 这个文本,现在内容就是 1234。此时执行 undo 删除操作对应的 undoinsertText

  • length 的设计能够的到的信息就是:通过 offset 找到在哪一个位置插入 length 个文本。
  • text 的设计能够的到的信息就是:通过 offset 找到在哪一个位置插入 text 文本。

很明显 length 字段不符合。

  1. insertNode Operation

export type BaseInsertNodeOperation = {
  type: 'insert_node'
  path: Path
  node: Node
}
  1. 通过 type: insert_node 表示是一个插入节点操作的 op

  2. 通过 path 找到要插入的位置,并且插入 node 节点

举个🌰:

下面 op 表示在[0,1]位置下插入一个 node 节点。原先在 [0,1] 位置节点就会被挤到右边。

{
  type: 'insert_node'
  path: [0,1]
  node: { text: 4 }
}

  1. removeNode Operation

export type BaseRemoveNodeOperation = {
  type: 'remove_node'
  path: Path
  node: Node
}
  1. 通过 type: remove_node 表示是一个删除节点操作的 op
  2. 通过 path 找到对应的 slateNode 节点,并将其删除。

这里的 node 字段设计就是为了适应 undo 。具体原因可以参考 removeText,这里不再赘述了。

举个🌰:

下面 op 表示在删除[0,1] 位置的slateNode节点。

{
  type: 'remove_node'
  path: [0,1]
  node: { text: 4 }
}

  1. splitNode operation

export type BaseSplitNodeOperation = {
  type: 'split_node'
  path: Path
  position: number
  properties: Partial<Node>
}
  1. 通过 type: split_node 表示是一个拆分节点操作的 op

  2. 通过 path 找到对应的 slateNode

  3. position 需要分情况:

    1. 如果 slateNode 是一个文本节点:position 表示需要分割的文本节点的位置。从 position 位置将 slateTextNode 拆分为两个 slateTextNode
    2. 如果 slateNode 不是文本节点:position 表示 slateNode 的第几个子节点。从 position 位置将 slateNode 的子节点拆分为两部分。
  4. properties 是节点的一些额外信息,比如 加粗/斜体 等

举个🌰(文本)

下面 op 表示从 [0,0,0]position位置将 slateTextNode 拆分为两个 slateTextNode。原本 [0,0,0] 节点的properties 存在加粗属性,拆分出来新的节点也会带上 properties 属性。

{
  type: 'split_node'
  path: [0,0,0]
  position: 1
  properties: { bold: true }
}

举个🌰(非文本)

下面 op 表示从 [1] 的第1子节点的位置拆分为2部分。并且将其移动到[1]的右边。原本 [1] 节点的properties 存在{ test: true }属性,拆分出来新的节点也会带上 properties 属性。

{
  type: 'split_node'
  path: [1]
  position: 1
  properties: { test: true }
}

  1. mergeNode operation

export type BaseMergeNodeOperation = {
  type: 'merge_node'
  path: Path
  position: number
  properties: Partial<Node>
}
  1. 通过 type: merge_node 表示是一个合并节点操作的 op

  2. 通过 path 找到对应的 slateNode,并将 slateNode 合并到 prevPath 的节点中。(prevPath 表示 path 的前一个节点)

    1. 如果 slateNode 是一个文本节点,则直接将当前节点文本合并到 prevPathSlateNode
    2. 如果 slateNode 是非文本节点,则直接将当前节点的子节点合并到 prevPathSlateNode,
    3. 最终都会删除当前节点。
  3. position 需要分情况。设计就是为了适应 undo

    1. 如果 prevPath 对应的节点是一个文本节点,拿 position 则是 prevNode 的文本长度。
    2. 如果 prevPath 对应的节点非文本节点,拿 position 则是 prevNode 的孩子节点个数。
  4. properties 就是 path 节点的属性 字段设计就是为了适应 undo

    case 'merge_node': {
      const { path } = op
      const node = Node.get(editor, path)
      const prevPath = Path.previous(path)
      const prev = Node.get(editor, prevPath)
      const parent = Node.parent(editor, path)
      const index = path[path.length - 1]

      if (Text.isText(node) && Text.isText(prev)) {
        prev.text += node.text
      } else if (!Text.isText(node) && !Text.isText(prev)) {
        prev.children.push(...node.children)
      } else {
        throw new Error('xxx')
      }

      // 删除当前节点
      parent.children.splice(index, 1)

      break
    }

举个🌰(文本)

下面 op 表示从 [0,0,1] 合并到 [0,0,0]中。其中 position 表示的就是 [0,0,0]的长度,properties就是 [0,0,1]properties。这两个字段都是为了 undo

{
  type: 'merge_node'
  path: [0,0,1]
  position: 1
  properties: { bold: true }
}

举个🌰(非文本)

下面 op 表示从 [2] 合并到[1]中。其中 position 表示[1]子节点的个数。properties[2]properties。这两个字段设计是为了 undo

{
  type: 'merge_node'
  path: [2]
  position: 1
  properties: { test: true }
}

  1. setSelection operation

export type BaseSetSelectionOperation =
  | {
      type: 'set_selection'
      properties: null
      newProperties: Range
    }
  | {
      type: 'set_selection'
      properties: Partial<Range>
      newProperties: Partial<Range>
    }
  | {
      type: 'set_selection'
      properties: Range
      newProperties: null
    }
  1. 通过 type: set_selection 表示是一个修改选区操作的 op

  2. properties 表示需要修改的属性原来的值,newProperties 表示需要修改的属性新的值。

  3. 通过 path 找到对应的 slateNode,通过 newProperties 设置/修改 新的属性

  4. setNode operation

export type BaseSetNodeOperation = {
  type: 'set_node'
  path: Path
  properties: Partial<Node>
  newProperties: Partial<Node>
}
  1. 通过 type: set_node 表示是一个修改节点属性操作的 op

  2. properties 表示需要修改的属性原来的值,newProperties 表示需要修改的属性新的值。

  3. 通过 path 找到对应的 slateNode,通过 newProperties 设置/修改 新的属性

举个🌰:

下面 op 表示修改[0,0,0] 位置的slateNode节点的属性。bold 从 true 变为 false,只记录前后不同的属性。

{
  type: 'set_node'
  path: [0,0,0]
  properties: { bold: true }
  newProperties: { bold: false }
}

  1. moveNode operation

export type BaseMoveNodeOperation = {
  type: 'move_node'
  path: Path
  newPath: Path
}
  1. 通过 type: move_node 表示是一个移动节点属性操作的 op

  2. 通过 path 找到对应的 slateNode,保存下来

  3. 删除 slateNode

  4. slateNode 增加到 newPath

举个🌰:

下面 op 表示移动[1] 位置的slateNode节点到[2,2]位置上

{
  type: 'move_node'
  path: [1]
  newPath: [2,2]
}

  1. 写在最后 & 参考资料

原文链接:slate operation 如何修改 model

欢迎 star:github.com/JokerLHF/mi…