Designing the Delta Format

富文本编辑器缺乏表达自己内容的规范。直到最近,大多数富文本编辑甚至都不知道他们自己的编辑区域是什么。这些编辑器只传递用户HTML,以及解析和解释它的负担。在任何给定时间,此解释将与主要浏览器供应商的解释不同,从而为用户带来不同的编辑体验。

Quill是第一个真正理解自己内容的富文本编辑器。关键是Deltas,这是描述富文本的规范。 Deltas旨在易于理解和使用。我们将介绍Deltas背后的一些想法,以阐明为什么事情就像他们一样。

如果您正在寻找关于Deltas的参考,Delta文档是一个更简洁的资源。

Plain Text

让我们从纯文本的基础开始。 已存在无处不在的格式来存储纯文本:字符串。现在,如果我们想要在此基础上构建并描述格式化文本,例如当范围为粗体时,我们需要添加其他信息。

数组是唯一可用的其他有序数据类型,因此我们使用对象数组。这也使我们能够利用JSON与各种工具兼容。

  1. var content = [
  2. { text: 'Hello' },
  3. { text: 'World', bold: true }
  4. ];

如果我们愿意,我们可以将斜体,下划线和其他格式添加到主对象; 但是将他们与text分开是更清晰的,因此我们在一个字段下组织格式,我们将其命名为attributes

  1. var content = [
  2. { text: 'Hello' },
  3. { text: 'World', attributes: { bold: true } }
  4. ];

Compact(紧凑的)

由于Delta格式太过于简单,上面的“Hello World”示例可以有不同的方式表示(就像下面的代码表现的一样),因此无法预测将生成哪个:

  1. var content = [
  2. { text: 'Hel' },
  3. { text: 'lo' },
  4. { text: 'World', attributes: { bold: true } }
  5. ];

为了解决这个问题,我们添加了Deltas必须紧凑的约束。有了这个约束,上面的表示不是有效的Delta,因为它可以通过前面的例子更紧凑地表示,其中“Hel”和“lo”不是分开的。同样,我们不能有{bold:false,italic:true,underline:null},因为{italic:true}更紧凑。

Canonical(标准)

我们没有为bold指定任何含义,只是它描述了一些文本格式。我们很可能使用了不同的名称,例如weightedstrong,或使用了不同范围的可能值,例如数字或描述性权重范围。可以在CSS中找到一个例子,其中大多数歧义都在发挥作用。如果我们在页面上看到粗体文本,我们无法预测其规则集是否为font-weight:boldfont-weight:700。这使得解析CSS的任务能够辨别其含义,这要复杂得多。

我们没有定义可能属性的集合,也没有定义它们的含义,但我们确实添加了一个额外的约束,Deltas必须是规范的。如果两个Deltas相等,则它们表示的内容必须相等,并且不能有两个表示相同内容的不等Deltas。以编程方式,这允许您简单地深入比较两个Deltas以确定它们所代表的内容是否相等。

因此,如果我们有以下内容,我们可以得出的唯一结论是ab不同,但不是不知道ab的意思。

  1. var content = [{
  2. text: "Mystery",
  3. attributes: {
  4. a: true,
  5. b: true
  6. }
  7. }];

实施者可以选择合适的名称:

  1. var content = [{
  2. text: "Mystery",
  3. attributes: {
  4. italic: true,
  5. bold: true
  6. }
  7. }];

此规范化适用于键和值,文本和属性。 例如,Quill默认情况下:

  • 使用六个字符的十六进制值来表示颜色而不是RGB

  • 只有一种方法可以表示换行符\n,而不是\r\r\n

  • text:“Hello World”明确表示“Hello”和“World”之间恰好有两个空格

其中一些选择可以由用户自定义,但Deltas中的规范约束规定选择必须是唯一的。

这种明确的可预测性使得Deltas更容易使用,因为您需要处理的案例更少,并且因为相应的Delta看起来没有意外。 从长远来看,这使得使用Deltas的应用程序更易于理解和维护。

Line Formatting

行格式会影响整行的内容,因此它们对我们的紧凑和规范约束提出了有趣的挑战。表示居中文本的一种看似合理的方法如下:

  1. var content = [
  2. { text: "Hello", attributes: { align: "center" } },
  3. { text: "\nWorld" }
  4. ];

但是如果用户删除换行符怎么办?如果我们很天真地去掉换行符,Delta就会变成这样:

  1. var content = [
  2. { text: "Hello", attributes: { align: "center" } },
  3. { text: "World" }
  4. ];

这行还居中吗?如果答案是否定的,那么我们的表示就不是紧凑的,因为我们不需要属性对象,并且可以组合两个字符串:

  1. var content = [
  2. { text: "HelloWorld" }
  3. ];

但如果答案是肯定的,那么我们违反了规范约束,因为具有align属性的字符的任何排列都代表相同的内容。

所以我们不能天真地去掉换行符。我们还必须删除行属性,或者扩展它们以填充行上的所有字符。

如果我们从下面删除换行符会怎么样

  1. var content = [
  2. { text: "Hello", attributes: { align: "center" } },
  3. { text: "\n" },
  4. { text: "World", attributes: { align: "right" } }
  5. ];

不清楚我们得到的行是对齐的中心还是右。我们可以删除两者或者有一些排序规则来支持另一个,但是我们的Delta在这条路上变得越来越复杂,越来越难处理。

这个问题引发了原子性,我们在换行符中发现了这一点。 但是我们有一个问题,如果我们有n行,我们只有n-1个换行符。

为了解决这个问题,Quill为所有文档“添加”换行符,并始终以“\ n”结束Deltas。

  1. // Hello World on two lines
  2. var content = [
  3. { text: "Hello" },
  4. { text: "\n", attributes: { align: "center" } },
  5. { text: "World" },
  6. { text: "\n", attributes: { align: "right" } } // Deltas must end with newline
  7. ];

Embedded Content

我们想添加嵌入的内容,如图像或视频。字符串用于文本是很自然的,但是对于嵌入我们有更多的选择。由于存在不同类型的嵌入,我们的选择只需要包含此类型信息,然后包含实际内容。这里有许多合理的选项,但是我们将使用一个对象,它唯一的键是embed类型,而值是内容表示,它可以有任何类型或值。

  1. var img = {
  2. image: {
  3. url: 'https://quilljs.com/logo.png'
  4. }
  5. };
  6. var f = {
  7. formula: 'e=mc^2'
  8. };

与文本类似,图像可能具有一些定义特征,以及一些瞬态特征。文本内容我们使用attributes,图像也可以使用相同的attributes字段。但正因为如此,我们可以保留我们一直使用的一般结构,但是应该将我们的key键重命名为更通用的内容。由于我们稍后将探讨的原因,我们将选择名称insert。 把这一切放在一起:

  1. var content = [{
  2. insert: 'Hello'
  3. }, {
  4. insert: 'World',
  5. attributes: { bold: true }
  6. }, {
  7. insert: {
  8. image: 'https://exclamation.com/mark.png'
  9. },
  10. attributes: { width: '100' }
  11. }];

Describing Changes

正如Delta所暗示的那样,我们的格式可以描述文档的更改以及文档本身。实际上,我们可以将文档视为我们对空文档所做的更改,以获取我们正在描述的文档。正如您可能已经猜到的,使用delta来描述更改是我们将text重命名为insert的原因。将Delta数组中的每个元素称为Operation。

Delete

要描述删除文本,我们需要知道要删除的字符的位置和数量。要删除嵌入,除了了解嵌入的长度之外,不需要任何特殊处理。如果它不是1,那么我们需要指定当只删除嵌入的一部分时会发生什么。目前还没有这样的规范,所以无论图像由多少像素组成,视频有多少分钟,或者一副牌中有多少张幻灯片;嵌入的长度都是 1

描述删除的一种合理方法是明确存储其索引和长度。

  1. var delta = [{
  2. delete: {
  3. index: 4,
  4. length: 1
  5. }
  6. }, {
  7. delete: {
  8. index: 12,
  9. length: 3
  10. }
  11. }];

我们必须根据索引对删除进行排序,并确保没有范围重叠,否则将违反我们的规范约束。这种索引和长度方法还有其他一些缺点,但是在描述格式更改之后,它们更容易理解。

Insert

既然Deltas可能正在描述对非空文档的更改,那么{insert:“Hello”}就不够了,因为我们不知道应该在哪里插入“Hello”。我们可以通过添加索引来解决这个问题,类似于删除。

Format

与删除类似,我们需要指定要格式化的文本范围,以及格式更改本身。格式化存在于attributes对象中,因此一个简单的解决方案是提供一个附加的attributes对象来与现有的属性对象合并。这种合并很浅,以保持简单。我们还没有发现一个足够令人信服的用例需要深度合并,并保证增加复杂性。

  1. var delta = [{
  2. format: {
  3. index: 4,
  4. length: 1
  5. },
  6. attributes: {
  7. bold: true
  8. }
  9. }];

