Day 6. Compare × G2 × Quill

https://ithelp.ithome.com.tw/upload/images/20210921/20139359LsUebmHkaL.png
Quill 是整个第二世代编辑器的开山始祖,也是第一个尝试脱离浏览器掌控的叛逆份子,目前在 Github 的 star 数量已经超过 30k 了。
https://ithelp.ithome.com.tw/upload/images/20210921/20139359tguO9oBR0t.png

附上 Quill 连结

笔者认为仅次於上一篇提到的 TinyMCE 与 CKEditor 以外,就属 Quill 的社群发展最为完整了。你可以直接用 Plain JS 轻松建立起基本的 Quill editor ,想套用在前端 framework 里也能在网路上轻松找到现成帮你建立好 component 的 libraries 。在 awesome-quill 这个 repo 里可以看到非常多各路 contributors 为 Quill 开发的套件们。

来看一下它简易的起手式

var quill = new Quill('#editor', {
  modules: { toolbar: true },
  theme: 'snow'
});

从官方文件上也看得出他们的苦心经营,不仅文件分类清楚,也写了一堆相关的 blog 教学实作,甚至专门写了一篇文章拿自己与其他 WYSIWYG libraries 做优劣势比较,文章连结也提供在 这边 大家有兴趣可以上前去看看。

要想认识 Quill 首先必须要先理解 Quill 团队另外开发的两个 repo ,分别是 『 Delta 』 ,以及 『 Parchment 』。

Delta


Delta 是 Quill 用来描述编辑器 content 以及变化的 plain JSON object 格式。

它透过三种动作:

  • insert :插入
  • retain :保留
  • delete :删除

以及 attributes :属性,描述 Quill Document 从初始( empty )状态所经历的操作变化。

举个简单的例子:假设 Quill 的编辑器里的 content 为 "Hello **World**" ,以 Delta 进行描述的方式则如下:

{
	"ops": [
		{ "insert": "Hello " },
		{ "insert": "World", "attributes": { "bold": true } },
		{ "insert": "\n" }
	]
}

假设我们再做如下的操作:

{
	"ops": [
		{ "retain": 6 },
		{ "retain": 5, "attributes": { "color": "#fff", "bold": false } }
	]
}

编辑器的解读顺序会如下:

  1. 保留编辑器的前 6 characters ( 即 "Hello " ),并将指标指到第 6 个 character
  2. 保留第 6 个 character 之後的 5 个 characters (即 "World" ),并将这 5 个 characters 的属性改为 color: "#fff", bold: false

Delta 也可以描述图片、影片等等在现代的富文本编辑器常看得到的格式。

但它并非 Quill 的 Document model 本身,只是让开发者以 JSON 格式与编辑器当前的内容沟通的管道而已。

Parchment & Blots


Parchment 才是 Quill 依赖的 Document model ,它们也是向 DOM tree structure 看齐所制造的。

一个 Parchment tree 是由复数个 Blot 所组成, Blot 里头提供了各种属性以及 methods 让开发者透过 extends 的方式去自定义不同的资料样式,诸如图片、影片等,都是由 Blot 延伸所定义出来的子类别。 Quill 也有事先定义出一些 build-in 的 Blot 子类别供开发者使用,像是: Inline blot 、 Block blot 等。

『我们可以将 Blot 格式理解为是对 DOM node 的抽象,而 Parchment 则是对整份 HTML document 的抽象,就像 DOM 节点是构成 HTML 文件的基本单元一样,Blot 则是构成 Parchment 文件的基本单元。』

在下方提供的 Blot class 里看到诸如: prevnextchildrendescendant 等属性能看出整个 Parchment 的树状结构,里头也包含了一切操作该 bolt 所需的 methods ,像是插入、删除、更新等, allowedChildren 定义了特定 Blot children 类型的白名单,以及在实际进行 create method 时会将建立出来的 Blot instance 绑在特定的 DOM node 上并返回该 Node ,开发者也能以一般的 DOM api 操作它,也能从 Blot 的 domNode 属性 access 绑定的 DOM node 。

class Blot {
  static blotName: string;
  static className: string;
  static tagName: string;
  static scope: Scope;

  domNode: Node;
  prev: Blot;
  next: Blot;
  parent: Blot;

  // Creates corresponding DOM node
  static create(value?: any): Node;

  constructor(domNode: Node, value?: any);

  // For leaves, length of blot's value()
  // For parents, sum of children's values
  length(): Number;

  // Manipulate at given index and length, if applicable.
  // Will often pass call onto appropriate child.
  deleteAt(index: number, length: number);
  formatAt(index: number, length: number, format: string, value: any);
  insertAt(index: number, text: string);
  insertAt(index: number, embed: string, value: any);

  // Returns offset between this blot and an ancestor's
  offset(ancestor: Blot = this.parent): number;

  // Called after update cycle completes. Cannot change the value or length
  // of the document, and any DOM operation must reduce complexity of the DOM
  // tree. A shared context object is passed through all blots.
  optimize(context: {[key: string]: any}): void;

  // Called when blot changes, with the mutation records of its change.
  // Internal records of the blot values can be updated, and modifcations of
  // the blot itself is permitted. Can be trigger from user change or API call.
  // A shared context object is passed through all blots.
  update(mutations: MutationRecord[], context: {[key: string]: any});

  /** Leaf Blots only **/

  // Returns the value represented by domNode if it is this Blot's type
  // No checking that domNode can represent this Blot type is required so
  // applications needing it should check externally before calling.
  static value(domNode): any;

  // Given location represented by node and offset from DOM Selection Range,
  // return index to that location.
  index(node: Node, offset: number): number;

  // Given index to location within blot, return node and offset representing
  // that location, consumable by DOM Selection Range
  position(index: number, inclusive: boolean): [Node, number];

  // Return value represented by this blot
  // Should not change without interaction from API or
  // user change detectable by update()
  value(): any;

  /** Parent blots only **/

  // Whitelist array of Blots that can be direct children.
  static allowedChildren: Blot[];

  // Default child blot to be inserted if this blot becomes empty.
  static defaultChild: string;

  children: LinkedList<Blot>;

  // Called during construction, should fill its own children LinkedList.
  build();

  // Useful search functions for descendant(s), should not modify
  descendant(type: BlotClass, index: number, inclusive): Blot
  descendants(type: BlotClass, index: number, length: number): Blot[];

  /** Formattable blots only **/

  // Returns format values represented by domNode if it is this Blot's type
  // No checking that domNode is this Blot's type is required.
  static formats(domNode: Node);

  // Apply format to blot. Should not pass onto child or other blot.
  format(format: name, value: any);

  // Return formats represented by blot, including from Attributors.
  formats(): Object;
}

如果是要建造功能相对单纯的 WYSIWYG 编辑器的话,Quill 其实已经很够用了,它的生态系很完整,不需要做任何客制化的动作也能快速上手,学习门槛相对低,出问题也几乎都能在社群上找到答案。

但也因为它先天性的设计限制,如果开发者想朝向功能更为进阶复杂的编辑器前进的话,就会撞上许多问题:

  • 作为 Document model 的 Parchment 并非纯粹的资料,这让开发者需要额外花费不少的一段时间去处理从 Document model 到真实储存的资料之间的转换。

    虽然有 Delta 能取得以 JSON 格式描述的编辑器资料,但它却也不是以 DOM tree 的逻辑去呈现的,开发者仍然有很大的机会需要转换取得的 Delta 资料。

    如果打算实现类似 Markdown 语法、 HTML serialize & de-serialize 这类需要将内容再多做一层转换的功能时,就有很大的机会生出一堆 boilerplate 。

  • 针对 View layer,Quill 提供了完整的系统也提供开发者各种 options 调整 Quill editor 的 Configuration ,以及 built-in 的 Themes 分别是:

    • Toolbar : Snow
    • Tooltip : Bubble

    开发者一样可以透过调整参数的方式使用,而不用从头建立。

    虽然那些可重用的模组与元件让开发上非常方便,但当要制作完全客制化的新功能时,就需要先花费时间成本去学习整个系统的运作方式,甚至要了解更底层的内容才能开发,有使用其他 framework 像是 React 进行开发的话需要考虑到的问题又会变得更复杂。


最後让笔者分享一篇由 “ Graphite ” 团队写的,说明为何要将自己的编辑器核心从 Quill 转移到 Slate.js 的 文章 来结束这回合,有兴趣的读者可以花点时间看看其他团队的取舍过程。

紧接着下一篇要介绍的是目前第二世代编辑器套件下载次数稳定维持最高,由大名鼎鼎的 Facebook 团队开发的 Draft.js 。


<<:  Day-7:Rails Turbolinks

>>:  [Day21] Flutter - Presentation AutoRouter(part5)

配置管理(Configuration management)是编排器(orchestrator )管理容器化(containerized)应用程序的最关键推动力

在部署基於容器的应用程序时,我们可以使用容器编排器来配置和管理容器。这意味着变更请求已获批准和实施,...

Day7 - 什麽是Snapshot及如何使用

今天要讲的是snapshot函数,顾名思义,snapshot函数能让我们收取程序执行当下的商品资料,...

CallStack

由於JavaScript是单线程的语言,所以从上而下设计就很重要,若有点困难可以先去看Functio...

Day12 - 套用 Tag Helper - 复杂型别 object + object collection

接下来开始着重使用 Tag Helper 在 Html 长出需要的 Html 控制项 name ! ...

Day 21 应用托管服务

在云端上不会只有一种布署服务的方式,而是拥有多种方式提供给使用者,对於Infra很熟悉的IT人员可...