初探富文本之富文本概述

581 阅读15分钟

初探富文本之富文本概述

富文本编辑器通常指的是可以对文字、图片等进行编辑的产品,具有所见即所得的能力。对于InputTextarea之类标签,他们是支持内容编辑的,但并不支持带格式的文本或者是图片的插入等功能,所以对于这类的需求就需要富文本编辑器来实现。现在的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、思维导图、附件、公式、格式刷等等比较复杂的功能。

描述

富文本编辑器实际上是一个水非常深的领域,其本身还是非常难以实现的,例如如何处理光标、如何处理选区等等,当然借助于浏览器的能力我们可以相对比较简单的实现类似的功能,但是由此就可能导致过于依赖浏览器而出现兼容性等问题。此外,当前有一个非常厉害的选手名为Word,当产品提出需求的时候,如果是参考Word来提的,那么这就是天坑了,Word支持的能力太强大了,那么大的安装包也不是没有理由的,其内部做了巨量的富文本相关case的处理。

当然在这里我们叙述的是在浏览器中实现的富文本,我们也不太可能在浏览器中凭借几百KB或者几MB来实现Word这种几GB安装包所提供的功能。虽然仅仅是在浏览器中实现富文本编辑的能力,但是这也并不是一件容易的事情。对于我们开发者而言,可能会更加喜欢使用Markdown来完成相关文档的编写,当然这就不属于富文本编辑器的范畴了,因为Markdown文件是纯文本的文件,关注点主要在渲染上,如果想在Markdown中拓展语法甚至嵌入React组件的话的话,可以参考markdown-itmdx项目。

演进之路

Web富文本编辑器也是在不断演进,在整个发展的过程中,也是遇到了不少困难,而正是因为这些问题,可以将发展历程分为L0L1L2三个阶段的发展历程。当然在这里没有好不好,只有适合不适合,通常来说L1的编辑器已经满足于绝大部分富文本编辑场景了,另外还有很多开箱即用的富文本编辑器可选择,具体的选型还是因需求而异。

类型特点产品优势劣势
L01. 基于浏览器提供的contenteditable实现富文本编辑。 2. 使用浏览器的document.execCommand执行命令操作。早期轻量编辑器。较短时间内快速完成开发。可定制的空间非常有限。
L11. 基于浏览器提供的contenteditable实现富文本编辑。 2. 数据驱动,自定义数据模型与命令的执行。石墨文档、飞书文档。满足绝大部分使用场景。无法突破浏览器自身的排版效果。
L21. 自主实现排版引擎。 2. 只依赖少量的浏览器APIGoogle Docs、腾讯文档。完全由自己控制排版。技术难度相当高。

L0

在前边是将当前的编辑器发展归为了L0L1L2三个阶段,也有将L0阶段再分两个阶段,总分为四个阶段的,不过由于这两阶段都是完全依赖于contenteditabledocument.execCommand的实现,所以在这里就统归于L0了。

在富文本编辑器出现之前,浏览器就已经具备了展示富文本的能力,通过编排HTMLCSS就可以展示富文本的内容,但对于用户输入,浏览器所提供的<textarea /><input />都只允许用户输入纯文本的内容,能力十分单薄。到后来,contenteditable的出现为节点赋予了编辑其HTML结构的能力,让我们可以直接在富文本的区域编辑而不需要借助于<textarea />或者是<input />来输入。下面是一个最最简单的本编辑器的实现,只要将下方的代码复制到浏览器的地址栏,就可以拥有一个简单的文本编辑器了。此时我们离富文本编辑器就差一个document.execCommand的执行了,这部分的主要工作是类似于通过完成一个工具栏来执行命令,将选中文本的格式转换为另一种格式。

data:text/html, <div contenteditable="true"></div>

做过文本复制功能的同学应该比较熟悉document.execCommand("copy")这个命令,这也是在navigator.clipboard不可用时的一个降级方案。在 MDN 中列出了document.execCommand支持的所有命令,可以看到其支持boldheading等等参数,我们可以通过配合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>来实现加粗,IESafari不支持通过heading命令来实现标题命令等等。document.execCommand只能实现一些相对比较简单的格式,对于一些比较复杂的功能,例如图片、代码块等等,document.execCommand是无法实现的。

为了更强的拓展性,也解决数据与视图无法对应的问题,L1的富文本编辑器使用了自定义数据模型的概念,就是在DOM树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的HTML文档的一致性。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的execCommand对数据描述模型进行修改。类似于MVVM模型,当执行命令时,会修改当前的模型,进行表现到视图的渲染上。

L1阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。通过数据驱动,可以更好的满足定制功能、跨端解析、在线协作等需求。在这里基于slate实现了一个L1的富文本的DEMOGithubEditor 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中的编辑器通常是自行维护了一套数据结构与渲染方案,所以一般都会有自己构建的一套模型体系,例如QuillParchmentBlotDelta等等,SlateTransformsNormalizingDOM 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 ElementSelection等等,在Slate中的选区也是经过处理的,同样也是因为其数据结构是类似于DOM MODEL结构的JSON数据类型,所以其Point是由Path + Offset来表示的,所以其选区则是由两个Point来构成的。此外,对比于QuillSlate保留了用户从左至右或者从右至左进行选区操作时的顺序,也就是说选择同样的区域,从左至右和从右至左的选区是不同的,具体而言就是anchorfocus是反过来的。

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树中的一段连续的范围,startContainerstartOffset用以描述Range的起始处,endContainerendOffset描述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结构,我们想完成复制行为那么就必须自行实现,而对于粘贴来说我们是更加关注的,因为当前的数据模型通常是我们自行维护的,所以我们从别的地方复制过来的富文本我们是需要解析成为我们能够使用的数据结构的,例如QuillDelta模型,SlateJSON 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

参考

https://zhuanlan.zhihu.com/p/90931631
https://www.zhihu.com/question/38699645
https://www.zhihu.com/question/404836496
https://juejin.cn/post/7114547099739357214
https://juejin.cn/post/6844903555900375048
https://juejin.cn/post/6955335319566680077
https://segmentfault.com/a/1190000040289187
https://segmentfault.com/a/1190000041457245
https://codechina.gitcode.host/programmer/application-architecture/7-youdao-note.html