特殊情况是我们想要删除格式。为此,我们将使用null,因此{bold: null}表示删除粗体格式。我们可以指定任何错误的值,但是属性值为0或空字符串可能有合法的用例。

注意*:我们现在必须小心应用层的索引。如前所述,Deltas不赋予任何属性键值对任何固有的含义,也不赋予任何嵌入类型或值。Deltas不知道图像没有持续时间,文本没有替代文本,视频也不能加粗。以下是一个合法的Delta,可能是应用其他合法Deltas的结果,因为应用程序没有注意格式范围。

  1. var delta = [{
  2. insert: {
  3. image: "https://imgur.com/"
  4. },
  5. attributes: {
  6. duration: 600
  7. }
  8. }, {
  9. insert: "Hello",
  10. attributes: {
  11. alt: "Funny cat photo"
  12. }
  13. }, {
  14. insert: {
  15. video: "https://youtube.com/"
  16. },
  17. attributes: {
  18. bold: true
  19. }
  20. }];

Pitfalls

首先,我们应该清楚,在应用任何操作之前,索引必须引用其在文档中的位置。否则,后面的操作可能会删除前面的插入,取消前面格式的格式,等等,这会破坏紧凑性。

操作也必须严格排序,以满足规范约束。先按索引排序,然后按长度排序,然后按类型排序,这是一种有效的方法。

如前所述,删除范围不能重叠。反对重叠格式范围的情况不那么简单,但是我们也不需要重叠格式。

Delta可能无效的原因越来越多。更好的格式将根本不允许表达这种情况。

Retain

如果我们暂时停止我们的紧凑性形式,我们可以描述一种更简单的格式来表示插入、删除和格式化:

  • Delta将具有至少与正在修改的文档一样长的操作。

  • 每个操作都将描述该索引处的角色会发生什么。

  • 可选插入操作可使Delta比其描述的文档更长。

这就需要创建一个新的操作,这只是意味着“保持这个字符的原样”。 我们称之为retain

  1. // Starting with "HelloWorld",
  2. // bold "Hello", and insert a space right after it
  3. var change = [
  4. { format: true, attributes: { bold: true } }, // H
  5. { format: true, attributes: { bold: true } }, // e
  6. { format: true, attributes: { bold: true } }, // l
  7. { format: true, attributes: { bold: true } }, // l
  8. { format: true, attributes: { bold: true } }, // o
  9. { insert: ' ' },
  10. { retain: true }, // W
  11. { retain: true }, // o
  12. { retain: true }, // r
  13. { retain: true }, // l
  14. { retain: true } // d
  15. ]

由于描述了每个字符,因此不再需要显式索引和长度。 这使得无法表达重叠范围和无序索引。

因此,我们可以轻松优化合并相邻的相等操作,重新引入长度。如果最后一个操作是retain,我们可以简单地删除它,因为它只是指示不对文档的其余部分执行任何操作。

  1. var change = [
  2. { format: 5, attributes: { bold: true } }
  3. { insert: ' ' }
  4. ]

此外,您可能注意到retain在某些方面只是format的一种特殊情况。例如,{format:1,attributes:{}}{retain:1}之间没有实际差异。压缩将丢弃空attributes对象,只留下{format:1},从而产生规范化冲突。因此,在我们的示例中,我们将简单地组合formatretain,并保留名称retain

  1. var change = [
  2. { retain: 5, attributes: { bold: true } },
  3. { insert: ' ' }
  4. ]

我们现在有一个非常接近当前标准格式的Delta。

Ops

现在我们有一个易于使用的JSON数组,它描述了富文本。这在存储和传输层非常出色,但应用程序可以从更多功能中受益。我们可以通过将Deltas实现为一个类来添加它,可以很容易地从JSON初始化或导出到JSON,然后为它提供相关的方法。

在Delta成立之初,不可能对Array进行子类化。出于这个原因,Deltas被表示为对象,具有单个属性ops,用于存储我们一直在讨论的操作数组。

  1. var delta = {
  2. ops: [
  3. {
  4. insert: 'Hello'
  5. },
  6. {
  7. insert: 'World',
  8. attributes: { bold: true }
  9. },
  10. {
  11. insert: {
  12. image: 'https://exclamation.com/mark.png'
  13. },
  14. attributes: { width: '100' }
  15. }
  16. ]
  17. };

最后,我们得出了今天存在的Delta format