在《从零写一个富文本编辑器(一)》中,我们实现了一个非常简单的富文本编辑器,但是,很明显的一个问题就是,我们并没有把富文本内容抽象成数据,即没有数据驱动。L1编辑器也只是部分数据驱动,没有脱离contenteditable,真正的数据驱动还得L2。
那么带来的问题是
-
开发不友好。当开发者使用我们实现的编辑器时,他依然要面对复杂、千变万化的dom。想对富文本内容做一些修改时,需要手动生成html,插入到编辑器中,并且开发者保存起来的数据也是一堆html,不利于从中做一些解析。
-
浏览器兼容问题。,在不同的浏览器上编辑生成相同的富文本内容,可能会有不同的dom结构,导致某些表现不符合预期(但符合浏览器的预期)。如果有了数据去描述富文本,那么就能基于数据生成相同的dom结构,避免这个问题,但顶多解决部分兼容性问题。
-
协同难做。协同是什么,当用户A在文档上输入了1,用户B在文档上相同时间相同位置输入了2,那么最终的文档内容是
21还是12?假设是12,用户A的文档内容怎么由1变成12,用户B的文档内容又怎么由2变成12?这时候就需要一个协同协议去解决这个问题。
因此,基于上述几个问题,我们需要一种开发友好、有成熟的协同协议支持的文档模型来描述富文本内容和富文本内容的变更。
其它富文本编辑器是怎么做的
目前,基本上合理的、可用的富文本表达都有相关的产品了,作者我也不是天才,没办法再去创造出一种新的表达方式,因此我们先去看看其它的编辑器是怎么做的。
腾讯文档
我们在腾讯文档的页面上打开控制台,在performance下面录制一个profile,在腾讯文档上输入一个文字时,做了这么一些事情
我们进入到它的applyDelta这个函数,然后把它的参数打印出来,重点看红框部分,range指的是输入前选区的位置,text指的是输入的内容(我在原有的12后面输入了一个1),这个就是变更operations。
但这种operations的表达其实不是最终变更的表达方式。
我们直接去看腾讯文档协同部分的逻辑,然后就找到了这堆难以理解的字符串,其实这个就是它富文本内容的表达。它是一种线性结构的表达方式。
上图中的text就是文档中存在的字符,那么问题就来了,富文本最重要的就是一个【富】字,text只有一串文本内容,怎么去表达【富】呢。
我们继续看上图中的_numToAttrib,这是文档中的属性集,将其展开,如下图所示,比如["bold", "true"],它的意思是bold属性的值是"true",表示的是加粗;再比如["font-family", "PT Sans"],意思是字体是PT Sans。
那么有了文本,又有了属性集,怎么将两者关联起来,关键点就在attribs,这一堆初看完全无法理解的字符串。*0的意思是取_numToAttrib中的第0个值,即["author", "p.144115215160803528"],同理*1、*2、*3指取_numToAttrib中的第1、2、3个值,*m是指拿到第22个值,因为这里是用36进制来表达的,m等于22。*m后面是+1,意思是影响了一个字符。也就是说,文档中的第一个字符【1】,拥有了_numToAttrib中的第0、1、2、3、22个属性,分别是
["author", "p.144115215160803528"]["bold", "true"]["font-family", "PT Sans"]["font-size", "9pt"]["italic", "true"]在+1的后面,是*0*1*2*3*m*7*6*4+1,那么这一段依然可以按照上面的逻辑去解析,不再赘述。
以上就是腾讯文档的富文本描述了,那么还有一个问题是,上面提到的变更operations是怎么应用到这堆字符串上的?
答案就是operations会转换成相应的changeset,简称cs,内容如下图所示。Z:忽略,可以理解成一个标识位,y指的是原文档长度(36进制),>1表示变更后,文档长度增加了1,=2表示保留(跳过)两个字符,*0*2*3*1*m*4+1上面已经解释过了,$1表示插入的字符是1,因为我上面执行的操作就是输入了1。
那么腾讯文档为什么用这种形式来表达富文本内容(开源富文本编辑器ethepad-lite也是采用了数据结构)?因为协同协议easysync,详细的文档可以看链接。
(这里补一句,协同协议不止easysync,还有ot-json等,理论上后面会有新的文档来专门写这块,包括easysync的详细解析)
slate
在slate的运行过程中打个断点,可以看到,slate的数据结构和对应的富文本内容如图所示。
这是一种有限层级的线性结构,用type这个字段表达这一行是什么,我们可以看到上面的富文本中有四种type
paragraph,表示这一行是个普通的文本,没有特殊的行属性block-quote,表示这一行是个引用numbered-list,表示这一行是个有序列表list-item,表示这是外层有序列表的一个子项
而这个树的叶子节点,则表达了文本,如{ text: 'bol', bold: true },意思是这个文本是bol,并且被加粗了。
那么slate中怎么去表示变更?
我们在源码中可以找到Operation的声明,分为NodeOperation、SelectionOperation、TextOperation。
这里我们以TextOperation为例,我们来看怎么表达插入一个文本,offset指插入位置在当前行的偏移,text指插入的文本,path指路[a, b],多数情况下,a指向某一行,b为0。
当我在第二行的第一个文字后面输入一个1,那么operation如图所示。
有道云笔记也采用了类似的结构,可以看mp.weixin.qq.com/s/wIu_8yv69…
quill
quill使用的文档模型是Delta,详细的内容可以去看quill的官方文档 github.com/quilljs/del… ,文档写的太太太太太好了,我就不详细补充了,简要说明一下。
下面的结构描述了一篇文档,insert表示文本,attributes表示属性,声明Gandalf是加粗的,the就是普通文本,Grey的字体颜色是#ccc。
[
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Grey', attributes: { color: '#ccc' } }
];
并且,相同的结构,Delta还能描述文档内容的变更,我在Gan后面插入了一个字符1,那么可以描述成
[
{ retain: 3 }, // 可以理解为跳过3个字符,即Gan
{ insert: '1' } // 插入字符1
]
假设我要删除文档的第4 和 第5个字符,那么可以描述成
[
{ retain: 3 }, // 前面3个不处理,保留
{ delete: 2 } // 删除第4、 第5个字符
]
相对于腾讯文档的模型,Delta这种描述虽然占用的空间更大了,但是从开发者的角度讲,却更加友好了。
相对于slate的模型,Delta的结构更加轻量。
最终的数据结构
各种对比之下,在下最终选择了Delta进行后续的开发,理由如下
-
开发友好,结构简单,简洁易懂。(假如第一次去看
easysync那堆字符串,正常人都得琢磨半天这玩意到底是个啥,真的不是乱码?) -
有成熟的协同协议
ot-json可以支持后续协同编辑的开发 -
线性的表达方式,在修改文档内容时,线性结构的修改成本比树状结构低(虽然富文本场景下,可能树状结构更容易被理解)。并且,有利于选区的表达,比如选区表达成第a行,偏移是b,那么线性结构的情况下,去找到选区对应文档中哪个位置显然更容易一些,如果是树的话,还得去深度遍历。
后记
各种表达方式其实都有比较成熟的产品了,上面只写了三种,还有其它一堆非常优秀的编辑器,有兴趣的朋友可以扒一扒代码。比如
Draftethepad-lite(dropbox paper其实最开始就是拿ethepad二次开发的)prose-mirrornotionGoogle Docs(这个源码扒的太费劲了,混淆大师,腾讯的混淆就没这么离谱)