附:第十六届D2前端技术论坛分享回放
与现在大部分友商推出的新文档相比,钉钉文档支持相对完善的专业排版能力,如分页、分栏、图文混排等。
而与传统文档相比,钉钉文档又支持大量的创新功能,如内嵌脑图、地图等能力。对于前端届而言,协同文档是一个较为有挑战的领域,除了传统天坑富文本编辑器外,还引入了协同编辑这一挑战,钉钉文档甚至还支持专业排版能力。
那么,钉钉文档是如何在支持着这些复杂富文本编辑能力、专业排版能力的前提下,还支持着多人协同编辑?这一切复杂的功能,是如何调和并在钉钉文档内一并支持的呢?
接下来将会以钉钉文档为例,讲解协同文档的工作机制。
所见即所得
富文本编辑器,最为基础的一个特性就是所见即所得,在这一特性上,钉钉文档与大部分友商相比最大的特点在于支持专业排版能力。
那么,钉钉文档是如何实现专业排版能力的?接下来将会以分行为例,简述排版能力是如何实现的。
以分行为例,是因为分行是排版中最简单,也是最基础的一个问题。在分页场景下,会出现某个段落刚好跨越两页的情况,这种情况必须对段落进行拆分,而段落拆分的最基本要求就是文本分行。如下图:
接下来,将介绍钉钉文档是如何对文档内容进行排版,并支持排版后的内容编辑的。
1、测量与拆分
因为用户在编辑文档时,输入的是内容,而内容不含分行或其他布局类信息。普通的内容经过排版处理后,加工为带分行等布局信息的视图模型。
其中排版流程可以简单概括为如下:
用户编辑行为产生的是文档模型,文档模型经过排版引擎的测量与拆分处理后,会得到视图模型,然后基于视图模型渲染最终的 DOM 结构。
在排版的过程中,最为基础的操作是字符测量,排版引擎需要对每个字符逐一进行测量:
通过对文本内的字符进行宽度测量并加总,再结合当前容器的宽度,可以得知该在哪里分行。
而基于每行的行高加总,可以知道该段落能否被当前页的剩余空间所容纳,如空间不够,会进一步触发拆分段落逻辑。
2、测量结果缓存
由于字符测量涉及 DOM 操作,所以对字符逐一测量,首先会面临的是性能问题:
为了解决性能问题,我们需要高效的缓存机制,在钉钉文档中,使用 字符+样式 作为缓存 key。
但对于中文这一特殊场景,每个字符一个缓存其实是极其低效的。考虑到中文方块字的特点,钉钉文档把所有的中文字符,都替换为同一个 “中” 字进行测量,可以极大的提高缓存的效率。
3、拆分与映射
排版后的视图模型,与原来的文档模型相比,在数据结构上已有不同。
当用户与视图模型交互时,底层需要知道该交互期待修改文档模型哪一部分,才能正确响应用户的编辑行为。
为了解决这一个问题,钉钉文档给每个数据节点,都加上唯一标识,如以下段落使用 paragraph-1 标识:
而排版后的视图模型,基于文档模型派生而来,所以数据节点的唯一标识,可以按特定的约定生成,钉钉文档中使用 原标识-拆分序号 的方式标记拆分结果:
按以上的约定,当用户与视图模型交互时,钉钉文档可以通过 视图模型+唯一标识 推导用户实际编辑的文档模型节点,然后正确的响应用户编辑行为。
4、小结
把以上的流程串起,可以得到钉钉文档的编辑数据流:
不依赖 contentEdiable 的编辑器
谈及富文本编辑器,前端工程师们的第一个反应应该都是 contentEditable,毕竟这是 HTML 提供的标准富文本编辑能力。
但 contentEditable 是基于 DOM 的编辑能力,即所编辑的是视图。而上文我们已经了解到钉钉文档是支持排版能力的,而对排版能力的支持,导致视图模型与文档模型异构。所以在需要支持排版的编辑器中使用 contentEditable 会有诸多的问题。
为了更好的支持排版场景,钉钉文档抛弃了 contentEditable,并自行实现相关的编辑能力,包括选区计算与绘制,以及输入上屏。
1、选区计算与绘制
“点击,选择一个位置输入” 是我们使用编辑器时,最为基础,也是最高频的一个操作。
如果基于 contentEditable 实现一个编辑器,那么这些能力都将会由浏览器提供。但是钉钉文档在抛弃 contentEditable 后,要如何实现相关的能力?
以下图为例,当用户点击图片中红点所在的位置时,最符合直觉的是选中 Good 与 ! 之间:
为了实现这一效果,钉钉文档通过监听鼠标、触摸事件计算光标位置,伪代码如下:
const { target, clientX, clientY } = event;
if ('target 自身是 void(如图片、视频) 节点') {
'直接选中该节点'
} else if ('target 自身是文本节点') {
'基于 clientX & clientY 二分查找所点击位置对应文本中第几个字符位置'
} else {
'按特定策略平移 clientX & clientY,找到 target 子树中最符合用户预期的 void 或文本节点' '以重新调整后的 target、clientX、clientY 递归计算'
}
其中平移算法效果如下图,上图用户所点击的 clientX、clientY 被平移到 Good 和 ! 之间,然后按二分查找光标具体应该落在哪个字符间隔内即可:
最终钉钉文档内以以下数据结构描述选区,该数据结构除了被视图层消费,用于光标绘制外,也用于协同场景中协同光标的同步、绘制:
Value.create({
selection: Selection.create({
anchor: Point.create({ key: 'Good', offset: 4 }),
focus: Point.create({ key: 'Good', offset: 4 }),
});
});
2、输入上屏
基于普通 div 实现的钉钉文档,除了需要自行处理光标、选区的计算与绘制,也需要实现用户输入上屏的效果。
如下图,当用户在钉钉文档中输入文本时,需要实时看到所输入的内容上屏,并能够选中所需的字符:
在钉钉文档中,使用一个被隐藏起来的 textarea 监听用户输入,该 textarea 同时也用作于定位输入法浮层:
在中文输入过程中,用户所输入的中间状态使用以下数据描述:
Value.create({ composing: 'hai' });
而用户选词后,所选文本需要插入文档中,结果以以下文档模型描述:
Value.create({
document: Document.create({
nodes: [Paragraph.create({
nodes: [Text.create('嗨')],
})],
});
});
3、小结
最终,编辑器所计算出的数据描述,将按以下逻辑整合并渲染:
<editor>
<content {value.document + value.composing} />
<selection {value.selection} />
</editor>
多人协同编辑
钉钉文档是在线文档产品,而在线文档很自然会产生的一种使用场景就是多人同时进入同一份文档并协同编辑。
所以钉钉文档必须支持协同编辑,自动处理用户协同编辑所产生的冲突。
以下图为例,A、B 两个用户,同时对同一份文档进行编辑,在钉钉文档中,最终可以保证所有的用户,最终能看到同一份冲突处理后的结果:
1、Operational Transformation(OT)
为了实现多人协同编辑,钉钉文档基于 OT 理论自行实现冲突处理算法,用于自动处理用户编辑冲突。
该理论可以简单理解为:把对数据结构的修改映射为差量数据 operation,在接收到他人 operation 时,使用 transform 处理潜在冲突:
上图中的 transform 算法基本思路类似 Git 的 rebase。
2、编辑器支持 OT 算法
以上简单介绍了 OT 算法的思路,钉钉文档为了支持 OT 算法,底层把用户的一切编辑行为,都转换为原子 9 种 operation 的组合,并使用 operation 驱动文档模型更新,而非直接修改文档模型:
在具备以上的基础能力后,我们只需要把本地所产生的 operations 提交给协同引擎,并由协同引擎通过 OT 算法处理本地以及服务端发来的 operations 冲突,最后以处理后的 operations 驱动模型更新,即可实现协同编辑效果:
开源计划
以上强大的能力,并非钉钉文档专属。因为钉钉文档在立项之初,编辑器部分就按通用 SDK 设计,所以早已具备服务二、三方业务的能力,至今已经支持包括 ATA、Aliway 在内超过 20 个产品。
接下来,该 SDK 还会更进一步,将开放源码,以鼓励更多的业务方参与共建:
如果对我们的开源计划有兴趣,可以关注钉钉文档团队知乎专栏,以获得后续信息: