基于OT与CRDT协同算法的文档划词评论能力实现

当我们实现在线文档平台时,划词评论的功能是非常必要的,特别是在重文档管理流程的在线文档产品中,文档反馈是非常重要的一环,这样可以帮助文档维护者提高文档质量。而即使是单纯的将划词评论作为讨论区,也是非常有用的,尤其是在文档并不那么完善的情况下,对接产品系统的时候可以得到文档之外的输入。那么本文将通过引入协同算法来解决冲突,从而实现在线文档的划词评论能力。

我们即将要聊的
OT

CRDT
的实现分别会有相关示例:

如果想了解关于协同的相关内容,也可以参考之前的文章:

描述

实际上实现划词评论在交互上并不是非常困难的事,我们可以先简单设想一下,无非是在文档中选中文本,然后在
onMouseUp
事件唤醒评论的按钮,当用户点击按钮时输入评论的内容,然后将评论的位置和数据传输到持久化存储即可。在这里不禁让我想起来了一个著名的问题,把大象放进冰箱需要几步?答案是三步:把冰箱门打开,把大象放进去,把冰箱门关上。而把长颈鹿放进冰箱需要四步:把冰箱门打开,把大象拿出来,把长颈鹿放进去,把冰箱门关上。

我们的划词评论也很像将大象放进冰箱,那么这个问题难点究竟是什么,很明显我们不容易找到评论的位置,如果此时不是富文本编辑器的话,我们可以考虑一种方案,即将
DOM
的具体层级存储起来,也就是保存一个路径数组,在渲染以及
Resize
的时候将其重新查找并计算即可。当然如果情况允许的话,对于每个文本节点都放置一个
id
,然后持久化的时候存储
id

offset
即可,只不过通常不太容易具备这种条件,入侵性太强且可能需要改造数据
->
渲染的存储结构,也就是说这个
id
是需要幂等地渲染,即多次渲染不会改变
id
,这样对于数据的存储也是额外增加了负担,当然如果对于位置计算比较复杂的话,这种空间换时间的实现也是可取的。

那么对于静态的内容,我们可能有很多办法来解决划词位置的持久化问题,而我们的在线文档是动态的内容,我们需要考虑到文档的变更,而文档内容的变更就有可能影响到划词位置的改变。例如原本划词的位置是
[2, 6]
,而此时在
0
位置上加入了文本或者图片等内容,此时如果还保持着
[2, 6]
的位置,那么划词的位置就不正确了,所以我们需要引入协同算法来解决这个问题,相当于
follow
文档的变更,重新计算划词的位置。请注意,在这里我们讨论的是非协同场景下的划词评论能力,如果此时文档系统已经引入了在线协同编辑的能力,那么基本就不需要考虑位置的计算问题,此时我们可以直接将后端同样作为一个协同编辑的客户端,直接使用协同算法来解决位置变换的问题。

OT

那么首先我们来聊一聊编辑时的评论位置同步,通常划词评论会分为两部分,一部分是在文档中划词的位置展示,另一部分是右侧的评论面板。那么在这里我们主要讨论的是文档中划词的位置展示,也就是如何在编辑的时候保持划词评论位置的正确
follow
,此部分的相关代码都在
https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/comment-ot.html
中。

我们可以设想一个问题,实际上在文档中的划词部分对于编辑器来说仅仅就是一个样式而已,与加粗等样式没有什么本质上的区别,也就是说我们可以通过在
attributes
上增加类似于
{ comment: id }
的形式将其表达出来。那么这种方式是能够正常跟随编辑区域移动的,本质上是编辑器引擎帮我们实现了这部分能力,对于这种方式我们可以在编辑器中轻松地实现,只需要对选区中的内容做
format
即可。

const onFormatComment = (id: string) => {
  editor.format("comment", id);
}

这种方案是最方便的实现方式,但是这里需要注意的是,此时我们是非协同场景下的划词评论,因为不存在协同编辑的实现,我们通常都是需要使用编辑锁来防止内容覆盖的问题。那么这种划词评论方式的问题是我们需要在内容中写入数据,相当于所有有权限的人都可以在整个流程中写入数据,所以我们通常不能将全量的数据存储到后端,而是应该在后端同样对数据做一次
format
,并且保持好多端的数据同步,否则在多人同时评论的场景下例如审核的流程中,存在内容覆盖的风险。

