深感一無所長,準備試著從零開始寫個富文本編輯器
富文本編輯器是允許用戶在輸入和編輯文本內容時,可以應用不同的格式、樣式等功能,例如圖文混排等,具有所見即所得的能力。與簡單的純文本編輯組件<input>等不同,富文本編輯器提供了更多的功能和靈活性,讓用戶可以創建更豐富和結構化的內容。現代的富文本編輯器也已經不僅限于文字和圖片,還包括視頻、表格、代碼塊、附件、公式等等比較復雜的模塊。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實現富文本編輯器項目的相關文章:
- 深感一無所長,準備試著從零開始寫個富文本編輯器
- 從零實現富文本編輯器#2-基于MVC模式的編輯器架構設計
- 從零實現富文本編輯器#3-基于Delta的線性數據結構模型
- 從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略
- 從零實現富文本編輯器#5-編輯器選區模型的狀態結構表達
- 從零實現富文本編輯器#6-瀏覽器選區與編輯器選區模型同步
- 從零實現富文本編輯器#7-基于組合事件的半受控輸入模式
- 從零實現富文本編輯器#8-瀏覽器輸入模式的非受控DOM行為
Why?
那么為什么要從零設計實現新的富文本編輯器,編輯器是公認的天坑,且當前已經有很多優秀的編輯器實現。例如極具表現力的數據結構設計Quill、結合React視圖層的Draft、純粹的編輯器引擎Slate、高度模塊化的ProseMirror、開箱即用的TinyMCE/TipTap、集成協同解決方案的EtherPad等等。
我也算是比較關注于各類富文本編輯器的實現,包括在各個站點上的編輯器實現文章我也會看。但是我發現這其中極少有講富文本編輯器的底層設計,絕大多數都是講的應用層,例如如何使用編輯器引擎實現某某功能等。雖然這些應用層的實現本身也會有一定復雜性,但是底層的設計卻是更值得探討的問題。
此外,我覺得富文本編輯器很類似于低代碼的設計,準確來說是No Code的一種實現。本質上低代碼和富文本都是基于DSL的描述來操作DOM結構,只不過富文本主要是通過鍵盤輸入來操作DOM,而無代碼則是通過拖拽等方式來操作DOM,我想這里應該是有些共通的設計思路。
而我恰好前段時間都在專注于編輯器的應用層實現,在具體實現的過程中也遇到了很多問題,并且記錄了相關文章。然而在應用層實現的過程中,遇到了很多我個人覺得可以優化的地方,特別是在數據結構層面上,希望能夠將我的一些想法應用出來。而具體來說,主要有下面的幾個原因:
編輯器專欄
紙上得來終覺淺,絕知此事要躬行。
我的博客是從20年開始寫的,記錄的內容很多,基本上是想到什么就寫什么,畢竟是作為平時學習的記錄。然后在24年寫了比較多的富文本編輯器的文章,主要是整理了平時遇到的問題以及解決方案,集中在應用層的設計上,例如:
此外,前段時間還研究了slate富文本編輯器相關的實現,并且也給slate的倉庫提過一些PR。還寫了一些slate相關的文章,并且還基于slate實現了一個文檔編輯器,同樣也是比較關注于應用層的實現,例如:
在實現了諸多的應用層的功能之后,發現整個編輯器有很多可以深入研究的地方。特別是有些實現看似很理所當然,但是仔細研究起來會發現這其中有很多細節可以探究,例如在DOM結構后常見的零寬字符、Mention節點的渲染等等,這些內容都可以單獨拿出來記錄文章,這其實就是我想從零實現編輯器的最重要原因。
24年開始寫了很多業務上的東西,到了25年就略感題窮,而目前我也沒有別的擅長的方面,由此寫編輯器相關的內容是比較好的選擇,這樣對于文章的選題也會簡單些。不過,雖然想的是深入寫編輯器相關的內容,但是在平時遇到問題的時候,還是會記錄下來,例如最近有個基于immer配合OT-JSON實現的狀態管理的想法可以實現。
而對于編輯器的具體實現,我目前的目標是實現可用的編輯器,而不是兼容性非常好且功能完備的編輯器。主要是現在已經有非常多優秀的編輯器實現,且有很多生態插件可以支持,能夠滿足大部分的需求。目前我想實現的編輯器主要是兼容Chrome瀏覽器即可,移動端的問題暫時不會考慮。不過,如果能夠將編輯器做得比較好的話,自然可以去做兼容性適配。
不過目前還是試探性地來設計并實現編輯器,期間必然會遇到很多問題,這些問題也將會成為專欄的主體內容。最開始的時候,我是準備將編輯器完善后再開始撰寫文章,后來發現設計過程中的歷史方案同樣很有價值,因此決定將設計過程也一并記錄下來。如果將來真的能夠將編輯器適用于生產環境,那么這些文章就能夠溯源到模塊為什么這么設計,想必也是極好的。整體來說,我們不能一口吃成胖子,但是一口一口吃卻是可以的。
深入編輯器
這部分是讓我想起來一句話:我們富文本編輯器是這樣的,你不寫你不懂。
編輯器是個非常注重細節的工程,很多時候都需要深入研究瀏覽器的API,例如document上的caretPositionFromPoint方法,用以獲取當前某個點所在的選區位置,通常用于拖拽文本后的落點定位。除此之外,還有很多選區相關的API,例如Selection、Range等等,這些都是編輯器實現的基礎。
那么深入編輯器底層就是很有意義的事情,很多時候我們都需要跟瀏覽器打交道,即使是對我們平時的業務開發也會有價值。在這里我想聊一下編輯器中的零寬字符,以此例學習編輯器的細節設計,這是一個非常有意思的話題,類似這種內容就是不研究則不會關注到的有趣事情。
零寬字符顧名思義是沒有寬度的字符,因此就很容易推斷出這些字符在視覺上是不顯示的。因此這些字符就可以作為不可見的占位內容,實現特殊的效果。例如可以實現信息隱藏,以此來實現水印的功能,以及加密的信息分享等等,某些小說站點會通過這種方式以及字形替換來追溯盜版。
而在富文本編輯器中,如果我們在開發者工具檢查元素時,可能會發現一些類似于​即U+200B類似的字符,這就是常見的零寬字符。例如在飛書文檔的編輯器中,我們通過("[data-enter]")就可以檢查到其中存在的零寬字符。
<!-- document.querySelectorAll("[data-enter]") -->
<span data-string="true" data-enter="true" data-leaf="true">\u200B</span>
<span data-string="true" data-enter="true" data-leaf="true">​</span>
那么從名字上來看,這個零寬字符在視覺上是不顯示的,因為其是零寬度。但是在編輯器中,這個字符卻是很重要的。簡單來說,我們需要這個字符來放置光標,以及做額外的顯示效果。需要注意的是我們在這里指的是ContentEditable實現的編輯器,如果是自繪選區的編輯器則不一定需要這部分設計。
我們先來聊一下額外的顯示效果,舉個例子,我們在選擇飛書文檔文本內容,如果選中到文本末尾時,會發現末尾會額外多出形似xxx|的效果。在平時不關注的話可能會覺得這是編輯器默認行為,但是實際上這個效果無論是slate還是quill中都是不存在的。
實際上這個效果就是使用零寬字符來實現的,在行內容的末尾后面插入零寬字符,就可以做到末尾的文本選中效果。實際上這個效果在word中更常見,也就是額外渲染的回車符號。
<div contenteditable="true">
<div><span>末尾零寬字符 Line 1</span><span>​</span></div>
<div><span>末尾零寬字符 Line 2</span><span>​</span></div>
<div><span>末尾純文本 Line 1</span></div>
<div><span>末尾純文本 Line 2</span></div>
</div>
那么在這個零寬字符如果只是渲染效果的話,那么可能實際上起的作用并不很必要。但是在交互上這個效果卻很有用,例如此時我們有3行文本,如果此時從第1行末尾選到第2行時,并且按下Tab鍵,那么此時這兩行的內容就會縮進。
那么如果沒有這個顯示效果,此時進行縮進操作,用戶可能認為僅僅是選中了第2行,但是實際上是選中了1/2兩行文本。這樣的話用戶可能會以為是BUG,而我們也實際接受過這個交互效果的反饋。
123|
4|x56
也對各個在線文檔實現進行了簡單調研: 基于contenteditable實現的編輯器中,飛書文檔、早期EtherPad存在這個交互實現;自繪選區的編輯器中,釘釘文檔存在這個實現;Canvas引擎實現的編輯器中,騰訊文檔、Google Doc存在這個實現。
在渲染效果部分,零寬字符還有一個重要的作用是撐起行內容。當我們的行內容為空時,此時這個行DOM結構的內容就是空,這就導致此行的高度塌陷為0,且無法放置光標。為了解決這個問題,我們可以選擇在行內容中插入零寬字符,這樣就可以撐起行內容且可以放置光標。當然使用<br>來撐起行高也是可以的,使用這兩種方案會各有優劣,且兼容性方面也有所不同。
<div data-line-node></div>
<div data-line-node><br></div>
<div data-line-node><span>​</span></div>
在類似于Notion這種塊結構的編輯器中,還有個比較重要的交互效果。即塊級結構獨立選擇,例如我們可以直接將整個代碼塊獨立選出來,而不是僅僅能選擇其中的文本。這種效果在目前的開源編輯器很少有實現,都是需要自行以塊結構重新組織設計選區。
通常來說,這個交互同樣可以使用零寬字符來實現。因為我們的選區通常是需要放置在文本節點上的,因此我們很容易可以想到,可以在塊結構所在行的末尾放置零寬字符,當選區在零寬字符上時就將整個塊選中。這里用零寬字符而不是<br>的好處是,零寬字符本身就是零寬,不會引起額外的換行。
<div>
<pre><code>
xxx
</code></pre>
<span data-zero-block>​</span>
</div>
在結構上,零寬字符還有個非常重要的實現。在編輯器內的contenteditable=false節點會存在特殊的表現,在類似于inline-block節點中,例如Mention節點中,當節點前后沒有任何內容時,我們就需要在其前后增加零寬字符,用以放置光標。
在下面的例子中,line-1是無法將光標放置在@xxx內容后的,雖然我們能夠將光標放置之前,但此時光標位置是在line node上,是不符合我們預期的文本節點的。那么我們就必須要在其后加入零寬字符,在line-2/3中我們就可以看到正確的光標放置效果。這里的0.1px也是個為了兼容光標的放置的magic,沒有這個hack的話,非同級節點光標同樣無法放置在inline-block節點后。
<div contenteditable style="outline: none">
<div data-line-node="1">
<span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
</div>
<div data-line-node="2">
<span data-leaf>​</span>
<span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
<span data-leaf>​</span>
</div>
<div data-line-node="3">
<span data-leaf>​<span contenteditable="false">@xxx</span>​</span>
</div>
</div>
除此之外,編輯器自然是需要跟字符打交道的,那么在js表現出來的Unicode編碼實現中,emoji就是最常見且容易出問題的表達。除了其單個長度為2這種情況外,組合的emoji也是使用獨特的零寬連字符\u200d來表示的。
"??".length
// 2
"??" + "\u200d" + "??"
// ?????
數據結構設計
編輯器數據結構的設計是影響面非常廣的事情,無論是在維護編輯器的文本內容、塊結構嵌套、序列化反序列化等,還是平臺應用層面上的diff算法、查找替換、協同算法等,以及后端服務的數據轉換、導出md/word/pdf、數據存儲等,都會涉及到編輯器的數據結構設計。
通常來說,基于JSON嵌套的數據結構來表達編輯器Model是很常見的,例如Slate、ProseMirror、Lexical等等。以slate編輯器為例,無論是數據結構還是選區的設計,都盡可能傾向于HTML的設計,因此可以存在諸多層級節點的嵌套。
[
{
type: "paragraph",
children: [{ text: "editable" }],
},
{
type: "ul",
children: [
{
type: "li",
children: [{ text: "list" }],
},
],
},
];
通過線性的扁平結構來表達文檔內容也是常見的實現方案,例如Quill、EtherPad、Google Doc等等。以quill編輯器為例,其內容上的數據結構表達不會存在嵌套,當然本質上還是JSON結構,而選區則采用了更精簡的表達。
[
{ insert: "editable\n" },
{ insert: "list\n", attributes: { list: "bullet" } },
];
當然還有很多特別的數據結構設計,例如vscode/monaco的piece table數據結構。代碼編輯器又何嘗不是一種富文本編輯器,畢竟其是可以支持代碼高亮的功能的,只不過類似piece table的結構我還沒有太深入研究。
在這里我希望能夠以線性的數據結構來表達整個富文本結構,雖然嵌套的結構能夠更加直觀地表達文檔內容,但是對于內容的操作起來會更加復雜,特別是存在嵌套的內容時。以slate為例,在0.50之前的版本API設計非常復雜,需要比較大的理解成本,雖然之后將其簡化了不少:
// https://github.com/ianstormtaylor/slate/blob/6aace0/packages/slate/src/interfaces/operation.ts
export type NodeOperation =
| InsertNodeOperation
| MergeNodeOperation
| MoveNodeOperation
| RemoveNodeOperation
| SetNodeOperation
| SplitNodeOperation;
export type TextOperation = InsertTextOperation | RemoveTextOperation;
從這里可以看出來,slate對于文檔內容的完整操作是需要9種類型的Op。而如果是基于線性結構的話,我們就只需要三種類型的操作,即可表達整個文檔的操作。當然對于一些類似Move的操作,則需要額外的選區Range計算處理,相當于將計算成本移交到了應用層。
// https://github.com/WindRunnerMax/BlockKit/blob/c24b9e/packages/delta/src/delta/interface.ts
export interface Op {
// Only one property out of {insert, delete, retain} will be present
insert?: string;
delete?: number;
retain?: number;
attributes?: AttributeMap;
}
此外,嵌套結構的normalize會變得很復雜,且變更造成的時間復雜度也會變高,特別是臟路徑標記算法,以及標記后的數據處理也需要由上述Op處理。還有用戶操作導致的嵌套層級無法非常好地控制,就要normalize過程時規范數據,否則下面例如粘貼HTML時就可能會出現大量的數據嵌套。
[{
children: [{
children: [{
children: [{
children: [{
// ...
text: "content"
}]
}]
}]
}]
}]
再舉個更加實用的例子,如果我們此時存在格式的嵌套內容。例如quote與list兩種格式嵌套,如果此時我們文檔的數據結構是嵌套結構,那么操作內容就會存在ul > quote或者quote > ul的兩種情況,正常情況下我們必須要設計規則來做normalize;而扁平結構下,屬性全部寫在attrs內,不同操作造成的數據格式變更是完全冪等的。
// slate
[{
type: "quote",
children: [{
type: "ul",
children: [{ text: "text" }]
}],
}, {
type: "ul",
children: [{
type: "quote",
children: [{ text: "text" }]
}],
}]
// quill
[{
insert: "text",
attributes: { blockquote: true, list: "bullet" }
}]
扁平的數據結構在數據處理方面會存在優勢,而在視圖層面上,扁平的數據結構表達結構化的數據會是比較困難的,例如表達代碼塊、表格等嵌套結構。但是這件事并非是不可行的,例如Google Doc的復雜表格嵌套就是完全的線性結構,這其中是存在很巧妙的設計在里邊的,在這里先不展開了。
此外,如果我們需要實現在線文檔的編輯器的話,在整個管理流程中可能會需要diff,即取得兩邊數據結構的增刪改。這種情況下扁平的數據結構能夠更好地處理文本內容,而JSON嵌套結構的數據則會麻煩很多。還有一些其他關于數據處理方面的周邊應用,整體復雜度都要提升不少。
最后還是有協同相關的實現,協同算法是富文本編輯器的可選模塊。無論是基于OT的協同算法,還是Op-Based CRDT的協同算法,都是需要傳輸上述的op類型與數據的,那么很顯然9種操作的op類型會比3種操作的op類型更加復雜。
- OT.js: Text 數據類型
- ShareDB Rich-Text: Delta OT 數據類型
- ShareDB JSON0: JSON OT 數據類型
- ShareDB Slate: Slate OT 數據結構適配器
- YJS YText: Delta 數據類型實現
- YJS YMap/YArray: JSON 數據類型實現
- YJS Slate: Slate 數據結構適配器
因此,我希望能夠以線性的數據結構來實現整個編輯器結構,這樣quill的delta就是非常好的選擇。但是quill是自行實現的視圖層結構,并非是可以組合react等視圖層的形式,組合這些視圖層的優勢就是可以直接使用組件庫樣式來實現編輯器,而避免了每個組件都需要自行實現。那么這里我準備基于quill的數據結構,來從零實現富文本編輯器核心層,并且像slate一樣以此組合基本的視圖層。
方案選型
其實這里有個有趣的問題,為什么用不到1mb的代碼量就可以實現部分類似office word編輯器的能力,是因為瀏覽器已經幫我們做了很多事情,并通過API提供給開發者,包括輸入法處理、字體解析、排版引擎、視圖渲染等等。
因此我們是需要設計出如何跟瀏覽器交互的方案,畢竟我們實際上是需要跟瀏覽器交互的。而對于富文本編輯器最經典的描述則是分為了三級:
L0: 基于瀏覽器提供的ContentEditable實現富文本編輯,使用瀏覽器的document.execCommand執行命令操作。 是作為早期輕量編輯器,可以較短時間內快速完成開發,但可定制的空間非常有限。L1: 同樣基于瀏覽器提供的ContentEditable實現富文本編輯,但數據驅動可以自定義數據模型與命令的執行。常見的實現有語雀、飛書文檔等等,可以滿足絕大部分使用場景,但無法突破瀏覽器自身的排版效果。 |L2: 基于Canvas自主實現排版引擎,只依賴少量的瀏覽器API。常見的實現有Google Docs、騰訊文檔等等,具體實現需要完全由自己控制排版,相當于使用畫板而不是DOM來繪制富文本,技術難度相當高。
實際上在目前的開源產品中,這三種類型的編輯器都有涉及到,特別是絕大多數開源的都是L1類型的實現。而這其中還分化了不依賴ContentEditable卻也不是完全自繪引擎,而是依賴DOM呈現內容外加自繪選區的實現,實際上倒是可以算作L1.5的級別。
本著學習的目的,自然要選擇開源產品多的實現,這樣遇到問題可以更好地借鑒和分析相關內容。因此我同樣打算選擇基于ContentEditable,實現數據驅動的標準MVC模型的富文本編輯器,基于這種方式來與瀏覽器交互,實現基本的富文本編輯能力。在此之前,我們還是先了解一下基本的編輯器實現:
ExecCommand
如果我們僅僅需要最基本的行內樣式,例如加粗、斜體、下劃線等,這可能在一些基本輸入框中是足夠的,那么我們自然可以選擇使用execCommand來實現。甚至直接基于execCommand的好處就是,其體積會非常小,例如 pell 的實現,僅僅需要3.54KB的代碼體積,此外還有 react-contenteditable 等實現。
我們也可以實現可以加粗的最小DEMO,execCommand命令可以在contenteditable元素中選區內的元素執行,document.execCommand方法接受三個參數,分別是命令名稱、顯示用戶界面、命令參數。顯示用戶界面一般都是false,Mozilla沒有實現,而命令參數則是可選的,例如超鏈接命令則需要傳遞具體鏈接地址。
<div>
<button id="$1">加粗</button>
<div style="border: 1px solid #eee; outline: none" contenteditable>
123123
</div>
</div>
<script>
$1.onclick = () => {
document.execCommand("bold");
};
</script>
當然,這個示例過于簡單,我們還可以在選區變換的時候,來判斷加粗按鈕的加粗狀態,以此來顯示當前選區狀態。不過我們需要對齊execCommand的命令行為,前邊也提到了可控性非常差,因此我們需要通過document.createTreeWalker迭代所有的選區節點,以此來判斷當前選區的狀態。
其實這里還需要注意的是,execCommand命令的行為在各個瀏覽器的表現是不一致的,這也是之前我們提到的瀏覽器兼容行為的一種,然而這些行為我們也沒有任何辦法去控制,這都是其默認的行為:
- 在空
contenteditable編輯器的情況下,直接按下回車鍵,在Chrome中的表現是會插入<div><br></div>,而在FireFox(<60)中的表現是會插入<br>,IE中的表現是會插入<p><br></p>。 - 在有文本的編輯器中,如果在文本中間插入回車例如
123|123,在Chrome中的表現內容是123<div>123</div>,而在FireFox中的表現則是會將內容格式化為<div>123</div><div>123</div>。 - 同樣在有文本的編輯器中,如果在文本中間插入回車后再刪除回車,例如
123|123->123123,在Chrome中的表現內容會恢復原本的123123,而在FireFox中的表現則是會變為<div>123123</div>。 - 在同時存在兩行文本的時候,如果同時選中兩行內容再執行
("formatBlock", false, "P")命令,在Chrome中的表現是會將兩行內容包裹在同個<p>中,而在FireFox中的表現則是會將兩行內容分別包裹<p>標簽。 - ...
此外還有類似于實現加粗的功能,我們無法控制是使用<b></b>來實現加粗還是<strong></strong>來實現加粗。還有瀏覽器的兼容性問題,例如在IE瀏覽器中是使用<strong></strong>來實現加粗,在Chrome中是使用<b></b>來實現加粗,IE和Safari不支持通過heading命令來實現標題命令等等。且對于一些比較復雜的功能,例如圖片、代碼塊等等,是無法很好實現的。
當然,默認的行為并不是完全沒有用的,在某些情況下,我們可能要實現純HTML的編輯器。畢竟如果在基于MVC模式的編輯器實現中,會處理掉對Model來說無效的數據內容,這樣就導致原本的HTML內容丟失,因此在這種需求背景下依賴瀏覽器的默認行為可能是最有效的,這種情況下我們可能主要關注的就是XSS的處理了。
ContentEditable
ContentEditable是HTML5中的一個屬性,可以讓元素變得可編輯,再配合上內置的execCommand就是我們上邊聊的最基本DEMO。那么如果要實現最基本的文本編輯器,就只需要在地址欄中輸入下面的內容:
data:text/html,<div contenteditable style="border: 1px solid black"></div>
那么通過document.execCommand來執行命令修改HTML的方案雖然簡單,我們也聊過了其可控性很差。除了上述的execCommand命令執行兼容性問題之外,還有很多DOM上的需要兼容處理的行為,例如下面存在簡單加粗格式的句子:
123**456**789
有許多方式可以表達這樣的內容,編輯器可以認為顯示效果是等價的,此時可能也需要對此類DOM結構等同處理:
<span>123<b>456</b>789</span>
<span>123<strong>456</strong>789</span>
<span>123<span style="font-weight: bold;">456</span>789</span>
但是這里僅僅是視覺上相等,將其完整對應到Model上時,自然會是件麻煩的事。除此之外,選區的表達同樣也是復雜的問題,以下面的DOM結構為例:
<span>123</span><b><em>456</em></b><span>789</span>
如果我們要表達選區折疊在4這個字符左側時,同樣會出現多種表達可以實現這個位置,這實際上就會很依賴瀏覽器的默認行為:
{ node: 123, offset: 3 }
{ node: <em></em>, offset: 0 }
{ node: <b></b>, offset: 0 }
因此為了更強的擴展以及可控性,也解決數據與視圖無法對應的問題,L1的富文本編輯器使用了自定義數據模型的概念。即在DOM樹的基礎上抽離出來的數據結構,相同的數據結構可以保證渲染的HTML也是相同的,配合自定義的命令直接控制數據模型,最終保證渲染的HTML文檔的一致性。對于選區的表達,則需要根據DOM選區來不斷normalize選區Model。
其實這也就是我們常見的MVC模型,當執行命令時會修改當前的模型,進而表現到視圖的渲染上。簡單來說就是構建一個描述文檔結構與內容的數據模型,并且使用自定義的execCommand對數據描述模型進行修改。在這個階段的富文本編輯器,通過抽離數據模型,解決了富文本中臟數據、復雜功能難以實現的問題。我們也可以大概描述流程:
<script>
const editor = {
// Model 選區
selection: {},
execCommand: (command, value) => {
// 執行具體的命令, 例如 bold
// 命令執行后, 更新 Model 以及 調用 DOM 渲染
},
}
const model = [
// 數據模型
{ type: "bold", text: "123" },
{ type: "span", text: "123123" },
];
const render = () => {
// 根據 type 渲染具體 DOM
};
document.addEventListener("selectionchange", () => {
// 選區變換時
// 根據 dom 選區來更新 model 選區
});
</script>
而類似這種方案,無論是 quill 還是 slate 都是這樣的調度。而類似于slate的實現,通過適配器來連接React之后,就需要更復雜的兼容處理。在React節點中加入ContentEditable后,會出現類似下面的warning:
<div
contentEditable
suppressContentEditableWarning
></div>
// A component is `contentEditable` and contains `children` managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.
這個warning的意思是,React無法保證ContentEditable中的children不會被意外修改或復制,這可能是不被意料到的。也就是說除了React本身是會需要執行DOM操作的,使用了ContentEditable之后,這個行為就變的不受控了,自然這個問題同樣會出現在我們的編輯器中。
此外還有一些其他的行為,例如下面的例子中,我們無法從123字符選中到456上。也就是這里存在跨越ContentEditable節點了,就不能夠正常使用瀏覽器的默認行為來處理,雖然這個處理是很合理的,但畢竟也會對我們實現blocks編輯器造成一些困擾。
<div contenteditable="false">
<div contenteditable="true">123</div>
<div contenteditable="true">456</div>
</div>
那么其實我們是可以避免使用ContentEditable的,設想一下即使我們沒有實現編輯器,同樣是可以選擇頁面上的文本內容的,就是我們普通的選區實現。那么如果借助原生的選區實現,然后在此基礎上實現控制器層,就可以實現完全受控的編輯器。
但是這里存在一個很大的問題,就是內容的輸入,因為不啟用ContentEditable的話是無法出現光標的,自然也無法輸入內容。而如果我們想喚醒內容輸入,特別是需要喚醒IME輸入法的話,瀏覽器給予的常規API就是借助<input>來完成,因此我們就必須要實現隱藏的<input>來實現輸入,實際上很多代碼編輯器例如 CodeMirror 就是類似實現。
但是使用隱藏的<input>就會出現其他問題,因為焦點在input上時,瀏覽器的文本就無法選中了。因為在同個頁面中,焦點只會存在一個位置,因此在這種情況下,我們就必須要自繪選區的實現了。例如釘釘文檔、有道云筆記就是自繪選區,開源的 Monaco Editor 同樣是自繪選區,TextBus 則繪制了光標,選區則是借助了瀏覽器實現。
在這里可以總結一下,使用ContentEditable需要處理很多DOM的特異行為,但是明顯我們是不需要太過于處理喚醒輸入這個行為。而如果不使用ContentEditable,卻使用DOM來呈現富文本內容,則必須要借助額外的隱藏input節點來實現輸入,由于焦點問題在這種情況下就不能使用瀏覽器的選區行為,因此就需要自繪選區的實現。
Canvas
基于Canvas繪制我們需要的內容,頗有些文藝復興的感覺,這種實現方式是完全不依賴DOM的,因此可以完全控制排版引擎。那么文藝復興指的是,基于DOM兼容實現的任何生態都會失效,例如無障礙、SEO、開發工具支持等等。
那么為什么要拋棄現有的DOM生態,轉而用Canvas來繪制富文本內容。特別是富文本會是非常復雜的內容,因為除了文本外,還有圖片的內容,以及很多結構話格式的內容,例如表格等。這些內容都需要自行實現,那么在Canvas中實現這些內容其實相當于重新實現了部分skia。
基于Canvas繪制的編輯器,當前主要有騰訊文檔、Google Doc等,而開源的編輯器實現有 Canvas Editor。而除了文檔編輯器之外,在線表格的實現基本都是Canvas實現,例如騰訊文檔Sheet、飛書多維表格等,開源的實現有 LuckySheet。
在Google Doc發布的Blog中,對于使用Canvas繪制文檔主要選了兩個原因:
- 文檔的一致性: 這里的一致性指的是瀏覽器對于類似行為的兼容,舉個例子: 在
Chrome中雙擊某段文本的內容,選區會自動選擇為整個單詞,而在早期FireFox中則是會自動選擇一句話。類似這種行為的不一致會導致用戶體驗的不一致,而使用Canvas繪制文檔可以自行實現保證這種一致性。 - 高效的繪制性能: 通過
Canvas繪制文檔,可以更好地控制繪制時機,而不需要等待重繪回流,更不需要考慮DOM本身復雜的兼容性考量,以此造成的性能損失。此外,Canvas繪圖替代繁重的DOM操作,通過逐幀渲染和硬件加速可以提升渲染性能,從而提高用戶操作的響應速度和整體使用體驗。
此外,排版引擎還可以控制文檔的排版效果,做富文本的各種需求,我們就可能面臨產品為什么不能支持像office word那樣的效果。例如如果我們編寫的文字正好排滿了一行,假如在這里再加一個句號,那么前邊的字就會擠一擠,從而可以使這個句號是不需要換行。而如果我們再敲一個字的話,這個字是會換行的。在瀏覽器的排版中是不會出現這個狀態的,所以假如需要突破瀏覽器的排版限制,就需要自己實現排版能力。
<!-- word -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
<!-- 瀏覽器 -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本
。
也就是說,在word中通常是不會出現句號在段落起始的,而在瀏覽器中是會存在這種情況的,特別是在純ASCII字符的情況下。如果想規避這種排版狀態的差異,就必須要自行實現排版引擎,以此來控制文檔的排版效果。
此外,還有一些其他的功能,例如受控的RTL布局、分頁、頁碼、頁眉、腳注、字體字形控制等等。特別是分頁的能力,在某些需要打印的情況下,這個效果是很必要的,但是DOM的實現在繪制前是無法得知其高度的,因此也就無法很好地實現分頁的效果。除此之外,還有大表格的分頁渲染效果等等,都變得難以控制。
因此,這些如果希望對齊word的實現,就必須要用Canvas從頭造一遍。除了這些額外的功能,還有原本的瀏覽器基于DOM實現的基本功能,例如輸入法的支持、復制粘貼的支持、拖拽的支持等等。而基本的Canvas是無法支持這些功能的,特別是輸入法IME的支持,以及文本選區的實現,都需要很復雜的交互實現,這樣的成本自然不會是很容易接受的。
總結
在本文我們聊了很多關于富文本編輯器基礎能力的實現,特別是在DOM結構表現和數據結構之間的設計。并且在瀏覽器交互的方案上,我們也聊了ExecCommand、ContentEditable、Canvas實現方案的特點,簡單總結了當前成熟產品以及開源編輯器,并且描述了相關實現的優缺點。
在后邊我們會基于ContentEditable實現基本的富文本編輯器,首先對于整體的架構設計,以及數據結構的操作做概述。然后開始分別實現具體的模塊,例如輸入模塊、剪貼板模塊、選區模塊等等。實現編輯器從來都不是一件簡單的事情,除了在核心層面的基礎框架設計,應用層上也會有很多問題需要兼容處理,因此這將會是一份大工程,需要慢慢積累。
每日一題
參考
- https://github.com/w3c/editing
- https://zhuanlan.zhihu.com/p/226002936
- https://zhuanlan.zhihu.com/p/407713779
- https://zhuanlan.zhihu.com/p/425265438
- https://zhuanlan.zhihu.com/p/259387658
- https://zhuanlan.zhihu.com/p/145605161
- https://www.zhihu.com/question/38699645
- https://www.zhihu.com/question/404836496
- https://juejin.cn/post/6974609015602937870
- https://github.com/yoyoyohamapi/book-slate-editor-design
- https://github.com/grassator/canvas-text-editor-tutorial
- https://www.zhihu.com/question/459251463/answer/1890325108
- https://www.oschina.net/translate/why-contenteditable-is-terrible
- https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs.html
- https://cdacamar.github.io/data structures/algorithms/benchmarking/text editors/c++/editor-data-structures/

浙公網安備 33010602011771號