深入淺出富文本編輯器

以下文章來(lái)源于ELab團(tuán)隊(duì) ,作者ELab.huanglei.cc

編輯器介紹
常見(jiàn)的富文本編輯器現(xiàn)實(shí)方式可以分成兩大類(lèi),分別是用 textarea 和 contenteditable 來(lái)實(shí)現(xiàn)。

textarea
結(jié)構(gòu)簡(jiǎn)單使用方便,一些文本格式和復(fù)雜的樣式難以實(shí)現(xiàn),推薦僅在對(duì)編輯要求不高的場(chǎng)景使用。

contenteditable
將元素的 contenteditable 屬性設(shè)為 true時(shí),該元素則成為了編輯器的主體。配合 document.execCommand 能夠?qū)崿F(xiàn)絕大多數(shù)功能,主流編輯器是基于 contenteditable 來(lái)設(shè)計(jì)的。

但是單純依賴(lài) contenteditable 直接產(chǎn)出 html 會(huì)帶來(lái)一些問(wèn)題,例如相同的輸入在不同瀏覽器下的輸出可能不一致,相同的輸出在不同瀏覽器中展示存在差異,并且這些問(wèn)題在移動(dòng)端會(huì)被放大,同時(shí) html 使用具有局限性,不方便在跨平臺(tái)間使用。

因此更好的方案是制定一套數(shù)據(jù)結(jié)構(gòu) + 文檔模型,所有的輸入都經(jīng)過(guò)編輯器生成約定的產(chǎn)物,這樣在不同的平臺(tái)均可解析并且保證得到預(yù)期的效果。

還有一類(lèi)是以 Google docs 為主的編輯器,不使用 contenteditable ,而是基于 canvas 渲染[1],通過(guò)監(jiān)聽(tīng)用戶(hù)輸入,模擬編輯器的運(yùn)行,此類(lèi)編輯器實(shí)現(xiàn)成本極高且復(fù)雜。

本文以 quill[2] 為例,介紹如何實(shí)現(xiàn)一個(gè)支持跨平臺(tái)渲染,且可以插入自定義模塊的富文本編輯器。

基本概念
delta[3]
用于描述富文本內(nèi)容或內(nèi)容變換的數(shù)據(jù)結(jié)構(gòu),純 json 格式,能夠轉(zhuǎn)化成 js 對(duì)象后方便操作,基本格式如下,由一組 op 組成。

op 是個(gè) js 對(duì)象,可理解為對(duì)當(dāng)前內(nèi)容的一次變更,它主要有以下幾個(gè)屬性。

insert: 插入,后面 【3.2 數(shù)據(jù)結(jié)構(gòu)】有介紹可能的值和對(duì)應(yīng)的含義

retain: 值為 number 類(lèi)型,保留相應(yīng)長(zhǎng)度的內(nèi)容

delete: 值為 number 類(lèi)型,刪除相應(yīng)長(zhǎng)度的內(nèi)容

上面三個(gè)屬性必有且僅有一個(gè)出現(xiàn)在 op 對(duì)象中

attributes: 可選,值為對(duì)象,可描述格式化信息

如何理解內(nèi)容或內(nèi)容變換,舉個(gè)??,下面這段數(shù)據(jù)表示了內(nèi)容 “Grass the Green”,

{
  ops: [
    { insert: 'Grass', attributes: { bold: true } },
    { insert: ' the ' },
    { insert: 'Green', attributes: { color: '#00ff00' } }
  ]
}
經(jīng)過(guò)下面一次 delta 內(nèi)容變換后新內(nèi)容為 “Grass the blue”。

{
  ops: [
    // 接下來(lái) 5 個(gè)字符取消加粗并加上斜體格式
    { retain: 5, attributes: { bold: null, italic: true } },
    // 維持 5 個(gè)字符不變
    { retain: 5 },
    // 插入
    { insert: "Blue", attributes: { color: '#0000ff' },
    // 刪除后面 5 個(gè)字符
    { delete: 5 }
  ]
}
Delta 本質(zhì)上是一系列操作記錄,在渲染時(shí)可以看作記錄了從空白到目標(biāo)文檔的一個(gè)過(guò)程,而 HTML 是一個(gè)樹(shù)形結(jié)構(gòu),所以 Delta 的線性結(jié)構(gòu)相比 HTML 在業(yè)務(wù)使用上有天生優(yōu)勢(shì)。

parchment[4]
一種文檔模型,由 blots 組成,用來(lái)描述數(shù)據(jù),可以拓展自定義的數(shù)據(jù)。

<p>
    一段文字加視頻的富文本內(nèi)容。
    <img src="xxx" alt="">
  </p>
  <p>
    <strong>加粗文本結(jié)尾。</strong>
</p>
parchment 與 blot 關(guān)系類(lèi)似于 DOM 與 element node,上面一段 html 內(nèi)容使用 dom tree 和 parchment tree 描述分別如下圖所示。



parchment 提供了幾種基礎(chǔ) blot,同時(shí)支持開(kāi)發(fā)中根據(jù)需求拓展定義自己的 blot,后面會(huì)演示如何開(kāi)發(fā)一個(gè)自定義的 blot。

{
  // 基礎(chǔ)節(jié)點(diǎn)
  ShadowBlot,
  // 容器節(jié)點(diǎn) => 基礎(chǔ)節(jié)點(diǎn)
  ContainerBlot,
  // 格式化節(jié)點(diǎn) => 容器節(jié)點(diǎn)
  FormatBlot,
  // 葉子節(jié)點(diǎn)
  LeafBlot,
  // 編輯器根節(jié)點(diǎn) => 容器節(jié)點(diǎn)
  ScrollBlot,
  // 塊級(jí)節(jié)點(diǎn) => 格式化節(jié)點(diǎn)
  BlockBlot,
  // 內(nèi)聯(lián)節(jié)點(diǎn) => 格式化節(jié)點(diǎn)
  InlineBlot,
  // 文本節(jié)點(diǎn) => 葉子節(jié)點(diǎn)
  TextBlot,
  // 嵌入式節(jié)點(diǎn) => 葉子節(jié)點(diǎn)
  EmbedBlot,
}
最后用一張圖了解下 quill 內(nèi)部的工作流程,其中開(kāi)發(fā)者需要關(guān)注的業(yè)務(wù)層邏輯十分簡(jiǎn)潔,可以通過(guò)手動(dòng)輸入和 api 方式變更編輯器內(nèi)容,同時(shí) editor-change 事件會(huì)輸出當(dāng)次操作和最新內(nèi)容對(duì)應(yīng)的 delta 數(shù)據(jù)。



實(shí)際應(yīng)用
數(shù)據(jù)流
在業(yè)務(wù)中,基本數(shù)據(jù)流應(yīng)該如下圖所示,由編輯器生成 delta 數(shù)據(jù),之后由相應(yīng)平臺(tái)的解析器渲染成對(duì)應(yīng)的內(nèi)容。



數(shù)據(jù)結(jié)構(gòu)
良好的內(nèi)容數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì),在后續(xù)維護(hù)和跨平臺(tái)渲染時(shí)起到關(guān)鍵作用,我們可以將富文本內(nèi)容中依賴(lài)的媒體(圖片、視頻、自定義的格式)數(shù)據(jù)放到外層來(lái),通過(guò) id 關(guān)聯(lián),這樣日后拓展和渲染時(shí)會(huì)比較方便。

interface ItemContent {
    // 富文本數(shù)據(jù),存儲(chǔ)著 delta-string
    text?: string;
    // 視頻
    videoList?: Video[];
    // 圖片
    imageList?: Image[];
     // 自定義的模塊,如投票、廣告卡片、問(wèn)卷卡片等等
    customList?: Custom[];
}
其中編輯器輸出的是標(biāo)準(zhǔn) delta 數(shù)據(jù), 結(jié)構(gòu)如下所示,

// 純文本, \n 代表?yè)Q行
{
    insert: string;
},
 // 特殊類(lèi)型的文本
{
    insert: '超鏈接文本',
    attributes: {
        // 文字顏色
        color: string,
        // 加粗
        bold:  boolean,
        // 超鏈接地址
        link: string;
        ...,
    }
},
// 有序無(wú)序列表
{
    insert:  '\n',
    attributes: {
      list: 'ordered' | 'bullet'
    }
 },
{
    insert: {
        uploading: {
            // 資源類(lèi)型
            type: 'image' | 'video' | 'vote' | 'and more...'
            // 資源 id
            uid: string
        },
    },
},
// 圖片
{
    insert: { image: '${image_uri}' }
},
// 視頻
{
    insert: {
        videoPoster: {
           /** 視頻封面地址 */            url: string;
           /** 視頻 id */            videoId: string;
        }
    }
},
// 投票
{
    insert: {
        vote: {
            voteId: string
        }
    }
},
// 縮進(jìn),作用域內(nèi)所有文本向右縮進(jìn) indent 個(gè)單位;
// 作用域:從當(dāng)前為起始位置向前回溯,遇到以下任意一種情況結(jié)束
// 1、純文本 \n
// 2、attributes的屬性含有indent并且indent值小于等于當(dāng)前值
{
    insert:  '\n',
    attributes: {
        indent: 1-8,
    }
},
圖片 / 視頻混排
圖片上傳需要支持展示上傳中的狀態(tài),并且不應(yīng)該阻塞用戶(hù)的編輯,所以需要先使用一個(gè)占位元素,待上傳完成后將占位替換成真實(shí)圖片或視頻。

自定義 blot
自定義 blot 的好處是能夠?qū)⒄麄€(gè)的功能(例如圖表功能)封裝到一個(gè) blot 中,這樣業(yè)務(wù)開(kāi)發(fā)時(shí)可直接使用,而不用管每個(gè)功能是怎么實(shí)現(xiàn)的。下面以圖片視頻上傳態(tài)占位 blot 為例,演示如何自定義一個(gè) blot。






import Quill from 'quill';

enum MediaType {
  Image = 'image',
  Video = 'video',
}

interface UploadingType {
  type: MediaType;
  // 唯一的 id,當(dāng)圖片或視頻上傳完成后,需要找到對(duì)應(yīng)的 uid 進(jìn)行替換
  uid: string;
}

export const BlockEmbed = Quill.import('blots/block/embed');

class Uploading extends BlockEmbed {
  static _value: Record<string, UploadingType> = {};

  static create(value: UploadingType) {
    const ELEMENT_SIZE = 60;
    // blot 對(duì)應(yīng)的 dom 節(jié)點(diǎn)
    const node = super.create();
    this._value[value.uid] = value;
    node.contentEditable = false;
    node.style.width = `${ELEMENT_SIZE}px`;
    node.style.height = `${ELEMENT_SIZE}px`;
    node.style.backgroundImage = `url(占位圖地址)`;
    node.style.backgroundSize = 'cover';
    node.style.margin = '0 auto';
    // 用來(lái)區(qū)分對(duì)應(yīng)資源
    node.setAttribute('data-uid', value.uid);
    return node;
  }

  static value(v) {
    return this._value[v.dataset?.uid];
  }
}

Uploading.blotName = 'uploading';
Uploading.tagName = 'div';

export default Uploading;
將自定義 blot 注冊(cè)到編輯器實(shí)例中,使用 quill 的 insertEmbed 來(lái)調(diào)用這個(gè)blot 即可。

// editor.tsx
Quill.register(VideoPosterBlot);

quill.insertEmbed(1, 'uploading', {
  type: 'image',
  uid: 'xxx',
});



處理粘貼操作
復(fù)制粘貼可以大幅提升編輯器效率,但是我們需要對(duì)剪切板中的視頻和圖片進(jìn)行特殊處理,將剪切板中的內(nèi)容轉(zhuǎn)化成自定義的格式,并自動(dòng)上傳其中圖片和視頻。

基本原理
監(jiān)聽(tīng)用戶(hù)的粘貼操作,讀取 paste event[5] 返回的 clipboardData[6] 數(shù)據(jù),二次加工后再插入編輯器中。

target.addEventListener('paste', (event) =>  {
    const clipboardData = (event.clipboardData || window.clipboardData)
    const text = clipboardData.getData(
      'text',
    );
    const html = clipboardData.getData(
      'text/html',
    );
    
    /**
    * 業(yè)務(wù)邏輯
    */
    
    event.preventDefault();
});
clipboardData.items 是 DataTransferItem 的數(shù)組集合,它包含了本次粘貼操作的數(shù)據(jù)內(nèi)容。

DataTransferItem 有兩個(gè)屬性分別是 kind和 type,其中 kind 值通常是 string 類(lèi)型,如果是文件類(lèi)型的數(shù)據(jù)那么值為 file;type 值是 MIME 類(lèi)型,常見(jiàn)的是 text/plain 和 text/html。

處理圖片
剪切板中的圖片來(lái)源分為兩大類(lèi),一是直接從文件系統(tǒng)中復(fù)制,這種情況我們

從文件系統(tǒng)中復(fù)制



從文件系統(tǒng)中復(fù)制粘貼后,能獲取到 File 對(duì)象,那么直接插入編輯器中,即可復(fù)用前面的圖片上傳邏輯。

從網(wǎng)頁(yè)復(fù)制




從上面右圖不難看出,從網(wǎng)頁(yè)中復(fù)制過(guò)來(lái)的內(nèi)容中包含 text/html 富文本類(lèi)型,由于圖片可能是臨時(shí)地址,直接使用三方圖片地址不可靠,需要把 html 中圖片地址提取出來(lái),下載后再上傳至我們自己的服務(wù)器中,圖片上傳模塊還能繼續(xù)復(fù)用上文的圖片混排。








上文內(nèi)容的 dom 樹(shù)基礎(chǔ)結(jié)構(gòu)如圖所示,可以經(jīng)過(guò)后序遍歷將所有節(jié)點(diǎn)處理成數(shù)組結(jié)構(gòu),當(dāng)遇到節(jié)點(diǎn)為圖片時(shí)則調(diào)用上面的圖片混排邏輯。

convert({ html, text }, formats = {}) {
    if (!html) {
      return new Delta().insert(text || '');
    }
    // 返回 HTMLDocument 對(duì)象
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const container = doc.body;
    // key - node
    // value - matcher: (node, delta, scroll) => newDelta
    const nodeMatches = new WeakMap();
    // 返回兩個(gè)匹配器,分別處理 ELEMENT_NODE 和 TEXT_NODE ,將 dom 轉(zhuǎn)化成 Delta
    const [elementMatchers, textMatchers] = this.prepareMatching(
      container,
      nodeMatches,
    );
    
    return traverse(
      this.quill.scroll,
      container,
      elementMatchers,
      textMatchers,
      nodeMatches,
    );
}

 function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
  // 節(jié)點(diǎn)為葉子節(jié)點(diǎn)即文本
  if (node.nodeType === node.TEXT_NODE) {
    return textMatchers.reduce((delta, matcher) =>  {
      return matcher(node, delta, scroll);
    }, new Delta());
  }
  if (node.nodeType === node.ELEMENT_NODE) {
    return Array.from(node.childNodes || []).reduce((delta, childNode) =>  {
      let childrenDelta = traverse(
        scroll,
        childNode,
        elementMatchers,
        textMatchers,
        nodeMatches,
      );
      if (childNode.nodeType === node.ELEMENT_NODE) {
        childrenDelta = elementMatchers.reduce((reducedDelta, matcher) =>  {
          return matcher(childNode, reducedDelta, scroll);
        }, childrenDelta);
        childrenDelta = (nodeMatches.get(childNode) || []).reduce(
          (reducedDelta, matcher) =>  {
            return matcher(childNode, reducedDelta, scroll);
          },
          childrenDelta,
        );
      }
      return delta.concat(childrenDelta);
    }, new Delta());
  }
  return new Delta();
}
上面例子中的數(shù)據(jù)可以轉(zhuǎn)化成以下 delta 數(shù)據(jù),視頻的處理方法與圖片類(lèi)似,這里不再贅述。


{
    ops: [
    {
        insert: '說(shuō)起艾冬梅這個(gè)名字,現(xiàn)在的年輕人可能不是很熟悉,但是她曾經(jīng)卻是家喻戶(hù)曉的人物,'
    },
    {
        insert: '艾冬梅是我國(guó)著名的馬拉松運(yùn)動(dòng)員'  ,         attribute: {
            bold: true
        },
    },
    {
        insert: '。她出生于1981年,是個(gè)來(lái)自東北的姑娘,和很多普通的八零后一樣,她來(lái)自一個(gè)平凡的家庭,從小生活十分幸福,家境雖然不富裕,但艾冬梅依然是父母的掌上明珠。'
    },
    {
       insert: {
           image: {
               url: 'xxx'
           }
       }
    },
    {
        insert: '但是艾冬梅和其他人不同的是她從小就展現(xiàn)出了驚人的長(zhǎng)跑天賦'  ,         attribute: {
            bold: true
        },
    },
    {
        insert: ' , 1993年當(dāng)時(shí)艾冬梅還在念小學(xué),她在一次跑步比賽中獲得了一個(gè)十分優(yōu)秀的成績(jī),在腳趾頭受傷的情況下打破了當(dāng)?shù)氐?000米項(xiàng)目記錄,遠(yuǎn)遠(yuǎn)超過(guò)了參賽的所有人。這讓很多人都十分震驚,于是艾冬梅順利地被齊齊哈爾體校選中。'
    }
   ]
}
解析數(shù)據(jù)
在 web 場(chǎng)景下可以使用 quill-delta-to-html[7] 這個(gè)庫(kù)來(lái)做解析,如果是小程序,對(duì)于媒體元素(如:小程序中圖片必須要指定寬高[8])支持相對(duì)不太友好,需要自己解析,下面簡(jiǎn)單介紹下如何渲染 delta 數(shù)據(jù)。

由于 delta 是一個(gè)線性結(jié)構(gòu),轉(zhuǎn)化成 dom 時(shí),需要構(gòu)建一棵樹(shù),將塊級(jí)元素的子元素關(guān)聯(lián)到它的 children 中。



上圖中的原數(shù)據(jù)經(jīng)過(guò)第一輪處理

純文本反規(guī)范化,將 abc\ndef\ng 格式轉(zhuǎn)化成 [abc, \n, def, \n, g]
將塊級(jí)元素的元信息,寫(xiě)入第一個(gè) op 中
塊級(jí)元素的元信息包括:縮進(jìn),有序列表序號(hào),【當(dāng)前元素所在塊級(jí)元素】在原數(shù)據(jù)中的起始與終止索引,【當(dāng)前元素所在塊級(jí)元素】在 dom 列表中的索引



經(jīng)過(guò)上面轉(zhuǎn)化后原數(shù)據(jù)變成上圖中的格式,每個(gè) op 都含有相應(yīng)的元數(shù)據(jù),接下要做的就是解析這些 op,將其轉(zhuǎn)化成 Element。



對(duì)于自定義 blot 的渲染,我們可以封裝成組件(react 或 vue 組件,取決你使用什么框架),這樣業(yè)務(wù)功能和編輯器開(kāi)發(fā)可解耦,不了解編輯器代碼的同學(xué)也能夠參與開(kāi)發(fā)。

小結(jié)
至此,我們已經(jīng)了解開(kāi)發(fā)編輯器的基本流程和需要重點(diǎn)關(guān)注的一些事項(xiàng)。如果業(yè)務(wù)中需要拓展一些功能卡片,如飛書(shū)文檔的各種應(yīng)用,可通過(guò)拓展 blot + 編寫(xiě)對(duì)應(yīng)的組件來(lái)實(shí)現(xiàn)。此外還能夠通過(guò)編寫(xiě)相應(yīng)平臺(tái)的解析器在非 web 場(chǎng)景的展示,輕松實(shí)現(xiàn)內(nèi)容跨平臺(tái)渲染。

參考資料
[1]
基于 canvas 渲染: https://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html

[2]
quill: https://quilljs.com/docs/quickstart/

[3]
delta: https://quilljs.com/docs/delta/

[4]
parchment: https://github.com/quilljs/parchment

[5]
paste event: https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event

[6]
clipboardData: https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/clipboardData

[7]
quill-delta-to-html: https://www.npmjs.com/package/quill-delta-to-html

[8]
指定寬高: https://developers.weixin.qq.com/miniprogram/dev/component/image.html

作者:ELab.huanglei.cc


歡迎關(guān)注微信公眾號(hào) :前端民工