引言
Quill 是一个基于 DOM 方案的富文本编辑器,在页面上使用 DIV + contenteditable=true
元素作为编辑器的容器,文章内容则是一个个 DOM 元素。
比如内容是这样:
<div class="ql-editor">
<h1>标题</h1>
<p>
第一段开头
<span style="color: red;">加点带样式的文字</span>
第一段结尾
</p>
<p>第二段</p>
</div>
数据模型
对于编辑器来说,需要有一种数据模型,能够做到和这些元素(结构和样式)互相转换:
在打开时,将数据转换为 HTML 进行渲染;在关闭时,将 HTML 转换为数据保存下来。
原封不动地把整个容器的 outerHTML
保存下来似乎也满足要求,但这样存在很多问题,比如存储效率比较低,一些复杂元素的 DOM 中可能包含了很多冗余内容并不需要被保存,另一方面也存在安全问题。
你可能开始考虑一些更常见的数据模型,比如 Markdown 格式的纯文本,或者是 Office 的 OOXML。
前者语法过于简洁,它从设计之初就专注于写作内容,根本没打算把文本样式这类信息保存下来,不够「富文本」;后者则过于复杂,凡人难以参透,除非打算做出一个像 Only Office 那样在 Web 上完整还原 Word 能力的编辑器,则没必要轻易尝试。
Delta 核心设计
Quill 则自己设计了一种数据模型 Delta,它是一个很简单但又足够强大的数据模型」。
强大体现在:它既支持描述文档,又支持描述文档的增量。
详细来说,是一篇文档可以与一个 delta 数据互相转换,当文档从 v1 版本编辑到了 v2 版本,这中间产生的 diff 也可以与一个 delta 互相转换。
另外,它使用了 OT 算法进行设计,天然支持协同编辑能力,这部分我不太了解,暂不讨论。
它的简单则更好说明了,把 quill-delta
源码的核心部分只有下面两个定义:
// quill-delta/src/Op.ts
interface Op {
// only one property out of {insert, delete, retain} will be present
insert?: string | Record<string, unknown>;
delete?: number;
retain?: number | Record<string, unknown>;
attributes?: AttributeMap;
}
// quill-delta/src/Delta.ts
class Delta {
ops: Op[];
}
从定义上看,一个 delta 实例(类型为 Delta)上有一个 ops 属性,它是一个 Op 类型的数组。
而 Op 则只有三种类型:insert、delete、retain,三选一,并且可以附带 attributes 属性。
只需要搞懂这三种类型的 Op 分别是什么含义,就基本上理解 Delta 的设计了,下面是一个例子:
// before
今天是星期五
// after
今天天气很好
当文档从「今天是星期五」变化为「今天天气很好」时,一种可行的修改方式为:
- 从 index 为 2 处开始往后删除 4 个字符:
retain(2).delete(4)
- 插入「天气很好」:
insert('天气很好')
对应的 delta 为:
[
{
retain: 2
},
{
delete: 4
},
{
insert: "天气很好"
}
];
我最开始接触 delta 结构时,为了方便理解,会想象有一个「虚拟光标」的存在,这样比较好理解 delta 的操作过程:
- 虚拟光标总是从下标 0 开始。
retain(x)
:把虚拟光标 往后 移动了 x 个字符。delete(x)
:从虚拟光标处 往后 删除 x 个字符,虚拟光标的下标没有发生变化。insert(str)
:从虚拟光标处插入一个 str 字符串,同时虚拟光标也移动到了 str 的末尾。
在此基础上,把 attributes
属性加上:
比如 retain(2, {bold: true})
表示从虚拟光标处往后移动 2 个字符,同时将这 2 个字符的 bold 属性设置为 true。对应场景是,框选了这两个字,设置了加粗样式。
同理 insert('天气', {color: 'red'})
表示在虚拟光标处插入「天气」两个字,并且这两个字带有 color 属性,值为 red。对应场景是,先设置了红色字体,然后键盘输入了两个字。
上面的例子都是描述「文档增量」的,如果要描述「文档」本身,则更简单,它就是从空白文档变成一个完整文档的增量。
Delta 从设计上并不区分到底是「文档」还是「文档的增量」,它们本质上都是一样的,「文档」实际就是从「空」变成「文档」的增量。
而 Quill 编辑器则会在不同的场景下使用不同的含义,比如:
// 获取的是当前「文档」
quill.getContents();
// 更新文档,传入的是「文档的增量」
quill.updateContents(diff);
// 设置文档,传入的是「文档」
quill.setContents(delta);
上面的例子都是对字符串的处理,而实际上 Delta 中的 retain、insert 操作都支持自定义复杂的数据结构,比如:
[
{
insert: {
image: {
src: "link",
alt: "图片描述",
...
},
attributes: {
width: 100,
}
}
}
]
这个例子表示在文档中插入了一个图片,包含了图片地址、描述等任意可扩展字段,并且可以附带与样式相关的属性。
Quill 编辑器内置图片格式中的结构是一个图片链接字符串,在我看来这是个失败的设计,这导致了其无法扩展更多属性。
Delta 的 API
了解了基本含义之后,就可以进一步深入数据转换的逻辑了。
Delta 提供了足够多内置的 API 来实现不同 delta 之间的数据处理:
1、创建 delta
const delta1 = new Delta()
: 它可以表示一个「空白文档」,也可以表示「没有任何改动的增量」
const delta2 = new Delta(delta1)
: 基于已有的 delta 创建一个含义相同,但是独立的 delta 实例
2、链式调用
delta 结构支持类似 Promise 的链式调用逻辑,每次调用都会返回一个新的 delta 实例,并且保持原有 delta 不变。
const delta = new Delta().retain(2).delete(4).insert("test");
3、数组的操作
Delta 的定义中 ops 是一个数组,因此它也提供了一些数组方法:
- push
- concat
- map
- forEach
- reduce
- filter
这些方法本质上就是操作 ops 数组,返回一个新的 delta 实例,与数组对应方法的含义完全一致。
- slice
slice 略有不同,它并不等同于 ops 数组的 slice 方法,而是有一套 delta 自己的切片逻辑。
每个 Op 操作都有一个 length 值,代表它影响的字符数。
retain(x)
: length 为 xdelete(x)
: length 为 xinsert(str)
: length 为str.length
对一个 delta 实例调用 slice(start, end)
方法,它返回的是 length 从 start 到 end 之间的操作,而不是 ops[start] 到 ops[end-1] 这些操作。
4、diff
delta1.diff(delta2)
方法,它会返回一个表示从 delta1 到 delta2 的增量。
5、compose、transform、invert
这三个方法用于处理多个 delta 之间的复杂关系,比如合并、转换、反转等。
通常是用于处理历史记录、协同编辑的场景,比如把多次操作合并为一次历史记录用 compose,把一次操作应用但不记录可以用 transfrom 对历史栈中所有数据进行一次转换,在处理撤销回退的逻辑时经常会使用 invert 方法把操作反转。
具体细节参考本系列后续关于 History 模块的介绍。
存在的问题
目前的 Delta 存在一个问题,就是它的设计不支持嵌套结构。
比如类似飞书文档、Notion 等编辑器,它们有一种块级结构,在块内是可以嵌套块的,Delta 却不支持在内部嵌套 Delta,这就会导致一些复杂场景难以实现。
比如 Quill v2.0.0 版本中新增的 tableEmbed 模块,就是为了实现这种嵌套结构,不得不重写了 Delta 结构的 compose/transform/invert 方法。