5

有道云笔记新版编辑器架构设计(下)

 2 years ago
source link: https://my.oschina.net/youdaotech/blog/5082508
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
有道云笔记新版编辑器架构设计(下) - 有道技术团队的个人空间 - OSCHINA - 中文开源技术交流社区



上期文章,我们从整体上介绍了富文本编辑器的背景,并分享了有道云笔记新版编辑器技术选型中的模型渲染部分。

有道云笔记新版编辑器架构设计(上)

本期文章,我们将继续分享技术选型中的编辑指令部分内容,并详细解读有道云笔记编辑器的分层架构设计。

作者/  金鑫

编辑/  刘振宇



云笔记新版编辑器技术选型



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。

b27e5c10-2642-473f-9732-393ef2b3474c.png

但是,一旦出现 span abc 上的事件没有被拦截或拦截了但没有正常处理,就会出现 bug。

例如我们旧版的编辑器就没有拦截 ctrl + delete 的事件,如果在 abc 这一行按 ctrl + delete 就没有对应的事件处理函数修改数据模型,数据模型还是 abc,但是由于 span abc 是 contentEditable 的,ctrl + delete 事件会直接修改 span abc 将 abc 整行删除,这样数据模型和视图上就出现了不一致。后续如果再输入 d,则会将数据模型修改成 abcd,这时候视图会根据新数据渲染为 span abcd,表现为已经删除的 abc 再次出现,对用户的使用造成困扰。

00db436c-6042-47d9-8bfb-19f9e5465df3.png

针对 contentEditable 的问题,我们决定将完全抛弃它,由此带来两个问题:

  • 没有可编辑的元素,不会触发输入事件
  • 没有可编辑的元素,无法使用浏览器自带的光标

触发输入事件:

我们采用了在用户光标位置后画一个隐藏的 Input 组件,Input 组件中有一个 textarea 来接受用户的输入,触发输入相关的事件,如下图所示:

fc12fd0f-4f9f-4dcd-bbfc-f0f5ccca3016.png

自绘光标/选区:

由于不能使用浏览器默认的光标,我们只能自绘光标。

我们参考浏览器的 Selection 的结构,设计了类似的Selection模型,并用Selection组件渲染 Selection 模型,在屏幕上用绝对定位画出用户的光标,同时用户拖蓝时产生的选区,也可以用这种办法绘制,由于光标和选区本质上一样,我们就放弃了浏览器的选区绘制,也改为自己绘制选区。具体流程如下:

37b73b7d-65f8-437a-9777-1147545c9e19.png

我们用 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 组件用于自己绘制光标和选区,最终我们的模型层和视图层如下图所示:

456a576a-60f4-482c-9a11-08f0ba0770d4.png

2.4 指令

新版编辑器实现了丰富的自定义的富文本编辑指令,自己实现了 execCommand 方法来执行指令。

下面以输入文字的指令作为例子说明指令是如何生成的。

输入文字:

输入文字的指令名称是 'insertText',它需要传入以下四个参数:

  • nodeId:  插入到的文本节点的 id
  • offset:  插入的位置相对文本节点起始位置的偏移量
  • text:  插入的文本内容
  • marks:  插入文本的行内样式

在下面的例子中,我们想在 'This is a text' 的位置10处插入一个红色的 'rich' 变为 'This is a rich text',需要生成的指令如图所示:

adb83cbb-79f3-41b3-bbff-48881f4a4359.png

生成指令上述的指令,需要我们将光标定位到 ‘This is a ’ 之后,然后点击工具栏的颜色按钮设置颜色为红色,再输入字符串 'rich ',根据用户操作生成指令的过程如下图所示:

55ccaeb3-910f-4ed8-859d-fc49c6fe3568.png

在用户点击将光标定位到 ‘This is a ’ 之后时,我们更新了 Selection 模型,它的 anchor 和 focus 中的 nodeId 变为当前文本的 id,而 offset 变为 10。

然后,用户点击了红色按钮,这时候我们在Selection模型上记录用户当前设置的行内样式,将在下一次输入时生效(如果点击其他地方,这个行内样式将会重置为新光标前面一个字符的行内样式)。

最后,在用户按下按键输入文字时,我们拦截用户的keydown事件,从event.data中拿到需要插入的文本r,再根据当前的Selection模型,拿到anchor节点的nodeId和offset,以及存储在Selection模型上的行内样式,根据这几个参数就可以生成insertText指令了。

指令的组合:

指令直接会有一些公用的逻辑,为了指令逻辑的复用,我们将一些公用逻辑也封装成指令。简单的指令(Operation)可以组合成复杂的命令(Command)。例如选中一块区域并输入文字,理想的表现是删除区域内的所有文字,再插入输入的文字,如下图所示:

69d3cf9d-9d81-46ff-9118-40fa948e0706.png

这个复杂的命令我们将它定义为 insertTextAtRange,它实际上是由三步组成:

第一步,先删除选区中的所有文字,这里我们用 deleteByRange 命令实现。而要删除选区中所有的内容,因为选区跨了三个段落,我们需要首先将第一个段落中的 ‘world’ 删除,用到了 deleteText 指令;然后将第二个段落节点 ‘hello javascript’ 整个删除,这里用的是 deleteNode 指令;最后我们还需要将最后一段中的 hello 删除,这里用的也是 deleteText 指令。所以一个 deleteByRange 命令又由多个 deleteText 和 deleteNode 指令组成。

第二步,删除完选区所有文字之后,我们需要插入 editor 到第一段的 hello 之后,用到了上面提到的 insertText 指令。

第三步,我们发现 hello editor 和2020都是文本段落,按照需求我们要将他们合并到一起变为'hello editor 2020',就用到了mergeNode 的指令。

23ebb0a3-557f-4eec-ab5e-ff43e145ac7a.png

由此可见一个复杂操作对于的命令是有多个指令和命令共同组成的,这种方式能充分解耦和复用的指令,让每个指令只关注于实现一类对数据模型的修改。

撤销重做:

将对数据模型的修改抽象成指令之后,撤销重做就变得比较好实现。

我们规定指令都是成对出现的,每个指令都有对应的逆指令,例如 insertText 的指令它的逆指令是 deleteText,文档模型 Document 在 insertText 指令的修改下变为了Document',那么根据 insertText 指令构造出的逆指令 deleteText 就可以修改Document‘ 让它恢复成 Document,这就是实现撤销重做的基础。

69b49d4d-dad8-40e0-a11d-aae0ca500202.png

对于复杂的命令,我们会在他执行的时候收集执行的所有简单指令。在撤销时,根据指令的执行顺序,反向的执行所有收集到的指令的逆指令。在重做时,则只需要正向的执行所有收集到的指令。

2a74a91b-8c51-4e45-b13a-26e6b2fd65ac.png


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK