深入淺出富文本編輯器
以下文章來(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) :前端民工