实际上我们可以发现,上述的方式是通过保证文档实例只存在一份的方式来实现的,也就是说无论是处于草稿状态下的编辑锁,还是审核状态下的评论能力,都是操作了同一份文档。那么如果场景再复杂一些,此时我们的文档平台存在线上态和草稿态,线上状态和草稿状态都分别可以评论,当然这里通常是分开管理的,草稿态是内部对文档的修改标注和评审意见等,线上态的评论主要是用户的反馈和讨论等,那么在编辑态的方案我们上边已经比较清晰地实现了,那么在线上状态的评论就没有这么简单了。试想一个场景,此时我们对文档发布了一个版本
A
,而在后台又将文档编辑了一部分此时内容为
B
版本,用户此时在
A
版本上评论了内容,然而此时我们的文档已经是
B
版本了,如何将用户评论的内容同步到
B
版本,以便于我们发布
C
版本的时候能够正确保持用户的评论位置,这就是我们即将要讨论的问题。

在讨论具体的问题之前,我们不妨先考虑一下这个问题的本质,实质上就是需要我们根据文档的改变来
transform
评论的位置,那么我们不如直接将这部分实现先抽象一下,将这个复杂的问题原子化实现一下,那么首先我们先定义一个选区列表用来存储评论的位置。

const COMMENT_LIST = [
  { index: 12, length: 2 },
  { index: 17, length: 4 },
];

因为先前我们是使用
format
来实现的,也就是将评论的实质性地写入到了
delta
当中,而在这里为了演示实际效果,此处的评论是使用虚拟图层的方式实现的,关于虚拟图层的实现我们在先前的 文档
diff
算法实现与对比视图 中已经抽象出来了通用能力,在这里就不具体展开了。使用虚拟图层实现就是相当于我们的数据表达是完全脱离于
delta
,也就是意味着我们可以将其独立存储起来,这样就可以做到完全的状态分离。

那么接下来就是在视图初始化时将虚拟图层渲染上去,并且为我们先前定义的评论按钮加入事件冒泡和默认行为的阻止,特别是我们不希望在点击评论按钮的时候失去编辑器的焦点,所以需要阻止其默认行为。

const applyComment = document.querySelector(".apply-comment");
applyComment.onmousedown = e => {
  e.stopPropagation();
  e.preventDefault();
};

接下来我们需要关注于点击评论按钮需要实现的功能,实际上也比较简单,主要是将选区的位置存储起来,然后将其渲染到虚拟图层上,最后将选区的位置移动到评论的位置上,也就是将选区折叠起来。

applyComment.onclick = e => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加评论:", sel);
    COMMENT_LIST.push(sel);
    editor.renderLayer(COMMENT_LIST);
    editor.setSelection(sel.index + sel.length);
  }
};

最后的重点来了,当我们编辑的时候会触发内容变更的事件,在这里是原子化的
op/delta
,那么我们就可以借助于这个
op
来对评论的位置进行
transform
,也就是说此时评论的位置会根据
op
的变化来重新计算,最后将评论的虚拟图层全部渲染出来。由此可以看到当我们编辑的时候,评论是会正常跟随我们的编辑进行位置变换的。而实际上我们不同版本的文档评论的位置同步也是类似的,只不过是单个
op
还是多个
op
的问题,而本身
op
又是可以进行
compose

ops
的,所以本质上就是同一个问题,那么就可以通过类似的方案解决问题。

editor.on("text-change", delta => {
  for(const item of COMMENT_LIST){
    const { index, length } = item;
    const start = delta.transformPosition(index);
    const end = delta.transformPosition(index + length, true);
    item.index = start;
    item.length = end - start;
  }
  editor.renderLayer(COMMENT_LIST);
});

在这里我们需要再看一下
transformPosition
这个方法,这个方法是根据
delta
变换索引,对于表示光标以及选区相关的操作时很有用,而第二个参数是比较有迷惑性的,我们可以借助
transform
方法来表示这个参数的意义,如果是
true
,那么就表示
this
的行为被认为是
first
也就是首先执行的
delta
,因为我们的
delta
都是从
0
开始索引位置,而对于同样的位置进行操作则会产生冲突,所以需要此标识来决定究竟谁在前,或者说谁是先执行的
delta

