-
什么是 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 。
-
slate operation 的设计
op 的设计上还需要特别注意 undo/redo,本质上 undo/redo 就是将之前的 op 取反并重新应用到文档模型上。举个例子:insertText 的 undo 就是 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
-
op 数据结构
-
insertText Operation
export type BaseInsertTextOperation = {
type: 'insert_text'
path: Path
offset: number
text: string
}
-
通过
type: insert_text表示是一个插入文本操作的op -
通过
path找到对应的slateNode节点 -
通过
offset找到在哪一个位置插入text文本
举个🌰:
下面 op 表示在[0,0,0]节点的第1个位置插入一个2文本
{
type: 'insert_text'
path: [0,0,0]
offset: 1
text: 2
}
-
removeText Operation
export type BaseRemoveTextOperation = {
type: 'remove_text'
path: Path
offset: number
text: string
}
-
通过
type: insert_text表示是一个删除文本操作的op -
通过
path找到对应的slateNode节点 -
通过
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
}
-
通过
type: insert_text表示是一个插入操作的op -
通过
path找到对应的slateNode节点 -
通过
offset找到在哪一个位置删除length个文本
那用 length 会有什么问题呢?假设有一个文档内容是 12345,用户执行一个 op 删除了5 这个文本,现在内容就是 1234。此时执行 undo 删除操作对应的 undo 是 insertText:
length的设计能够的到的信息就是:通过offset找到在哪一个位置插入length个文本。text的设计能够的到的信息就是:通过offset找到在哪一个位置插入text文本。
很明显 length 字段不符合。
-
insertNode Operation
export type BaseInsertNodeOperation = {
type: 'insert_node'
path: Path
node: Node
}
-
通过
type: insert_node表示是一个插入节点操作的op -
通过
path找到要插入的位置,并且插入node节点
举个🌰:
下面 op 表示在[0,1]位置下插入一个 node 节点。原先在 [0,1] 位置节点就会被挤到右边。
{
type: 'insert_node'
path: [0,1]
node: { text: 4 }
}
-
removeNode Operation
export type BaseRemoveNodeOperation = {
type: 'remove_node'
path: Path
node: Node
}
- 通过
type: remove_node表示是一个删除节点操作的op - 通过
path找到对应的slateNode节点,并将其删除。
这里的 node 字段设计就是为了适应 undo 。具体原因可以参考 removeText,这里不再赘述了。
举个🌰:
下面 op 表示在删除[0,1] 位置的slateNode节点。
{
type: 'remove_node'
path: [0,1]
node: { text: 4 }
}
-
splitNode operation
export type BaseSplitNodeOperation = {
type: 'split_node'
path: Path
position: number
properties: Partial<Node>
}
-
通过
type: split_node表示是一个拆分节点操作的op -
通过
path找到对应的slateNode -
position需要分情况:- 如果
slateNode是一个文本节点:position表示需要分割的文本节点的位置。从position位置将slateTextNode拆分为两个slateTextNode。 - 如果
slateNode不是文本节点:position表示slateNode的第几个子节点。从position位置将slateNode的子节点拆分为两部分。
- 如果
-
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 }
}
-
mergeNode operation
export type BaseMergeNodeOperation = {
type: 'merge_node'
path: Path
position: number
properties: Partial<Node>
}
-
通过
type: merge_node表示是一个合并节点操作的op -
通过
path找到对应的slateNode,并将slateNode合并到prevPath的节点中。(prevPath 表示 path 的前一个节点)- 如果 slateNode 是一个文本节点,则直接将当前节点文本合并到 prevPathSlateNode
- 如果 slateNode 是非文本节点,则直接将当前节点的子节点合并到 prevPathSlateNode,
- 最终都会删除当前节点。
-
position需要分情况。设计就是为了适应undo。- 如果
prevPath对应的节点是一个文本节点,拿position则是prevNode的文本长度。 - 如果
prevPath对应的节点非文本节点,拿position则是prevNode的孩子节点个数。
- 如果
-
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 }
}
-
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
}
-
通过
type: set_selection表示是一个修改选区操作的op -
properties 表示需要修改的属性原来的值,newProperties 表示需要修改的属性新的值。
-
通过
path找到对应的slateNode,通过 newProperties 设置/修改 新的属性 -
setNode operation
export type BaseSetNodeOperation = {
type: 'set_node'
path: Path
properties: Partial<Node>
newProperties: Partial<Node>
}
-
通过
type: set_node表示是一个修改节点属性操作的op -
properties 表示需要修改的属性原来的值,newProperties 表示需要修改的属性新的值。
-
通过
path找到对应的slateNode,通过 newProperties 设置/修改 新的属性
举个🌰:
下面 op 表示修改[0,0,0] 位置的slateNode节点的属性。bold 从 true 变为 false,只记录前后不同的属性。
{
type: 'set_node'
path: [0,0,0]
properties: { bold: true }
newProperties: { bold: false }
}
-
moveNode operation
export type BaseMoveNodeOperation = {
type: 'move_node'
path: Path
newPath: Path
}
-
通过
type: move_node表示是一个移动节点属性操作的op -
通过
path找到对应的slateNode,保存下来 -
删除 slateNode
-
slateNode 增加到
newPath上
举个🌰:
下面 op 表示移动[1] 位置的slateNode节点到[2,2]位置上
{
type: 'move_node'
path: [1]
newPath: [2,2]
}
-
写在最后 & 参考资料
原文链接:slate operation 如何修改 model
欢迎 star:github.com/JokerLHF/mi…