初探富文字之富文字概述

2022-11-19 12:00:24

初探富文字之富文字概述

富文字編輯器通常指的是可以對文字、圖片等進行編輯的產品,具有所見即所得的能力。對於InputTextarea之類標籤,他們是支援內容編輯的,但並不支援帶格式的文字或者是圖片的插入等功能,所以對於這類的需求就需要富文字編輯器來實現。現在的富文字編輯器也已經不僅限於文字和圖片,還包括視訊、表格、程式碼塊、思維導圖、附件、公式、格式刷等等比較複雜的功能。

描述

富文字編輯器實際上是一個水非常深的領域,其本身還是非常難以實現的,例如如何處理遊標、如何處理選區等等,當然藉助於瀏覽器的能力我們可以相對比較簡單的實現類似的功能,但是由此就可能導致過於依賴瀏覽器而出現相容性等問題。此外,當前有一個非常厲害的選手名為Word,當產品提出需求的時候,如果是參考Word來提的,那麼這就是天坑了,Word支援的能力太強大了,那麼大的安裝包也不是沒有理由的,其內部做了巨量的富文字相關case的處理。

當然在這裡我們敘述的是在瀏覽器中實現的富文字,我們也不太可能在瀏覽器中憑藉幾百KB或者幾MB來實現Word這種幾GB安裝包所提供的功能。雖然僅僅是在瀏覽器中實現富文字編輯的能力,但是這也並不是一件容易的事情。對於我們開發者而言,可能會更加喜歡使用Markdown來完成相關檔案的編寫,當然這就不屬於富文字編輯器的範疇了,因為Markdown檔案是純文字的檔案,關注點主要在渲染上,如果想在Markdown中拓展語法甚至嵌入React元件的話的話,可以參考markdown-itmdx專案。

演進之路

Web富文字編輯器也是在不斷演進,在整個發展的過程中,也是遇到了不少困難,而正是因為這些問題,可以將發展歷程分為L0L1L2三個階段的發展歷程。當然在這裡沒有好不好,只有適合不適合,通常來說L1的編輯器已經滿足於絕大部分富文字編輯場景了,另外還有很多開箱即用的富文字編輯器可選擇,具體的選型還是因需求而異。

型別 特點 產品 優勢 劣勢
L0 1. 基於瀏覽器提供的contenteditable實現富文字編輯。
2. 使用瀏覽器的document.execCommand執行命令操作。
早期輕量編輯器。 較短時間內快速完成開發。 可客製化的空間非常有限。
L1 1. 基於瀏覽器提供的contenteditable實現富文字編輯。
2. 資料驅動,自定義資料模型與命令的執行。
石墨檔案、飛書檔案。 滿足絕大部分使用場景。 無法突破瀏覽器自身的排版效果。
L2 1. 自主實現排版引擎。
2. 只依賴少量的瀏覽器API
Google Docs、騰訊檔案。 完全由自己控制排版。 技術難度相當高。

L0

在前邊是將當前的編輯器發展歸為了L0L1L2三個階段,也有將L0階段再分兩個階段,總分為四個階段的,不過由於這兩階段都是完全依賴於contenteditabledocument.execCommand的實現,所以在這裡就統歸於L0了。

下面是一個最最簡單的本編輯器的實現,只要將下方的程式碼複製到瀏覽器的位址列,就可以擁有一個簡單的文字編輯器了。此時我們離富文字編輯器就差一個document.execCommand的執行了,可以通過完成一個工具列來執行命令,將選中文字的格式轉換為另一種格式。

data:text/html, <div contenteditable="true"></div>

做過文字複製功能的同學應該比較熟悉document.execCommand("copy")這個命令,這也是在navigator.clipboard不可用時的一個降級方案。在 MDN 中列出了document.execCommand支援的所有命令,可以看到其支援boldheading等等引數,我們可以通過配合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>來實現加粗,IESafari不支援通過heading命令來實現標題命令等等。document.execCommand只能實現一些相對比較簡單的格式,對於一些比較複雜的功能,例如圖片、程式碼塊等等,document.execCommand是無法實現的。

為了更強的拓展性,也解決資料與檢視無法對應的問題,L1的富文字編輯器使用了自定義資料模型的概念,就是在DOM樹的基礎上抽離出來的資料結構,相同的資料結構可以保證渲染的HTML也是相同的,配合自定義的命令直接控制資料模型,最終保證渲染的HTML檔案的一致性。簡單來說就是構建一個描述檔案結構與內容的資料模型,並且使用自定義的execCommand對資料描述模型進行修改。類似於MVVM模型,當執行命令時,會修改當前的模型,進行表現到檢視的渲染上。

L1階段的富文字編輯器,通過抽離資料模型,解決了富文字中髒資料、複雜功能難以實現的問題。通過資料驅動,可以更好的滿足客製化功能、跨端解析、線上共同作業等需求。在這裡基於slate實現了一個L1的富文字的DEMOGithubEditor 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中的編輯器通常是自行維護了一套資料結構與渲染方案,所以一般都會有自己構建的一套模型體系,例如QuillParchmentBlotDelta等等,SlateTransformsNormalizingDOM 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 ElementSelection等等,在Slate中的選區也是經過處理的,同樣也是因為其資料結構是類似於DOM MODEL結構的JSON資料型別,所以其Point是由Path + Offset來表示的,所以其選區則是由兩個Point來構成的。此外,對比於QuillSlate保留了使用者從左至右或者從右至左進行選區操作時的順序,也就是說選擇同樣的區域,從左至右和從右至左的選區是不同的,具體而言就是anchorfocus是反過來的。

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樹中的一段連續的範圍,startContainerstartOffset用以描述Range的起始處,endContainerendOffset描述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結構,我們想完成複製行為那麼就必須自行實現,而對於貼上來說我們是更加關注的,因為當前的資料模型通常是我們自行維護的,所以我們從別的地方複製過來的富文字我們是需要解析成為我們能夠使用的資料結構的,例如QuillDelta模型,SlateJSON 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