Quill 是整個第二世代編輯器的開山始祖,也是第一個嘗試脫離瀏覽器掌控的叛逆份子,目前在 Github 的 star 數量已經超過 30k 了。
附上 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 是 Quill 用來描述編輯器 content 以及變化的 plain JSON object 格式。
它透過三種動作:
以及 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 } }
]
}
編輯器的解讀順序會如下:
"Hello "
),並將指標指到第 6 個 character"World"
),並將這 5 個 characters 的屬性改為 color: "#fff", bold: false
Delta 也可以描述圖片、影片等等在現代的富文本編輯器常看得到的格式。
但它並非 Quill 的 Document model 本身,只是讓開發者以 JSON 格式與編輯器當前的內容溝通的管道而已。
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 裡看到諸如: prev
、 next
、 children
、 descendant
等屬性能看出整個 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 分別是:
開發者一樣可以透過調整參數的方式使用,而不用從頭建立。
雖然那些可重用的模組與元件讓開發上非常方便,但當要製作完全客製化的新功能時,就需要先花費時間成本去學習整個系統的運作方式,甚至要了解更底層的內容才能開發,有使用其他 framework 像是 React 進行開發的話需要考慮到的問題又會變得更複雜。
最後讓筆者分享一篇由 “ Graphite ” 團隊寫的,說明為何要將自己的編輯器核心從 Quill 轉移到 Slate.js 的 文章 來結束這回合,有興趣的讀者可以花點時間看看其他團隊的取捨過程。
緊接著下一篇要介紹的是目前第二世代編輯器套件下載次數穩定維持最高,由大名鼎鼎的 Facebook 團隊開發的 Draft.js 。