const a = new Delta().insert('a');
const b = new Delta().insert('b').retain(5).insert('c');

// Ob' = OT(Oa, Ob)
a.transform(b, true);  // new Delta().retain(1).insert('b').retain(5).insert('c');
a.transform(b, false); // new Delta().insert('b').retain(6).insert('c');

回到我们线上文档也就是消费侧评论的场景,我们不如举一个具体的例子来描述要解决的问题,此时线上文档的状态是
A
内容是
yyy
,在草稿态我们又重新编辑了文档,此时文档的状态是
B
内容是
xxxyyy
,也就是在
yyy
前边加了
3

x
字符,那么此时有个用户在线上
A
版本划词评论了内容
yyy
,此时的标记索引是
[0, 3]
,过后我们将
B
版本发布到了线上,如果此时评论还保持着
[0, 3]
的位置,那么就会出现位置不正确的问题,此时评论标记内容将会是
xxx
,并不符合用户最初划词的内容,所以我们需要将评论的位置根据
A -> B
的变化来重新计算,也就是将
[0, 3]
变换为
[3, 6]

实际上这里有个点需要注意的是,我们并不会将消费侧的评论同步到草稿状态上,如果此时用户正在评论且作者正在写文档的话,这个状态同步将会是比较麻烦的问题,相当于实现了简化的协同编辑,复杂性上升且不容易把控,在这种情况下甚至可以直接考虑接入成熟的协同系统。那么根据我们之前实现的原子化的
transform
评论位置的方法,我们只需要将版本
A
到版本
B
的变更
ops
找出来,并且将评论的位置根据
delta
进行
transform
即可,那么如何找出
A

B
的变更呢,这里就有两个办法:

  • 一种方案是记录版本之间的
    ops
    ,实际上我们的线上状态文档和草稿状态的文档并不是完全不相关的两个文档,草稿状态实际上就是由前一个线上文档版本得到的,那么我们就完全可以将文档变更时的
    ops
    完整记录下来,需要的时候再取得相关的
    ops
    进行
    transform
    即可,这种方式实际上是实现
    OT
    协同的常见操作,并且通过记录
    ops
    的方式可以更方便地实现 细粒度的操作记录回滚、字数变更统计、追溯字粒度的作者 等等。
  • 另一种方案是在发布时对版本内容做
    diff
    ,如果我们的在线文档系统最开始就没有设计
    ops
    的记录以及做协同能力的储备的话,突然想加入相关的能力成本是会比较高的,而我们如果单单为了评论就引入完整的协同能力显然并不是那么必要,所以此时我们直接对两个版本做
    diff
    就可以以更加低成本的方式实现评论的位置同步,关于
    diff
    的性能消耗可以参考之前的 文档
    diff
    算法实现与对比视图 中的相关内容。

那么先前我们实现的方案可以看作是记录了
ops
的方案,接下来我们以上述的例子演示一下基于
diff
的实现算法,首先将线上状态
online

draft
的表达按照之前的例子表示出来,然后标记出评论的位置,在这里需要注意的是评论的位置是我们数据库持久化存储的内容,实际做
transform
时需要将其转换为
delta
表达,之后将线上内容和评论
compose
,这就是实际上要展示给用户带评论划线的内容,然后对线上和草稿状态做
diff

diff
的顺序是
online -> draft
,下面就是将评论的内容进行
transform
,这里需要注意的是我们是需要在
diff
的基础上做
comment
变换,因为我们的
draft
相当于已经应用了
diff
,所以根据
Ob' = OT(Oa, Ob)
,我们实际想要得到的是
Ob'
,之后新的
comment
表达应用到
draft
上即可得到最终的评论内容。可以看到我们的评论是正确
follow
了原来的位置,此外因为最终还是要把新的评论位置存储到数据库中,所以我们需要将
delta
转换为
index

length
的形式存储,也可以在做
transform
时直接使用
transformPosition
来构造新的位置,然后根据新的位置构造
delta
表达来做应用与存储。

const Delta = Quill.import("delta");
// 线上状态
const online = new Delta().insert("yyy");
// 草稿状态
const draft = new Delta().insert("xxxyyy");
// 评论位置
const comment = { index: 0, length: 3 };
// 评论位置的`delta`表示
const commentDelta = new Delta().retain(comment.index).retain(comment.length, { id: "xxx" });
// 线上版本展示的实际内容
const onlineContent = online.compose(commentDelta);
// [{ "insert": "yyy", "attributes": { "id": "xxx" } }]
// `diff`结果
const diff = online.diff(draft); // [{ "insert": "xxx" }]
// 更新的评论`delta`表示
const nextCommentDelta = diff.transform(commentDelta); 
// [{ "retain": 3 }, { "retain": 3, "attributes": { "id": "xxx" } }]
// 更新之后的线上版本实际内容
const nextOnlineContent = draft.compose(nextCommentDelta);
// [{ "insert": "xxx" }, { "insert": "yyy", "attributes": { "id": "xxx" } }]

此外使用
diff
来实现评论的同步时,还有一个需要关注的点是采用
diff
的方案可能会存在意图不一致的问题,,统一进行
diff
计算而不是完整记录
ops
可能会存在数据精度上的损失,例如此时我们有
N
个连续的
xxx
块,编辑时删除了某个
xxx
块,此块上又恰好携带了消费侧的评论,如果按照我们的实际意图来计算,下次发布新版本时这个评论应该会消失或者被收起来,然而事实上可能并不如此,因为
diff
的时候是根据内容来计算的,究竟删除的是哪个
xxx
块只是算法上的解而非是意图上的解,所以在这种情况下如果我们需要保证完整的意图的话就需要引入额外的标记信息,或者采用第一种方案来记录
ops
,甚至完整引入协同算法,这样才能保证我们的意图是完整的。

在这里聊了这么多关于评论位置的记录与变换操作,别忘了我们还有右侧的评论面板部分,这部分实际上没有涉及到很复杂的操作,通常只需要跟文档编辑器通信来获取评论距离文档顶部的实际
top
来做位置计算即可,可以直接使用
CSS

transform: translateY(Npx);
,当然这里边细节还是很多的,例如 何时更新评论位置、避免多个评论卡片重叠、选择评论时可能需要移动评论卡片 等等,交互上需要的实现比较多。当然实现展示评论的交互还有很多种,例如
Hover
或者点击文档内评论时展示具体的评论内容,这些都是可以根据实际需求来实现的。

当然这里还有个可以关注的点,就是如何获取评论距离文档顶部的位置,通常编辑器内部会提供相关的
API
,例如在
Quill
中可以通过
editor.getBounds(index, 0)
来获取具体选区的
rect
。那么为什么需要关注这里呢,因为这里的实现是比较有趣的,因为我们的选区并不一定是个完整的
DOM
,可能存在只选择了一个文本表达的某
N
个字,我们不能直接取这个
DOM
节点的位置,因为可能这是个长段落发生了很多次折行,高度实际上是发生偏移的,那么在这种情况下我们就需要构造
Range
并且使用
Range.getClientRects
方法来得到选区信息了,当然通常我们是可以直接取选区的首个位置即直接使用
Range.getBoundingClientRect
就可以了,在获取这部分位置之后我们还需要根据编辑器的位置信息作额外计算,在这里就不赘述了。

const node = $0; // <strong>123123</strong>
const text = node.firstChild; // "123123"
const range = new Range();
range.setStart(text, 0);
range.setEnd(text, 1);
const rangeRect = range.getBoundingClientRect();
const editorRect = editor.container.getBoundingClientRect();
const selectionRect = editor.getBounds(editor.getSelection().index, 0);
rangeRect.top - editorRect.top === selectionRect.top; // true
rangeRect.left - editorRect.left === selectionRect.left; // true

CRDT

在上述的实现中我们使用了
OT
的方式来解决评论位置的同步问题,而本质上我们就是通过协同来解决的同步问题,那么同样的我们也可以使用
CRDT
的协同方案来解决这个问题,那么在这里我们使用
yjs
来实现与上述
OT
的功能类似的评论位置同步,此部分的相关代码都在
https://codesandbox.io/p/devbox/comment-crdt-psm548
中。

首先我们需要定义
yjs
的数据结构即
Y.Doc
,然后为了方便我们直接采用
indexeddb
作为存储而不是使用
websocket
来与后端
yjs
通信,由此我们可以直接在本地进行测试,在
yjs
中内置了
getText
的富文本数据结构表达,实际上在使用上是等同于
quill-delta
的数据结构,并且使用
yjs
提供的
y-quill
将数据结构与编辑器绑定。

const ydoc = new Y.Doc();
new IndexeddbPersistence("y-indexeddb", ydoc);
// ...
const ytext = ydoc.getText("quill");
new QuillBinding(ytext, editor);

紧接着我们同样初始化一个评论列表,这就是我们持久化存储的内容,与之前不同的是此时我们存储的是
CRDT
的相对位置,也就是说我们存储的是
yjs

RelativePosition
,这个位置是相对于文档的位置而不是绝对的
index
,这是由协同算法的特性决定的,在这里就不具体展开了,有兴趣的话可以看一下之前的
OT
协同算法、
CRDT
协同算法 文章的相关内容。然后我们需要初始化虚拟图层的实现,在这里我们同样借助虚拟图层来实现评论的位置展示,接下来我们需要在具体渲染之前,将相对位置转换为绝对位置。这里需要注意的是,我们创建相对位置时时使用的
yText
,而通过相对位置创建绝对位置时是使用的
yDoc

const COMMENT_LIST: [string, string][] = [];
const layerDOM = initLayerDOM();
const renderAllCommentWithRelativePosition = () => {
  const ranges: Range[] = [];
  for (const item of COMMENT_LIST) {
    const start = JSON.parse(item[0]);
    const end = JSON.parse(item[1]);
    const stratPosition = Y.createAbsolutePositionFromRelativePosition(
      start,
      ydoc,
    );
    const endPosition = Y.createAbsolutePositionFromRelativePosition(end, ydoc);
    if (stratPosition && endPosition) {
      ranges.push({
        index: stratPosition.index,
        length: endPosition.index - stratPosition.index,
      });
    }
  }
  renderLayer(layerDOM, ranges);
};

同样的,我们依然需要为我们先前定义的评论按钮加入事件冒泡和默认行为的阻止,特别是我们不希望在点击评论按钮的时候失去编辑器的焦点,所以需要阻止其默认行为。

const applyComment = document.querySelector(".apply-comment") as HTMLDivElement;
applyComment.onmousedown = (e) => {
  e.stopPropagation();
  e.preventDefault();
};

接下来我们需要关注于点击评论按钮需要实现的功能,此时我们需要将选区的内容转换为相对位置,通过
createRelativePositionFromTypeIndex
方法可以根据我们的数据类型与索引值取得
client

clock
用以标识全序的相对位置,取得相对位置之后我们将其存储到
COMMENT_LIST
中,然后将其渲染到虚拟图层上,最后同样将选区的位置移动到评论的位置上。

applyComment.onclick = () => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加评论:", sel);
    const start = Y.createRelativePositionFromTypeIndex(ytext, sel.index);
    const end = Y.createRelativePositionFromTypeIndex(
      ytext,
      sel.index + sel.length,
    );
    COMMENT_LIST.push([JSON.stringify(start), JSON.stringify(end)]);
    renderAllCommentWithRelativePosition();
    editor.setSelection(sel.index + sel.length);
  }
};

那么最后我们在文本内容发生变动的时候重新渲染即可,因为是标识了相对位置,在这里我们不需要对选区作
transform
,我们只需要重新渲染虚拟图层即可。通过添加评论并且编辑内容之后,发现我们的评论位置也是能够正常
follow
初始选区的,那么由此也可以说明
CRDT
能够根据相对位置实现评论位置的同步,我们不需要为其作
transform
或者
diff
的操作,只需要保持数据结构是完整存储与更新即可,而之后的评论面板部分内容是基本一致的实现,通过
Range
对象的操作来获取评论的位置,然后根据编辑器的位置信息作高度计算即可。

editor.on("text-change", () => {
  renderAllCommentWithRelativePosition();
});

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://quilljs.com/docs/delta
https://docs.yjs.dev/api/relative-positions
https://www.npmjs.com/package/quill-delta/v/4.2.2

标签: none

添加新评论