3

富文本编辑器 Quill.js 系列一:Delta 文档结构

 1 year ago
source link: https://webfe.kujiale.com/fu-wen-ben-bian-ji-qi-quill-js-xi-lie-yi-delta-wen-dang-jie-gou/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

15 November 2022 / 富文本

富文本编辑器 Quill.js 系列一:Delta 文档结构

近期在整理富文本 Quill.js 相关的资料,并应用到项目上。由于富文本可学习与应用的场景蛮多的,并且有一定的复杂度,故会整理相对比较系统的系列文章,来让大家能快速上手,并了解原理。

Delta 是用于描述富文本文档结构的内容与变更。由于其描述的通用性,quill.js 将其独立维护。它的数据结构是基于 JSON 格式的,方便服务间进行互解析,例如 一份描述富文本格式的 数据,可很方便的渲染于 Web 与 Android or iOS。相比于复杂和带有歧义的 HTML,其更简单纯粹。

一个 Delta 实例用 Array 来描述变更,这些操作包含 insert delete retain,通过这些操作,来达到增删改的目的。注意 Delta 本身是不包含当前操作 Index 的,所有操作都是从头开始,这让整体逻辑更加纯净。跳过或保持一些内容通过 retain 来操作即可。

我们通过官方的示例,来表达下 Delta 的数据操作。

// Document with text "Gandalf the Grey"
// with "Gandalf" bolded, and "Grey" in grey
const delta = new Delta([
  { insert: 'Gandalf', attributes: { bold: true } },
  { insert: ' the ' },
  { insert: 'Grey', attributes: { color: '#ccc' } }
]);

// Change intended to be applied to above:
// Keep the first 12 characters, insert a white 'White'
// and delete the next four characters ('Grey')
const death = new Delta().retain(12)
                         .insert('White', { color: '#fff' })
                         .delete(4);
// {
//   ops: [
//     { retain: 12 },
//     { insert: 'White', attributes: { color: '#fff' } },
//     { delete: 4 }
//   ]
// }

// Applying the above:
const restored = delta.compose(death);
// {
//   ops: [
//     { insert: 'Gandalf', attributes: { bold: true } },
//     { insert: ' the ' },
//     { insert: 'White', attributes: { color: '#fff' } }
//   ]
// }

我们构造了文档的信息:delta,又构造了对此结构的一些操作,新增和 删除,最终通过 compose 函数对 此操作进行应用。

基于此示例,我们了解到了 Delta 的基本操作,更多 API 参见:Delta README 。本篇文章不会介绍具体 API 的用法,后续咱们一起来看下 Delta 的设计思想,并深入了解下文档描述对象的 diff, compose 等操作算法是如何实现的。

Delta 中数据插入类型分为 纯文本 与 embed 嵌入式内容。通过 attributes 区分不同的表现形式,应用场景例如我们常见的 标题、加粗、斜体、列表等。

var delta = {
  ops: [{
    insert: 'Hello'
  }, {
    insert: 'World',
    attributes: { bold: true }
  }, {
    insert: {
    image: 'https://exclamation.com/mark.png'
    },
    attributes: { width: '100' }
  }]
};

扁平化数据

Delta 中数据都是扁平的,不存在子节点一说。那他如何表达 文档 dom 树结构的?

关键在于 Quill 假定富文本不存在块元素的嵌套,即一行中不能同时存在标题和列表。遇到换行符则新建一行作为块级别 tag open,直到遇到下一个换行符,作为 tag close。

我们必须约定 Delta 中的数据格式标识形式,否则同一种数据可能存在不同表示,例如表示 “Hello Word”的不紧凑形式:

var ops = [
  { insert: 'Hel' },
  { insert: 'lo ' },
  { insert: 'World', attributes: { bold: true } }
];

