初探富文本之富文本概述
富文本编辑器通常指的是可以对文字、图片等进行编辑的产品,具有所见即所得的能力。对于Input
、Textarea
之类标签,他们是支持内容编辑的,但并不支持带格式的文本或者是图片的插入等功能,所以对于这类的需求就需要富文本编辑器来实现。现在的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、思维导图、附件、公式、格式刷等等比较复杂的功能。
描述
富文本编辑器实际上是一个水非常深的领域,其本身还是非常难以实现的,例如如何处理光标、如何处理选区等等,当然借助于浏览器的能力我们可以相对比较简单的实现类似的功能,但是由此就可能导致过于依赖浏览器而出现兼容性等问题。此外,当前有一个非常厉害的选手名为Word
,当产品提出需求的时候,如果是参考Word
来提的,那么这就是天坑了,Word
支持的能力太强大了,那么大的安装包也不是没有理由的,其内部做了巨量的富文本相关case
的处理。
当然在这里我们叙述的是在浏览器中实现的富文本,我们也不太可能在浏览器中凭借几百KB
或者几MB
来实现Word
这种几GB
安装包所提供的功能。虽然仅仅是在浏览器中实现富文本编辑的能力,但是这也并不是一件容易的事情。对于我们开发者而言,可能会更加喜欢使用Markdown
来完成相关文档的编写,当然这就不属于富文本编辑器的范畴了,因为Markdown
文件是纯文本的文件,关注点主要在渲染上,如果想在Markdown
中拓展语法甚至嵌入React
组件的话的话,可以参考markdown-it
与mdx
项目。
演进之路
Web
富文本编辑器也是在不断演进,在整个发展的过程中,也是遇到了不少困难,而正是因为这些问题,可以将发展历程分为L0
、L1
、L2
三个阶段的发展历程。当然在这里没有好不好,只有适合不适合,通常来说L1
的编辑器已经满足于绝大部分富文本编辑场景了,另外还有很多开箱即用的富文本编辑器可选择,具体的选型还是因需求而异。
类型 | 特点 | 产品 | 优势 | 劣势 |
| 1. 基于浏览器提供的 2. 使用浏览器的 | 早期轻量编辑器。 | 较短时间内快速完成开发。 | 可定制的空间非常有限。 |
| 1. 基于浏览器提供的 2. 数据驱动,自定义数据模型与命令的执行。 | 石墨文档、飞书文档。 | 满足绝大部分使用场景。 | 无法突破浏览器自身的排版效果。 |
| 1. 自主实现排版引擎。 2. 只依赖少量的浏览器 | | 完全由自己控制排版。 | 技术难度相当高。 |
L0
在前边是将当前的编辑器发展归为了L0
、L1
、L2
三个阶段,也有将L0
阶段再分两个阶段,总分为四个阶段的,不过由于这两阶段都是完全依赖于contenteditable
与document.execCommand
的实现,所以在这里就统归于L0
了。
下面是一个最最简单的本编辑器的实现,只要将下方的代码复制到浏览器的地址栏,就可以拥有一个简单的文本编辑器了。此时我们离富文本编辑器就差一个document.execCommand
的执行了,可以通过完成一个工具栏来执行命令,将选中文本的格式转换为另一种格式。
data:text/html, <div contenteditable="true"></div>
做过文本复制功能的同学应该比较熟悉document.execCommand("copy")
这个命令,这也是在navigator.clipboard
不可用时的一个降级方案。在 MDN 中列出了document.execCommand
支持的所有命令,可以看到其支持bold
、heading
等等参数,我们可以通过配合contenteditable
以及这些参数实现一个简单的富文本编辑器。
当然如果需要配合命令的执行,我们就不能这么简单地只使用一行代码来实现了,在这里以加粗为示例完成一个DEMO
。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L0</title>
</head>
<body>
<div contenteditable style="border: 1px solid #aaa;">测试文本加粗<br>选中文字后点击下边的加粗按钮即可加粗</div>
<button onclick="document.execCommand('bold')" style="margin-top: 10px;">加粗</button>
</body>
</html>
L1
通过document.execCommand
来执行命令修改HTML
的方案虽然简单,但是很明显他的可控性很差,例如实现加粗的功能,我们无法控制是使用<b></b>
来实现加粗还是<strong></strong>
来实现加粗,而且还有浏览器的兼容性问题,例如在IE
浏览器中是使用<strong></strong>
来实现加粗,在Chrome
中是使用<b></b>
来实现加粗,IE
和Safari
不支持通过heading
命令来实现标题命令等等。document.execCommand
只能实现一些相对比较简单的格式,对于一些比较复杂的功能,例如图片、代码块等等,document.execCommand
是无法实现的。
为了更强的拓展性,也解决数据与视图无法对应的问题,L1
的富文本编辑器使用了自定义数据模型的概念,就是在DOM
树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML
也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的HTML
文档的一致性。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的execCommand
对数据描述模型进行修改。类似于MVVM
模型,当执行命令时,会修改当前的模型,进行表现到视图的渲染上。
L1
阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。通过数据驱动,可以更好的满足定制功能、跨端解析、在线协作等需求。在这里基于slate
实现了一个L1
的富文本的DEMO
,Github | Editor DEMO。
L2
实际上L1
已经能够满足绝大部分的场景了,如果对排版和光标也有深度的订制,才有考虑使用L2
定制的必要,当然一般这种情况下我的建议是直接砍需求。关于排版和光标的定制化需求,举个例子,Word
使用的比较多的同学应该会注意到,如果我们编写的文字正好排满了一行,假如在这里再加一个句号,那么前边的字就会挤一挤,从而可以使这个句号是不需要换行,而如果我们再敲一个字的话,这个字是会换行的,在浏览器的排版中是不会出现这个状态的,所以假如需要突破浏览器的排版限制,就需要自己实现排版能力。。
如果有用过CodeMirror5
的用户可能会注意到,在默认配置下的CodeMirror
,除了排版的能力不是完全自行实现的,其他的方面都有自己的一套实现方案,例如光标是通过div
来模拟定位的、输入是通过一个跟随光标移动的Textarea
输入事件的监听来完成的,选区也是通过一层div
覆盖来完成的,滚动条也是自行模拟实现的。这就很有L2
的味道了,当然这还不能算是完全的L2
,毕竟还是借助了浏览器来帮我们排版文字,计算光标的位置也是借助了浏览器的Range
,但是这种几乎完全由自己来模拟的方案已经非常具有难度了。
完全实现L2
的能力不亚于自研一个浏览器了,因为需要支持浏览器的各种排版能力,复杂性是相当高的。此外,在这方面的性能损耗也是比较大的,在排版的时候需要自己实现一个排版引擎,在排版时需要不断的计算每个字符的位置,这个计算量是非常大的,如果性能不好的话,会导致排版的时候卡顿,这个体验是非常糟糕的。现在主流的L2
富文本编辑器都是借助于Canvas
来绘制所有的内容,而因为Canvas
只是一个画板,所以无论是排版还是选区、光标等等都需要自行计算与实现。由于计算量很大,所以有大量计算的部分通常都交予Web Worker
去处理,再由postMessage
来完成数据通信,用来提高性能。
正如游戏角色所突破的瓶颈期,富文本编辑器在L0
跃迁至L1
发生的改变是自定义数据模型的抽离,在L1
跃迁至L2
的改变则是自定义的排版引擎。
核心概念
这里的核心概念主要是指的L1
富文本编辑器中一些通用的概念,因为在L1
中的编辑器通常是自行维护了一套数据结构与渲染方案,所以一般都会有自己构建的一套模型体系,例如Quill
的Parchment
、Blot
、Delta
等等,Slate
的Transforms
、Normalizing
、DOM DATA MODEL
等等,但是只要是借助于浏览器以及contenteditable
的实现,便离不开一些基本概念。
Selection
Selection
对象表示用户选择的文本范围或插入符号的当前位置,其代表页面中的文本选区,可能横跨多个元素,由用户拖拽鼠标经过文字而产生。当 Selection
处于Collapsed
状态时,即是日常所说的光标,也就是说光标其实是Selection
的一种特殊状态。此外,注意其与focus
事件或document.activeElement
等值没有必然联系。
因为还是运行在浏览器中嘛,所以实现富文本编辑器还是需要依赖于这个选区的变化的,通常来说当选中的文本内容发生变动时,会触发SelectionChange
事件,通过这个事件的回调触发来完成一些事情。
window.getSelection();
// {
// anchorNode: text,
// anchorOffset: 0,
// baseNode: text,
// baseOffset: 0,
// extentNode: text,
// extentOffset: 3,
// focusNode: text,
// focusOffset: 3,
// isCollapsed: false,
// rangeCount: 1,
// type: "Range",
// }
在编辑器中通常不会直接使用这些选区来完成想要的操作,例如在Quill
中的选区是以起始位置配合长度来表示选区的,这也主要是配合其Delta
来描述文档模型而决定的,那么这样的话在Quill
中就完成了Selection
选区到Delta
选区的操作,以此来获取我们可以操作Delta
的选区。
quill.getSelection()
// {index: 0, length: 3}
在Slate
中借助了很多DOM
中的概念,例如Void Element
、Selection
等等,在Slate
中的选区也是经过处理的,同样也是因为其数据结构是类似于DOM MODEL
结构的JSON
数据类型,所以其Point
是由Path + Offset
来表示的,所以其选区则是由两个Point
来构成的。此外,对比于Quill
,Slate
保留了用户从左至右或者从右至左进行选区操作时的顺序,也就是说选择同样的区域,从左至右和从右至左的选区是不同的,具体而言就是anchor
和focus
是反过来的。
slate.selection
// {
// anchor: {
// offset: 0,
// path: [0, 0],
// },
// focus: {
// offset: 3, // 文本偏移量
// path: [0, 0], // 文本节点
// },
// };
Range
无论是基于contenteditable
还是超越contenteditable
的编辑器都会有Range
的概念。Range
翻译过来是范围、幅度的意思,与数学上的区间概念类似,也就是说Range
指的是一个内容范围。实际上浏览器中的Selection
就是由Range
来组成的,我们可以通过selection.getRangeAt
来取得当前选区的Range
对象。
具体的,浏览器提供的Range
用来描述DOM
树中的一段连续的范围,startContainer
、startOffset
用以描述Range
的起始处,endContainer
、endOffset
描述Range
的结尾处。当一个Range
的起始处和结尾处是同一个位置时,该Range
就处于Collapsed
状态,也就是我们常见的光标状态。其实Selection
就是表示Range
的一个方式,而且Selection
通常是只读的,但是构造的Range
对象是可以操作的。通过配合Selection
对象以及Range
对象我们可以完成选区的一些操作,例如增加或取消当前选区的选中。
const selection = document.getSelection();
const range = document.createRange();
range.setStart(node, 0); // 文本节点 偏移量
range.setEnd(node, 1); // 文本节点 偏移量
selection.removeAllRanges();
selection.addRange(range);
Copy & Paste
复制粘贴也是一个比较核心的概念,因为在当前的富文本编辑器中我们通常是维护了一套自定义程度非常高的DOM
结构,例如我们使用一级标题的时候可能不会去使用H1
标签,而是通过div
去模拟,以避免H1
的嵌套带来的问题,但是这样就会造成另外的问题。
首先对于复制来说,我们希望复制出来的text/html
节点是比较符合标准的,一级标题就应该用H1
来表示,由于数据结构是我们自己维护的,由我们自己的数据结构生成怎样的text/html
也应该由我们自己说了算,尤其是在L2
编辑器中,直接都没有DOM
结构,我们想完成复制行为那么就必须自行实现,而对于粘贴来说我们是更加关注的,因为当前的数据模型通常是我们自行维护的,所以我们从别的地方复制过来的富文本我们是需要解析成为我们能够使用的数据结构的,例如Quill
的Delta
模型,Slate
的JSON DOM
模型,所以对于复制粘贴行为我们也需要进行一个劫持,阻止默认行为的发生。
对于复制行为,我们可以在复制的时候取得当前选区的内容,然后将其进行序列化,拼接好HTML
字符串,之后如果可以使用navigator.clipboard
以及window.ClipboardItem
两个对象,就可以直接构造Blob
进行写入了,如果不支持的话也可以兜底,通过onCopy
事件的clipboardData
对象的setData
方法将其设置到剪贴板中,如果依旧无法完成的话,就直接写入text/plain
而不写入text/html
了,兜底策略还是很多的。
// slate example // serializing
const editor = {
children: [
{
type: 'paragraph',
children: [
{ text: 'An opening paragraph with a ' },
{
type: 'link',
url: 'https://example.com',
children: [{ text: 'link' }],
},
{ text: ' in it.' },
],
},
{
type: 'quote',
children: [{ text: 'A wise quote.' }],
},
{
type: 'paragraph',
children: [{ text: 'A closing paragraph!' }],
},
],
}
// ===>
// <p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p>
// <blockquote><p>A wise quote.</p></blockquote>
// <p>A closing paragraph!</p>
而对于粘贴行为,我们就需要通过监听onPaste
事件,通过event.clipboardData.getData("text/html")
来获取当前粘贴的text/html
字符串,当然如果没有的话就取得text/plain
就好了,都没有的话就相当于粘贴了个寂寞,如果有text/html
字符串的话,我们就可以利用DOMParser
来解析字符串了,然后再去构建我们自己需要的数据结构。
<!-- slate example --> <!-- deserializing -->
<p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p>
<blockquote><p>A wise quote.</p></blockquote>
<p>A closing paragraph!</p>
<!-- ===> -->
<!-- const fragment = [
{
type: 'paragraph',
children: [
{ text: 'An opening paragraph with a ' },
{
type: 'link',
url: 'https://example.com',
children: [{ text: 'link' }],
},
{ text: ' in it.' },
],
},
{
type: 'quote',
children: [
{
type: 'paragraph',
children: [{ text: 'A wise quote.' }],
},
],
},
{
type: 'paragraph',
children: [{ text: 'A closing paragraph!' }],
},
] -->
History
History
也就是Undo/Redo
操作,其操作的实现方式分为两类:记录数据和记录操作。
记录数据的操作类似于保存快照,当用户进行操作的时候,无论发生任何操作,都将整篇内容进行保存,并维护一个线性的栈。当进行Undo/Redo
操作的时候,将即将要恢复的栈中的内容完全呈现出来。这种操作类似于以空间换时间,我们不必考虑用户究竟改变了哪写数据,反正是变化的时候就会记录所有可能改变的部分,这种做法实现比较简单,但是如果数据量比较大的话,就比较耗费内存了。
记录操作保存的是操作,包括具体的操作动作以及操作改变的数据,同样也是维护一个线性的栈。当进行Undo/Redo
操作的时候,将保存的操作进行反向操作。这种方法类似于以时间换空间,每次只需要记录用户的操作类型以及相关的操作数据,而不需要将整篇内容进行存储,节省了空间,但是相对的,复杂程度提高了很多。由于我们现在对于富文本的操作实际上都是通过命令来实现的,也就是说我们完全可以将这些内容存储下来,维护一个保存操作记录的方式更加符合现在的设计,此外这部分设计好的话,对于实现Operation Transform
的协同算法也是很有帮助的。
每日一题
https://github.com/WindrunnerMax/EveryDay