Skip to content

Quill 编辑器(二)文档模型 Parchment

Published:

引言

上一篇文章只介绍了 quill 在存储数据时使用的模型 Delta,但并没有具体说明 DOM 与 Delta 是如何实现互相转换的。实际上,在这个过程中还存在一个中间层,那就是 Parchment。

本文将介绍 Parchment 的几个核心概念,以及 quill 数据渲染与更新的过程。

Parchment 核心概念

Parchment 被称为 quill 的文档模型,它主要包含了 Blot、Scroll、Attributor 这三种概念。Parchment 整体结构与浏览器的 Document 文档模型比较相似,它从设计上就是 Document 的一个简化版本。

1、Blot

Blot 是 Parchment 中的基本单元,就像是 Document 中的 DOM 节点一样。Parchment 文档模型把所有的元素都抽象为 Blot,且支持嵌套,整个文档就形成了一个嵌套的树状结构。

Blot 的核心定义如下:

export interface Blot {
  // 整个树状结构的根节点
  scroll: Root;
  // 父节点
  parent: Parent;
  // 前一个兄弟节点
  prev: Blot | null;
  // 后一个兄弟节点
  next: Blot | null;
  // 当前 blot 对应到页面上的 dom 节点
  domNode: Node;

  // 一些静态属性
  statics: {
    blotName: string;
    className?: string;
    scope: Scope;
    tagName: string;
  };
}

这个结构与 DOM 节点的结构非常相似,整体是一个多叉树,同一层级的节点组成一个双向链表,便于在数据更新时进行增删改查操作。

<div class="ql-editor">
  <h1>标题</h1>
  <p>
    段落的开头
    <span>一个行内文本</span>
    段落的结尾
  </p>
  <p>第二个段落</p>
</div>

比如上面的 HTML 结构,就会被抽象类似如下的 Blot 树状结构:

{
  blotName: 'Scroll',
  children: [
    {
      blotName: 'Heading',
      value: 'h1',
      children: [
        {
          blotName: 'Text'
          value: '标题'
        }
      ]
    },
    {
      blotName: 'Paragraph',
      children: [
        {
          blotName: 'Text',
          value: '段落的开头'
        },
        {
          blotName: 'Span',
          children: [
            {
              blotName: 'Text',
              value: '一个行内文本'
            }
          ]
        },
        {
          blotName: 'Text',
          value: '段落的结尾'
        },
      ]
    },
    {
      blotName: 'Paragraph',
      children: [
        {
          blotName: 'Text',
          value: '第二个段落'
        }
      ]
    }
  ]
}

Blot 有很多种分类方式:

两种特殊的 Blot:

2、Scroll

可以把 Scroll 与 document.body 做类比,它是整个文档的根 Blot,对应的 DOM 也是整个文档的根节点。

Scroll 的子节点可以是 Block Blot 或者 Container Blot。

它对外提供了很多方法用来操作整个 Blot 树,比如插入、删除、更新节点等等。

3、Attributor

Attributor 用于管理一个 Blot 的属性,它可以是 Style 属性(colorfont-size 等),也可以是 Class 属性,当然也可以扩展成自定义的任何属性。

它就是整个富文本编辑器中「格式」的底层实现。任何一个 Blot 都持有 Attributor 的实例,用于获取和设置属性。

举一个例子,现在有一个 <span style="color: red;">红色的文字</span>,那么它就会被抽象为如下的 Blot 结构:

{
  blotName: 'span',
  value: '红色的文字',
  attributes: {
    color: 'red'
  }
}

DOM 与 Blot 是一一对应的,class、style 等属性又能通过 Attributor 存储和管理,这样就实现了 Document 与 Parchment 之间的双向映射关系。

当然,Parchment 映射的是 Document 的一个子集,它只包含了富文本编辑器需要存储的数据,比如特定的元素、特定的属性。对于无法识别的 DOM 节点或者样式属性,可以忽略掉。

而 Parchment 与 Delta 之间则是可以做到互相转换的,Parchment 中无非只有「Blot 类型」、「Attributor 属性」这两种数据,而 Delta 已经足够表达。

Blot 的详细定义

上文提到过 Blot 的核心属性,下面将详细介绍下 Blot 定义中的一些方法。

以一个 BlockEmbed 格式的自定义 blot 为例:

class CustomBlot extends BlockEmbed {
  // 创建 Blot,在此之前已经创建过 DOM 节点
  constructor(scroll: Scroll, domNode: HTMLElement) {
    super(domNode);
  }
  // 通过 value 创建 DOM 节点的静态方法
  static create(value: CustomBlotValue): HTMLElement {
    // 自行实现从 value 创建 DOM 的逻辑
  }

  static value(domNode: Element): CustomBlotValue {
    // 自行实现从 DOM 节点中获取 value 的逻辑
  }

  // 对当前 blot 进行一次 format 操作,比如设置样式
  format(name: string, value: any) {
    // 自行实现 format 逻辑,比如修改当前 domNode 上的属性
  }

  // 文档中新增了该元素时,会调用该方法
  attach() {
    // 通常在这里去挂载元素、注册元素上的事件监听等逻辑
  }

  // 用于自定义复制粘贴时的 HTML 结构
  html() {
    // 默认返回的是 Blot 的 DOM
    // 对于复杂的元素(比如表格、图片、文件等)很实用。
  }
}

创建一个 Blot 的过程如下:

// 1、通过 value 创建 DOM 节点
const domNode = CustomBlot.create(value);
// 2、创建 Blot 实例
const blot = new CustomBlot(scroll, domNode);

Blot 上还有一些方法,比如 insertAtdeleteAtinsertBefore 等等,它们则是用于在 Blot 树结构中进行增删改查操作的。

quill 的数据渲染与更新

首先介绍 updateContents 方法的流程,大致可以分为以下几个步骤:

1、遍历 Delta 中的每一个操作,根据不同类型(insert、delete、retain)的参数,在 Parchment 中找到对应的 Blot,解析 value 与 attributes,创建对应的 Blot。

insert:

{
  insert: {
    image: {
      src: 'url',
      alt: 'alt'
    }
  },
  attributes: {
    width: '100px'
  }
}

这个 Op 将被解析为:插入一个 value 为 {src: 'url', alt: 'alt'} 的 Image Blot,它带有 width 为 '100px' 的 Attributor。

delete

{
  delete: 10
}

这个 Op 将被解析为:从当前的虚拟光标开始往后删除 10 个字符。Parchment 内部会把这段文本对应的 Blot 删除掉,如果长度不能完整对应上,将会对 Blot 做拆分和合并。

retain

{
  retain: 10,
  attributes: {
    color: 'red'
  }
}

这个 Op 将被解析为:从当前的虚拟光标开始往后数 10 个字符,把它们对应的 Blot 上添加一个 color 为 red 的 Attributor。

2、将 Parchment 结构的变更渲染更新到 DOM 中。

而对于文档的 setContents 操作实际上相当于在空文档的基础上调用 updateContents 方法。所以这里就不再详细介绍。

这就是从 Delta 到 Parchment 再到 DOM 的整个过程,对于它的反向操作,也就是 DOM 到 Delta,通常是复制粘贴场景会遇到。

在执行复制操作时,quill 会把选区内容的 HTML 写入剪贴板(单个元素的 HTML 可以通过 Blot 上的 html 方法进行自定义),在粘贴时,先转换为 Delta 数据,再转换为 Parchment 进一步生成 DOM。

这一步是由 quill 内置的 Clipboard 模块负责,它实现了一套后序遍历算法来把 HTML 转换为 Delta,本系列后续会有一篇文章详细介绍这个模块。

总结

本文主要介绍了 Parchment 的核心概念,以及它与 Delta 之间的转换逻辑。

Parchment 是 Quill 实现富文本编辑器底层逻辑的核心部分,它抽象出了 Blot 的概念,并通过 Attributor 管理 Blot 的属性,实现了 Delta 与 DOM 之间双向映射的关系。