Rust+React創建富文本編輯器

簡介

在Fiberplane,我們最近遇到瞭一個有趣的挑戰:我們正在使用的富文本編輯器庫已經過時瞭。我們曾經使用Slate.js——一個很好的編輯器——但是當我們為協作編輯實現我們自己的富文本基元時,我們發現我們自己的基元和Slate的數據模型之間的脫節是一個阻礙因素。所以我們開始思考——如果我們建立自己的富文本編輯器(RTE, Rich Text Editor)會怎樣?

從一個非常高層次的角度來看,一個富文本編輯器是由兩個部分組成的。

  • 一個數據模型和對其進行操作的核心邏輯。
  • 一個渲染上述數據模型的狀態並處理用戶互動的視圖。

我們在視圖中使用瞭Slate,但結果是它也拉入瞭自己的數據模型。如果我們可以直接在React中實現視圖,我們可以大大簡化我們的堆棧,並完全控制它的每個方面。缺點是什麼?RTEs因為需要支持復雜的用戶交互而臭名昭著,而現在我們需要自己處理每一個交互。

在這篇文章中,我們將討論我們所面臨的挑戰以及我們如何解決這些問題。

數據模型

我們的產品是一個協作式的筆記本編輯器。筆記本是一個基於塊的編輯器,由不同類型的單元組成,從文本單元到圖片和圖表。因此,我們確定瞭一個數據模型,它既有利於我們的協作功能,也有利於為我們在單元格內使用的任何富文本字段提供動力的RTE。在這篇文章中,我們將重點討論TextCell

struct TextCell {
    pub id: String,
    pub content: String,
    pub formatting: Option<Formatting>,
}

這裡的content隻是純文本內容,而formatting是將純文本變成富文本的東西。"多汁"的部分都在格式化類型裡面。

type Formatting = Vec<AnnotationWithOffset>;
​
struct AnnotationWithOffset {
    annotation: Annotation,
    offset: u32,
}
​
enum Annotation {
    StartBold,
    EndBold,
    StartItalics,
    EndItalics,
    StartLink { url: String },
    EndLink,
    /* more like these... */
}

正如你所看到的,這隻不過是一個註釋列表,它定義瞭要應用的格式化類型和它開始的偏移量。我們有意不選擇類似於HTML的樹狀結構,因為格式化范圍可以重疊,這將導致復雜的樹狀操作。此外,每個註釋隻有一個偏移量的簡單性使我們很容易實現我們用於協作的操作轉換(OT)算法。

核心邏輯

隨著數據模型的出現,也帶來瞭與之互動的代碼。當你在一個單元格中打字時,我們在哪裡插入新打的字符?這如何影響content和相關的formatting?如果你在一個選擇上切換格式,應該發生什麼?如果你將一個單元格從中間分割開來,又該怎麼辦?所有這些以及更多都在Rust的核心邏輯中實現。

你要知道,無論如何我們都需要這些邏輯,因為我們的OT算法也需要它。但現在我們也能用同樣的原語來驅動我們的編輯器。

為瞭使這個邏輯易於測試,它被實現為純函數,我們在TypeScript的Redux reducer中調用。我們創建瞭fp-bindgen來生成Rust代碼和調用它的TypeScript代碼之間的綁定關系。

為瞭適應RTE(當我們還在使用Slate時還不需要),我們不得不自己引入一段邏輯,就是光標管理。例如,當用戶按下左方向鍵時,我們分派一個MoveCursor動作,其有效載荷如下。

struct MoveCursorPayload {
    pub delta: i32,
    pub extend_selection: bool,
    pub unit: CursorUnit,
}

delta指定光標是向前還是向後移動,通過指定一個1-1的值。extend_selection屬性是在用戶按住Shift鍵時使用的,用來擴展當前的選擇,或者在還沒有選擇的情況下創建一個。這個unit決定瞭我們是按Unicode字母群(用戶通常稱之為 "字符")還是按單詞移動光標,用於用戶按住Ctrl/鍵時。然後,我們的Rust還原器會處理這些動作,並處理所有的邊緣情況,包括確保光標不會出現在@的中間。

視圖

在我們RTE的大部分開發過程中,我們的編輯器甚至不是一個編輯器。至少從瀏覽器的角度來看不是。這是因為瀏覽器通常隻識別兩種類型的編輯器:純文本編輯器,如<input><textarea>元素,以及使用一種叫做contenteditable的屬性創建的自由格式編輯器。我們的編輯器兩者都不是。

我們在最終版本中仍然使用contenteditable屬性,因為我們很快會討論一些實際的影響,但我們有意識地決定盡可能少地依賴它。這對我們最初構建RTE的方式產生瞭深遠的影響,你將在本節中看到。

