首页 > 其他分享 >初探富文本之富文本概述

初探富文本之富文本概述

时间:2022-11-26 12:35:24浏览次数:73  
标签:文本编辑 选区 浏览器 text 之富 Range 初探 文本

初探富文本之富文本概述

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

描述

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

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

演进之路

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

类型

特点

产品

优势

劣势

​L0​

1. 基于浏览器提供的​​contenteditable​​实现富文本编辑。

2. 使用浏览器的​​document.execCommand​​执行命令操作。

早期轻量编辑器。

较短时间内快速完成开发。

可定制的空间非常有限。

​L1​

1. 基于浏览器提供的​​contenteditable​​实现富文本编辑。

2. 数据驱动,自定义数据模型与命令的执行。

石墨文档、飞书文档。

满足绝大部分使用场景。

无法突破浏览器自身的排版效果。

​L2​

1. 自主实现排版引擎。

2. 只依赖少量的浏览器​​API​​。

​Google Docs​​、腾讯文档。

完全由自己控制排版。

技术难度相当高。

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



标签:文本编辑,选区,浏览器,text,之富,Range,初探,文本
From: https://blog.51cto.com/u_15659138/5888873

相关文章