初探富文本之富文本概述
富文本編輯器通常指的是可以對文字、圖片等進行編輯的產品,具有所見即所得的能力。對於Input
、Textarea
之類標籤,他們是支持內容編輯的,但並不支持帶格式的文本或者是圖片的插入等功能,所以對於這類的需求就需要富文本編輯器來實現。現在的富文本編輯器也已經不僅限於文字和圖片,還包括視頻、表格、代碼塊、思維導圖、附件、公式、格式刷等等比較複雜的功能。
描述
富文本編輯器實際上是一個水非常深的領域,其本身還是非常難以實現的,例如如何處理光標、如何處理選區等等,當然藉助於瀏覽器的能力我們可以相對比較簡單的實現類似的功能,但是由此就可能導致過於依賴瀏覽器而出現兼容性等問題。此外,當前有一個非常厲害的選手名爲Word
,當產品提出需求的時候,如果是參考Word
來提的,那麼這就是天坑了,Word
支持的能力太強大了,那麼大的安裝包也不是沒有理由的,其內部做了巨量的富文本相關case
的處理。
當然在這裏我們敘述的是在瀏覽器中實現的富文本,我們也不太可能在瀏覽器中憑藉幾百KB
或者幾MB
來實現Word
這種幾GB
安裝包所提供的功能。雖然僅僅是在瀏覽器中實現富文本編輯的能力,但是這也並不是一件容易的事情。對於我們開發者而言,可能會更加喜歡使用Markdown
來完成相關文檔的編寫,當然這就不屬於富文本編輯器的範疇了,因爲Markdown
文件是純文本的文件,關注點主要在渲染上,如果想在Markdown
中拓展語法甚至嵌入React
組件的話的話,可以參考markdown-it
與mdx
項目。
演進之路
Web
富文本編輯器也是在不斷演進,在整個發展的過程中,也是遇到了不少困難,而正是因爲這些問題,可以將發展歷程分爲L0
、L1
、L2
三個階段的發展歷程。當然在這裏沒有好不好,只有適合不適合,通常來說L1
的編輯器已經滿足於絕大部分富文本編輯場景了,另外還有很多開箱即用的富文本編輯器可選擇,具體的選型還是因需求而異。
類型 | 特點 | 產品 | 優勢 | 劣勢 |
---|---|---|---|---|
L0 |
1. 基於瀏覽器提供的contenteditable 實現富文本編輯。 2. 使用瀏覽器的 document.execCommand 執行命令操作。 |
早期輕量編輯器。 | 較短時間內快速完成開發。 | 可定製的空間非常有限。 |
L1 |
1. 基於瀏覽器提供的contenteditable 實現富文本編輯。 2. 數據驅動,自定義數據模型與命令的執行。 |
石墨文檔、飛書文檔。 | 滿足絕大部分使用場景。 | 無法突破瀏覽器自身的排版效果。 |
L2 |
1. 自主實現排版引擎。 2. 只依賴少量的瀏覽器 API 。 |
Google Docs 、騰訊文檔。 |
完全由自己控制排版。 | 技術難度相當高。 |
L0
在前邊是將當前的編輯器發展歸爲了L0
、L1
、L2
三個階段,也有將L0
階段再分兩個階段,總分爲四個階段的,不過由於這兩階段都是完全依賴於contenteditable
與document.execCommand
的實現,所以在這裏就統歸於L0
了。
下面是一個最最簡單的本編輯器的實現,只要將下方的代碼複製到瀏覽器的地址欄,就可以擁有一個簡單的文本編輯器了。此時我們離富文本編輯器就差一個document.execCommand
的執行了,可以通過完成一個工具欄來執行命令,將選中文本的格式轉換爲另一種格式。
data:text/html, <div contenteditable="true"></div>
做過文本複製功能的同學應該比較熟悉document.execCommand("copy")
這個命令,這也是在navigator.clipboard
不可用時的一個降級方案。在 MDN 中列出了document.execCommand
支持的所有命令,可以看到其支持bold
、heading
等等參數,我們可以通過配合contenteditable
以及這些參數實現一個簡單的富文本編輯器。
當然如果需要配合命令的執行,我們就不能這麼簡單地只使用一行代碼來實現了,在這裏以加粗爲示例完成一個DEMO
。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L0</title>
</head>
<body>
<div contenteditable style="border: 1px solid #aaa;">測試文本加粗<br>選中文字後點擊下邊的加粗按鈕即可加粗</div>
<button onclick="document.execCommand('bold')" style="margin-top: 10px;">加粗</button>
</body>
</html>
L1
通過document.execCommand
來執行命令修改HTML
的方案雖然簡單,但是很明顯他的可控性很差,例如實現加粗的功能,我們無法控制是使用<b></b>
來實現加粗還是<strong></strong>
來實現加粗,而且還有瀏覽器的兼容性問題,例如在IE
瀏覽器中是使用<strong></strong>
來實現加粗,在Chrome
中是使用<b></b>
來實現加粗,IE
和Safari
不支持通過heading
命令來實現標題命令等等。document.execCommand
只能實現一些相對比較簡單的格式,對於一些比較複雜的功能,例如圖片、代碼塊等等,document.execCommand
是無法實現的。
爲了更強的拓展性,也解決數據與視圖無法對應的問題,L1
的富文本編輯器使用了自定義數據模型的概念,就是在DOM
樹的基礎上抽離出來的數據結構,相同的數據結構可以保證渲染的HTML
也是相同的,配合自定義的命令直接控制數據模型,最終保證渲染的HTML
文檔的一致性。簡單來說就是構建一個描述文檔結構與內容的數據模型,並且使用自定義的execCommand
對數據描述模型進行修改。類似於MVVM
模型,當執行命令時,會修改當前的模型,進行表現到視圖的渲染上。
L1
階段的富文本編輯器,通過抽離數據模型,解決了富文本中髒數據、複雜功能難以實現的問題。通過數據驅動,可以更好的滿足定製功能、跨端解析、在線協作等需求。在這裏基於slate
實現了一個L1
的富文本的DEMO
,Github | Editor DEMO。
L2
實際上L1
已經能夠滿足絕大部分的場景了,如果對排版和光標也有深度的訂製,纔有考慮使用L2
定製的必要,當然一般這種情況下我的建議是直接砍需求。關於排版和光標的定製化需求,舉個例子,Word
使用的比較多的同學應該會注意到,如果我們編寫的文字正好排滿了一行,假如在這裏再加一個句號,那麼前邊的字就會擠一擠,從而可以使這個句號是不需要換行,而如果我們再敲一個字的話,這個字是會換行的,在瀏覽器的排版中是不會出現這個狀態的,所以假如需要突破瀏覽器的排版限制,就需要自己實現排版能力。。
如果有用過CodeMirror5
的用戶可能會注意到,在默認配置下的CodeMirror
,除了排版的能力不是完全自行實現的,其他的方面都有自己的一套實現方案,例如光標是通過div
來模擬定位的、輸入是通過一個跟隨光標移動的Textarea
輸入事件的監聽來完成的,選區也是通過一層div
覆蓋來完成的,滾動條也是自行模擬實現的。這就很有L2
的味道了,當然這還不能算是完全的L2
,畢竟還是藉助了瀏覽器來幫我們排版文字,計算光標的位置也是藉助了瀏覽器的Range
,但是這種幾乎完全由自己來模擬的方案已經非常具有難度了。
完全實現L2
的能力不亞於自研一個瀏覽器了,因爲需要支持瀏覽器的各種排版能力,複雜性是相當高的。此外,在這方面的性能損耗也是比較大的,在排版的時候需要自己實現一個排版引擎,在排版時需要不斷的計算每個字符的位置,這個計算量是非常大的,如果性能不好的話,會導致排版的時候卡頓,這個體驗是非常糟糕的。現在主流的L2
富文本編輯器都是藉助於Canvas
來繪製所有的內容,而因爲Canvas
只是一個畫板,所以無論是排版還是選區、光標等等都需要自行計算與實現。由於計算量很大,所以有大量計算的部分通常都交予Web Worker
去處理,再由postMessage
來完成數據通信,用來提高性能。
正如遊戲角色所突破的瓶頸期,富文本編輯器在L0
躍遷至L1
發生的改變是自定義數據模型的抽離,在L1
躍遷至L2
的改變則是自定義的排版引擎。
核心概念
這裏的核心概念主要是指的L1
富文本編輯器中一些通用的概念,因爲在L1
中的編輯器通常是自行維護了一套數據結構與渲染方案,所以一般都會有自己構建的一套模型體系,例如Quill
的Parchment
、Blot
、Delta
等等,Slate
的Transforms
、Normalizing
、DOM DATA MODEL
等等,但是隻要是藉助於瀏覽器以及contenteditable
的實現,便離不開一些基本概念。
Selection
Selection
對象表示用戶選擇的文本範圍或插入符號的當前位置,其代表頁面中的文本選區,可能橫跨多個元素,由用戶拖拽鼠標經過文字而產生。當 Selection
處於Collapsed
狀態時,即是日常所說的光標,也就是說光標其實是Selection
的一種特殊狀態。此外,注意其與focus
事件或document.activeElement
等值沒有必然聯繫。
因爲還是運行在瀏覽器中嘛,所以實現富文本編輯器還是需要依賴於這個選區的變化的,通常來說當選中的文本內容發生變動時,會觸發SelectionChange
事件,通過這個事件的回調觸發來完成一些事情。
window.getSelection();
// {
// anchorNode: text,
// anchorOffset: 0,
// baseNode: text,
// baseOffset: 0,
// extentNode: text,
// extentOffset: 3,
// focusNode: text,
// focusOffset: 3,
// isCollapsed: false,
// rangeCount: 1,
// type: "Range",
// }
在編輯器中通常不會直接使用這些選區來完成想要的操作,例如在Quill
中的選區是以起始位置配合長度來表示選區的,這也主要是配合其Delta
來描述文檔模型而決定的,那麼這樣的話在Quill
中就完成了Selection
選區到Delta
選區的操作,以此來獲取我們可以操作Delta
的選區。
quill.getSelection()
// {index: 0, length: 3}
在Slate
中藉助了很多DOM
中的概念,例如Void Element
、Selection
等等,在Slate
中的選區也是經過處理的,同樣也是因爲其數據結構是類似於DOM MODEL
結構的JSON
數據類型,所以其Point
是由Path + Offset
來表示的,所以其選區則是由兩個Point
來構成的。此外,對比於Quill
,Slate
保留了用戶從左至右或者從右至左進行選區操作時的順序,也就是說選擇同樣的區域,從左至右和從右至左的選區是不同的,具體而言就是anchor
和focus
是反過來的。
slate.selection
// {
// anchor: {
// offset: 0,
// path: [0, 0],
// },
// focus: {
// offset: 3, // 文本偏移量
// path: [0, 0], // 文本節點
// },
// };
Range
無論是基於contenteditable
還是超越contenteditable
的編輯器都會有Range
的概念。Range
翻譯過來是範圍、幅度的意思,與數學上的區間概念類似,也就是說Range
指的是一個內容範圍。實際上瀏覽器中的Selection
就是由Range
來組成的,我們可以通過selection.getRangeAt
來取得當前選區的Range
對象。
具體的,瀏覽器提供的Range
用來描述DOM
樹中的一段連續的範圍,startContainer
、startOffset
用以描述Range
的起始處,endContainer
、endOffset
描述Range
的結尾處。當一個Range
的起始處和結尾處是同一個位置時,該Range
就處於Collapsed
狀態,也就是我們常見的光標狀態。其實Selection
就是表示Range
的一個方式,而且Selection
通常是隻讀的,但是構造的Range
對象是可以操作的。通過配合Selection
對象以及Range
對象我們可以完成選區的一些操作,例如增加或取消當前選區的選中。
const selection = document.getSelection();
const range = document.createRange();
range.setStart(node, 0); // 文本節點 偏移量
range.setEnd(node, 1); // 文本節點 偏移量
selection.removeAllRanges();
selection.addRange(range);
Copy & Paste
複製粘貼也是一個比較核心的概念,因爲在當前的富文本編輯器中我們通常是維護了一套自定義程度非常高的DOM
結構,例如我們使用一級標題的時候可能不會去使用H1
標籤,而是通過div
去模擬,以避免H1
的嵌套帶來的問題,但是這樣就會造成另外的問題。
首先對於複製來說,我們希望複製出來的text/html
節點是比較符合標準的,一級標題就應該用H1
來表示,由於數據結構是我們自己維護的,由我們自己的數據結構生成怎樣的text/html
也應該由我們自己說了算,尤其是在L2
編輯器中,直接都沒有DOM
結構,我們想完成複製行爲那麼就必須自行實現,而對於粘貼來說我們是更加關注的,因爲當前的數據模型通常是我們自行維護的,所以我們從別的地方複製過來的富文本我們是需要解析成爲我們能夠使用的數據結構的,例如Quill
的Delta
模型,Slate
的JSON DOM
模型,所以對於複製粘貼行爲我們也需要進行一個劫持,阻止默認行爲的發生。
對於複製行爲,我們可以在複製的時候取得當前選區的內容,然後將其進行序列化,拼接好HTML
字符串,之後如果可以使用navigator.clipboard
以及window.ClipboardItem
兩個對象,就可以直接構造Blob
進行寫入了,如果不支持的話也可以兜底,通過onCopy
事件的clipboardData
對象的setData
方法將其設置到剪貼板中,如果依舊無法完成的話,就直接寫入text/plain
而不寫入text/html
了,兜底策略還是很多的。
// slate example // serializing
const editor = {
children: [
{
type: 'paragraph',
children: [
{ text: 'An opening paragraph with a ' },
{
type: 'link',
url: 'https://example.com',
children: [{ text: 'link' }],
},
{ text: ' in it.' },
],
},
{
type: 'quote',
children: [{ text: 'A wise quote.' }],
},
{
type: 'paragraph',
children: [{ text: 'A closing paragraph!' }],
},
],
}
// ===>
// <p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p>
// <blockquote><p>A wise quote.</p></blockquote>
// <p>A closing paragraph!</p>
而對於粘貼行爲,我們就需要通過監聽onPaste
事件,通過event.clipboardData.getData("text/html")
來獲取當前粘貼的text/html
字符串,當然如果沒有的話就取得text/plain
就好了,都沒有的話就相當於粘貼了個寂寞,如果有text/html
字符串的話,我們就可以利用DOMParser
來解析字符串了,然後再去構建我們自己需要的數據結構。
<!-- slate example --> <!-- deserializing -->
<p>An opening paragraph with a <a href="https://example.com">link</a> in it.</p>
<blockquote><p>A wise quote.</p></blockquote>
<p>A closing paragraph!</p>
<!-- ===> -->
<!-- const fragment = [
{
type: 'paragraph',
children: [
{ text: 'An opening paragraph with a ' },
{
type: 'link',
url: 'https://example.com',
children: [{ text: 'link' }],
},
{ text: ' in it.' },
],
},
{
type: 'quote',
children: [
{
type: 'paragraph',
children: [{ text: 'A wise quote.' }],
},
],
},
{
type: 'paragraph',
children: [{ text: 'A closing paragraph!' }],
},
] -->
History
History
也就是Undo/Redo
操作,其操作的實現方式分爲兩類:記錄數據和記錄操作。
記錄數據的操作類似於保存快照,當用戶進行操作的時候,無論發生任何操作,都將整篇內容進行保存,並維護一個線性的棧。當進行Undo/Redo
操作的時候,將即將要恢復的棧中的內容完全呈現出來。這種操作類似於以空間換時間,我們不必考慮用戶究竟改變了哪寫數據,反正是變化的時候就會記錄所有可能改變的部分,這種做法實現比較簡單,但是如果數據量比較大的話,就比較耗費內存了。
記錄操作保存的是操作,包括具體的操作動作以及操作改變的數據,同樣也是維護一個線性的棧。當進行Undo/Redo
操作的時候,將保存的操作進行反向操作。這種方法類似於以時間換空間,每次只需要記錄用戶的操作類型以及相關的操作數據,而不需要將整篇內容進行存儲,節省了空間,但是相對的,複雜程度提高了很多。由於我們現在對於富文本的操作實際上都是通過命令來實現的,也就是說我們完全可以將這些內容存儲下來,維護一個保存操作記錄的方式更加符合現在的設計,此外這部分設計好的話,對於實現Operation Transform
的協同算法也是很有幫助的。
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/90931631
https://www.zhihu.com/question/38699645
https://www.zhihu.com/question/404836496
https://juejin.cn/post/7114547099739357214
https://juejin.cn/post/6844903555900375048
https://juejin.cn/post/6955335319566680077
https://segmentfault.com/a/1190000040289187
https://segmentfault.com/a/1190000041457245
https://codechina.gitcode.host/programmer/application-architecture/7-youdao-note.html