如果我們最初的版本根本沒有使用contenteditable,那麼我們怎麼能夠創建一個富文本編輯器?從用戶的角度來看,RTE隻不過是一個看起來像文本字段的東西,有一個光標,允許他們輸入任何他們喜歡的內容。

所以我們創建瞭一個普通的React組件,並根據單元格的contentformatting生成瞭富文本內容,然後使用React.createElement()插入實際的元素,這些元素隻是一個應用瞭樣式的<span>元素的平面列表(偶爾會有<a>元素灑在鏈接上)。然後,我們添加瞭必要的事件處理程序來捕捉用戶的互動,這又將再次調用數據模型上的適當邏輯。

那麼用戶的光標呢?隻是另一個我們自己插入的小React組件。我們會在useLayoutEffect()鉤子中測量它需要的位置,然後根據這個來定位它。

所以……很簡單,很容易,對嗎?好吧,我們現在需要處理的大量的交互使這成為一個重大的挑戰。例如,讓我們再看一下光標導航。上一節中的例子顯示瞭如何向左和向右移動光標。但是如果用戶按瞭向下的箭頭,他們的光標最終會在哪兩個字符之間呢?這不是一個簡單的問題,因為保持光標的垂直位置需要測量上面那一行的字符的位置。但你如何定義什麼是 "上面那一行"?無論是content還是formatting都不包含這些信息。然後記住我們還必須支持選擇。還有鼠標互動…

這當然會讓人感到不知所措,在開發過程中,可能很難保持對哪些工作和哪些不工作的概述。而這正是我們覺得最初沒有contenteditable的工作很好的原因。我們自己做所有的事情,使我們非常清楚自己的位置。任何不工作的交互都是我們仍然需要實現的。沒有什麼會意外地工作,因為瀏覽器為我們解決瞭這個問題–瀏覽器在這裡處於次要地位。

當然,對於最終的版本,很難繞過使用contenteditable。這是因為如果沒有它,瀏覽器擴展將無法識別你的編輯器。而移動瀏覽器甚至會頑固地拒絕調出屏幕鍵盤……

手動差異化

所以我們確實需要contenteditable,但是還有一個問題。React不支持對已啟用contenteditable的元素的內容進行修補。這是有原因的:contenteditable基本上是告訴瀏覽器去玩吧。這就像一個沒有規則的操場。

React並不喜歡這樣。它依靠虛擬DOM來決定它需要如何更新實際的DOM,但當瀏覽器可以在它不知情的情況下把地毯從它下面拉出來並更新實際的DOM時,這種方法就陷入瞭困境。這也是我們一開始就避免的原因。為瞭在更新我們的數據模型時能夠保留用戶的意圖(OT算法的一個重要方面),最好是瞭解導致任何變化的互動。但是,如果你試圖理解瀏覽器對DOM在內容可編輯元素中的變化,你最多隻能是猜測。

所以我們借鑒瞭React的玩法,實現我們自己的差異算法。但我們不是針對虛擬DOM進行差分,而是在useLayoutEffect()鉤子函數中針對真實DOM進行差分和修補。這相對簡單,因為我們的用例非常專業,而且它還有一個好處,如果真實DOM中發生任何意外(可能是由於瀏覽器擴展),我們的算法將簡單地將視圖恢復到我們基於數據模型的預期。

雜項

上述所有內容可能會讓你對編輯器的工作原理有一個較高的認識,但魔鬼是在細節中的。下面是我們需要解決的一些小問題。

  • 支持Unicode。每個人都喜歡的標準,但在工作中卻很麻煩。幸運的是,Rust有優秀的unicode_segmentation板塊,對我們幫助很大。這幫助我們解決瞭一些問題,比如按字進行光標導航,以及確保光標能正確地跳過字母群。
  • 光標定位是很棘手的,但我們發現最好的方法是使用瀏覽器的Selection對象,並通過這種方式設置一個(透明的)本地光標。然後我們使用getBoundingClientRect()來測量瀏覽器渲染光標的位置,然後我們就可以在那裡定位我們自己的光標。
  • 組合事件被瀏覽器用來組成帶有重音的字符和處理拼音等輸入。不要忘記處理這些。

總結

創建你自己的富文本編輯器是一項艱巨的任務,但隻要有正確的架構和良好的規劃,它肯定是可以做到的。如果你發現自己處於必須選擇或開發一個富文本編輯器的位置,我們希望你能發現這篇文章的有用信息。

註:特別感謝技術指導dazhao(趙達)對本文翻譯的審閱指正。

作者:Arend van Beelen

原文鏈接:Creating a Rich Text Editor using Rust and React

以上就是Rust+React創建富文本編輯器的詳細內容,更多關於Rust React富文本編輯器的資料請關註WalkonNet其它相關文章!

推薦閱讀: