从零实现富文本编辑器系列文章
- [深感一无所长,准备试着从零开始写个富文本编辑器](./从零设计实现富文本编辑器.md)
- [从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计](./基于MVC模式的编辑器架构设计.md)
- [从零实现富文本编辑器#3-基于Delta的线性数据结构模型](./基于Delta的线性数据结构模型.md)
- [从零实现富文本编辑器#4-浏览器选区模型的核心交互策略](./浏览器选区模型的核心交互策略.md)
- [从零实现富文本编辑器#5-编辑器选区模型的状态结构表达](./编辑器选区模型的状态结构表达.md)
- [从零实现富文本编辑器#6-浏览器选区与编辑器选区模型同步](./浏览器选区与编辑器选区模型同步.md)
- [从零实现富文本编辑器#7-基于组合事件的半受控输入模式](./基于组合事件的半受控输入模式.md)
- [从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为](./浏览器输入模式的非受控DOM行为.md)
- [从零实现富文本编辑器#9-编辑器文本结构变更的受控处理](./编辑器文本结构变更的受控处理.md)
- [从零实现富文本编辑器#10-React视图层适配器的模式扩展](./React视图层适配器的模式扩展.md)
- [从零实现富文本编辑器#11-Immutable状态维护与增量渲染](./Immutable状态维护与增量渲染.md)
- [从零实现富文本编辑器#12-React可编辑节点的组件预设](./React可编辑节点的组件预设.md)
- [从零实现富文本编辑器#13-React非编辑节点的内容渲染](./React非编辑节点的内容渲染.md)
- [从零实现富文本编辑器#14-编辑器历史变更管理与状态回溯](./编辑器历史变更管理与状态回溯.md)
## Why?
那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计`Quill`、结合`React`视图层的`Draft`、纯粹的编辑器引擎`Slate`、高度模块化的`ProseMirror`、开箱即用的`TinyMCE/TipTap`、集成协同解决方案的`EtherPad`等等。
我也算是比较关注于各类富文本编辑器的实现,包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计,绝大多数都是讲的应用层,例如如何使用编辑器引擎实现某某功能等。虽然这些应用层的实现本身也会有一定复杂性,但是底层的设计却是更值得探讨的问题。
此外,我觉得富文本编辑器很类似于低代码的设计,准确来说是`No Code`的一种实现。本质上低代码和富文本都是基于`DSL`的描述来操作`DOM`结构,只不过富文本主要是通过键盘输入来操作`DOM`,而无代码则是通过拖拽等方式来操作`DOM`,我想这里应该是有些共通的设计思路。
而我恰好前段时间都在专注于编辑器的应用层实现,在具体实现的过程中也遇到了很多问题,并且记录了相关文章。然而在应用层实现的过程中,遇到了很多我个人觉得可以优化的地方,特别是在数据结构层面上,希望能够将我的一些想法应用出来。而具体来说,主要有下面的几个原因:
### 编辑器专栏
纸上得来终觉浅,绝知此事要躬行。
我的博客是从`20`年开始写的,记录的内容很多,基本上是想到什么就写什么,毕竟是作为平时学习的记录。然后在`24`年写了比较多的富文本编辑器的文章,主要是整理了平时遇到的问题以及解决方案,集中在应用层的设计上,例如:
- [初探富文本之文档虚拟滚动](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之文档虚拟滚动.md)
- [初探富文本之OT协同算法](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/初探富文本之OT协同算法.md)
- [...](https://github.com/WindRunnerMax/EveryDay#richtext)
此外,前段时间还研究了`slate`富文本编辑器相关的实现,并且也给`slate`的仓库提过一些`PR`。还写了一些`slate`相关的文章,并且还基于`slate`实现了一个文档编辑器,同样也是比较关注于应用层的实现,例如:
- [WrapNode数据结构与操作变换](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/WrapNode数据结构与操作变换.md)
- [Node节点与Path路径映射](https://github.com/WindRunnerMax/EveryDay/blob/master/RichText/Node节点与Path路径映射.md)
- [...](https://github.com/WindRunnerMax/EveryDay#richtext)
在实现了诸多的应用层的功能之后,发现整个编辑器有很多可以深入研究的地方。特别是有些实现看似很理所当然,但是仔细研究起来会发现这其中有很多细节可以探究,例如在`DOM`结构后常见的零宽字符、`Mention`节点的渲染等等,这些内容都可以单独拿出来记录文章,这其实就是我想从零实现编辑器的最重要原因。
`24`年开始写了很多业务上的东西,到了`25`年就略感题穷,而目前我也没有别的擅长的方面,由此写编辑器相关的内容是比较好的选择,这样对于文章的选题也会简单些。不过,虽然想的是深入写编辑器相关的内容,但是在平时遇到问题的时候,还是会记录下来,例如最近有个基于`immer`配合`OT-JSON`实现的状态管理的想法可以实现。
而对于编辑器的具体实现,我目前的目标是实现可用的编辑器,而不是兼容性非常好且功能完备的编辑器。主要是现在已经有非常多优秀的编辑器实现,且有很多生态插件可以支持,能够满足大部分的需求。目前我想实现的编辑器主要是兼容`Chrome`浏览器即可,移动端的问题暂时不会考虑。不过,如果能够将编辑器做得比较好的话,自然可以去做兼容性适配。
不过目前还是试探性地来设计并实现编辑器,期间必然会遇到很多问题,这些问题也将会成为专栏的主体内容。最开始的时候,我是准备将编辑器完善后再开始撰写文章,后来发现设计过程中的历史方案同样很有价值,因此决定将设计过程也一并记录下来。如果将来真的能够将编辑器适用于生产环境,那么这些文章就能够溯源到模块为什么这么设计,想必也是极好的。整体来说,我们不能一口吃成胖子,但是一口一口吃却是可以的。
### 深入编辑器
这部分是让我想起来一句话:我们富文本编辑器是这样的,你不写你不懂。
编辑器是个非常注重细节的工程,很多时候都需要深入研究浏览器的`API`,例如`document`上的`caretPositionFromPoint`方法,用以获取当前某个点所在的选区位置,通常用于拖拽文本后的落点定位。除此之外,还有很多选区相关的`API`,例如`Selection`、`Range`等等,这些都是编辑器实现的基础。
那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。
零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。
而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于`​`即`U+200B`类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过`$("[data-enter]")`就可以检查到其中存在的零宽字符。
```html
\u200B
​
```
那么从名字上来看,这个零宽字符在视觉上是不显示的,因为其是零宽度。但是在编辑器中,这个字符却是很重要的。简单来说,我们需要这个字符来放置光标,以及做额外的显示效果。需要注意的是我们在这里指的是`ContentEditable`实现的编辑器,如果是自绘选区的编辑器则不一定需要这部分设计。
我们先来聊一下额外的显示效果,举个例子,我们在选择飞书文档文本内容,如果选中到文本末尾时,会发现末尾会额外多出形似`xxx|`的效果。在平时不关注的话可能会觉得这是编辑器默认行为,但是实际上这个效果无论是`slate`还是`quill`中都是不存在的。
实际上这个效果就是使用零宽字符来实现的,在行内容的末尾后面插入零宽字符,就可以做到末尾的文本选中效果。实际上这个效果在`word`中更常见,也就是额外渲染的回车符号。
```html
末尾零宽字符 Line 1
末尾零宽字符 Line 2
末尾纯文本 Line 1
末尾纯文本 Line 2
```
那么在这个零宽字符如果只是渲染效果的话,那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用,例如此时我们有`3`行文本,如果此时从第`1`行末尾选到第`2`行时,并且按下`Tab`键,那么此时这两行的内容就会缩进。
那么如果没有这个显示效果,此时进行缩进操作,用户可能认为仅仅是选中了第`2`行,但是实际上是选中了`1/2`两行文本。这样的话用户可能会以为是`BUG`,而我们也实际接受过这个交互效果的反馈。
```plain
123|
4|x56
```
也对各个在线文档实现进行了简单调研: 基于`contenteditable`实现的编辑器中,飞书文档、早期`EtherPad`存在这个交互实现;自绘选区的编辑器中,钉钉文档存在这个实现;`Canvas`引擎实现的编辑器中,腾讯文档、`Google Doc`存在这个实现。
在渲染效果部分,零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时,此时这个行`DOM`结构的内容就是空,这就导致此行的高度塌陷为`0`,且无法放置光标。为了解决这个问题,我们可以选择在行内容中插入零宽字符,这样就可以撑起行内容且可以放置光标。当然使用` `来撑起行高也是可以的,使用这两种方案会各有优劣,且兼容性方面也有所不同。
```html
```
在类似于`Notion`这种块结构的编辑器中,还有个比较重要的交互效果。即块级结构独立选择,例如我们可以直接将整个代码块独立选出来,而不是仅仅能选择其中的文本。这种效果在目前的开源编辑器很少有实现,都是需要自行以块结构重新组织设计选区。
通常来说,这个交互同样可以使用零宽字符来实现。因为我们的选区通常是需要放置在文本节点上的,因此我们很容易可以想到,可以在块结构所在行的末尾放置零宽字符,当选区在零宽字符上时就将整个块选中。这里用零宽字符而不是` `的好处是,零宽字符本身就是零宽,不会引起额外的换行。
```html
```
在结构上,零宽字符还有个非常重要的实现。在编辑器内的`contenteditable=false`节点会存在特殊的表现,在类似于`inline-block`节点中,例如`Mention`节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,`line-1`是无法将光标放置在`@xxx`内容后的,虽然我们能够将光标放置之前,但此时光标位置是在`line node`上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在`line-2/3`中我们就可以看到正确的光标放置效果。这里的`0.1px`也是个为了兼容光标的放置的`magic`,没有这个`hack`的话,非同级节点光标同样无法放置在`inline-block`节点后。
```html
```
除此之外,编辑器自然是需要跟字符打交道的,那么在`js`表现出来的`Unicode`编码实现中,`emoji`就是最常见且容易出问题的表达。除了其单个长度为`2`这种情况外,组合的`emoji`也是使用独特的零宽连字符`\u200d`来表示的。
```js
"🎨".length
// 2
"🧑" + "\u200d" + "🎨"
// 🧑🎨
```
零宽字符还可以实现一些特殊的效果,在`Markdown`编辑器的解析中,`**test.**test`是不能解析的,因为右侧定界符序列前面不能是空白,且当后面没有空白或标点符号时,前面不能是标点符号。这是`CommonMark`规范中实际定义的,而不是`BUG`或者`UB`。
虽然这种情况下,我们可以直接在`**`后加入空格来解决问题,即`**test.** test`格式。在英文格式中是非常自然地携带空格表达的,但是在中文中是没有这种习惯的,因此这种情况下不好处理,特别是在自动化的`Md`数据转换中就容易出现特殊问题。
因此这里使用零宽字符`U+200B`可以实现特殊的效果,`**`文本表现为界定符的表现是前面不能是空白,实际上规范中的空格是不包含零宽字符表达的,甚至于说零宽字符本身还是字符。因此在规范中界定符前既不是空格,也不是标点符号的情况,无论后边是不是空白都可以解析。
```js
"**test." + "\u200B" + "**test"
// **test.**test
```
### 数据结构设计
编辑器数据结构的设计是影响面非常广的事情,无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等,还是平台应用层面上的`diff`算法、查找替换、协同算法等,以及后端服务的数据转换、导出`md/word/pdf`、数据存储等,都会涉及到编辑器的数据结构设计。
通常来说,基于`JSON`嵌套的数据结构来表达编辑器`Model`是很常见的,例如`Slate`、`ProseMirror`、`Lexical`等等。以`slate`编辑器为例,无论是数据结构还是选区的设计,都尽可能倾向于`HTML`的设计,因此可以存在诸多层级节点的嵌套。
```js
[
{
type: "paragraph",
children: [{ text: "editable" }],
},
{
type: "ul",
children: [
{
type: "li",
children: [{ text: "list" }],
},
],
},
];
```
通过线性的扁平结构来表达文档内容也是常见的实现方案,例如`Quill`、`EtherPad`、`Google Doc`等等。以`quill`编辑器为例,其内容上的数据结构表达不会存在嵌套,当然本质上还是`JSON`结构,而选区则采用了更精简的表达。
```js
[
{ insert: "editable\n" },
{ insert: "list\n", attributes: { list: "bullet" } },
];
```
当然还有很多特别的数据结构设计,例如`vscode/monaco`的`piece table`数据结构。代码编辑器又何尝不是一种富文本编辑器,毕竟其是可以支持代码高亮的功能的,只不过类似`piece table`的结构我还没有太深入研究。
在这里我希望能够以线性的数据结构来表达整个富文本结构,虽然嵌套的结构能够更加直观地表达文档内容,但是对于内容的操作起来会更加复杂,特别是存在嵌套的内容时。以`slate`为例,在`0.50`之前的版本`API`设计非常复杂,需要比较大的理解成本,虽然之后将其简化了不少:
```js
// https://github.com/ianstormtaylor/slate/blob/6aace0/packages/slate/src/interfaces/operation.ts
export type NodeOperation =
| InsertNodeOperation
| MergeNodeOperation
| MoveNodeOperation
| RemoveNodeOperation
| SetNodeOperation
| SplitNodeOperation;
export type TextOperation = InsertTextOperation | RemoveTextOperation;
```
从这里可以看出来,`slate`对于文档内容的完整操作是需要`9`种类型的`Op`。而如果是基于线性结构的话,我们就只需要三种类型的操作,即可表达整个文档的操作。当然对于一些类似`Move`的操作,则需要额外的选区`Range`计算处理,相当于将计算成本移交到了应用层。
```js
// https://github.com/WindRunnerMax/BlockKit/blob/c24b9e/packages/delta/src/delta/interface.ts
export interface Op {
// Only one property out of {insert, delete, retain} will be present
insert?: string;
delete?: number;
retain?: number;
attributes?: AttributeMap;
}
```
此外,嵌套结构的`normalize`会变得很复杂,且变更造成的时间复杂度也会变高,特别是脏路径标记算法,以及标记后的数据处理也需要由上述`Op`处理。还有用户操作导致的嵌套层级无法非常好地控制,就要`normalize`过程时规范数据,否则下面例如粘贴`HTML`时就可能会出现大量的数据嵌套。
```js
[{
children: [{
children: [{
children: [{
children: [{
// ...
text: "content"
}]
}]
}]
}]
}]
```
再举个更加实用的例子,如果我们此时存在格式的嵌套内容。例如`quote`与`list`两种格式嵌套,如果此时我们文档的数据结构是嵌套结构,那么操作内容就会存在`ul > quote`或者`quote > ul`的两种情况,正常情况下我们必须要设计规则来做`normalize`;而扁平结构下,属性全部写在`attrs`内,不同操作造成的数据格式变更是完全幂等的。
```js
// slate
[{
type: "quote",
children: [{
type: "ul",
children: [{ text: "text" }]
}],
}, {
type: "ul",
children: [{
type: "quote",
children: [{ text: "text" }]
}],
}]
// quill
[{
insert: "text",
attributes: { blockquote: true, list: "bullet" }
}]
```
扁平的数据结构在数据处理方面会存在优势,而在视图层面上,扁平的数据结构表达结构化的数据会是比较困难的,例如表达代码块、表格等嵌套结构。但是这件事并非是不可行的,例如`Google Doc`的复杂表格嵌套就是完全的线性结构,这其中是存在很巧妙的设计在里边的,在这里先不展开了。
此外,如果我们需要实现在线文档的编辑器的话,在整个管理流程中可能会需要`diff`,即取得两边数据结构的增删改。这种情况下扁平的数据结构能够更好地处理文本内容,而`JSON`嵌套结构的数据则会麻烦很多。还有一些其他关于数据处理方面的周边应用,整体复杂度都要提升不少。
最后还是有协同相关的实现,协同算法是富文本编辑器的可选模块。无论是基于`OT`的协同算法,还是`Op-Based CRDT`的协同算法,都是需要传输上述的`op`类型与数据的,那么很显然`9`种操作的`op`类型会比`3`种操作的`op`类型更加复杂。
- [OT.js: Text 数据类型](https://github.com/Operational-Transformation/ot.js/blob/e9a3a0e/lib/text-operation.js#L39)
- [ShareDB Rich-Text: Delta OT 数据类型](https://github.com/ottypes/rich-text/tree/b53cd97)
- [ShareDB JSON0: JSON OT 数据类型](https://github.com/ottypes/json0/tree/90a3ae)
- [ShareDB Slate: Slate OT 数据结构适配器](https://github.com/pubuzhixing8/ottype-slate/tree/f88274)
- [YJS YText: Delta 数据类型实现](https://github.com/yjs/yjs/blob/4b8657/src/types/YText.js)
- [YJS YMap/YArray: JSON 数据类型实现](https://github.com/yjs/yjs/blob/4b8657/src/types/)
- [YJS Slate: Slate 数据结构适配器](https://github.com/BitPhinix/slate-yjs/tree/69f3e0e/packages/core/src/applyToYjs)
因此,我希望能够以线性的数据结构来实现整个编辑器结构,这样`quill`的`delta`就是非常好的选择。但是`quill`是自行实现的视图层结构,并非是可以组合`react`等视图层的形式,组合这些视图层的优势就是可以直接使用组件库样式来实现编辑器,而避免了每个组件都需要自行实现。那么这里我准备基于`quill`的数据结构,来从零实现富文本编辑器核心层,并且像`slate`一样以此组合基本的视图层。
## 方案选型
其实这里有个有趣的问题,为什么用不到`1mb`的代码量就可以实现部分类似`office word`编辑器的能力,是因为浏览器已经帮我们做了很多事情,并通过`API`提供给开发者,包括输入法处理、字体解析、排版引擎、视图渲染等等。
因此我们是需要设计出如何跟浏览器交互的方案,毕竟我们实际上是需要跟浏览器交互的。而对于富文本编辑器最经典的描述则是分为了三级:
- `L0`: 基于浏览器提供的`ContentEditable`实现富文本编辑,使用浏览器的`document.execCommand`执行命令操作。 是作为早期轻量编辑器,可以较短时间内快速完成开发,但可定制的空间非常有限。
- `L1`: 同样基于浏览器提供的`ContentEditable`实现富文本编辑,但数据驱动可以自定义数据模型与命令的执行。常见的实现有语雀、飞书文档等等,可以满足绝大部分使用场景,但无法突破浏览器自身的排版效果。 |
- `L2`: 基于`Canvas`自主实现排版引擎,只依赖少量的浏览器`API`。常见的实现有`Google Docs`、腾讯文档等等,具体实现需要完全由自己控制排版,相当于使用画板而不是`DOM`来绘制富文本,技术难度相当高。
实际上在目前的开源产品中,这三种类型的编辑器都有涉及到,特别是绝大多数开源的都是`L1`类型的实现。而这其中还分化了不依赖`ContentEditable`却也不是完全自绘引擎,而是依赖`DOM`呈现内容外加自绘选区的实现,实际上倒是可以算作`L1.5`的级别。
本着学习的目的,自然要选择开源产品多的实现,这样遇到问题可以更好地借鉴和分析相关内容。因此我同样打算选择基于`ContentEditable`,实现数据驱动的标准`MVC`模型的富文本编辑器,基于这种方式来与浏览器交互,实现基本的富文本编辑能力。在此之前,我们还是先了解一下基本的编辑器实现:
### ExecCommand
如果我们仅仅需要最基本的行内样式,例如加粗、斜体、下划线等,这可能在一些基本输入框中是足够的,那么我们自然可以选择使用`execCommand`来实现。甚至直接基于`execCommand`的好处就是,其体积会非常小,例如 [pell](https://github.com/jaredreich/pell) 的实现,仅仅需要`3.54KB`的代码体积,此外还有 [react-contenteditable](https://github.com/lovasoa/react-contenteditable) 等实现。
我们也可以实现可以加粗的最小`DEMO`,`execCommand`命令可以在`contenteditable`元素中选区内的元素执行,`document.execCommand`方法接受三个参数,分别是命令名称、显示用户界面、命令参数。显示用户界面一般都是`false`,`Mozilla`没有实现,而命令参数则是可选的,例如超链接命令则需要传递具体链接地址。
```html
```
当然,这个示例过于简单,我们还可以在选区变换的时候,来判断加粗按钮的加粗状态,以此来显示当前选区状态。不过我们需要对齐`execCommand`的命令行为,前边也提到了可控性非常差,因此我们需要通过`document.createTreeWalker`迭代所有的选区节点,以此来判断当前选区的状态。
其实这里还需要注意的是,`execCommand`命令的行为在各个浏览器的表现是不一致的,这也是之前我们提到的浏览器兼容行为的一种,然而这些行为我们也没有任何办法去控制,这都是其默认的行为:
- 在空`contenteditable`编辑器的情况下,直接按下回车键,在`Chrome`中的表现是会插入`
`,而在`FireFox(<60)`中的表现是会插入` `,`IE`中的表现是会插入`
`。
- 在有文本的编辑器中,如果在文本中间插入回车例如`123|123`,在`Chrome`中的表现内容是`123123
`,而在`FireFox`中的表现则是会将内容格式化为`123
123
`。
- 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如`123|123->123123`,在`Chrome`中的表现内容会恢复原本的`123123`,而在`FireFox`中的表现则是会变为`123123
`。
- 在同时存在两行文本的时候,如果同时选中两行内容再执行`("formatBlock", false, "P")`命令,在`Chrome`中的表现是会将两行内容包裹在同个``中,而在`FireFox`中的表现则是会将两行内容分别包裹`
`标签。
- ...
此外还有类似于实现加粗的功能,我们无法控制是使用` `来实现加粗还是` `来实现加粗。还有浏览器的兼容性问题,例如在`IE`浏览器中是使用` `来实现加粗,在`Chrome`中是使用` `来实现加粗,`IE`和`Safari`不支持通过`heading`命令来实现标题命令等等。且对于一些比较复杂的功能,例如图片、代码块等等,是无法很好实现的。
当然,默认的行为并不是完全没有用的,在某些情况下,我们可能要实现纯`HTML`的编辑器。毕竟如果在基于`MVC`模式的编辑器实现中,会处理掉对`Model`来说无效的数据内容,这样就导致原本的`HTML`内容丢失,因此在这种需求背景下依赖浏览器的默认行为可能是最有效的,这种情况下我们可能主要关注的就是`XSS`的处理了。
### ContentEditable
`ContentEditable`是`HTML5`中的一个属性,可以让元素变得可编辑,再配合上内置的`execCommand`就是我们上边聊的最基本`DEMO`。那么如果要实现最基本的文本编辑器,就只需要在地址栏中输入下面的内容:
```text
data:text/html,
```
那么通过`document.execCommand`来执行命令修改`HTML`的方案虽然简单,我们也聊过了其可控性很差。除了上述的`execCommand`命令执行兼容性问题之外,还有很多`DOM`上的需要兼容处理的行为,例如下面存在简单加粗格式的句子:
```md
123**456**789
```
有许多方式可以表达这样的内容,编辑器可以认为显示效果是等价的,此时可能也需要对此类`DOM`结构等同处理:
```html
123456 789
123456 789
123456 789
```
但是这里仅仅是视觉上相等,将其完整对应到`Model`上时,自然会是件麻烦的事。除此之外,选区的表达同样也是复杂的问题,以下面的`DOM`结构为例:
```html
123 456 789
```
如果我们要表达选区折叠在`4`这个字符左侧时,同样会出现多种表达可以实现这个位置,这实际上就会很依赖浏览器的默认行为:
```js
{ node: 123, offset: 3 }
{ node: , offset: 0 }
{ node: , offset: 0 }
```
因此为了更强的扩展以及可控性,也解决数据与视图无法对应的问题,`L1`的富文本编辑器使用了自定义数据模型的概念。即在`DOM`树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的`HTML`也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的`HTML`文档的一致性。对于选区的表达,则需要根据`DOM`选区来不断`normalize`选区`Model`。
其实这也就是我们常见的`MVC`模型,当执行命令时会修改当前的模型,进而表现到视图的渲染上。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的`execCommand`对数据描述模型进行修改。在这个阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。我们也可以大概描述流程:
```html
```
而类似这种方案,无论是 [quill](https://github.com/slab/quill) 还是 [slate](https://github.com/ianstormtaylor/slate) 都是这样的调度。而类似于`slate`的实现,通过适配器来连接`React`之后,就需要更复杂的兼容处理。在`React`节点中加入`ContentEditable`后,会出现类似下面的`warning`:
```js
// A component is `contentEditable` and contains `children` managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.
```
这个`warning`的意思是,`React`无法保证`ContentEditable`中的`children`不会被意外修改或复制,这可能是不被意料到的。也就是说除了`React`本身是会需要执行`DOM`操作的,使用了`ContentEditable`之后,这个行为就变的不受控了,自然这个问题同样会出现在我们的编辑器中。
此外还有一些其他的行为,例如下面的例子中,我们无法从`123`字符选中到`456`上。也就是这里存在跨越`ContentEditable`节点了,就不能够正常使用浏览器的默认行为来处理,虽然这个处理是很合理的,但毕竟也会对我们实现`blocks`编辑器造成一些困扰。
```html
```
那么其实我们是可以避免使用`ContentEditable`的,设想一下即使我们没有实现编辑器,同样是可以选择页面上的文本内容的,就是我们普通的选区实现。那么如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。
但是这里存在一个很大的问题,就是内容的输入,因为不启用`ContentEditable`的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒`IME`输入法的话,浏览器给予的常规`API`就是借助` `来完成,因此我们就必须要实现隐藏的` `来实现输入,实际上很多代码编辑器例如 [CodeMirror](https://github.com/codemirror/codemirror5) 就是类似实现。
但是使用隐藏的` `就会出现其他问题,因为焦点在`input`上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 [Monaco Editor](https://github.com/microsoft/monaco-editor) 同样是自绘选区,[TextBus](https://github.com/textbus/textbus) 则绘制了光标,选区则是借助了浏览器实现。
在这里可以总结一下,使用`ContentEditable`需要处理很多`DOM`的特异行为,但是明显我们是不需要太过于处理唤醒输入这个行为。而如果不使用`ContentEditable`,却使用`DOM`来呈现富文本内容,则必须要借助额外的隐藏`input`节点来实现输入,由于焦点问题在这种情况下就不能使用浏览器的选区行为,因此就需要自绘选区的实现。
### Canvas
基于`Canvas`绘制我们需要的内容,颇有些文艺复兴的感觉,这种实现方式是完全不依赖`DOM`的,因此可以完全控制排版引擎。那么文艺复兴指的是,基于`DOM`兼容实现的任何生态都会失效,例如无障碍、`SEO`、开发工具支持等等。
那么为什么要抛弃现有的`DOM`生态,转而用`Canvas`来绘制富文本内容。特别是富文本会是非常复杂的内容,因为除了文本外,还有图片的内容,以及很多结构化格式的内容,例如表格等。这些内容都需要自行实现,那么在`Canvas`中实现这些内容其实相当于重新实现了部分`skia`。
基于`Canvas`绘制的编辑器,当前主要有腾讯文档、`Google Doc`等,而开源的编辑器实现有 [Canvas Editor](https://github.com/Hufe921/canvas-editor)。而除了文档编辑器之外,在线表格的实现基本都是`Canvas`实现,例如腾讯文档`Sheet`、飞书多维表格等,开源的实现有 [LuckySheet](https://github.com/dream-num/Luckysheet)。
在`Google Doc`发布的`Blog`中,对于使用`Canvas`绘制文档主要选了两个原因:
- 文档的一致性: 这里的一致性指的是浏览器对于类似行为的兼容,举个例子: 在`Chrome`中双击某段文本的内容,选区会自动选择为整个单词,而在早期`FireFox`中则是会自动选择一句话。类似这种行为的不一致会导致用户体验的不一致,而使用`Canvas`绘制文档可以自行实现保证这种一致性。
- 高效的绘制性能: 通过`Canvas`绘制文档,可以更好地控制绘制时机,而不需要等待重绘回流,更不需要考虑`DOM`本身复杂的兼容性考量,以此造成的性能损失。此外,`Canvas`绘图替代繁重的`DOM`操作,通过逐帧渲染和硬件加速可以提升渲染性能,从而提高用户操作的响应速度和整体使用体验。
此外,排版引擎还可以控制文档的排版效果,做富文本的各种需求,我们就可能面临产品为什么不能支持像`office word`那样的效果。例如如果我们编写的文字正好排满了一行,假如在这里再加一个句号,那么前边的字就会挤一挤,从而可以使这个句号是不需要换行。而如果我们再敲一个字的话,这个字是会换行的。在浏览器的排版中是不会出现这个状态的,所以假如需要突破浏览器的排版限制,就需要自己实现排版能力。
```html
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
文本文本文本文本文本文本文本文本文本文本文本文本文本文本
。
```
也就是说,在`word`中通常是不会出现句号在段落起始的,而在浏览器中是会存在这种情况的,特别是在纯`ASCII`字符的情况下。如果想规避这种排版状态的差异,就必须要自行实现排版引擎,以此来控制文档的排版效果。
此外,还有一些其他的功能,例如受控的`RTL`布局、分页、页码、页眉、脚注、字体字形控制等等。特别是分页的能力,在某些需要打印的情况下,这个效果是很必要的,但是`DOM`的实现在绘制前是无法得知其高度的,因此也就无法很好地实现分页的效果。除此之外,还有大表格的分页渲染效果等等,都变得难以控制。
因此,这些如果希望对齐`word`的实现,就必须要用`Canvas`从头造一遍。除了这些额外的功能,还有原本的浏览器基于`DOM`实现的基本功能,例如输入法的支持、复制粘贴的支持、拖拽的支持等等。而基本的`Canvas`是无法支持这些功能的,特别是输入法`IME`的支持,以及文本选区的实现,都需要很复杂的交互实现,这样的成本自然不会是很容易接受的。
## 总结
在本文我们聊了很多关于富文本编辑器基础能力的实现,特别是在`DOM`结构表现和数据结构之间的设计。并且在浏览器交互的方案上,我们也聊了`ExecCommand`、`ContentEditable`、`Canvas`实现方案的特点,简单总结了当前成熟产品以及开源编辑器,并且描述了相关实现的优缺点。
在后边我们会基于`ContentEditable`实现基本的富文本编辑器,首先对于整体的架构设计,以及数据结构的操作做概述。然后开始分别实现具体的模块,例如输入模块、剪贴板模块、选区模块等等。实现编辑器从来都不是一件简单的事情,除了在核心层面的基础框架设计,应用层上也会有很多问题需要兼容处理,因此这将会是一份大工程,需要慢慢积累。
## 每日一题
-
## 参考
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-