Slate文件編輯器-WrapNode資料結構與操作變換

WindRunnerMax發表於2024-11-18

Slate文件編輯器-WrapNode資料結構與操作變換

在之前我們聊到了一些關於slate富文字引擎的基本概念,並且對基於slate實現文件編輯器的一些外掛化能力設計、型別擴充、具體方案等作了探討,那麼接下來我們更專注於文件編輯器的細節,由淺入深聊聊文件編輯器的相關能力設計。

  • 線上編輯: https://windrunnermax.github.io/DocEditor
  • 開源地址: https://github.com/WindrunnerMax/DocEditor

關於slate文件編輯器專案的相關文章:

  • 基於Slate構建文件編輯器
  • Slate文件編輯器-WrapNode資料結構與操作變換
  • Slate文件編輯器-TS型別擴充套件與節點型別檢查
  • Slate文件編輯器-Decorator裝飾器渲染排程
  • Slate文件編輯器-Node節點與Path路徑對映

Normalize

slate中資料結構的規整是比較麻煩的事情,特別是對於需要巢狀的結構來說,例如在本專案中存在的QuoteList,那麼在規整資料結構的時候就有著多種方案,同樣以這兩組資料結構為例,每個Wrap必須有相應的Pair的結構巢狀,那麼對於資料結構就有如下的方案。實際上我覺得對於這類問題是很難解決的,巢狀的資料結構對於增刪改查都沒有那麼高效,因此在缺乏最佳實踐相關的輸入情況下,也只能不斷摸索。

首先是複用當前的塊結構,也就是說Quote KeyList Key都是平級的,同樣的其Pair Key也都複用起來,這樣的好處是不會出現太多的層級巢狀關係,對於內容的查詢和相關處理會簡單很多。但是同樣也會出現問題,如果在QuoteList不配齊的情況下,也就是說其並不是完全等同關係的情況下,就會需要存在Pair不對應Wrap的情況,此時就很難保證Normalize,因為我們是需要可預測的結構。

{
    "quote-wrap": true,
    "list-wrap": true,
    children: [
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
    ]
}

那麼如果我們不對內容做很複雜的控制,在slate中使用預設行為進行處理,那麼其資料結構表達會出現如下的情況,在這種情況下資料結構是可預測的,那麼Normalize就不成問題,而且由於這是其預設行為,不會有太多的運算元據處理需要關注。但是問題也比較明顯,這種情況下資料雖然是可預測的,但是處理起來特別麻煩,當我們維護對應關係時,必須要遞迴處理所有子節點,在特別多層次的巢狀情況下,這個計算量就頗顯複雜了,如果在支援表格等結構的情況下,就變得更加難以控制。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            children: [
                { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
                { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那麼這個資料結構實際上也並不是很完善,其最大的問題是wrap - pair的間隔太大,這樣的處理方式就會出現比較多的邊界問題,舉個比較極端的例子,假設我們最外層存在引用塊,在引用塊中又巢狀了表格,表格中又巢狀了高亮塊,高亮塊中又巢狀了引用塊,這種情況下我們的wrap需要傳遞N多層才能匹配到pair,這種情況下影響最大的就是Normalize,我們需要有非常深層次的DFS處理才行,處理起來不僅需要耗費效能深度遍歷,還容易由於處理不好造成很多問題。

那麼在這種情況下,我們可以儘可能簡化層級的巢狀,也就是說我們需要避免wrap - pair的間隔問題,那麼很明顯我們直接嚴格規定wrap的所有children必須是pair,在這種情況下我們做Normalize就簡單了很多,只需要在wrap的情況下遍歷其子節點以及在pair的情況下檢查其父節點即可。當然這種方案也不是沒有缺點,這讓我們對於資料的操作精確性有著更嚴格的要求,因為在這裡我們不會走預設行為,而是全部需要自己控制,特別是所有的巢狀關係以及邊界都需要嚴格定義,這對編輯器行為的設計也有更高的要求。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
                { "list-pair": 2, children: [/* ... */] },
                { "list-pair": 3, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那麼為什麼說資料結構會變得複雜了起來,就以上述的結構為例,假如我們將list-pair: 2這個節點解除了list-wrap節點的巢狀結構,那麼我們就需要將節點變為如下的型別,我們可以發現這裡的結構差別會比較大,除了除了將list-wrap分割成了兩份之外,我們還需要處理其他list-pair的有序列表索引值更新,這裡要做的操作就比較多了,因此我們如果想實現比較通用的Schema就需要更多的設計和規範。

而在這裡最容易忽略的一點是,我們需要為原本的list-pair: 2這個節點加入"quote-pair": true,因為此時該行變成了quote-wrap的子元素,總結起來也就是我們需要將原本在list-wrap的屬性再複製一份給到list-pair: 2中來保持正確的巢狀結構。那麼為什麼不是藉助normalize來被動新增而是要主動複製呢,原因很簡單,如果是quote-pair的話還好,如果是被動處理則直接設定為true就可以了,但是如果是list-pair來實現的話,我們無法得知這個值的資料結構應該是什麼樣子的,這個實現則只能歸於外掛的normalize來實現了。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

Transformers

前邊也提到了,在巢狀的資料結構中是存在預設行為的,而在之前由於一直遵守著預設行為所以並沒有發現太多的資料處理方面的問題,然而當將資料結構改變之後,就發現了很多時候資料結構並不那麼容易控制。先前在處理SetBlock的時候通常我都會透過match引數匹配Block型別的節點,因為在預設行為的情況下這個處理通常不會出什麼問題。

然而在變更資料結構的過程中,處理Normalize的時候就出現了問題,在塊元素的匹配上其表現與預期的並不一致,這樣就導致其處理的資料一直無法正常處理,Normalize也就無法完成直至丟擲異常。在這裡主要是其迭代順序與我預期的不一致造成的問題,例如在DEMO頁上執行[...Editor.nodes(editor, {at: [9, 1, 0] })],其返回的結果是由頂Editor至底Node,當然這裡還會包括範圍內的所有Leaf節點相當於是Range

[]          Editor
[9]         Wrap
[9, 1]      List
[9, 1, 9]   Line
[9, 1, 0]   Text

實際上在這種情況下如果按照原本的Path.equals(path, at)是不會出現問題的,在這裡就是之前太依賴其預設行為了,這也就導致了對於資料的精確性把控太差,我們對資料的處理應該是需要有可預期性的,而不是依賴預設行為。此外,slate的文件還是太過於簡練了,很多細節都沒有提及,在這種情況下還是需要去閱讀原始碼才會對資料處理有更好的理解,例如在這裡看原始碼讓我瞭解到了每次做操作都會取Range所有符合條件的元素進行match,在一次呼叫中可能會發生多次Op排程。

此外,因為這次的處理主要是對於巢狀元素的支援,所以在這裡還發現了unwrapNodes或者說相關資料處理的特性,當我呼叫unwrapNodes時僅at傳入的值不一樣,分別是A-[3, 1, 0]B-[3, 1, 0, 0],這裡有一個關鍵點是在匹配的時候我們都是嚴格等於[3, 1, 0],但是呼叫結果卻是不一樣的,在A[3, 1, 0]所有元素都被unwrap了,而B中僅[3, 1, 0, 0]unwrap了,在這裡我們能夠保證的是match結果是完全一致的,那麼問題就出在了at上。此時如果不理解slate資料操作的模型的話,就必須要去看原始碼了,在讀原始碼的時候我們可以發現其會存在Range.intersection幫我們縮小了範圍,所以在這裡at的值就會影響到最終的結果。

unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0] }); // A
unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0, 0] }); // B

