实现分布式富文本编辑器的经验教训


CKEditor 5推出分布式修改同一份文档的功能,好像以后大家可以一起愉快地修改代码了,再也不用手工解决Git的冲突,在选择你的源码还是我的源码之间冲突,大师Kent beck还为此提出对人行为的约束规则:test && commit || revert。闲话少说,看看它的分布式一致性方案是怎么解决的,据说Google Docs也有类似协同编辑功能。

本文描述了如何在CKEditor 5中实现实时协作编辑。术语“协作”和“实时协作编辑”在整个文档中可互换使用,以指代由CKEditor 5实现的“真实实时协作”。替代方案从一开始我们的目标就是提供一种解决方案,在协作编辑方面不会带来任何妥协。可能会尝试使用许多快捷方式来在应用程序中实现协作,而这些快捷方式并非专为此设计,但最终它们都会导致糟糕的用户体验:

  • 完全或部分内容锁定。只有一个用户可以同时编辑文档或文档的给定部分(块元素:段落,表格,列表项目等)。
  • “只读”模式下启用协作功能。用户可以对文本进行注释,但前提是编辑器处于“只读”模式。
  • 手动解决冲突。必须由其中一个用户手动解决同一位置的编辑。
  • 在协作编辑中启用基本功能。您可以加粗文本或创建标题,但忘记对表或嵌套列表的支持。
  • 缺乏意图保存。在解决冲突之后,用户最终得到的内容与他们打算创建的内容不同(换句话说:冲突解决能力差)。

我们想避免所有这些陷阱。它需要创建一个真正实时的协作编辑解决方案,使所有用户能够同时创建和编辑内容,而不受任何限制或功能剥离。我们总是有一个想法:无论协作编辑是打开还是关闭,编辑器的外观,感觉和行为都应该相同。一切都与冲突有关在协作编辑期间,用户不断修改其本地编辑器内容并在它们之间同步更改。当两个或多个用户编辑内容的相同部分时,可能会和将要出现冲突。解决冲突是制定或破坏协作编辑体验的原因。

例如,当两个用户删除同一段落的一部分时,他们的编辑状态需要同步。但是,这是有问题的:当用户A用户B接收信息时,此信息基于用户B的内容 - 这与用户A当前正在处理内容不同。

这是最简单的方案之一,但即便如此,如果没有适当的机制,也会导致缺乏最终的一致性 - 这是任何协作编辑解决方案的基本要求。一些编辑引入完全或部分内

有几种方法可以在实时协作编辑中实现冲突解决。两个主要候选者是操作转换(OT)和无冲突复制数据类型(CRDT)。我们选择了OT,也许有一天我们会写下我们对正在进行的OT与CRDT战斗的看法。长话短说,CKEditor 5使用OT来确保它能够解决冲突。OT基于一组操作(描述更改的对象)和相应地转换这些操作的算法,因此无论接收这些操作的顺序如何,所有用户都以相同的编辑器内容结束。作为一个概念,它在IT文献中得到了很好的描述([ 1 ],[ 2 ]),现有的实现证明了这一点(尽管没有一个可以作为我们需求的稳定和强大的基础)。
 因此,在2015年,我们开始着手实施OT实施。我们很快意识到,基本的操作转换(通常描述和实现)不足以为富文本编辑提供高质量的用户体验。OT的基本形式定义了三个操作:insertdeleteset属性。这些操作旨在在线性数据模型上执行。他们负责插入文本字符,删除文本字符和更改其属性(例如设置粗体)。但是,强大的富文本编辑器需要更多。 支持复杂的数据结构线性数据模型是一个简单的数据模型足以代表纯文本。相反,HTML是一种基于树的语言,其中元素可以包含多个其他元素。HTML文档在浏览器中表示为文档对象模型(或DOM),它是树形结构。可以在线性模型中表示简单,平坦的结构化数据,但是当涉及复杂的数据结构(如表格,字幕图像或包含块元素的列表)时,此模型会失败。元素根本不能包含其他元素。例如,块引用不能包含列表项或标题。

因此,我们需要更进一步,并提供适用于树数据结构的操作转换算法。早在2015年,我们可以找到一篇关于树木的OT([1])的文章,并且没有任何证据证明有人在OT上为树木工作。我们基于这项研究,但事实证明这比我们预期的更具挑战性。第一次实施花了我们一年多的时间,在接下来的两年中进行了几次重大的修复工作。然而,结果非常出色。我们不仅设法构建了用于实时协作的引擎,而且还实现了一个完整的最终用户解决方案,该解决方案可以验证哪些是理论工作。高级冲突解决切换到树数据模型还不足以实现防弹实时协作。我们很快意识到基本的操作集(插入删除设置属性)不足以以优雅的方式处理现实场景。虽然这三个操作可能提供了足够的语义来实现线性数据模型中的冲突解决,但它们并不满足富文本编辑的语义。以下是用户同时对内容的同一部分执行操作的一些示例:

(1)当用户B拆分该列表项时,用户A更改列表项类型(从HTML项目符号到编号)(2)用户A用户B对同一段确认按Enter

(3)当用户B对一个段落按下Enter确认时,用户A在段落中加入块引用

(4)用户A添加一个句子的链接,而用户B修改该句子文字部分

(5)用户A添加一些文本的链接,而用户B删除该文本的一部分,然后撤消删除


为了正确处理这些和许多其他情况,我们需要大力增强我们的运算转换算法。我们做的最重要的增强是向基本三(insertremoveset属性)添加一组新操作。目标是更好地表达任何用户更改的语义。反过来,这使我们能够实施更好的冲突解决算法。对于我们添加的基本三项操作:

  • 重命名操作,处理元素的重命名(使用,例如,改变一个段落为标题或列表项)。
  • 分割,合并,包装,拆开包装操作,以更好地描述用户的意图。
  • 所述插入文本操作,插入文本内容和元素之间进行区分。
  • 与解决冲突无关,我们还引入了标记操作。

 为什么我们需要这些新业务?重命名,分割,合并,包裹解开的“行动”可以通过一个组合来执行插入,移动删除操作。例如,拆分段落可以表示为一对“ 插入新段落”+“ 旧段落的一部分移动到新段落”。但是,拆分操作是以语义为中心的 - 它传达了用户的意图。这意味着不仅仅是插入+移动恰好一个接一个地执行。
 由于新的操作,我们可以编写更多的上下文转换算法。这样我们就可以解决更复杂的用例,例如上面描述的场景(1-4)。
旁注:我们认为必要操作集与您所表示的树数据的语义紧密相关。富文本编辑器与族谱树具有不同的性质,因此需要不同的操作集。 进一步扩展添加新操作仍然无法解决所有问题。我们需要进一步扩展我们的运营转型实施,以处理我们多年来发现的情景。以下是我们最重要的补充:
  • graveyard root  -一种特殊的数据树的根,其中移除的节点被移动,这使得能够在更好的方案解决冲突时用户A改变数据的部件,它是在由去除的同时用户B(方案(5)和类似的)。
  • 推广操作以处理范围而不是奇异节点,以获得更好的处理和内存效率。
  • 操作中断 - 有时,在转换时,操作需要分成两个操作,例如当一部分内容被删除时(方案(5))。
  • 选择性撤消机制 - 撤消功能需要了解协作编辑,因此,例如,用户只能撤消自己的更改。