为了解决这个问题,Delta 约定,数据格式必须是紧凑的,同一个富文本仅有一种数据表示形式。它的目的也很明确,就是方便我们以编程的形式,简单的比较 2 个 Delta 实例是否相等,也让应用程序更易于理解和维护。

Delta 中 ops 属性用于储存数据的最终表现,以数组形式表示,这个上文中也有提现。但是直接使用数组中的方法肯定是不行的,它需要处理富文本操作的一些场景,核心主要是这些:

  • 连续的 insert/retain/delete 需要判断做合并操作,对于 insert/retain 则是 attrs 相同且 insert 都为 string 即可合并,例如 new Delta().insert('A').insert('B'),需要合并成 insert: 'AB';
  • delete 之后的 insert/retain,需要做顺序调整,delete 永远在最后;
  • 其他场景则直接 push ops 即可。

这主要是为了 ops 数据格式的一致性,为了之后的各种操作运算做逻辑统一化处理。

Slice

截取 ops 中的某部分内容,这是比较有意思的地方,因为 start, end 是基于富文本整体的开始结束,而不是 ops 数组的下标索引。拿以下格式举例:

const ops = [
  {
    insert: 'Hello'
  },
  {
    insert: 'World',
    attributes: { bold: true }
  }
]

整体 delta length 为 10,假设我们要取 delta.slice(2,6),结果为:

const ops = [
  {
    insert: 'llo'
  },
  {
    insert: 'Wo',
    attributes: { bold: true }
  }
]

重点是需要对 ops 进行迭代遍历,支持截断遍历,名词解释:

  • index: ops 数组下标;
  • offset: 单个 op 操作获取的偏移量,例如单个 op insert: 'abcd',offset 则表示此 op string 的开始索引;
  • pickLength:所需长度

OpIterator 算法逻辑为:

  1. 对比 pickLength 与当前 index 对应 ops 的大小,若大,则进入步骤 2,否则进入步骤 3;
  2. index++,直接拿去当前 op 信息,并返回;
  3. 取 [offset, picLength] 之前长度 op 的数据返回;

基于上述 Iterator 可以很方便的进行各种截断操作。

Diff 与 Compose

对比文档 a 与文档 b 的不同 diff ,有以下等式:

a.compose(diff) == b

a.diff(b) == diff

Diff 用来表示 a 变换到 b需要做的 delta 操作,以单测举例 Diff:

const a = new Delta().insert('A');
const b = new Delta().insert('AB');
const expected = new Delta().retain(1).insert('B');
expect(a.diff(b)).toEqual(expected);

compose 中有几条原则,决定计算规则:

  1. insert/delete/retail,类型相同时选择性直接合并;
  2. insert 优先于 delete/retail;
  3. delete、retain 同时存在时,按先后顺序即可,对于 retain -> delete 场景,可直接忽略 retain 操作;

以上 3 条,决定了 9 种排列组合方式。

diff 是基于 fast-diff (Node.js 纯文本 diff 算法实现,暂不展开),将 ops 结构转换成纯文本进行对比,只关注 insert string 的部分,其他一律使用占位符替代。更多参考:https://www.npmjs.com/package/fast-diff

这些 api 在 Quill 中的其中一个重要应用场景是历史记录,例如 v1 -> v2,想要做撤回操作时,只需要做如下:

v2.compose(v2.diff(v1)),即可回退到 v1 版本,所以 dom 操作也只关注变动的内容,所以效率非常高。

回顾一下,我们本篇文章开始初步了解了富文本编辑器 Quill.js 的核心组成 Delta,其功能是表达富文本的数据格式与数组操作。后深入了解了下设计思想,了解其约定规范,同样的文章内容,仅有一种数据格式的表现形式,这样做会让代码实现更简单与利于维护。最后我们通过几个核心的 Api,介绍了其中的算法实现,了解算法过程。

本篇文章为 Quill.js 系列文章的首篇内容,后续文章:《富文本编辑器 Quill.js 系列二:Parchment 文档模型》,敬请期待!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK