上期文章,咱们从整体上介绍了富文本编辑器的背景,并分享了有道云笔记新版编辑器技术选型中的模型和渲染局部。
本期文章,咱们将持续分享技术选型中的编辑和指令局部内容,并具体解读有道云笔记编辑器的分层架构设计。作者/ 金鑫
编辑/ 刘振宇
二、云笔记新版编辑器技术选型
2.3 编辑
因为 contentEditable 会产生不受控事件,导致很多 bug,例如,一开始数据是 abc,对应渲染出的视图是一个 span,内容是 abc。因为须要提供可编辑,span abc 是一个 contentEditable 的元素。
失常状况下,当编辑 span abc 时,例如输出了 d,咱们拦挡 keyup 事件,在处理函数中将事件 preventDefault,这一步是不让 contentEditable 元素本人批改 span abc为abcd,而后咱们在处理函数里调用自定义的 insertText 指令,批改数据 abc 变为 abcd,再用新的数据进行渲染,批改 span abc 为 span abcd。
然而,一旦呈现 span abc 上的事件没有被拦挡或拦挡了但没有失常解决,就会呈现 bug。
例如咱们旧版的编辑器就没有拦挡 ctrl + delete 的事件,如果在 abc 这一行按 ctrl + delete 就没有对应的事件处理函数批改数据模型,数据模型还是 abc,然而因为 span abc 是 contentEditable 的,ctrl + delete 事件会间接批改 span abc 将 abc 整行删除,这样数据模型和视图上就呈现了不统一。后续如果再输出 d,则会将数据模型批改成 abcd,这时候视图会依据新数据渲染为 span abcd,体现为曾经删除的 abc 再次出现,对用户的应用造成困扰。
针对 contentEditable 的问题,咱们决定将齐全摈弃它,由此带来两个问题:
- 没有可编辑的元素,不会触发输出事件
- 没有可编辑的元素,无奈应用浏览器自带的光标
触发输出事件:
咱们采纳了在用户光标地位后画一个暗藏的 Input 组件,Input 组件中有一个 textarea 来承受用户的输出,触发输出相干的事件,如下图所示:
自绘光标/选区:
因为不能应用浏览器默认的光标,咱们只能自绘光标。
咱们参考浏览器的 Selection 的构造,设计了相似的Selection模型,并用Selection组件渲染 Selection 模型,在屏幕上用相对定位画出用户的光标,同时用户拖蓝时产生的选区,也能够用这种方法绘制,因为光标和选区实质上一样,咱们就放弃了浏览器的选区绘制,也改为本人绘制选区。具体流程如下:
咱们用 anchor 示意用户开始托选的地位,focus 示意用户完结托选的地位。anchor 和focus 都蕴含了 nodeId 和 offset 两个属性,nodeId 示意地位所在的文本节点,offset 示意地位绝对于文本节点开始的偏移量,以字符为单位。
当用户点击鼠标开始拖选时,找到鼠标所在的 dom 节点对应模型上的文本节点,咱们拿到 id 存储在 anchor 的 nodeId 中,再计算鼠标在 dom 节点上的地位,转换为数据模型上绝对文本节点开始处的偏移量,存储在 anchor 的 offset 中。
当用户挪动鼠标或者抬起鼠标时,咱们用相似的方法更新 focus 数据,将 anchor 和 focus 数据组合成为一个区域(Range)放入 Selection 模型中。这样咱们就能够依据用户的点击/拖蓝操作结构出用户的光标/选区对应的Selection模型了。
而后,咱们开发 Selection 组件渲染 Selection 模型。当 anchor 和 focus 在同一个地位时,Selection 组件将 Selection 模型渲染为一个闪动的短线,示意用户的光标,当 anchor 和 focus 不在同一个地位时,Selection 组件将 Selection 模型渲染为一个从 anchor 地位到 focus 地位的一个或者多个矩形区域,示意用户的选区。
总结这一节,咱们用 Input 组件触发用户输出事件,结构 Selection 模型和 Selection 组件用于本人绘制光标和选区,最终咱们的模型层和视图层如下图所示:
2.4 指令
新版编辑器实现了丰盛的自定义的富文本编辑指令,本人实现了 execCommand 办法来执行指令。
上面以输出文字的指令作为例子阐明指令是如何生成的。
输出文字:
输出文字的指令名称是 'insertText',它须要传入以下四个参数:
- nodeId: 插入到的文本节点的 id
- offset: 插入的地位绝对文本节点起始地位的偏移量
- text: 插入的文本内容
- marks: 插入文本的行内款式
在上面的例子中,咱们想在 'This is a text' 的地位10处插入一个红色的 'rich' 变为 'This is a rich text',须要生成的指令如图所示:
生成指令上述的指令,须要咱们将光标定位到 ‘This is a ’ 之后,而后点击工具栏的色彩按钮设置色彩为红色,再输出字符串 'rich ',依据用户操作生成指令的过程如下图所示:
在用户点击将光标定位到 ‘This is a ’ 之后时,咱们更新了 Selection 模型,它的 anchor 和 focus 中的 nodeId 变为以后文本的 id,而 offset 变为 10。
而后,用户点击了红色按钮,这时候咱们在Selection模型上记录用户以后设置的行内款式,将在下一次输出时失效(如果点击其余中央,这个行内款式将会重置为新光标后面一个字符的行内款式)。
最初,在用户按下按键输出文字时,咱们拦挡用户的keydown事件,从event.data中拿到须要插入的文本r,再依据以后的Selection模型,拿到anchor节点的nodeId和offset,以及存储在Selection模型上的行内款式,依据这几个参数就能够生成insertText指令了。
指令的组合:
指令间接会有一些专用的逻辑,为了指令逻辑的复用,咱们将一些专用逻辑也封装成指令。简略的指令(Operation)能够组合成简单的命令(Command)。例如选中一块区域并输出文字,现实的体现是删除区域内的所有文字,再插入输出的文字,如下图所示:
这个简单的命令咱们将它定义为 insertTextAtRange,它实际上是由三步组成:
第一步,先删除选区中的所有文字,这里咱们用 deleteByRange 命令实现。而要删除选区中所有的内容,因为选区跨了三个段落,咱们须要首先将第一个段落中的 ‘world’ 删除,用到了 deleteText 指令;而后将第二个段落节点 ‘hello javascript’ 整个删除,这里用的是 deleteNode 指令;最初咱们还须要将最初一段中的 hello 删除,这里用的也是 deleteText 指令。所以一个 deleteByRange 命令又由多个 deleteText 和 deleteNode 指令组成。
第二步,删除完选区所有文字之后,咱们须要插入 editor 到第一段的 hello 之后,用到了下面提到的 insertText 指令。
第三步,咱们发现 hello editor 和2020都是文本段落,依照需要咱们要将他们合并到一起变为'hello editor 2020',就用到了mergeNode 的指令。
由此可见一个简单操作对于的命令是有多个指令和命令独特组成的,这种形式能充沛解耦和复用的指令,让每个指令只关注于实现一类对数据模型的批改。
撤销重做:
将对数据模型的批改形象成指令之后,撤销重做就变得比拟好实现。
咱们规定指令都是成对呈现的,每个指令都有对应的逆指令,例如 insertText 的指令它的逆指令是 deleteText,文档模型 Document 在 insertText 指令的批改下变为了Document',那么依据 insertText 指令结构出的逆指令 deleteText 就能够批改Document‘ 让它复原成 Document,这就是实现撤销重做的根底。
对于简单的命令,咱们会在他执行的时候收集执行的所有简略指令。在撤销时,依据指令的执行程序,反向的执行所有收集到的指令的逆指令。在重做时,则只须要正向的执行所有收集到的指令。
2.5 小结
本章从模型、渲染、编辑、指令四个角度中的前两个阐明了新编辑器的技术选型。
总结起来,新编辑器采纳典型的 MVC 模式,联合了 React 等前端框架的数据驱动的思维,通过批改数据模型来解决更新视图,因为放弃了 execCommand 和 contentEditable 这两个浏览器的 API,所以本人实现了指令系统、事件拦挡和光标绘制。
整个富文本编辑的模块如下图所示:
然而因为富文本编辑器除了须要反对富文本的编辑性能,还须要反对图片、附件、表格、代码块等其余简单性能,在上述框架内如何扩大反对这些性能,如何实现性能的解耦和可配置,这就是下一节咱们探讨的问题。
三、新编辑器的分层架构
首先咱们用图片性能为例,阐明如何在现有框架下实现。
3.1 实现图片性能
咱们先只思考占据一行的图片,这类图片能够独自当做一个段落,所以是能够放入咱们的三层文档模型的第二层,如下图所示:
对应的咱们须要开发 Image 组件渲染图片,它与 Paragraph 组件一样,也是 Document 组件的子组件,如下图所示:
点击工具栏按钮,咱们须要在文档光标处插入对应的图片,这就须要咱们生成 insertImage 命令,用它批改文档模型,生成 insertImage 命令的过程如下:
由上述增加图片性能的做法能够看出,新增加一个性能,咱们须要设计实现对应的模型、组件和命令,每个性能都波及到这三处性能的批改,随着性能越来越多,不同性能之间的代码会相互耦合。
并且,在不同利用场景下,须要不同的性能,例如编辑器 A 只须要图片附件和表格的性能,编辑器 B 须要图片、代办、列表的性能,这种编辑器定制是比拟难实现的,之前只能通过屏蔽入口实现,js 包里有很多无用代码。
如何解决这些问题呢?
3.2 分层架构
为了解决编辑器外围性能和业务性能的解耦,咱们将云笔记新版编辑器的架构分为了核心层和业务层:
- 核心层 只负责提供富文本的编辑能力,以及多种拓展机制。
- 业务层 负责实现各种各样的扩大性能,例如图片、附件、表格、代办、列表等。
核心层:
核心层的次要能力是通过第二节的 MVC 框架提供富文本编辑能力。它裸露了以下接口:
- 首先,是 Editor 组件,Editor 组件提供蕴含富文本编辑能力的编辑器组件,业务层只须要将它作为一个 React 组件进行调用就能够了。
- 其次,是 editor 全局对象,它下面挂在了执行编辑命令、撤销、重做等富文本编辑的外围接口。
- 最初,核心层还提供了丰盛的扩大机制,用于业务层对编辑器能力进行扩大。常见的扩大机制蕴含数据模型扩大、组件扩大、插件扩大、自定义命令等。
业务层:
在核心层提供的富文本编辑器的根底上实现云笔记编辑器的泛滥简单的业务性能。大抵蕴含以下几个须要开发模块:
- 首先,须要将核心层提供的 Editor 组件与业务层开发的工具栏、右键菜单等组件组合成为一个性能残缺编辑器。
- 其次,须要利用核心层的扩大机制开发互相解耦的编辑器个性,扩大编辑器的性能,例如图片、附件、表格、代办、列表等等。
- 最初,还须要开发与云笔记编辑器与不同平台宿主 WebView 交互的接口层 editorAPI,实现云笔记编辑器的跨平台个性。
所以用如下图这样的分层构造,咱们就能够解决编辑器性能耦合和定制化的问题。
编辑器和外围富文本编辑性能和扩大性能之间以及不同的扩大性能之间都是独自开发的,耦合的可能性大大降低。同时针对不同的编辑器定制需要,能够组合不同的编辑器个性进行打包,这样就能够实现按需打包出定制版的编辑器。
3.3 扩大机制
用核心层提供的扩大机制,咱们从新实现图片的性能。首先,咱们将三层文档模型的第二层由段落泛化为块(Block),块上提供 name 字段示意块的类型,默认类型为示意段落的 paragraph,针对图片类型,name 能够标记位 image。
图片模型中咱们须要记录图片的地址,咱们在块的模型中增加 data 字段用于存储不同类型块的自定数据,对于图片就能够在 data 中存储 url 字段。
其次,咱们在渲染时,针对块用 Block 组件进行渲染。同时在 editor 裸露。registerComponent 接口,针对不同 name 的块,将对应的渲染组件注册进编辑器。Block 组件就能够在渲染数据时,依据 name 抉择对应的注册组件进行渲染。例如,针对 name 是 paragraph 的段落数据用 Paragraph 组件进行渲染,针对 name 是 image 的图片组件,则用 Image 组件进行渲染。
最初,咱们须要实现 insertImage 的自定义命令,通过 editor 的 registerCommand 注册命令,就能够在点击工具栏插入图片时调用 insertImage 的命令批改数据模型。在实现 insertImage 自定义命令的过程中,咱们可能会用到 editor 上保留的编辑器内置命令和指令。
做完这三步,咱们就利用编辑器的扩大机制实现了图片的性能。能够看出这样实现的图片性能,有扩展性强、耦合低、可插拔等长处。
四、总结
综上所述,云笔记新版编辑器采纳了核心层和业务层的两层架构,如下图所示:
在核心层,舍弃了存在较多问题的 contentEditable 和 execCommand 接口,自定义了数据格式,攻克了光标绘制、事件拦挡、命令零碎等技术难题,实现了富文本编辑的外围性能。同时还裸露了丰盛的扩大机制。
在业务层,通过核心层裸露的扩大机制,咱们能够开发各种不同编辑器个性,通过注册机制将它们注册回编辑器丰盛编辑器的性能。
在开发有道云笔记的新版编辑器的过程中,咱们遇到很多理论问题,愈发感觉到这是一个十分有深度的前端技术畛域,所以咱们将新版编辑器的技术选型、架构和局部实现细节拿进去分享给大家,心愿对大家开发富文本编辑器、做简单零碎的架构设计有肯定参考意义。
- END -