上邊這個問題也就意味著我們所有的資料都不應該亂傳,我們應該非常明確地知道我們要操作的資料及其結構。其實前邊還提到一個問題,就是多級巢狀的情況很難處理,這其中實際上涉及了一個編輯邊界情況,使得資料的維護就變得複雜了起來。舉個例子,加入此時我們有個表格巢狀了比較多的Cell,如果我們是多例項的Cell結構,此時我們篩選出Editor例項之後處理任何資料都不會影響其他的Editor例項,而如果我們此時是JSON巢狀表達的結構,我們就可能存在超過操作邊界而影響到其他資料特別是父級資料結構的情況。所以我們對於邊界條件的處理也必須要關注到,也就是前邊提到的我們需要非常明確要處理的資料結構,明確劃分操作節點與範圍。

{
    children: [
        {
            BLOCK_EDGE: true, // 塊結構邊界
            children: [
                { children: [/* ... */] },
                { children: [/* ... */] },
            ]
        },
        {  children: [/* ... */] },
        {  children: [/* ... */] },
    ]
}

此外,線上上已有頁面中除錯程式碼可能是個難題,特別是在editor並沒有暴露給window的情況下,想要直接獲得編輯器例項則需要在本地復現線上環境,在這種情況下我們可以藉助React會將Fiber實際寫在DOM節點的特性,透過DOM節點直接取得Editor例項,不過原生的slate使用了大量的WeakMap來儲存資料,在這種情況下暫時沒有很好的解決辦法,除非editor實際引用了此類物件或者擁有其例項,否則就只能透過debug打斷點,然後將物件在除錯的過程中暫儲為全域性變數使用了。

const el = document.querySelector(`[data-slate-editor="true"]`);
const key = Object.keys(el).find(it => it.startsWith("__react"));
const editor = el[key].child.memoizedProps.node;

最後

在這裡我們聊到了WrapNode資料結構與操作變換,主要是對於巢狀型別的資料結構需要關注的內容,而實際上節點的型別還可以分為很多種,我們在大範圍上可以有BlockNodeTextBlockNodeTextNode,在BlockNode中我們又可以劃分出BaseNodeWrapNodePairNodeInlineBlockNodeVoidNodeInstanceNode等,因此文中敘述的內容還是屬於比較基本的,在slate中還有很多額外的概念和操作需要關注,例如RangeOperationEditorElementPath等。那麼在後邊的文章中我們就主要聊一聊在slatePath的表達,以及在React中是如何控制其內容表達與正確維護Path路徑與Element內容渲染的。

相關文章