富文本编辑器是允许用户在输入和编辑文本内容时,可以应用不同的格式、样式等功能,例如图文混排等,具有所见即所得的能力。与简单的纯文本编辑组件<input>
等不同,富文本编辑器提供了更多的功能和灵活性,让用户可以创建更丰富和结构化的内容。现代的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、附件、公式等等比较复杂的模块。
- 开源地址: https://github.com/WindRunnerMax/BlockKit
- 在线编辑: https://windrunnermax.github.io/BlockKit/
- 项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
Why?
那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计Quill
、结合React
视图层的Draft
、纯粹的编辑器引擎Slate
、高度模块化的ProseMirror
、开箱即用的TinyMCE/TipTap
、集成协同解决方案的EtherPad
等等。
我也算是比较关注于各类富文本编辑器的实现,包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计,绝大多数都是讲的应用层,例如如何使用编辑器引擎实现某某功能等。虽然这些应用层的实现本身也会有一定复杂性,但是底层的设计却是更值得探讨的问题。
此外,我觉得富文本编辑器很类似于低代码的设计,准确来说是No Code
的一种实现。本质上低代码和富文本都是基于DSL
的描述来操作DOM
结构,只不过富文本主要是通过键盘输入来操作DOM
,而无代码则是通过拖拽等方式来操作DOM
,我想这里应该是有些共通的设计思路。
而我恰好前段时间都在专注于编辑器的应用层实现,在具体实现的过程中也遇到了很多问题,并且记录了相关文章。然而在应用层实现的过程中,遇到了很多我个人觉得可以优化的地方,特别是在数据结构层面上,希望能够将我的一些想法应用出来。而具体来说,主要有下面的几个原因:
编辑器专栏
纸上得来终觉浅,绝知此事要躬行。
我的博客是从20
年开始写的,记录的内容很多,基本上是想到什么就写什么,毕竟是作为平时学习的记录。然后在24
年写了比较多的富文本编辑器的文章,主要是整理了平时遇到的问题以及解决方案,集中在应用层的设计上,例如:
- 初探富文本之文档虚拟滚动
- 初探富文本之OT协同算法
- ...
此外,前段时间还研究了slate
富文本编辑器相关的实现,并且也给slate
的仓库提过一些PR
。还写了一些slate
相关的文章,并且还基于slate
实现了一个文档编辑器,同样也是比较关注于应用层的实现,例如:
- WrapNode数据结构与操作变换
- Node节点与Path路径映射
- ...
在实现了诸多的应用层的功能之后,发现整个编辑器有很多可以深入研究的地方。特别是有些实现看似很理所当然,但是仔细研究起来会发现这其中有很多细节可以探究,例如在DOM
结构后常见的零宽字符、Mention
节点的渲染等等,这些内容都可以单独拿出来记录文章,这其实就是我想从零实现编辑器的最重要原因。
24
年开始写了很多业务上的东西,到了25
年就略感题穷,而目前我也没有别的擅长的方面,由此写编辑器相关的内容是比较好的选择,这样对于文章的选题也会简单些。不过,虽然想的是深入写编辑器相关的内容,但是在平时遇到问题的时候,还是会记录下来,例如最近有个基于immer
配合OT-JSON
实现的状态管理的想法可以实现。
而对于编辑器的具体实现,我目前的目标是实现可用的编辑器,而不是兼容性非常好且功能完备的编辑器。主要是现在已经有非常多优秀的编辑器实现,且有很多生态插件可以支持,能够满足大部分的需求。目前我想实现的编辑器主要是兼容Chrome
浏览器即可,移动端的问题暂时不会考虑。不过,如果能够将编辑器做得比较好的话,自然可以去做兼容性适配。
不过目前还是试探性地来设计并实现编辑器,期间必然会遇到很多问题,这些问题也将会成为专栏的主体内容。最开始的时候,我是准备将编辑器完善后再开始撰写文章,后来发现设计过程中的历史方案同样很有价值,因此决定将设计过程也一并记录下来。如果将来真的能够将编辑器适用于生产环境,那么这些文章就能够溯源到模块为什么这么设计,想必也是极好的。整体来说,我们不能一口吃成胖子,但是一口一口吃却是可以的。
深入编辑器
这部分是让我想起来一句话:我们富文本编辑器是这样的,你不写你不懂。
编辑器是个非常注重细节的工程,很多时候都需要深入研究浏览器的API
,例如document
上的caretPositionFromPoint
方法,用以获取当前某个点所在的选区位置,通常用于拖拽文本后的落点定位。除此之外,还有很多选区相关的API
,例如Selection
、Range
等等,这些都是编辑器实现的基础。
那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。
零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。
而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于​
即U+200B
类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过("[data-enter]")
就可以检查到其中存在的零宽字符。
<!-- document.querySelectorAll("[data-enter]") -->
<span data-string="true" data-enter="true" data-leaf="true">\u200B</span>
<span data-string="true" data-enter="true" data-leaf="true">​</span>
那么从名字上来看,这个零宽字符在视觉上是不显示的,因为其是零宽度。但是在编辑器中,这个字符却是很重要的。简单来说,我们需要这个字符来放置光标,以及做额外的显示效果。需要注意的是我们在这里指的是ContentEditable
实现的编辑器,如果是自绘选区的编辑器则不一定需要这部分设计。
我们先来聊一下额外的显示效果,举个例子,我们在选择飞书文档文本内容,如果选中到文本末尾时,会发现末尾会额外多出形似xxx|
的效果。在平时不关注的话可能会觉得这是编辑器默认行为,但是实际上这个效果无论是slate
还是quill
中都是不存在的。
实际上这个效果就是使用零宽字符来实现的,在行内容的末尾后面插入零宽字符,就可以做到末尾的文本选中效果。实际上这个效果在word
中更常见,也就是额外渲染的回车符号。
<div contenteditable="true">
<div><span>末尾零宽字符 Line 1</span><span>​</span></div>
<div><span>末尾零宽字符 Line 2</span><span>​</span></div>
<div><span>末尾纯文本 Line 1</span></div>
<div><span>末尾纯文本 Line 2</span></div>
</div>
那么在这个零宽字符如果只是渲染效果的话,那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用,例如此时我们有3
行文本,如果此时从第1
行末尾选到第2
行时,并且按下Tab
键,那么此时这两行的内容就会缩进。
那么如果没有这个显示效果,此时进行缩进操作,用户可能认为仅仅是选中了第2
行,但是实际上是选中了1/2
两行文本。这样的话用户可能会以为是BUG
,而我们也实际接受过这个交互效果的反馈。
123|
4|x56
也对各个在线文档实现进行了简单调研: 基于contenteditable
实现的编辑器中,飞书文档、早期EtherPad
存在这个交互实现;自绘选区的编辑器中,钉钉文档存在这个实现;Canvas
引擎实现的编辑器中,腾讯文档、Google Doc
存在这个实现。
在渲染效果部分,零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时,此时这个行DOM
结构的内容就是空,这就导致此行的高度塌陷为0
,且无法放置光标。为了解决这个问题,我们可以选择在行内容中插入零宽字符,这样就可以撑起行内容且可以放置光标。当然使用<br>
来撑起行高也是可以的,使用这两种方案会各有优劣,且兼容性方面也有所不同。
<div data-line-node></div>
<div data-line-node><br></div>
<div data-line-node><span>​</span></div>
在类似于Notion
这种块结构的编辑器中,还有个比较重要的交互效果。即块级结构独立选择,例如我们可以直接将整个代码块独立选出来,而不是仅仅能选择其中的文本。这种效果在目前的开源编辑器很少有实现,都是需要自行以块结构重新组织设计选区。
通常来说,这个交互同样可以使用零宽字符来实现。因为我们的选区通常是需要放置在文本节点上的,因此我们很容易可以想到,可以在块结构所在行的末尾放置零宽字符,当选区在零宽字符上时就将整个块选中。这里用零宽字符而不是<br>
的好处是,零宽字符本身就是零宽,不会引起额外的换行。
<div>
<pre><code>
xxx
</code></pre>
<span data-zero-block>​</span>
</div>
在结构上,零宽字符还有个非常重要的实现。在编辑器内的contenteditable=false
节点会存在特殊的表现,在类似于inline-block
节点中,例如Mention
节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。
在下面的例子中,line-1
是无法将光标放置在@xxx
内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node
上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3
中我们就可以看到正确的光标放置效果。这里的0.1px
也是个为了兼容光标的放置的magic
,没有这个hack
的话,非同级节点光标同样无法放置在inline-block
节点后。
<div contenteditable style="outline: none">
<div data-line-node="1">
<span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
</div>
<div data-line-node="2">
<span data-leaf>​</span>
<span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
<span data-leaf>​</span>
</div>
<div data-line-node="3">
<span data-leaf>​<span contenteditable="false">@xxx</span>​</span>
</div>
</div>
除此之外,编辑器自然是需要跟字符打交道的,那么在js
表现出来的Unicode
编码实现中,emoji
就是最常见且容易出问题的表达。除了其单个长度为2
这种情况外,组合的emoji
也是使用独特的零宽连字符\u200d
来表示的。
"????".length
// 2
"????" + "\u200d" + "????"
// ????????
数据结构设计
编辑器数据结构的设计是影响面非常广的事情,无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等,还是平台应用层面上的diff
算法、查找替换、协同算法等,以及后端服务的数据转换、导出md/word/pdf
、数据存储等,都会涉及到编辑器的数据结构设计。
通常来说,基于JSON
嵌套的数据结构来表达编辑器Model
是很常见的,例如Slate
、ProseMirror
、Lexical
等等。以slate
编辑器为例,无论是数据结构还是选区的设计,都尽可能倾向于HTML
的设计,因此可以存在诸多层级节点的嵌套。
[
{
type: "paragraph",
children: [{ text: "editable" }],
},
{
type: "ul",
children: [
{
type: "li",
children: [{ text: "list" }],
},
],
},
];
通过线性的扁平结构来表达文档内容也是常见的实现方案,例如Quill
、EtherPad
、Google Doc
等等。以quill
编辑器为例,其内容上的数据结构表达不会存在嵌套,当然本质上还是JSON
结构,而选区则采用了更精简的表达。
[
{ insert: "editable\n" },
{ insert: "list\n", attributes: { list: "bullet" } },
];
当然还有很多特别的数据结构设计,例如vscode/monaco
的piece table
数据结构。代码编辑器又何尝不是一种富文本编辑器,毕竟其是可以支持代码高亮的功能的,只不过类似piece table
的结构我还没有太深入研究。
在这里我希望能够以线性的数据结构来表达整个富文本结构,虽然嵌套的结构能够更加直观地表达文档内容,但是对于内容的操作起来会更加复杂,特别是存在嵌套的内容时。以slate
为例,在0.50
之前的版本API
设计非常复杂,需要比较大的理解成本,虽然之后将其简化了不少:
// 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
计算处理,相当于将计算成本移交到了应用层。
// 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
时就可能会出现大量的数据嵌套。
[{
children: [{
children: [{
children: [{
children: [{
// ...
text: "content"
}]
}]
}]
}]
}]
再举个更加实用的例子,如果我们此时存在格式的嵌套内容。例如quote
与list
两种格式嵌套,如果此时我们文档的数据结构是嵌套结构,那么操作内容就会存在ul > quote
或者quote > ul
的两种情况,正常情况下我们必须要设计规则来做normalize
;而扁平结构下,属性全部写在attrs
内,不同操作造成的数据格式变更是完全幂等的。
// 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 数据类型
- ShareDB Rich-Text: Delta OT 数据类型
- ShareDB JSON0: JSON OT 数据类型
- ShareDB Slate: Slate OT 数据结构适配器
- YJS YText: Delta 数据类型实现
- YJS YMap/YArray: JSON 数据类型实现
- YJS Slate: Slate 数据结构适配器
因此,我希望能够以线性的数据结构来实现整个编辑器结构,这样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 的实现,仅仅需要3.54KB
的代码体积,此外还有 react-contenteditable 等实现。
我们也可以实现可以加粗的最小DEMO
,execCommand
命令可以在contenteditable
元素中选区内的元素执行,document.execCommand
方法接受三个参数,分别是命令名称、显示用户界面、命令参数。显示用户界面一般都是false
,Mozilla
没有实现,而命令参数则是可选的,例如超链接命令则需要传递具体链接地址。
<div>
<button id="$1">加粗</button>
<div style="border: 1px solid #eee; outline: none" contenteditable>
123123
</div>
</div>
<script>
$1.onclick = () => {
document.execCommand("bold");
};
</script>
当然,这个示例过于简单,我们还可以在选区变换的时候,来判断加粗按钮的加粗状态,以此来显示当前选区状态。不过我们需要对齐execCommand
的命令行为,前边也提到了可控性非常差,因此我们需要通过document.createTreeWalker
迭代所有的选区节点,以此来判断当前选区的状态。
其实这里还需要注意的是,execCommand
命令的行为在各个浏览器的表现是不一致的,这也是之前我们提到的浏览器兼容行为的一种,然而这些行为我们也没有任何办法去控制,这都是其默认的行为:
- 在空
contenteditable
编辑器的情况下,直接按下回车键,在Chrome
中的表现是会插入<div><br></div>
,而在FireFox(<60)
中的表现是会插入<br>
,IE
中的表现是会插入<p><br></p>
。 - 在有文本的编辑器中,如果在文本中间插入回车例如
123|123
,在Chrome
中的表现内容是123<div>123</div>
,而在FireFox
中的表现则是会将内容格式化为<div>123</div><div>123</div>
。 - 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如
123|123->123123
,在Chrome
中的表现内容会恢复原本的123123
,而在FireFox
中的表现则是会变为<div>123123</div>
。 - 在同时存在两行文本的时候,如果同时选中两行内容再执行
("formatBlock", false, "P")
命令,在Chrome
中的表现是会将两行内容包裹在同个<p>
中,而在FireFox
中的表现则是会将两行内容分别包裹<p>
标签。 - ...
此外还有类似于实现加粗的功能,我们无法控制是使用<b></b>
来实现加粗还是<strong></strong>
来实现加粗。还有浏览器的兼容性问题,例如在IE
浏览器中是使用<strong></strong>
来实现加粗,在Chrome
中是使用<b></b>
来实现加粗,IE
和Safari
不支持通过heading
命令来实现标题命令等等。且对于一些比较复杂的功能,例如图片、代码块等等,是无法很好实现的。
当然,默认的行为并不是完全没有用的,在某些情况下,我们可能要实现纯HTML
的编辑器。毕竟如果在基于MVC
模式的编辑器实现中,会处理掉对Model
来说无效的数据内容,这样就导致原本的HTML
内容丢失,因此在这种需求背景下依赖浏览器的默认行为可能是最有效的,这种情况下我们可能主要关注的就是XSS
的处理了。
ContentEditable
ContentEditable
是HTML5
中的一个属性,可以让元素变得可编辑,再配合上内置的execCommand
就是我们上边聊的最基本DEMO
。那么如果要实现最基本的文本编辑器,就只需要在地址栏中输入下面的内容:
data:text/html,<div contenteditable style="border: 1px solid black"></div>
那么通过document.execCommand
来执行命令修改HTML
的方案虽然简单,我们也聊过了其可控性很差。除了上述的execCommand
命令执行兼容性问题之外,还有很多DOM
上的需要兼容处理的行为,例如下面存在简单加粗格式的句子:
123**456**789
有许多方式可以表达这样的内容,编辑器可以认为显示效果是等价的,此时可能也需要对此类DOM
结构等同处理:
<span>123<b>456</b>789</span>
<span>123<strong>456</strong>789</span>
<span>123<span style="font-weight: bold;">456</span>789</span>
但是这里仅仅是视觉上相等,将其完整对应到Model
上时,自然会是件麻烦的事。除此之外,选区的表达同样也是复杂的问题,以下面的DOM
结构为例:
<span>123</span><b><em>456</em></b><span>789</span>
如果我们要表达选区折叠在4
这个字符左侧时,同样会出现多种表达可以实现这个位置,这实际上就会很依赖浏览器的默认行为:
{ node: 123, offset: 3 }
{ node: <em></em>, offset: 0 }
{ node: <b></b>, offset: 0 }
因此为了更强的扩展以及可控性,也解决数据与视图无法对应的问题,L1
的富文本编辑器使用了自定义数据模型的概念。即在DOM
树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML
也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的HTML
文档的一致性。对于选区的表达,则需要根据DOM
选区来不断normalize
选区Model
。
其实这也就是我们常见的MVC
模型,当执行命令时会修改当前的模型,进而表现到视图的渲染上。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的execCommand
对数据描述模型进行修改。在这个阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。我们也可以大概描述流程:
<script>
const editor = {
// Model 选区
selection: {},
execCommand: (command, value) => {
// 执行具体的命令, 例如 bold
// 命令执行后, 更新 Model 以及 调用 DOM 渲染
},
}
const model = [
// 数据模型
{ type: "bold", text: "123" },
{ type: "span", text: "123123" },
];
const render = () => {
// 根据 type 渲染具体 DOM
};
document.addEventListener("selectionchange", () => {
// 选区变换时
// 根据 dom 选区来更新 model 选区
});
</script>
而类似这种方案,无论是 quill 还是 slate 都是这样的调度。而类似于slate
的实现,通过适配器来连接React
之后,就需要更复杂的兼容处理。在React
节点中加入ContentEditable
后,会出现类似下面的warning
:
<div
contentEditable
suppressContentEditableWarning
></div>
// 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
编辑器造成一些困扰。
<div contenteditable="false">
<div contenteditable="true">123</div>
<div contenteditable="true">456</div>
</div>
那么其实我们是可以避免使用ContentEditable
的,设想一下即使我们没有实现编辑器,同样是可以选择页面上的文本内容的,就是我们普通的选区实现。那么如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。
但是这里存在一个很大的问题,就是内容的输入,因为不启用ContentEditable
的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒IME
输入法的话,浏览器给予的常规API
就是借助<input>
来完成,因此我们就必须要实现隐藏的<input>
来实现输入,实际上很多代码编辑器例如 CodeMirror 就是类似实现。
但是使用隐藏的<input>
就会出现其他问题,因为焦点在input
上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 Monaco Editor 同样是自绘选区,TextBus 则绘制了光标,选区则是借助了浏览器实现。
在这里可以总结一下,使用ContentEditable
需要处理很多DOM
的特异行为,但是明显我们是不需要太过于处理唤醒输入这个行为。而如果不使用ContentEditable
,却使用DOM
来呈现富文本内容,则必须要借助额外的隐藏input
节点来实现输入,由于焦点问题在这种情况下就不能使用浏览器的选区行为,因此就需要自绘选区的实现。
Canvas
基于Canvas
绘制我们需要的内容,颇有些文艺复兴的感觉,这种实现方式是完全不依赖DOM
的,因此可以完全控制排版引擎。那么文艺复兴指的是,基于DOM
兼容实现的任何生态都会失效,例如无障碍、SEO
、开发工具支持等等。
那么为什么要抛弃现有的DOM
生态,转而用Canvas
来绘制富文本内容。特别是富文本会是非常复杂的内容,因为除了文本外,还有图片的内容,以及很多结构话格式的内容,例如表格等。这些内容都需要自行实现,那么在Canvas
中实现这些内容其实相当于重新实现了部分skia
。
基于Canvas
绘制的编辑器,当前主要有腾讯文档、Google Doc
等,而开源的编辑器实现有 Canvas Editor。而除了文档编辑器之外,在线表格的实现基本都是Canvas
实现,例如腾讯文档Sheet
、飞书多维表格等,开源的实现有 LuckySheet。
在Google Doc
发布的Blog
中,对于使用Canvas
绘制文档主要选了两个原因:
- 文档的一致性: 这里的一致性指的是浏览器对于类似行为的兼容,举个例子: 在
Chrome
中双击某段文本的内容,选区会自动选择为整个单词,而在早期FireFox
中则是会自动选择一句话。类似这种行为的不一致会导致用户体验的不一致,而使用Canvas
绘制文档可以自行实现保证这种一致性。 - 高效的绘制性能: 通过
Canvas
绘制文档,可以更好地控制绘制时机,而不需要等待重绘回流,更不需要考虑DOM
本身复杂的兼容性考量,以此造成的性能损失。此外,Canvas
绘图替代繁重的DOM
操作,通过逐帧渲染和硬件加速可以提升渲染性能,从而提高用户操作的响应速度和整体使用体验。
此外,排版引擎还可以控制文档的排版效果,做富文本的各种需求,我们就可能面临产品为什么不能支持像office word
那样的效果。例如如果我们编写的文字正好排满了一行,假如在这里再加一个句号,那么前边的字就会挤一挤,从而可以使这个句号是不需要换行。而如果我们再敲一个字的话,这个字是会换行的。在浏览器的排版中是不会出现这个状态的,所以假如需要突破浏览器的排版限制,就需要自己实现排版能力。
<!-- word -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
<!-- 浏览器 -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本
。
也就是说,在word
中通常是不会出现句号在段落起始的,而在浏览器中是会存在这种情况的,特别是在纯ASCII
字符的情况下。如果想规避这种排版状态的差异,就必须要自行实现排版引擎,以此来控制文档的排版效果。
此外,还有一些其他的功能,例如受控的RTL
布局、分页、页码、页眉、脚注、字体字形控制等等。特别是分页的能力,在某些需要打印的情况下,这个效果是很必要的,但是DOM
的实现在绘制前是无法得知其高度的,因此也就无法很好地实现分页的效果。除此之外,还有大表格的分页渲染效果等等,都变得难以控制。
因此,这些如果希望对齐word
的实现,就必须要用Canvas
从头造一遍。除了这些额外的功能,还有原本的浏览器基于DOM
实现的基本功能,例如输入法的支持、复制粘贴的支持、拖拽的支持等等。而基本的Canvas
是无法支持这些功能的,特别是输入法IME
的支持,以及文本选区的实现,都需要很复杂的交互实现,这样的成本自然不会是很容易接受的。
总结
在本文我们聊了很多关于富文本编辑器基础能力的实现,特别是在DOM
结构表现和数据结构之间的设计。并且在浏览器交互的方案上,我们也聊了ExecCommand
、ContentEditable
、Canvas
实现方案的特点,简单总结了当前成熟产品以及开源编辑器,并且描述了相关实现的优缺点。
在后边我们会基于ContentEditable
实现基本的富文本编辑器,首先对于整体的架构设计,以及数据结构的操作做概述。然后开始分别实现具体的模块,例如输入模块、剪贴板模块、选区模块等等。实现编辑器从来都不是一件简单的事情,除了在核心层面的基础框架设计,应用层上也会有很多问题需要兼容处理,因此这将会是一份大工程,需要慢慢积累。
每日一题
- https://github.com/WindRunnerMax/EveryDay
参考
- https://github.com/w3c/editing
- https://zhuanlan.zhihu.com/p/226002936
- https://zhuanlan.zhihu.com/p/407713779
- https://zhuanlan.zhihu.com/p/425265438
- https://zhuanlan.zhihu.com/p/259387658
- https://zhuanlan.zhihu.com/p/145605161
- https://www.zhihu.com/question/38699645
- https://www.zhihu.com/question/404836496
- https://juejin.cn/post/6974609015602937870
- https://github.com/yoyoyohamapi/book-slate-editor-design
- https://github.com/grassator/canvas-text-editor-tutorial
- https://www.zhihu.com/question/459251463/answer/1890325108
- https://www.oschina.net/translate/why-contenteditable-is-terrible
- https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs.html
- https://cdacamar.github.io/data structures/algorithms/benchmarking/text editors/c++/editor-data-structures/