深入浅出富文本编辑器
以下文章来源于ELab团队 ,作者ELab.huanglei.cc
编辑器介绍
常见的富文本编辑器现实方式可以分成两大类,分别是用 textarea 和 contenteditable 来实现。
textarea
结构简单使用方便,一些文本格式和复杂的样式难以实现,推荐仅在对编辑要求不高的场景使用。
contenteditable
将元素的 contenteditable 属性设为 true时,该元素则成为了编辑器的主体。配合 document.execCommand 能够实现绝大多数功能,主流编辑器是基于 contenteditable 来设计的。
但是单纯依赖 contenteditable 直接产出 html 会带来一些问题,例如相同的输入在不同浏览器下的输出可能不一致,相同的输出在不同浏览器中展示存在差异,并且这些问题在移动端会被放大,同时 html 使用具有局限性,不方便在跨平台间使用。
因此更好的方案是制定一套数据结构 + 文档模型,所有的输入都经过编辑器生成约定的产物,这样在不同的平台均可解析并且保证得到预期的效果。
还有一类是以 Google docs 为主的编辑器,不使用 contenteditable ,而是基于 canvas 渲染[1],通过监听用户输入,模拟编辑器的运行,此类编辑器实现成本极高且复杂。
本文以 quill[2] 为例,介绍如何实现一个支持跨平台渲染,且可以插入自定义模块的富文本编辑器。
基本概念
delta[3]
用于描述富文本内容或内容变换的数据结构,纯 json 格式,能够转化成 js 对象后方便操作,基本格式如下,由一组 op 组成。
op 是个 js 对象,可理解为对当前内容的一次变更,它主要有以下几个属性。
insert: 插入,后面 【3.2 数据结构】有介绍可能的值和对应的含义
retain: 值为 number 类型,保留相应长度的内容
delete: 值为 number 类型,删除相应长度的内容
上面三个属性必有且仅有一个出现在 op 对象中
attributes: 可选,值为对象,可描述格式化信息
如何理解内容或内容变换,举个🌰,下面这段数据表示了内容 “Grass the Green”,
{
ops: [
{ insert: 'Grass', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Green', attributes: { color: '#00ff00' } }
]
}
经过下面一次 delta 内容变换后新内容为 “Grass the blue”。
{
ops: [
// 接下来 5 个字符取消加粗并加上斜体格式
{ retain: 5, attributes: { bold: null, italic: true } },
// 维持 5 个字符不变
{ retain: 5 },
// 插入
{ insert: "Blue", attributes: { color: '#0000ff' },
// 删除后面 5 个字符
{ delete: 5 }
]
}
Delta 本质上是一系列操作记录,在渲染时可以看作记录了从空白到目标文档的一个过程,而 HTML 是一个树形结构,所以 Delta 的线性结构相比 HTML 在业务使用上有天生优势。
parchment[4]
一种文档模型,由 blots 组成,用来描述数据,可以拓展自定义的数据。
<p>
一段文字加视频的富文本内容。
<img src="xxx" alt="">
</p>
<p>
<strong>加粗文本结尾。</strong>
</p>
parchment 与 blot 关系类似于 DOM 与 element node,上面一段 html 内容使用 dom tree 和 parchment tree 描述分别如下图所示。
parchment 提供了几种基础 blot,同时支持开发中根据需求拓展定义自己的 blot,后面会演示如何开发一个自定义的 blot。
{
// 基础节点
ShadowBlot,
// 容器节点 => 基础节点
ContainerBlot,
// 格式化节点 => 容器节点
FormatBlot,
// 叶子节点
LeafBlot,
// 编辑器根节点 => 容器节点
ScrollBlot,
// 块级节点 => 格式化节点
BlockBlot,
// 内联节点 => 格式化节点
InlineBlot,
// 文本节点 => 叶子节点
TextBlot,
// 嵌入式节点 => 叶子节点
EmbedBlot,
}
最后用一张图了解下 quill 内部的工作流程,其中开发者需要关注的业务层逻辑十分简洁,可以通过手动输入和 api 方式变更编辑器内容,同时 editor-change 事件会输出当次操作和最新内容对应的 delta 数据。
实际应用
数据流
在业务中,基本数据流应该如下图所示,由编辑器生成 delta 数据,之后由相应平台的解析器渲染成对应的内容。
数据结构
良好的内容数据结构设计,在后续维护和跨平台渲染时起到关键作用,我们可以将富文本内容中依赖的媒体(图片、视频、自定义的格式)数据放到外层来,通过 id 关联,这样日后拓展和渲染时会比较方便。
interface ItemContent {
// 富文本数据,存储着 delta-string
text?: string;
// 视频
videoList?: Video[];
// 图片
imageList?: Image[];
// 自定义的模块,如投票、广告卡片、问卷卡片等等
customList?: Custom[];
}
其中编辑器输出的是标准 delta 数据, 结构如下所示,
// 纯文本, \n 代表换行
{
insert: string;
},
// 特殊类型的文本
{
insert: '超链接文本',
attributes: {
// 文字颜色
color: string,
// 加粗
bold: boolean,
// 超链接地址
link: string;
...,
}
},
// 有序无序列表
{
insert: '\n',
attributes: {
list: 'ordered' | 'bullet'
}
},
{
insert: {
uploading: {
// 资源类型
type: 'image' | 'video' | 'vote' | 'and more...'
// 资源 id
uid: string
},
},
},
// 图片
{
insert: { image: '${image_uri}' }
},
// 视频
{
insert: {
videoPoster: {
/** 视频封面地址 */ url: string;
/** 视频 id */ videoId: string;
}
}
},
// 投票
{
insert: {
vote: {
voteId: string
}
}
},
// 缩进,作用域内所有文本向右缩进 indent 个单位;
// 作用域:从当前为起始位置向前回溯,遇到以下任意一种情况结束
// 1、纯文本 \n
// 2、attributes的属性含有indent并且indent值小于等于当前值
{
insert: '\n',
attributes: {
indent: 1-8,
}
},
图片 / 视频混排
图片上传需要支持展示上传中的状态,并且不应该阻塞用户的编辑,所以需要先使用一个占位元素,待上传完成后将占位替换成真实图片或视频。
自定义 blot
自定义 blot 的好处是能够将整个的功能(例如图表功能)封装到一个 blot 中,这样业务开发时可直接使用,而不用管每个功能是怎么实现的。下面以图片视频上传态占位 blot 为例,演示如何自定义一个 blot。
import Quill from 'quill';
enum MediaType {
Image = 'image',
Video = 'video',
}
interface UploadingType {
type: MediaType;
// 唯一的 id,当图片或视频上传完成后,需要找到对应的 uid 进行替换
uid: string;
}
export const BlockEmbed = Quill.import('blots/block/embed');
class Uploading extends BlockEmbed {
static _value: Record<string, UploadingType> = {};
static create(value: UploadingType) {
const ELEMENT_SIZE = 60;
// blot 对应的 dom 节点
const node = super.create();
this._value[value.uid] = value;
node.contentEditable = false;
node.style.width = `${ELEMENT_SIZE}px`;
node.style.height = `${ELEMENT_SIZE}px`;
node.style.backgroundImage = `url(占位图地址)`;
node.style.backgroundSize = 'cover';
node.style.margin = '0 auto';
// 用来区分对应资源
node.setAttribute('data-uid', value.uid);
return node;
}
static value(v) {
return this._value[v.dataset?.uid];
}
}
Uploading.blotName = 'uploading';
Uploading.tagName = 'div';
export default Uploading;
将自定义 blot 注册到编辑器实例中,使用 quill 的 insertEmbed 来调用这个blot 即可。
// editor.tsx
Quill.register(VideoPosterBlot);
quill.insertEmbed(1, 'uploading', {
type: 'image',
uid: 'xxx',
});
处理粘贴操作
复制粘贴可以大幅提升编辑器效率,但是我们需要对剪切板中的视频和图片进行特殊处理,将剪切板中的内容转化成自定义的格式,并自动上传其中图片和视频。
基本原理
监听用户的粘贴操作,读取 paste event[5] 返回的 clipboardData[6] 数据,二次加工后再插入编辑器中。
target.addEventListener('paste', (event) => {
const clipboardData = (event.clipboardData || window.clipboardData)
const text = clipboardData.getData(
'text',
);
const html = clipboardData.getData(
'text/html',
);
/**
* 业务逻辑
*/
event.preventDefault();
});
clipboardData.items 是 DataTransferItem 的数组集合,它包含了本次粘贴操作的数据内容。
DataTransferItem 有两个属性分别是 kind和 type,其中 kind 值通常是 string 类型,如果是文件类型的数据那么值为 file;type 值是 MIME 类型,常见的是 text/plain 和 text/html。
处理图片
剪切板中的图片来源分为两大类,一是直接从文件系统中复制,这种情况我们
从文件系统中复制
从文件系统中复制粘贴后,能获取到 File 对象,那么直接插入编辑器中,即可复用前面的图片上传逻辑。
从网页复制
从上面右图不难看出,从网页中复制过来的内容中包含 text/html 富文本类型,由于图片可能是临时地址,直接使用三方图片地址不可靠,需要把 html 中图片地址提取出来,下载后再上传至我们自己的服务器中,图片上传模块还能继续复用上文的图片混排。
上文内容的 dom 树基础结构如图所示,可以经过后序遍历将所有节点处理成数组结构,当遇到节点为图片时则调用上面的图片混排逻辑。
convert({ html, text }, formats = {}) {
if (!html) {
return new Delta().insert(text || '');
}
// 返回 HTMLDocument 对象
const doc = new DOMParser().parseFromString(html, 'text/html');
const container = doc.body;
// key - node
// value - matcher: (node, delta, scroll) => newDelta
const nodeMatches = new WeakMap();
// 返回两个匹配器,分别处理 ELEMENT_NODE 和 TEXT_NODE ,将 dom 转化成 Delta
const [elementMatchers, textMatchers] = this.prepareMatching(
container,
nodeMatches,
);
return traverse(
this.quill.scroll,
container,
elementMatchers,
textMatchers,
nodeMatches,
);
}
function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
// 节点为叶子节点即文本
if (node.nodeType === node.TEXT_NODE) {
return textMatchers.reduce((delta, matcher) => {
return matcher(node, delta, scroll);
}, new Delta());
}
if (node.nodeType === node.ELEMENT_NODE) {
return Array.from(node.childNodes || []).reduce((delta, childNode) => {
let childrenDelta = traverse(
scroll,
childNode,
elementMatchers,
textMatchers,
nodeMatches,
);
if (childNode.nodeType === node.ELEMENT_NODE) {
childrenDelta = elementMatchers.reduce((reducedDelta, matcher) => {
return matcher(childNode, reducedDelta, scroll);
}, childrenDelta);
childrenDelta = (nodeMatches.get(childNode) || []).reduce(
(reducedDelta, matcher) => {
return matcher(childNode, reducedDelta, scroll);
},
childrenDelta,
);
}
return delta.concat(childrenDelta);
}, new Delta());
}
return new Delta();
}
上面例子中的数据可以转化成以下 delta 数据,视频的处理方法与图片类似,这里不再赘述。
{
ops: [
{
insert: '说起艾冬梅这个名字,现在的年轻人可能不是很熟悉,但是她曾经却是家喻户晓的人物,'
},
{
insert: '艾冬梅是我国著名的马拉松运动员' , attribute: {
bold: true
},
},
{
insert: '。她出生于1981年,是个来自东北的姑娘,和很多普通的八零后一样,她来自一个平凡的家庭,从小生活十分幸福,家境虽然不富裕,但艾冬梅依然是父母的掌上明珠。'
},
{
insert: {
image: {
url: 'xxx'
}
}
},
{
insert: '但是艾冬梅和其他人不同的是她从小就展现出了惊人的长跑天赋' , attribute: {
bold: true
},
},
{
insert: ' , 1993年当时艾冬梅还在念小学,她在一次跑步比赛中获得了一个十分优秀的成绩,在脚趾头受伤的情况下打破了当地的3000米项目记录,远远超过了参赛的所有人。这让很多人都十分震惊,于是艾冬梅顺利地被齐齐哈尔体校选中。'
}
]
}
解析数据
在 web 场景下可以使用 quill-delta-to-html[7] 这个库来做解析,如果是小程序,对于媒体元素(如:小程序中图片必须要指定宽高[8])支持相对不太友好,需要自己解析,下面简单介绍下如何渲染 delta 数据。
由于 delta 是一个线性结构,转化成 dom 时,需要构建一棵树,将块级元素的子元素关联到它的 children 中。
上图中的原数据经过第一轮处理
纯文本反规范化,将 abc\ndef\ng 格式转化成 [abc, \n, def, \n, g]
将块级元素的元信息,写入第一个 op 中
块级元素的元信息包括:缩进,有序列表序号,【当前元素所在块级元素】在原数据中的起始与终止索引,【当前元素所在块级元素】在 dom 列表中的索引
经过上面转化后原数据变成上图中的格式,每个 op 都含有相应的元数据,接下要做的就是解析这些 op,将其转化成 Element。
对于自定义 blot 的渲染,我们可以封装成组件(react 或 vue 组件,取决你使用什么框架),这样业务功能和编辑器开发可解耦,不了解编辑器代码的同学也能够参与开发。
小结
至此,我们已经了解开发编辑器的基本流程和需要重点关注的一些事项。如果业务中需要拓展一些功能卡片,如飞书文档的各种应用,可通过拓展 blot + 编写对应的组件来实现。此外还能够通过编写相应平台的解析器在非 web 场景的展示,轻松实现内容跨平台渲染。
参考资料
[1]
基于 canvas 渲染: https://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html
[2]
quill: https://quilljs.com/docs/quickstart/
[3]
delta: https://quilljs.com/docs/delta/
[4]
parchment: https://github.com/quilljs/parchment
[5]
paste event: https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event
[6]
clipboardData: https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/clipboardData
[7]
quill-delta-to-html: https://www.npmjs.com/package/quill-delta-to-html
[8]
指定宽高: https://developers.weixin.qq.com/miniprogram/dev/component/image.html
作者:ELab.huanglei.cc
欢迎关注微信公众号 :前端民工