编辑器 的保存怎么绑定事件_从零开始设计一个Web端多人协同编辑器

作者:韦家骥,肖丹阳,甘露

原本区块链(https://yuanben.io)

先来看看最后的效果。

基本的协同编辑功能和作者信息展示:

05a821a4dcbffbfc1c76dfefa5a6b42c.gif

中文输入法的处理,在我们输入中文的过程中,有其他人输入了文字,其他人的输入会等到我们的中文输入完毕再出现,不影响我们的输入:

d8c373a3b2e2d2cd6f2bde2e6ad032f7.gif

图片上传,上传过程中会先在本地加载出图片预览,显示loading状态,上传完毕后替换掉本地的图片(图片选择框没显示出来,看起来略略有点奇怪):

da301c5ec0fa27fc1a8c0e6144615084.gif

然后发布开源代码,可以直接用npm安装使用:

https://github.com/we-miks/collaborative-editor

接下来说说到底是怎么做的。

Web端的富文本编辑器,无论怎么架构,最终依赖的,都是浏览器的contenteditable属性,加了这个属性的元素,里面的内容就可以随意编辑。把用户编辑过的html读取出来,保存下来,然后在需要的地方展示,就是最基础的富文本编辑器的原理。

然而,html元素异常复杂,如果完全放开让用户随意编辑,很容易出现各种不可预知的情况。用户在编辑文章的时候,会发现很多情况和自己想象的不一样;编辑过的内容在展示的时候,也会出现各种无法正常展示的问题。

早些年的富文本编辑器,基本都是采用过滤的做法。设定一个元素、属性的白名单,如果编辑区域中出现了不在白名单中的东西,就直接通通删除。通过这种方法保证编辑区域中只有我们确认过可以正常编辑和处理的元素,以保证用户编辑的体验,以及展示的效果。

随着前端要处理的逻辑越来越复杂,出现了一些新的设计模式,比如双向绑定、单向数据流等等。富文本编辑器也采用了这样的模型。用户正在编辑的文档,不再以页面上contenteditable元素中的HTML为准,而是抽象出来了一个代表文档的Model,页面上实际展示出来的文档,仅仅做为View层,从Model渲染得到。用户对HTML的修改,都要先转化成对Model的修改,然后再由Model渲染到页面。最终的文档都以Model为准。

Model用什么样的数据结构来表示文档,各家的设计也是千差万别。有的采用过滤出来的HTML的JSON表示,有的更加抽象,表示成段落、文字、图片、样式等结构。

然后要出现我们协同编辑器中的第一个主角了,那就是:

Operational Transformation(OT)

在任何多人协同的场景下,最难处理的事情,一定是冲突:假如有两个人同时打开了一篇文档,各自进行了一些编辑,然后先后保存修改。如果我们不做任何处理,那后保存的那个人的内容,就会覆盖掉先保存的人的内容,先保存的人的修改,就丢失了。要实现协同编辑,我们必须能够识别出这两个人的具体的修改,然后将两个人的修改进行正确的合并,把两个人的修改都保存下来。

要说进行文档合并的方法,我们第一个想到的一定是文档级别的diff。但是每次修改都要进行全文的diff,开销是很大的。另外如何合并两个diff,还是很有挑战的,前一个diff的合并会导致后一个diff的变化,处理不当的话,还是会有很多意料之外的异常情况。git在进行单个文档的diff合并时,碰到任何无法处理的情况,都会报conflict,让后提交的人自行解决冲突。如果我们在协同编辑的时候,不停地给用户提示,有冲突需要手工解决,那这个编辑器的体验真是糟透了。

于是OT出现了。OT把文档表示成一组顺序的操作,操作只有三种,insert、delete和retain。用户的任何一次修改,或者是整篇文档,都可以用一组OT操作进行表示。比如我删除了文章中的第三个字,用OT表示就是:

retain(2), delete(1)

把文档想象成一个一维的字符串,OT操作里面的数字代表了字符串中的位置,以及长度。上面的操作意思就是,走到第2个字后面,删除一个字。

整篇文档表示成OT,就是一组insert操作。

这种表示方法有什么厉害的地方呢?就是每一组OT,都可以根据另一组OT进行转换(transform),转换成一组可以在那一组OT执行后再执行的操作。

举个例子,之前的文章中删除第三个字的例子,假如有人在我提交这个修改之前,提交了另一个修改,在文章开头插入了两个字:

op_a: insert('哈哈')

然后我提交了刚才的删除第三个字的OT:

op_b: retain(2),delete(1)

如果什么都不处理,直接执行,那删除的字就错误了,因为之前的插入操作已经改变了我要删除的字的位置。这里就是transform发挥作用的地方了,把op_b根据op_a做一次转换,会变成:

op_b.transform(op_a): retain(4).delete(1)

我提交的删除操作的位置被更新了,执行这组操作,就正确删除了我真正想删除的那个字。

通过OT,我们可以实现快速对多人的编辑进行合并,解决了文档冲突的问题。

开源项目ShareDB实现了对于OT操作的存储和合并功能,并且可以通过WebSocket接口提交修改。我们的编辑器在后端使用了ShareDB来实现OT的合并和存储。

https://github.com/share/sharedb

接下来的问题是,OT操作和HTML并没有什么对应关系,如何实现OT操作和HTML之间的转换。

这里我们就要介绍另一个主角:

Quill编辑器

https://github.com/quilljs/quill

Quill编辑器是medium开源的富文本编辑器,使用OT操作做为Model来表示文档。Quill编辑器中的OT模型的名字叫做Delta。Delta既可以表示整篇文档,也可以表示对文档的一次修改。

Quill使用了一个中间层进行OT操作和HTML之间的转换,叫Parchment。

Parchment的核心是Blot,每个Blot代表着文档中的一个元素,比如段落、图片、文字等等。Parchment将文档表示成一组树状结构的Blot,树的结构有三层,顶层是一个ScrollBlot,代表整个文档。中间一层是BlockBlot,代表文档中的段落,在HTML中展示为块级元素。叶子层是InlineBlot,代表文档中的文字、图片等实际内容,在HTML中展示为行级元素。

每个行级Blot都有长度,比如文字类型的TextBlot,其长度就是里面的文字的长度。第二层的块级元素的长度,就是它包含的叶子Blot的长度之和。

对于一个OT操作,Parchment可以找出这个操作的位置和长度中包含的Blot,然后把对应的操作交给这个Blot进行处理。Blot根据操作对自己的内容进行修改,完成Model的更新。

Blot树可以直接转换为HTML,在页面上加载出来,在父级元素添加contenteditable属性让用户编辑。顶层的ScrollBlot监听浏览器中元素的修改事件,获取mutation records,将mutation records交给对应位置的Blot,由对应的Blot完成模型更新,并转换为Delta,发送给服务器。完成整个的文档协同编辑功能。

Quill编辑器是外国人做的,那么就有一个很重要的问题他们没考虑到:

中文支持

有些中文输入法在输入的过程中,会先把拼音字母放到编辑器里面去,在选择了文字以后,删除拼音字母,再把文字插入进去。由于Quill中的ScrollBlot会实时监控HTML的变化,生成对应的Delta,输入过程中的拼音字母就也被识别成了Delta,进入了修改历史中。

如果仅仅只是污染历史记录,还算可以接受。另一个更大的问题是,在协同编辑的时候,假如我们在输入拼音的过程中,有其他人编辑了文章,这些编辑的OT操作应用到我们的编辑器中,修改了HTML结构,会导致ScrollBlot获取到的mutation record不一致。出现的现象就是,我在打字的过程中,会出现好多拼音混杂在最后的文字中,有时候文字还会出现重复,打了一个字,出现了两个,之类的。这个就会导致协同编辑完全无法使用了。

那么解决方案的思路也是简单的,就是用户正在输入拼音的时候,暂停一切对编辑器内容的修改,从其他用户推过来的OT操作暂存在队列里,其他编辑器插件(比如后面我们使用到的在内容里添加作者信息的插件)对内容的修改也暂存在队列里,等到用户输入完毕了,拼音都已经删除了,再把暂存的操作,应用到编辑器里面。当然应用之前,要做一次转换,保证操作的正确性。

思路说起来简单,实现起来就比较困难,Quill的数据流的设计不是特别好,没有一个单一的数据流向,每个插件都可以随意修改内容,也没有足够的事件来切入一个数据流,很难实现在OT操作应用到编辑器之前对其进行拦截,一般等我们拿到text-change事件时候,HTML修改已经完成了,我们就只能采用后续补救的方式来操作。针对不同的内容修改,补救方式也完全不一样,需要一个一个的看,比较麻烦。

在我们的实现中,采用了一个Composition组件来保护Quill编辑器,所有数据进入、离开编辑器,都需要通过Composition组件进行,后续的功能添加,都需要使用Composition提供的数据流,而不使用Quill原生的数据流,这样就可以实现在中文输入下,安全地对内容进行修改。

作者信息保存和展示

在多人编辑一篇文章时,用户需要看到每段话、每句话到底是谁编辑的,那我们就需要在作者编辑信息时,把作者的ID也写到文档中。我们可以通过Quill的format功能,把作者ID的信息做为属性保存在span元素上。当用户输入一段话,Composition组件生成了最终要提交的OT操作后,我们截获这段操作,在OT操作上增加作者的ID,然后再发送到服务端保存。在本地的话,由于不带ID的内容已经被作者写入了编辑器,我们就只能再生成一段专门用来修复的OT操作,用span标签包裹用户输入的文字,在span的class中写入用户的ID,用于本地作者信息的展示。

我们在编辑器的旁边做了一个侧边栏,来展示每段话的作者姓名。假如一段话中包含了多个作者的句子,哪个作者的句子总长度最大,段落的作者就展示这个作者。目前在同一段话中的不同作者信息并没有被展示出来,如果需要的话,可以通过简单的CSS实现出来。

侧边栏中,对于文章中的每一段话,都有一个item来展示颜色和作者的姓名,随着文章内容变化, item的数量、长度、颜色、作者姓名,都是需要更新的。最简单的实现方法,就是在每一次内容更新后,从头到尾刷新一次整个侧边栏,这样的更新开销肯定是非常大的,尤其是当文章比较长,段落很多的时候。

比较理想的做法,是在每次内容更新后,通过本次生成的OT操作,判断出有哪些行需要更新,然后只在对应的item上执行更新操作,这样子可以极大减小开销。

通过OT记录来判断影响的行数,对于insert和retain操作都还可以实现,insert操作,只要判断文字里面有多少个换行符,就知道新添加了多少行。retain操作也可以容易的知道影响了哪几行,只有delete操作不好实现,因为delete操作中只记录了delete的内容的长度,而并没有实际删除的内容,当我们拿到OT记录的时候,内容已经删除了,我们无法知道用户删掉了多少行。

幸好Quill编辑器保留了OT操作应用前的文档状态,会在每一次编辑后,用oldDelta参数传递出来,通过对比OT操作和原有内容,我们可以计算出一个delete操作具体删掉的内容,确定用户删除了多少行,然后更新对应的侧边栏item。最终我们实现了侧边栏item的局部刷新。

其他功能

走到这里,协同编辑器的基础已经构建完成了,能满足基本的使用需求。但是还是有一些功能可以添加,比如对于插入图片的支持。

用户有三种方式可以插入图片:通过上传图片按钮,通过本地文件浏览器中拖曳、通过其他网页的复制粘贴。我们都进行了适配和支持。

图片上传是需要时间的,在图片上传过程中,最好能在编辑器中提前预览这张图片,并显示一个加载中的提示。我们在Quill中增加一个专门用来预览图片的Blot,通过HTML5的FileReader从本地读取图片内容进行展示,并在图片上传完成后,完成图片的替换并生成对应的OT记录上传。

至此,一个能满足基本用户需求的协同编辑器,就构建完毕了。

文中描述的协同编辑器已经在github上开源出来了,欢迎大家使用并提出意见。目前的版本也还有很多可以优化的地方、可以添加的功能。欢迎大家一起来升级和维护这个项目,让更多人可以在项目中方便地实现协同编辑的功能。

https://github.com/we-miks/collaborative-editor

01d6a2676e99f5e8fae3700a66745dd8.png

c43410cd4cdc5de6da85f3142b0efb7c.png

本文经「原本」原创认证,作者一个洋葱,点击“阅读原文”或访问yuanben.io查询【L90XWE2E】获取授权

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值