<output id="qn6qe"></output>

    1. <output id="qn6qe"><tt id="qn6qe"></tt></output>
    2. <strike id="qn6qe"></strike>

      亚洲 日本 欧洲 欧美 视频,日韩中文字幕有码av,一本一道av中文字幕无码,国产线播放免费人成视频播放,人妻少妇偷人无码视频,日夜啪啪一区二区三区,国产尤物精品自在拍视频首页,久热这里只有精品12

      從零實現(xiàn)富文本編輯器#5-編輯器選區(qū)模型的狀態(tài)結(jié)構(gòu)表達

      先前我們總結(jié)了瀏覽器選區(qū)模型的交互策略,并且實現(xiàn)了基本的選區(qū)操作,還調(diào)研了自繪選區(qū)的實現(xiàn)。那么相對的,我們還需要設(shè)計編輯器的選區(qū)表達,也可以稱為模型選區(qū),編輯器中應(yīng)用變更時的操作范圍,就是以模型選區(qū)為基準(zhǔn)來實現(xiàn)的。在這里我們就以編輯器狀態(tài)為基礎(chǔ),來設(shè)計模型選區(qū)的結(jié)構(gòu)表達。

      從零實現(xiàn)富文本編輯器項目的相關(guān)文章:

      編輯器選區(qū)模型

      在編輯器中的選區(qū)模型設(shè)計是涉及面比較廣的命題,因為其作為應(yīng)用變更或者稱為執(zhí)行命令的基礎(chǔ)范圍,涉及到了廣泛的模塊交互。雖然本質(zhì)上是需要其他模塊需要適配選區(qū)模型的表達,但明顯的如果選區(qū)模型不夠清晰的話,其他模塊的適配工作就會變得復(fù)雜,交互自然是需要相互配合來實現(xiàn)的。

      而選區(qū)模型最直接依賴的就是編輯器的狀態(tài)模型,而狀態(tài)模型的設(shè)計又非常依賴數(shù)據(jù)結(jié)構(gòu)的設(shè)計。因此,在這里我們會從數(shù)據(jù)結(jié)構(gòu)以及狀態(tài)模型的角度出發(fā),先來調(diào)研一下當(dāng)前主流編輯器的選區(qū)模型設(shè)計。

      Quill

      Quill是一個現(xiàn)代富文本編輯器,具備良好的兼容性及強大的可擴展性,還提供了部分開箱即用的功能。Quill的出現(xiàn)給富文本編輯器帶了很多新的東西,也是目前開源編輯器里面受眾非常大的一款編輯器,至今為止的生態(tài)已經(jīng)非常的豐富,目前也推出了2.0版本。

      Quill的數(shù)據(jù)結(jié)構(gòu)表達是基于Delta實現(xiàn)的,當(dāng)然既然其名為Delta,那么數(shù)據(jù)的變更也可以基于Delta來實現(xiàn)。那么使用Delta表達基本的富文本數(shù)據(jù)結(jié)構(gòu)如下所示,可以觀察到其不會存在嵌套的數(shù)據(jù)結(jié)構(gòu)表達,所有內(nèi)容以及格式表達都是線性的。

      {
        ops: [
          { insert: "這樣" },
          { insert: "一段文本", attributes: { italic: true } },
          { insert: "的" },
          { insert: "數(shù)據(jù)結(jié)構(gòu)", attributes: { bold: true } },
          { insert: "如下所示。\n" },
        ],
      };
      

      既然數(shù)據(jù)結(jié)構(gòu)是扁平的表達,那么選區(qū)的表達也不需要復(fù)雜的結(jié)構(gòu)來實現(xiàn)。直觀感受上來說,扁平化結(jié)構(gòu)要特殊處理的Case會更少,狀態(tài)結(jié)構(gòu)會更好維護,編輯器架構(gòu)會更加輕量。通常編輯器的選區(qū)也會構(gòu)造Range對象,那么在Quill中的選區(qū)結(jié)構(gòu)如下所示:

      {
        index: 5, // 光標(biāo)位置
        length: 10, // 選區(qū)長度
      }
      

      選區(qū)作為編輯器變更的應(yīng)用范圍,不可避免地需要提及應(yīng)用變更的操作。在應(yīng)用變更時,自然需要進行狀態(tài)結(jié)構(gòu)的遍歷,以取出需要變更的節(jié)點來實際應(yīng)用變更。那么自然而然地,扁平的結(jié)構(gòu)本身的順序就是渲染的順序,不需要嵌套的結(jié)構(gòu)來進行遞歸地查找,查找的效率自然會更高。

      此外,Quill的視圖層是重新設(shè)計了一套模型parchment,維護了視圖和狀態(tài)模型,雖然在倉庫中通過繼承的方式重寫了部分類結(jié)構(gòu),例如ScrollBlot類。但是在閱讀源碼的時候難以確定很多視圖模塊設(shè)計意圖,所以對于DOM事件與狀態(tài)模型的交互暫時還沒有很好的理解。

      Slate

      Slate是一個高度靈活、完全可定制的富文本編輯器框架,其提供了一套核心模型和API,讓開發(fā)者能夠深度定制編輯器的行為,其更像是富文本編輯器的引擎,而不是開箱即用的組件。Slate經(jīng)歷過一次大版本重構(gòu),雖然目前仍然處于Beta狀態(tài),但是已經(jīng)有相當(dāng)多的線上服務(wù)使用。

      Slate的數(shù)據(jù)結(jié)構(gòu)表達就不是扁平的內(nèi)容表示的,依據(jù)其TS類型的定義,Node類型是Editor | Element | Text三種類型的元組。當(dāng)然拋開Editor本身不談,我們從下面的內(nèi)容描述上可以很容易看出來ElementText類型的定義。

      {
        type: "paragraph",
        children: [
          { text: "這樣" },
          { text: "一段文本", italic: true },
          { text: "的" },
          { text: "數(shù)據(jù)結(jié)構(gòu)", bold: true },
          { text: "如下所示。" },
        ]
      }
      

      其中children屬性可以認為是Element類型的節(jié)點,而text屬性則是Text類型的節(jié)點,Element類型的節(jié)點可以無限嵌套Element類型以及Text類型節(jié)點。那么這種情況下,扁平的選區(qū)表達就無法承載這樣的結(jié)構(gòu),因此Slate的選區(qū)表達是用端點來實現(xiàn)的。

      {
        anchor: { path: [0, 1], offset: 2 }, // 起始位置
        focus: { path: [0, 3], offset: 4 }, // 結(jié)束位置
      }
      

      這種選區(qū)的表達是不是非常眼熟,沒錯Slate的選區(qū)表達是完全與瀏覽器選區(qū)對齊的,因為其從最開始的設(shè)計原則就是與DOM對齊的。說句題外的話,Slate對于數(shù)據(jù)結(jié)構(gòu)的表現(xiàn)也是完全與選區(qū)對齊的,例如表達圖片時必須要放置一個空的Text作為零寬字符。

      {
        type: "image",
        url: "https://example.com/image.png",
        children: [{ text: "" }]
      }
      
      <div data-slate-node="element" data-slate-void="true">
        <div data-slate-spacer="true">
          <span data-slate-zero-width="z" data-slate-length="0">\u200B</span>
        </div>
        <div contenteditable="false">
          <img src="https://example.com/image.png">
        </div>
      </div>
      

      除了類似于圖片的Void節(jié)點表達,針對于行內(nèi)的Void節(jié)點,更能夠表現(xiàn)其內(nèi)建維護的數(shù)據(jù)結(jié)構(gòu)是完全對齊DOM的。例如下面的Mention結(jié)構(gòu)表達,由于需要在兩側(cè)放置光標(biāo),因此在DOM中引入了兩個零寬字符,此時內(nèi)建數(shù)據(jù)結(jié)構(gòu)也維護了兩個Text節(jié)點。

      [
        { text: "" },
        {
          type: "mention",
          user: "Mace",
          children: [{ text: "" }],
        },
        { text: "" },
      ];
      
      <p data-slate-node="element">
        <span data-slate-node="text">
          <span data-slate-zero-width="z" data-slate-length="0">\u200B</span>
        </span>
        <span contenteditable="false">
          <span data-slate-spacer="true">
            <span data-slate-zero-width="z" data-slate-length="0">\u200B</span>
          </span>
          @Mace
        </span>
        <span data-slate-node="text">
          <span data-slate-zero-width="z" data-slate-length="0">\u200B</span>
        </span>
      </p>
      

      說回選區(qū)模型,Slate在應(yīng)用數(shù)據(jù)變更時,同樣需要依賴兩個端點來進行遍歷查找,特別是進行諸如setNode等操作時。那么查找就非常依賴渲染順序來決定,由于文檔整體結(jié)構(gòu)還是二維的,因此通過path + offset對比找到起始/結(jié)束的節(jié)點,然后從首節(jié)點開始遞歸遍歷查找到尾節(jié)點就可以了。

      嵌套的數(shù)據(jù)結(jié)構(gòu)針對于批量的變更整體會變得更復(fù)雜,因為Op之間的索引關(guān)系需要維護,這就依賴transform的實現(xiàn)。但是,針對于單個Op的變更實際上是更清晰的,類似于Delta的變更是不容易非常針對性地處理單個操作變更,而JSON結(jié)構(gòu)下的單次變更非常清晰。

      transform這部分也是核心實現(xiàn),先前基于OT-JSON以及Immer實現(xiàn)的低代碼場景的狀態(tài)管理方案也提到過。單個Op變更時通常不會影響Path,批量的Op變更時是需要考慮到其間相互影響的關(guān)系的,例如在Slateremove-nodes實現(xiàn)中,通過ref維護的影響關(guān)系。

      // packages/slate/src/transforms-node/remove-nodes.ts
      const depths = Editor.nodes(editor, { at, match, mode, voids })
      const pathRefs = Array.from(depths, ([, p]) => Editor.pathRef(editor, p))
      
      for (const pathRef of pathRefs) {
        const path = pathRef.unref()!
      
        if (path) {
          const [node] = Editor.node(editor, path)
          editor.apply({ type: 'remove_node', path, node })
        }
      }
      

      Lexical

      LexicalMeta開源的現(xiàn)代化富文本編輯器框架,專注于提供極致的性能、可訪問性和可靠性。其作為Draft.js的繼任者,提供了更好的可擴展性和靈活性。特別的,Lexical還提供了IOS的原生支持,基于Swift/TextKit編寫的可擴展文本編輯器,共享Lexical設(shè)計理念與API。

      Lexical的數(shù)據(jù)結(jié)構(gòu)與Slate類似,都是基于樹形的嵌套結(jié)構(gòu)來表達內(nèi)容。但是其整體設(shè)計上增加了很多的預(yù)設(shè)內(nèi)容,并沒有像Slate設(shè)計為足夠靈活的編輯器引擎,從下面的數(shù)據(jù)結(jié)構(gòu)表達中也可以看出并沒有那么簡潔,這已經(jīng)是精簡過后的內(nèi)容,原本還存在諸如detail等屬性。

      [
        {
          children: [
            { format: 0, mode: "normal", text: "這樣", type: "text" },
            { format: 2, mode: "normal", text: "一段文本", type: "text" },
            { format: 0, mode: "normal", text: "的", type: "text" },
            { format: 1, mode: "normal", text: "數(shù)據(jù)結(jié)構(gòu)", type: "text" },
            { format: 0, mode: "normal", text: "如下所示。", type: "text" },
          ],
          direction: "ltr",
          indent: 0,
          type: "paragraph",
          version: 1,
          textFormat: 0,
        },
      ]
      

      由于其本身是嵌套的數(shù)據(jù)結(jié)構(gòu),那么選區(qū)的表達也類似于Slate的實現(xiàn)。只不過Lexical的選區(qū)類似于ProseMirror的實現(xiàn),將選區(qū)分為了RangeSelectionNodeSelectionTableSelection、null選區(qū)類型。

      其中Table以及null的選區(qū)類型比較特殊,我們就暫且不論。而針對于RangeNode實際上是可以同構(gòu)表達的,Node是針對于Void類型如圖片、分割線的表達,而類似于Quill以及Slate是將其直接融入Range的表達,以零寬字符文本作為選區(qū)落點。

      實際上特殊的表達自然是有特殊的含義,Quill以及Slate通過零寬字符同構(gòu)了選區(qū)的文本表達,而Lexical的設(shè)計是不存在零寬字符占位的,所以無法直接同構(gòu)文本選區(qū)表達。那么對于Lexical的選區(qū),基礎(chǔ)的文本RangeSelection選區(qū)如下所示:

      {
        anchor: { key: "51", offset: 2, type: "text" },
        focus: { key: "51", offset: 3, type: "text" }
      }
      

      這里可以看出來雖然數(shù)據(jù)結(jié)構(gòu)與Slate類似,但是path這部分被替換成了key,而需要注意的是,在我們先前展示的數(shù)據(jù)結(jié)構(gòu)中是沒有key這個標(biāo)識的。也就是說這個key值是臨時的狀態(tài)值而不是維護在數(shù)據(jù)結(jié)構(gòu)內(nèi)的,雖然看起來這個key很像是數(shù)字,實際上是字符串來表達唯一id。

      // packages/lexical/src/LexicalUtils.ts
      let keyCounter = 1;
      
      export function generateRandomKey(): string {
        return '' + keyCounter++;
      }
      

      實際上這種id生成的方式在很多地方都存在,包括Slate以及我們的BlockKit中,而恰好我們都是通過這種方式來生成key值的。因為我們的視圖層是通過React來渲染的,因此不可避免地需要維護唯一的key值,而在Lexicalkey值還維護了狀態(tài)映射的作用。

      那么這里的key值本質(zhì)上是維護了idpath的關(guān)系,我們應(yīng)該可以直接通過key值取得渲染的節(jié)點狀態(tài)。并且通過這種方式實際上就相當(dāng)于借助了臨時的path做到了任意key字符串可比較,并且可以沿著相對應(yīng)的nextSibling剪枝查找到尾節(jié)點,其實這里讓我想到了偏序關(guān)系的維護。

      // packages/lexical/src/LexicalSelection.ts
      const cachedNodes = this._cachedNodes;
      if (cachedNodes !== null) {
        return cachedNodes;
      }
      const range = $getCaretRangeInDirection(
        $caretRangeFromSelection(this),
        'next',
      );
      const nodes = $getNodesFromCaretRangeCompat(range);
      return nodes;
      

      在這里比較重要的是key值變更時的狀態(tài)保持,因為編輯器的內(nèi)容實際上是需要編輯的。然而如果做到immutable話,很明顯直接根據(jù)狀態(tài)對象的引用來映射key會導(dǎo)致整個編輯器DOM無效的重建。例如調(diào)整標(biāo)題的等級,就由于整個行key的變化導(dǎo)致整行重建。

      那么如何盡可能地復(fù)用key值就成了需要研究的問題,我們的編輯器行級別的key是被特殊維護的,即實現(xiàn)了immutable以及key值復(fù)用。而葉子狀態(tài)的key依賴了index值,因此如果調(diào)研Lexical的實現(xiàn),同樣可以將其應(yīng)用到我們的key值維護中。

      通過在playground中調(diào)試可以發(fā)現(xiàn),即使我們不能得知其是否為immutable的實現(xiàn),依然可以發(fā)現(xiàn)Lexicalkey是以一種偏左的方式維護。因此在我們的編輯器實現(xiàn)中,也可以借助同樣的方式,合并直接以左值為準(zhǔn)復(fù)用,拆分時若以0起始直接復(fù)用,起始非0則創(chuàng)建新key

      1. [123456(key1)][789(bold-key2)]文本,將789的加粗取消,整段文本的key值保持為key1。
      2. [123456789(key1)]]文本,將789這段文本加粗,左側(cè)123456文本的key值保持為key1789則是新的key。
      3. [123456789(key1)]]文本,將123這段文本加粗,左側(cè)123文本的key值保持為key1456789則是新的key
      4. [123456789(key1)]]文本,將456這段文本加粗,左側(cè)123文本的key值保持為key1,456789分別是新的key

      說起來,Lexical其實并不是完全由React作為視圖層的,其僅僅是可以支持React組件節(jié)點的掛載。而主要的DOM節(jié)點,例如加粗、標(biāo)題等格式還是自行實現(xiàn)的視圖層,判斷是否是通過React渲染的方式很簡單,通過控制臺查看是否存在類似__reactFiber$的屬性即可。

      // Slate
      __reactFiber$lercf2nvv2a: {}
      __reactProps$lercf2nvv2a: {}
      
      // Lexical
      __lexicalDir: "ltr"
      __lexicalDirTextContent: "這樣一段文本的數(shù)據(jù)結(jié)構(gòu)如下所示。"
      __lexicalKey_siggg: "47"
      __lexicalTextContent: "這樣一段文本的數(shù)據(jù)結(jié)構(gòu)如下所示。\n\n"
      

      飛書文檔

      飛書文檔是基于Blocks的設(shè)計思想實現(xiàn)的富文本編輯器,其作為飛書的核心應(yīng)用之一,提供了強大的文檔編輯功能。飛書文檔深度融合文檔、表格、思維筆記等組件,支持多人云端實時協(xié)作、深度集成飛書套件、高度移動端適配,是非常不錯的商業(yè)產(chǎn)品。

      飛書文檔是商業(yè)化的產(chǎn)品,并非開源的編輯器,因此數(shù)據(jù)結(jié)構(gòu)只能從接口來查閱。如果直接查閱飛書的SSR數(shù)據(jù)結(jié)構(gòu),可以發(fā)現(xiàn)其基本內(nèi)容如下,直接在控制臺上輸出DATA即可。當(dāng)然由于文本數(shù)據(jù)是EtherPadEasySync協(xié)同算法的數(shù)據(jù),這部分在Delta數(shù)據(jù)結(jié)構(gòu)的文章也介紹過。

      {
        doxcnTYlsMboJlTMcxwXerq6wqc: {
          id: "doxcnTYlsMboJlTMcxwXerq6wqc",
          data: {
            children: ["doxcnXFzVSkuH3XoiT3mqXvOx4b"],
          },
        },
        doxcnXFzVSkuH3XoiT3mqXvOx4b: {
          id: "doxcnXFzVSkuH3XoiT3mqXvOx4b",
          data: {
            type: "text",
            parent_id: "doxcnTYlsMboJlTMcxwXerq6wqc",
            text: {
              apool: {
                nextNum: 3,
                numToAttrib: { "1": ["italic", "true"], "2": ["bold", "true"] },
              },
              initialAttributedTexts: {
                attribs: { "0": "*0+2*0*1+4*0+1*0*2+4*0+5" },
                text: { "0": "這樣一段文本的數(shù)據(jù)結(jié)構(gòu)如下所示。" },
              },
            },
          },
        },
      }
      

      當(dāng)然這個數(shù)據(jù)結(jié)構(gòu)看起來比較復(fù)雜,我們也可以直接從飛書開放平臺的服務(wù)端API的獲取文檔所有塊中,響應(yīng)示例中得到整個數(shù)據(jù)結(jié)構(gòu)的概覽。當(dāng)然看起來這部分?jǐn)?shù)據(jù)結(jié)構(gòu)是經(jīng)歷過一次數(shù)據(jù)轉(zhuǎn)換的,并非直接將數(shù)據(jù)結(jié)構(gòu)直接響應(yīng)。其實這部分結(jié)構(gòu)可以認為跟Slate的樹結(jié)構(gòu)類似,但每行都是獨立實例。

      當(dāng)然看起來飛書文檔是扁平化的結(jié)構(gòu),但是實際上構(gòu)建出來的狀態(tài)值還是樹形的結(jié)構(gòu),只不過是使用id來實現(xiàn)類似鏈?zhǔn)浇Y(jié)構(gòu),從而可以構(gòu)建整個樹結(jié)構(gòu)。那么對于飛書文檔的選區(qū)結(jié)構(gòu)來說,自然也是需要適配數(shù)據(jù)結(jié)構(gòu)狀態(tài)的模型。對于文本選區(qū)而言,飛書文檔的結(jié)構(gòu)如下:

      // PageMain.editor.selectionAPI.getSelection()
      [
        { id: 2, type: "text", selection: { start: 3, end: 16 } },
        { id: 6, type: "text", selection: { start: 0, end: 2 } },
        { id: 7, type: "text", selection: { start: 0, end: 1 } },
      ];
      

      既然飛書文檔是Blocks的塊結(jié)構(gòu)設(shè)計,那么僅僅是純文本的選區(qū)維護自然是不夠的。那么在塊結(jié)構(gòu)的選區(qū)表達,飛書的選區(qū)表達如下所示。說起來,飛書文檔的結(jié)構(gòu)選區(qū)如果執(zhí)行跨級別的塊選區(qū)操作,那么飛書文檔會在抬起鼠標(biāo)的時候會將更深層級的塊選區(qū)合并為同層級的選區(qū)。

      // PageMain.editor.selectionAPI.getSelection()
      [
        { id: 2, type: "block" },
        { id: 6, type: "block" },
        { id: 7, type: "block" },
      ];
      

      針對深度層級的Blocks級別文本選區(qū),類似Editor.js完全不支持文本的跨節(jié)點選中;飛書文檔支持跨行的文本選區(qū),但是在跨層級的文本選區(qū)會提升為同級塊選區(qū);Slate/Lexical等編輯器則是支持跨層級的文本選區(qū),當(dāng)然其本身并非Blocks設(shè)計的編輯器,主要是支持節(jié)點嵌套。

      基于Blocks思想設(shè)計的編輯器其實更類似于低代碼的設(shè)計,相當(dāng)于實現(xiàn)了一套受限的低代碼引擎,準(zhǔn)確來說是無代碼引擎。這里的受限主要是指的不會像圖形畫板那么靈活的拖拽操作,而是基于某些規(guī)則設(shè)計下的編輯器形式,例如塊組件需要獨占一行、結(jié)構(gòu)不能任意嵌套等。

      其實在這種嵌套的數(shù)據(jù)結(jié)構(gòu)下,選區(qū)的表達方式自然有很多可行的表達。飛書文檔的同層級提升方法應(yīng)該是更加合適的,因為這種情況下處理相關(guān)的數(shù)據(jù)會更簡單,而且也并非不支持跨節(jié)點的文本選區(qū),也不需要像深層次嵌套結(jié)構(gòu)那樣在取得相關(guān)節(jié)點時需要遞歸查找,屬于數(shù)據(jù)表達與性能的折中取舍。

      選區(qū)結(jié)構(gòu)設(shè)計

      那么在調(diào)研了當(dāng)前主流編輯器的選區(qū)模型設(shè)計后,我們就需要依照類似的原則來設(shè)計我們的編輯器選區(qū)模型。首先是針對數(shù)據(jù)結(jié)構(gòu)設(shè)計選區(qū)對象,也就是編輯器中通常存在的Range對象,其次是針對狀態(tài)模型的表達,并且需要考慮以此為基準(zhǔn)設(shè)計編輯器的狀態(tài)選區(qū)結(jié)構(gòu)表達。

      RawRange

      在瀏覽器中Range對象是瀏覽器選區(qū)的基礎(chǔ)表達,而對于編輯器而言,通常都會實現(xiàn)編輯器本身的Range對象。當(dāng)然在這種情況下,編輯器Range對象和瀏覽器Range對象,特別是可能實現(xiàn)IOC DI的情況下,我們可以對瀏覽器的Range對象獨立分配對象名。

      import DOMNode = globalThis.Node;
      import DOMText = globalThis.Text;
      import DOMElement = globalThis.Element;
      

      那么先前已經(jīng)提到了我們的編輯器數(shù)據(jù)結(jié)構(gòu)設(shè)計,是基于Delta的實現(xiàn)改造而來的。因此Range對象的設(shè)計同樣可以與Quill編輯器的選區(qū)設(shè)計保持一致,畢竟選區(qū)設(shè)計的直接依賴便是數(shù)據(jù)結(jié)構(gòu)的設(shè)計。為了區(qū)分我們后邊的設(shè)計方案,我們這里命名為RawRange。

      export class RawRange {
        constructor(
          /** 起始點 */
          public start: number,
          /** 長度 */
          public len: number
        ) {}
      }
      

      由于實際上RawRange對象相當(dāng)于是表達的一個線性范圍,本身僅需要兩個number即可以表達。但是為了更好地表達其語義,以及相關(guān)的調(diào)用方法,例如isBefore以及isAfter等方法,因此我們還有其內(nèi)部的Point對象的表達。

      export class RawPoint {
        constructor(
          /** 起始偏移 */
          public offset: number
        ) {}
      
        /**
         * 判斷 Point1 是否在 Point2 之前
         * - 即 < (p1 p2), 反之則 >= (p2 p1)
         */
        public static isBefore(point1: RawPoint | null, point2: RawPoint | null): boolean {
          if (!point1 || !point2) return false;
          return point1.offset < point2.offset;
        }
      }
      

      在此基礎(chǔ)上就可以實現(xiàn)諸如intersects、includes等方法,對于選區(qū)的各種操作還是比較重要的。例如intersects方法可以用來判斷選區(qū)塊級節(jié)點的選中狀態(tài),因為void節(jié)點本身是非文本的內(nèi)容,瀏覽器本身是沒有選區(qū)狀態(tài)的。

      export class RawRange {
       public static intersects(range1: Range | null, range2: Range | null) {
          if (!range1 || !range2) return false;
          const { start: start1, end: end1 } = range1;
          const { start: start2, end: end2 } = range2;
          // --start1--end1--start2--end2--
          // => --end1--start2--
          // --start1--start2--end1--end2--  ?
          // => --start2--end1--
          const start = Point.isBefore(start1, start2) ? start2 : start1;
          const end = Point.isBefore(end1, end2) ? end1 : end2;
          return !Point.isAfter(start, end);
        }
      }
      
      export class SelectionHOC extends React.PureComponent<Props, State> {
        public onSelectionChange(range: Range | null) {
          const leaf = this.props.leaf;
          const leafRange = leaf.toRange();
          const nextState = range ? Range.intersects(leafRange, range) : false;
          if (this.state.selected !== nextState) {
            this.setState({ selected: nextState });
          }
        }
      
        public render() {
          const selected = this.state.selected;
          return (
            <div
              className={cs(this.props.className, selected && "block-kit-embed-selected")}
              data-selection
            >
              {React.Children.map(this.props.children, child => {
                if (React.isValidElement(child)) {
                  const { props } = child;
                  return React.cloneElement(child, { ...props, selected: selected });
                } 
                return child;
              })}
            </div>
          );
        }
      }
      

      Range

      既然有RawRange選區(qū)對象的設(shè)計,那么相對應(yīng)的自然是Range對象的設(shè)計。在這里我們Range對象的設(shè)計直接基于編輯器狀態(tài)的實現(xiàn),因此其實可以認為,我們的RawRange對象是基于數(shù)據(jù)結(jié)構(gòu)的實現(xiàn),Range對象則是基于編輯器狀態(tài)模型的實現(xiàn)。

      先來看Range對象的聲明,實際上這里的實現(xiàn)是相對更精細的表達。在Point對象中,我們維護了行索引和行內(nèi)偏移,而在Range對象中,我們維護了選區(qū)的起始點和結(jié)束點,此時的Range對象中區(qū)間永遠是從start指向end,通過isBackward來標(biāo)記此時是否反選狀態(tài)。

      
      export class Point {
        constructor(
          /** 行索引 */
          public line: number,
          /** 行內(nèi)偏移 */
          public offset: number
        ) {}
      }
      
      export class Range {
        /** 選區(qū)起始點 */
        public readonly start: Point;
        /** 選區(qū)結(jié)束點 */
        public readonly end: Point;
        /** 選區(qū)方向反選 */
        public isBackward: boolean;
        /** 選區(qū)折疊狀態(tài) */
        public isCollapsed: boolean;
      }
      

      其中,選區(qū)區(qū)間永遠是從start指向end這點非常重要,在我們后續(xù)的瀏覽器選區(qū)與編輯器選區(qū)狀態(tài)同步中會非常有用。因為瀏覽器的Selection對象得到的anchorfocus并非總是由start指向end,此時維護我們的Range對象需要從Selection取得相關(guān)節(jié)點。

      那么從先前的數(shù)據(jù)結(jié)構(gòu)上來看,Delta數(shù)據(jù)結(jié)構(gòu)是不存在任何行相關(guān)的數(shù)據(jù)信息,因此我們需要從編輯器維護的狀態(tài)上來獲取行索引和行內(nèi)偏移。維護獨立的狀態(tài)變更本身也是一件復(fù)雜的事情,這件事我們需要后續(xù)再看,此時我們先來看下各個渲染節(jié)點狀態(tài)維護的數(shù)據(jù)。

      export class LeafState {
        /** Op 所屬 Line 的索引 */
        public index: number;
        /** Op 起始偏移量 */
        public offset: number;
        /** Op 長度 */
        public readonly length: number;
      }
      
      export class LineState {
        /** 行 Leaf 數(shù)量 */
        public size: number;
        /** 行起始偏移 */
        public start: number;
        /** 行號索引 */
        public index: number;
        /** 行文本總寬度 */
        public length: number;
        /** Leaf 節(jié)點 */
        protected leaves: LeafState[] = [];
      }
      

      因此,瀏覽器選區(qū)實現(xiàn)的Selection對象都是基于DOM來實現(xiàn)的,那么通過DOM節(jié)點來同步編輯器的選區(qū)模型同樣需要需要處理。不過在這里,我們先以從狀態(tài)模型中獲取選區(qū)的方式來構(gòu)建Range對象。而由于上述實現(xiàn)中我們是基于點Point來實現(xiàn)的,那么自然可以分離點來處理區(qū)間。

      const leafNode = getLeafNode(node);
      let lineIndex = 0;
      let leafOffset = 0;
      
      const lineNode = getLineNode(leafNode);
      const lineModel = editor.model.getLineState(lineNode);
      // 在沒有 LineModel 的情況, 選區(qū)會置于 BlockState 最前
      if (lineModel) {
        lineIndex = lineModel.index;
      }
      
      const leafModel = editor.model.getLeafState(leafNode);
      // 在沒有 LeafModel 的情況, 選區(qū)會置于 Line 最前
      if (leafModel) {
        leafOffset = leafModel.offset + offset;
      }
      return new Point(lineIndex, leafOffset);
      

      這樣看起來,我們分離了LineStateLeafState的狀態(tài),然后直接從相關(guān)狀態(tài)中就可以取出Point對象的行索引和行內(nèi)偏移。注意這里我們得到的是行內(nèi)偏移而不是葉子結(jié)點的偏移。類似Slate計算得到的偏移是Text節(jié)點的偏移,這也是對于選區(qū)模型的設(shè)計相關(guān)。

      換句話說,當(dāng)前我們的選區(qū)實現(xiàn)是L-O的實現(xiàn),也就是LineOffset索引級別的實現(xiàn),也就是說這里的Offset是會跨越多個實際的LeafState節(jié)點的。那么這里的Offset就會導(dǎo)致我們在實現(xiàn)選區(qū)查找的時候需要額外的迭代,實際實現(xiàn)很比較靈活的。

      const lineNode = editor.model.getLineNode(lineState);
      const selector = `[${LEAF_STRING}], [${ZERO_SPACE_KEY}]`;
      const leaves = Array.from(lineNode.querySelectorAll(selector));
      let start = 0;
      for (let i = 0; i < leaves.length; i++) {
        const leaf = leaves[i];
        let len = leaf.textContent.length;
        const end = start + len;
        if (offset <= end) {
          return { node: leaf, offset: Math.max(offset - start, 0) };
        }
      }
      return { node: null, offset: 0 };
      

      其實我也思考過使用L-I-O選區(qū)的實現(xiàn),也就是說像是Slate的數(shù)據(jù)結(jié)構(gòu),只不過我們將其簡化為3級,而不是像Slate一樣可以無限層即嵌套下去。這樣的好處是,選區(qū)模型會更加清晰,因為不需要在行的基礎(chǔ)上循環(huán)查找,但是缺點是增加了選區(qū)復(fù)雜度,L-O模型屬于靈活復(fù)雜的折中實現(xiàn)。

      export class Point {
        constructor(
          /** 行索引 */
          public line: number,
          /** 節(jié)點索引 */
          public index: number,
          /** 節(jié)點內(nèi)偏移 */
          public offset: number
        ) {}
      }
      

      那么最后,我們實際上還需要實現(xiàn)RawRangeRange對象的轉(zhuǎn)換方法。即使是編輯器內(nèi)部也需要經(jīng)常相互轉(zhuǎn)換,例如在執(zhí)行換行、刪除行等操作時,為了方便處理都是用Range對象構(gòu)造的,而基于Delta實際執(zhí)行選區(qū)變換時,則需要使用RawRange對象處理。

      RawRangeRange對象的轉(zhuǎn)換相當(dāng)于從線性范圍到二維范圍的轉(zhuǎn)換,因此我們需要對選區(qū)的行狀態(tài)進行一次檢索。那么由于我們的LineState對象索引是線性增長的,那么針對有序序列查找的方式,最常見的方式就是二分查找。

      export class Point {
        public static fromRaw(editor: Editor, rawPoint: RawPoint): Point | null {
          const block = editor.state.block;
          const lines = block.getLines();
          const line = binarySearch(lines, rawPoint.offset);
          if (!line) return null;
          return new Point(line.index, rawPoint.offset - line.start);
        }
      }
      
      export class Range {
        public static fromRaw(editor: Editor, raw: RawRange): Range | null {
          const start = Point.fromRaw(editor, new RawPoint(raw.start));
          if (!start) return null;
          const end = !raw.len ? start.clone() : Point.fromRaw(editor, new RawPoint(raw.start + raw.len));
          if (!end) return null;
          return new Range(start, end, false, raw.len === 0);
        }
      }
      

      Range對象到RawRange對象的轉(zhuǎn)換相對簡單,因為我們只需要將行索引和行內(nèi)偏移轉(zhuǎn)換為線性偏移即可。而由于此時我們針對行的偏移信息都是記錄在行對象上的,因此我們直接取相關(guān)的值相加即可。

      export class RawPoint {
        public static fromPoint(editor: Editor, point: Point | null): RawPoint | null {
          if (!point) return null;
          const block = editor.state.block;
          const line = block.getLine(point.line);
          if (!line || point.offset > line.length) {
            editor.logger.warning("Line Offset Error", point.line);
            return null;
          }
          return new RawPoint(line.start + point.offset);
        }
      }
      
      export class RawRange {
        public static fromRange(editor: Editor, range: Range | null): RawRange | null {
          if (!range) return null;
          const start = RawPoint.fromPoint(editor, range.start);
          const end = range.isCollapsed ? start : RawPoint.fromPoint(editor, range.end);
          if (start && end) {
            // 此處保證 start 指向 end
            return new RawRange(start.offset, Math.max(end.offset - start.offset, 0));
          }
          return null;
        }
      }
      

      總結(jié)

      在先前我們基于Range對象與Selection對象實現(xiàn)了基本的選區(qū)操作,并且舉了相關(guān)的應(yīng)用具體場景和示例。與之相對應(yīng)的,在這里我們總結(jié)了調(diào)研了現(xiàn)代富文本編輯器的選區(qū)模型設(shè)計,并且基于數(shù)據(jù)模型設(shè)計了RawRangeRange對象兩種選區(qū)模型。

      接下來我們需要基于編輯器選區(qū)模型的表示,然后在瀏覽器選區(qū)相關(guān)的API基礎(chǔ)上,實現(xiàn)編輯器選區(qū)模型與瀏覽器選區(qū)的同步。通過選區(qū)模型作為編輯器操作的范圍目標(biāo),來實現(xiàn)編輯器的基礎(chǔ)操作,例如插入、刪除、格式化等操作,以及諸多選區(qū)相關(guān)的邊界操作問題。

      每日一題

      參考

      posted @ 2025-06-10 10:23  WindRunnerMax  閱讀(308)  評論(0)    收藏  舉報
      ?Copyright    @Blog    @WindRunnerMax
      主站蜘蛛池模板: 国产亚洲无线码一区二区| 国产精品白丝一区二区三区| 日本一区二区三区免费播放视频站 | 日日碰狠狠躁久久躁综合小说 | 无码一级视频在线| 日韩精品一区二区三区vr| 最新的国产成人精品2020| 精品视频福利| 亚洲无码a∨在线视频| 国产99视频精品免费观看9| 亚洲日韩AV秘 无码一区二区| 无遮挡又黄又刺激的视频| 亚洲成aⅴ人在线电影| 日韩伦理片一区二区三区| 洛阳市| 99久久久国产精品免费无卡顿 | 亚洲人成网站77777在线观看| 一本本月无码-| 无码国产一区二区三区四区| av午夜福利亚洲精品福利| 无码日韩av一区二区三区| 卫辉市| 国产色婷婷亚洲99精品小说| 日韩 欧美 亚洲 一区二区| 中文国产不卡一区二区| 国偷自产一区二区三区在线视频 | 中文字幕无线码中文字幕| 国产亚洲精品第一综合| 久久精品亚洲精品国产区| 97久久综合亚洲色hezyo| 97视频精品全国免费观看| 亚洲av永久无码精品水牛影视| 欧美精品一产区二产区| 日本边添边摸边做边爱喷水| 欧美高清精品一区二区| 欧美牲交a欧美牲交aⅴ一| 色噜噜久久综合伊人一本| 国产精品露脸视频观看| 无码人妻aⅴ一区二区三区蜜桃| 久久久久亚洲av成人网址| 丰满无码人妻热妇无码区|