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

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

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

      從零實(shí)現(xiàn)富文本編輯器#2-基于MVC模式的編輯器架構(gòu)設(shè)計(jì)

      在先前的規(guī)劃中我們是需要實(shí)現(xiàn)MVC架構(gòu)的編輯器,將應(yīng)用程序分為控制器、模型、視圖三個(gè)核心組件,通過控制器執(zhí)行命令時(shí)會(huì)修改當(dāng)前的數(shù)據(jù)模型,進(jìn)而表現(xiàn)到視圖的渲染上。簡單來說就是構(gòu)建一個(gè)描述文檔結(jié)構(gòu)與內(nèi)容的數(shù)據(jù)模型,并且使用自定義的execCommand對數(shù)據(jù)描述模型進(jìn)行修改。以此實(shí)現(xiàn)的L1級(jí)富文本編輯器,通過抽離數(shù)據(jù)模型,解決了富文本中臟數(shù)據(jù)、復(fù)雜功能難以實(shí)現(xiàn)的問題。

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

      精簡的編輯器

      在整套系統(tǒng)架構(gòu)的設(shè)計(jì)中,最重要的核心理念便是狀態(tài)同步,如果以狀態(tài)模型為基準(zhǔn),那么我們需要維護(hù)的狀態(tài)同步就可以歸納為下面的兩方面:

      • 將用戶操作狀態(tài)同步到狀態(tài)模型中,當(dāng)用戶操作文本狀態(tài)時(shí),例如用戶的選區(qū)操作、輸入操作、刪除操作等,需要將變更操作到狀態(tài)模型中。
      • 將狀態(tài)模型狀態(tài)同步到視圖層中,當(dāng)在控制層中執(zhí)行命令時(shí),需要將經(jīng)過變更后生成的新狀態(tài)模型同步到視圖層中,保證數(shù)據(jù)狀態(tài)與視圖的一致。

      其實(shí)這兩個(gè)狀態(tài)同步是個(gè)正向依賴的過程,用戶操作形成的狀態(tài)同步到狀態(tài)模型,狀態(tài)模型的變更同步到視圖層,視圖層則又是用戶操作的基礎(chǔ)。舉個(gè)例子,當(dāng)用戶通過拖拽選擇部分文本時(shí),需要將其選中的范圍同步到狀態(tài)模型。當(dāng)此時(shí)執(zhí)行刪除操作時(shí),需要將數(shù)據(jù)中的這部分文本刪除,之后再刷新視圖的到新的DOM結(jié)構(gòu)。下次循環(huán)就需要繼續(xù)保證狀態(tài)的同步,然后執(zhí)行輸入、刷新視圖等操作。

      由此我們的目標(biāo)主要是狀態(tài)同步,雖然看起來僅有簡單的兩個(gè)原則,但是這件事做起來并沒有那么簡單。當(dāng)我們執(zhí)行狀態(tài)同步時(shí),是非常依賴瀏覽器的相關(guān)API的,例如選區(qū)、輸入、鍵盤等事件。然而此時(shí)我們必須要處理瀏覽器的相關(guān)問題,例如截止目前ContentEditable無法真正阻止IME的輸入,EditContext的兼容性也還有待提升,這些都是我們需要處理的問題。

      實(shí)際上當(dāng)我們用到了越多的瀏覽器API實(shí)現(xiàn),我們就需要考慮越多的瀏覽器兼容性問題。因此富文本編輯器的實(shí)現(xiàn)才會(huì)出現(xiàn)很多非ContentEditable的實(shí)現(xiàn),例如如釘釘文檔的自繪選區(qū)、Google DocCanvas文檔繪制等。但是這樣雖然能夠降低部分瀏覽器API的依賴,但是也無法真正完全脫離瀏覽器的實(shí)現(xiàn),因此即使是Canvas繪制的文檔,也必須要依賴瀏覽器的API來實(shí)現(xiàn)輸入、位置計(jì)算等等。

      回到我們的精簡編輯器模型,先前的文章已經(jīng)提到了ContentEditable屬性以及execCommand命令,通過document.execCommand來執(zhí)行命令修改HTML的方案雖然簡單,但是很明顯其可控性比較差。execCommand命令的行為在各個(gè)瀏覽器的表現(xiàn)是不一致的,這也是之前我們提到的瀏覽器兼容行為的一種,然而這些行為我們也沒有任何辦法去控制,這都是其默認(rèn)的行為。

      <div>
        <button id="$1">加粗</button>
        <div style="border: 1px solid #eee; outline: none" contenteditable>123123</div>
      </div>
      <script>
        $1.onclick = () => {
          document.execCommand("bold");
        };
      </script>
      

      因此為了更強(qiáng)的擴(kuò)展以及可控性,也解決數(shù)據(jù)與視圖無法對應(yīng)的問題,L1的富文本編輯器使用了自定義數(shù)據(jù)模型的概念。即在DOM樹的基礎(chǔ)上抽離出來的數(shù)據(jù)結(jié)構(gòu),相同的數(shù)據(jù)結(jié)構(gòu)可以保證渲染的HTML也是相同的,配合自定義的命令直接控制數(shù)據(jù)模型,最終保證渲染的HTML文檔的一致性。對于選區(qū)的表達(dá),則需要根據(jù)DOM選區(qū)來不斷normalize選區(qū)Model

      這也就是我們今天要聊的MVC模型架構(gòu),我們組織編輯器項(xiàng)目是通過monorepo的形式來管理的相關(guān)包,這樣就自然而然地可以形成分層的架構(gòu)。不過在此之前,我們可以在HTML文件中實(shí)現(xiàn)最基準(zhǔn)的編輯器 simple-mvc.html,當(dāng)然我們還是實(shí)現(xiàn)最基本的加粗能力,主要關(guān)注點(diǎn)在于整個(gè)流程的控制。而針對輸入的能力則是更加復(fù)雜的問題,我們暫時(shí)就不處理了,這部分需要單獨(dú)開章節(jié)來敘述。

      數(shù)據(jù)模型

      首先我們需要定義數(shù)據(jù)模型,這里的數(shù)據(jù)模型需要有兩部分,一部分是描述文檔內(nèi)容的節(jié)點(diǎn),另一部分是針對數(shù)據(jù)結(jié)構(gòu)的操作。首先來看描述文檔的內(nèi)容,我們?nèi)匀灰员馄降臄?shù)據(jù)結(jié)構(gòu)來描述內(nèi)容,此外為了簡單描述DOM結(jié)構(gòu),此處不會(huì)存在多級(jí)的DOM嵌套。

      let model = [
        { type: "strong", text: "123", start: 0, len: 3 },
        { type: "span", text: "123123", start: 3, len: 6 },
      ];
      

      在上述的數(shù)據(jù)中,type即為節(jié)點(diǎn)類型,text則為文本內(nèi)容。而數(shù)據(jù)模型僅是描述數(shù)據(jù)結(jié)構(gòu)還不夠,我們還需要額外增加狀態(tài)來描述位置信息,也就是上述數(shù)據(jù)中的startlen,這部分?jǐn)?shù)據(jù)對于我們計(jì)算選區(qū)變換很有用。

      因此數(shù)據(jù)模型這部分不僅僅是數(shù)據(jù),更應(yīng)該被稱作為狀態(tài)。接下來則是針對數(shù)據(jù)結(jié)構(gòu)的操作,也就是說針對數(shù)據(jù)模型的插入、刪除、修改等操作。在這里我們簡單定義了數(shù)據(jù)截取的操作,而完整的compose操作則可以參考 delta.ts

      截取數(shù)據(jù)的操作是執(zhí)行compose操作的基礎(chǔ),當(dāng)我們存在原文和變更描述時(shí),需要分別將其轉(zhuǎn)換為迭代器對象來截取數(shù)據(jù),以此來構(gòu)造新的數(shù)據(jù)模型。這里的迭代器部分先定義了peekLengthhasNext兩個(gè)方法,用于判斷當(dāng)前數(shù)據(jù)是否存在剩余可取得的部分,以及是否可繼續(xù)迭代。

      peekLength() {
        if (this.data[this.index]) {
          return this.data[this.index].text.length - this.offset;
        } else {
          return Infinity;
        }
      }
      
      hasNext() {
        return this.peekLength() < Infinity;
      }
      

      next方法的處理方式要復(fù)雜一些,這里我們的目標(biāo)主要就是取text的部分內(nèi)容。注意我們每次調(diào)用next是不會(huì)跨節(jié)點(diǎn)的,也就是說每次next最多取當(dāng)前index的節(jié)點(diǎn)所存儲(chǔ)的insert長度。因?yàn)槿绻〉膬?nèi)容超過了單個(gè)op的長度,理論上其對應(yīng)屬性是不一致的,所以不能直接合并。

      調(diào)用next方法時(shí),如果不存在length參數(shù),則默認(rèn)為Infinity。然后我們?nèi)‘?dāng)前index的節(jié)點(diǎn),計(jì)算出當(dāng)前節(jié)點(diǎn)的剩余長度,如果取length大于剩余長度,則取剩余長度,否則取希望取得的length長度。然后根據(jù)offsetlength來截取text內(nèi)容。

      next(length) {
        if (!length) length = Infinity;
        const nextOp = this.data[this.index];
        if (nextOp) {
          const offset = this.offset;
          const opLength = nextOp.text.length;
          const restLength = opLength - offset;
          if (length >= restLength) {
            length = restLength;
            this.index = this.index + 1;
            this.offset = 0;
          } else {
            this.offset = this.offset + length;
          }
          const newOp = { ...nextOp };
          newOp.text = newOp.text.slice(offset, offset + length);
          return newOp;
        }
        return null;
      }
      

      以此我們簡單定義了描述數(shù)據(jù)模型的狀態(tài),以及可以用來截取數(shù)據(jù)結(jié)構(gòu)的迭代器。這部分是描述數(shù)據(jù)結(jié)構(gòu)內(nèi)容以及變更的基礎(chǔ),當(dāng)然在這里我們精簡了非常多的內(nèi)容,因此看起來比較簡單。實(shí)際上這里還有非常復(fù)雜的實(shí)現(xiàn),例如如何實(shí)現(xiàn)immutable來減少重復(fù)渲染保證性能。

      視圖層

      視圖層主要負(fù)責(zé)渲染數(shù)據(jù)模型,這部分我們是可以使用React來渲染的,只不過在這個(gè)簡單例子中,我們可以直接全量創(chuàng)建DOM即可。因此在這里我們直接遍歷數(shù)據(jù)模型,根據(jù)節(jié)點(diǎn)類型來創(chuàng)建對應(yīng)的DOM節(jié)點(diǎn),然后將其插入到contenteditablediv中。

      const render = () => {
        container.innerHTML = "";
        for (const data of model) {
          const node = document.createElement(data.type);
          node.setAttribute("data-leaf", "true");
          node.textContent = data.text;
          container.appendChild(node);
          MODEL_TO_DOM.set(data, node);
          DOM_TO_MODEL.set(node, data);
        }
        editor.updateDOMselection();
      };
      

      這里我們還額外增加了data-leaf屬性,以便于標(biāo)記葉子結(jié)點(diǎn)。我們的選區(qū)更新是需要標(biāo)記葉子結(jié)點(diǎn),以便于能夠正確計(jì)算選區(qū)需要落在某個(gè)DOM節(jié)點(diǎn)上。而MODEL_TO_DOMDOM_TO_MODEL則是用來維護(hù)ModelDOM的映射關(guān)系,因?yàn)槲覀冃枰鶕?jù)DOMMODEL來相互獲取對應(yīng)值。

      以此我們定義了非常簡單的視圖層,示例中我們不需要考慮太多的性能問題。但是在React真正完成視圖層的時(shí)候,由于非受控的ContentEditable的表現(xiàn),我們就需要考慮非常多的問題,例如key值的維護(hù)、臟DOM的檢查、減少重復(fù)渲染、批量調(diào)度刷新、選區(qū)修正等等。

      控制器

      控制器則是我們的架構(gòu)中最復(fù)雜的部分,這里存在了大量的邏輯處理。我們的編輯器控制器模型需要在數(shù)據(jù)結(jié)構(gòu)和視圖層的基礎(chǔ)上實(shí)現(xiàn),因此我們就在最后將其敘述,恰好在這里的MVC模型順序的最后即是Controller。在控制器層,總結(jié)起來最主要的功能就是同步,即同步數(shù)據(jù)模型和視圖層的狀態(tài)。

      舉個(gè)例子,我們的視圖層是基于數(shù)據(jù)模型來渲染的,假如此時(shí)我們在某個(gè)節(jié)點(diǎn)上輸入了內(nèi)容,那么我們需要將輸入的內(nèi)容同步到數(shù)據(jù)模型中。而如果此時(shí)我們沒有正確同步數(shù)據(jù)模型,那么選區(qū)的長度計(jì)算就會(huì)出現(xiàn)問題,這種情況下自然還會(huì)導(dǎo)致選區(qū)的索引同步出現(xiàn)問題,這里還要區(qū)分受控和非受控問題。

      那么首先我們需要關(guān)注選區(qū)的同步,選區(qū)是編輯器操作的基礎(chǔ),選中的狀態(tài)則是操作的基準(zhǔn)位置。同步的本質(zhì)實(shí)現(xiàn)則是需要用瀏覽器的API來同步到數(shù)據(jù)模型中,瀏覽器的選區(qū)存在selectionchange事件,通過這個(gè)事件我們可以關(guān)注到選區(qū)的變化,此時(shí)便可以獲取最新的選區(qū)信息。

      通過window.getSelection方法我們可以獲取到當(dāng)前選區(qū)的信息,然后通過getRangeAt就可以拿到選區(qū)的Range對象,我們自然就可以通過Range對象來獲取選區(qū)的開始和結(jié)束位置。有了選區(qū)的起始和結(jié)束位置,我們就可以通過先前設(shè)置的映射關(guān)系來取的對應(yīng)的位置。

      document.addEventListener("selectionchange", () => {
        const selection = window.getSelection();
        const range = selection.getRangeAt(0);
        const { startContainer, endContainer, startOffset, endOffset } = range;
        const startLeaf = startContainer.parentElement.closest("[data-leaf]");
        const endLeaf = endContainer.parentElement.closest("[data-leaf]");
        const startModel = DOM_TO_MODEL.get(startLeaf);
        const endModel = DOM_TO_MODEL.get(endLeaf);
        const start = startModel.start + startOffset;
        const end = endModel.start + endOffset;
        editor.setSelection({ start, len: end - start });
        editor.updateDOMselection();
      });
      

      這里通過選區(qū)節(jié)點(diǎn)獲取對應(yīng)的DOM節(jié)點(diǎn)并不一定是我們需要的節(jié)點(diǎn),瀏覽器的選區(qū)位置規(guī)則對我們的模型來說是不確定的,因此我們需要根據(jù)選區(qū)節(jié)點(diǎn)來查找目標(biāo)的葉子節(jié)點(diǎn)。舉個(gè)例子,普通的文本選中情況下選區(qū)是在文本節(jié)點(diǎn)上的,三擊選中則是在整個(gè)行DOM節(jié)點(diǎn)上的。

      因此這里的closest只是處理最普通的文本節(jié)點(diǎn)選區(qū),復(fù)雜的情況還需要進(jìn)行normalize操作。而DOM_TO_MODEL則是狀態(tài)映射,獲取到最近的[data-leaf]節(jié)點(diǎn)就是為了拿到對應(yīng)的狀態(tài),當(dāng)獲取到最新選區(qū)位置之后,是需要更新DOM的實(shí)際選區(qū)位置的,相當(dāng)于校正了瀏覽器本身的選區(qū)狀態(tài)。

      updateDOMselection方法則是完全相反的操作,上述的事件處理是通過DOM選區(qū)更新Model選區(qū),而updateDOMselection則是通過Model選區(qū)更新DOM選區(qū)。那么此時(shí)我們是只有start/len,基于這兩個(gè)數(shù)字的到對應(yīng)的DOM并不是簡單的事情,此時(shí)我們需要查找DOM節(jié)點(diǎn)。

      const leaves = Array.from(container.querySelectorAll("[data-leaf]"));
      

      這里同樣會(huì)存在不少的DOM查找,因此實(shí)際的操作中也需要盡可能地減少選擇的范圍,在我們實(shí)際的設(shè)計(jì)中,則是以行為基準(zhǔn)查找span類型的節(jié)點(diǎn)。緊接著就需要遍歷整個(gè)leaves數(shù)組,然后繼續(xù)通過DOM_TO_MODEL來獲取DOM對應(yīng)的狀態(tài),然后來獲取構(gòu)造range需要的節(jié)點(diǎn)和偏移。

      const { start, len } = editor.selection;
      const end = start + len;
      for (const leaf of leaves) {
        const data = DOM_TO_MODEL.get(leaf);
        const leafStart = data.start;
        const leafLen = data.text.length;
        if (start >= leafStart && start <= leafStart + leafLen) {
          startLeaf = leaf;
          startLeafOffset = start - leafStart;
          // 折疊選區(qū)狀態(tài)下可以 start 與 end 一致
          if (windowSelection.isCollapsed) {
            endLeaf = startLeaf;
            endLeafOffset = startLeafOffset;
            break;
          }
        }
        if (end >= leafStart && end <= leafStart + leafLen) {
          endLeaf = leaf;
          endLeafOffset = end - leafStart;
          break;
        }
      }
      

      當(dāng)查找到目標(biāo)的DOM節(jié)點(diǎn)之后,我們那就可以構(gòu)造出modelRange,并且將其設(shè)置為瀏覽器選區(qū)。但是需要注意的是,我們需要在此處檢查當(dāng)前選區(qū)是否與原本的選區(qū)相同,設(shè)想一下如果再次設(shè)置選區(qū),那么就會(huì)觸發(fā)SelectionChange事件,這樣就會(huì)導(dǎo)致無限循環(huán),自然是需要避免此問題。

      if (windowSelection.rangeCount > 0) {
        range = windowSelection.getRangeAt(0);
        // 當(dāng)前選區(qū)與 Model 選區(qū)相同, 則不需要更新
        if (
          range.startContainer === modelRange.startContainer &&
          range.startOffset === modelRange.startOffset &&
          range.endContainer === modelRange.endContainer &&
          range.endOffset === modelRange.endOffset
        ) {
          return void 0;
        }
      }
      windowSelection.setBaseAndExtent(
        startLeaf.firstChild,
        startLeafOffset,
        endLeaf.firstChild,
        endLeafOffset
      );
      

      實(shí)際上選區(qū)的問題不比輸入法的問題少,在這里我們就是非常簡單地實(shí)現(xiàn)了瀏覽器選區(qū)與我們模型選區(qū)的同步,核心仍然是狀態(tài)的同步。接下來就可以實(shí)現(xiàn)數(shù)據(jù)模型的同步,在這里也就是我們實(shí)際執(zhí)行命令的實(shí)現(xiàn),而不是直接使用document.execCommand

      此時(shí)我們先前定義的數(shù)據(jù)迭代器就派上用場了,我們操作的目標(biāo)也是需要使用range來實(shí)現(xiàn),例如123123這段文本在start: 3, len: 2的選區(qū),以及strong的類型,在這區(qū)間內(nèi)的數(shù)據(jù)類型就會(huì)變成123[12 strong]3,這也就是將長數(shù)據(jù)進(jìn)行裁剪的操作。

      我們首先根據(jù)需要操作的選區(qū)來構(gòu)造retain數(shù)組,雖然這部分描述本身應(yīng)該構(gòu)造ops來操作,然而這里就需要更多的補(bǔ)充compose的實(shí)現(xiàn),因此這里我們只使用一個(gè)數(shù)組和索引來標(biāo)識(shí)了。

      let retain = [start, len, Infinity];
      let retainIndex = 0;
      

      然后則需要定義迭代器和retain來合并數(shù)據(jù),這里我們的操作是0索引來移動(dòng)指針以及截取索引內(nèi)的數(shù)據(jù),1索引來實(shí)際變化類型的type2索引我們將其固定為Infinity,在這種情況下我們是取剩余的所有數(shù)據(jù)。這里重要的length則是取兩者較短的值,以此來實(shí)現(xiàn)數(shù)據(jù)的截取。

      const iterator = new Iterator(model);
      while (iterator.hasNext()) {
        const length = Math.min(iterator.peekLength(), retain[retainIndex]);
        const isApplyAttrs = retainIndex === 1;
        const thisOp = iterator.next(length);
        const nextRetain = retain[retainIndex] - length;
        retain[retainIndex] = nextRetain;
        if (retain[retainIndex] === 0) {
          retainIndex = retainIndex + 1;
        }
        if (!thisOp) break;
        isApplyAttrs && (thisOp.type = type);
        newModel.push(thisOp);
      }
      

      在最后,還記得我們維護(hù)的數(shù)據(jù)不僅是數(shù)據(jù)表達(dá),更是描述整個(gè)數(shù)據(jù)的狀態(tài)。因此最后我們還需要將所有的數(shù)據(jù)刷新一遍,以此來保證最后的數(shù)據(jù)模型正確,此時(shí)還需要調(diào)用render來重新渲染視圖層,然后重新刷新瀏覽器選區(qū)。

      let index = 0;
      for (const data of newModel) {
        data.start = index;
        data.len = data.text.length;
        index = index + data.text.length;
      }
      render();
      editor.updateDOMselection();
      

      以此我們定義了相對復(fù)雜的控制器層,這里的控制器層主要是同步數(shù)據(jù)模型和視圖層的狀態(tài),以及實(shí)現(xiàn)了最基本的命令操作,當(dāng)然沒有處理很多復(fù)雜的邊界情況。在實(shí)際的編輯器實(shí)現(xiàn)中,這部分邏輯會(huì)非常復(fù)雜,因?yàn)槲覀冃枰幚矸浅6嗟膯栴},例如輸入法、選區(qū)模型、剪貼板等等。

      項(xiàng)目架構(gòu)設(shè)計(jì)

      那么我們基本編輯器MVC模型已經(jīng)實(shí)現(xiàn),因此自然而然就可以將其抽象為獨(dú)立的package,恰好我們也是通過monorepo的形式來管理項(xiàng)目的。因此在這里就可以將其抽象為coredeltareactutils四個(gè)核心包,分別對應(yīng)編輯器的核心邏輯、數(shù)據(jù)模型、視圖層、工具函數(shù)。而具體的編輯器模塊實(shí)現(xiàn),則全部以插件的形式定義在plugin包中。

      Core

      Core模塊封裝了編輯器的核心邏輯,包括剪貼板模塊、歷史操作模塊、輸入模塊、選區(qū)模塊、狀態(tài)模塊等等,所有的模塊通過實(shí)例化的editor對象引用。這里除了本身分層的邏輯實(shí)現(xiàn)外,還希望能夠?qū)崿F(xiàn)模塊的擴(kuò)展能力,可以通過引用編輯器模塊并且擴(kuò)展能力后,可以重新裝載到編輯器上。

      Core
       ├── clipboard
       ├── collect
       ├── editor
       ├── event
       ├── history
       ├── input
       ├── model
       ├── perform
       ├── plugin
       ├── rect
       ├── ref
       ├── schema
       ├── selection
       ├── state
       └── ...
      

      實(shí)際上Core模塊中存在本身的依賴關(guān)系,例如選區(qū)模塊依賴于事件模塊的事件分發(fā),這主要是由于模塊在構(gòu)造時(shí)需要依賴其他模塊的實(shí)例,以此來初始化本身的數(shù)據(jù)和事件等。因此事件實(shí)例化的順序會(huì)比較重要,但是我們在實(shí)際聊下來的時(shí)候則直接按上述定義順序,并未按照直接依賴的有向圖順序。

      clipboard模塊主要負(fù)責(zé)數(shù)據(jù)的序列化與反序列化,以及剪貼板的操作。通常來說,富文本編輯器的DOM結(jié)構(gòu)并沒有那么的規(guī)范,舉個(gè)例子,在slate中我們可以看到諸如data-slate-nodedata-slate-leaf等節(jié)點(diǎn)屬性,我們可以將其理解為模版結(jié)構(gòu)。

      <p data-slate-node="element">
        <span data-slate-node="text">
          <span data-slate-leaf="true">
            <span data-slate-string="true">text</span>
          </span>
        </span>
      </p>
      

      那么我們通過react來構(gòu)建視圖層自然也會(huì)存在這樣的模版結(jié)構(gòu),因此在序列化的過程中就是需要將這部分復(fù)雜的結(jié)構(gòu)序列化為相對規(guī)范的HTML。特別是很多樣式我們并不是使用規(guī)范的語義標(biāo)簽,而是通過style屬性來實(shí)現(xiàn)的,因此將其規(guī)整化是非常重要的。

      反序列化則將HTML轉(zhuǎn)換為編輯器的數(shù)據(jù)模型,這部分實(shí)現(xiàn)則是為了跨編輯器的內(nèi)容粘貼。編輯器內(nèi)建的數(shù)據(jù)結(jié)構(gòu)通常都不一致,因此跨編輯器就需要較為規(guī)范的中間結(jié)構(gòu)。這其實(shí)也是編輯器中不成文的規(guī)定,A編輯器序列化的時(shí)候盡可能規(guī)范,B編輯器反序列化才可以更好地處理。

      collect模塊是可以根據(jù)選區(qū)數(shù)據(jù)來得到相關(guān)的數(shù)據(jù),舉個(gè)例子,當(dāng)用戶選中了一段文本,執(zhí)行復(fù)制的時(shí)候就需要將選中的這部分?jǐn)?shù)據(jù)內(nèi)容取出來,然后才能進(jìn)行序列化操作。此外,collect模塊還可以取得某個(gè)位置的op節(jié)點(diǎn)、marks繼承處理等等。

      editor模塊是編輯器的模塊聚合類,其本身主要是管理整個(gè)編輯器的生命周期,例如實(shí)例化、掛載DOM、銷毀等狀態(tài)。此模塊需要組合所有的模塊,并且還需要關(guān)注模塊的有向圖組織依賴關(guān)系,主要的編輯器API都應(yīng)該從此模塊暴露出來。

      event模塊是事件分發(fā)模塊,原生事件的綁定都是在該模塊中實(shí)現(xiàn),編輯器內(nèi)所有的事件都應(yīng)該從該模塊來分發(fā)。這種方式可以有更高度的自定義空間,例如擴(kuò)展插件級(jí)別的事件執(zhí)行,并且可以減少內(nèi)存泄漏的概率,畢竟只要我們能夠保證編輯器的銷毀方法調(diào)用,那么所有的事件都可以被正確卸載。

      history模塊是維護(hù)歷史操作的模塊,在編輯器中實(shí)現(xiàn)undoredo是比較復(fù)雜的,我們需要基于原子化的操作執(zhí)行,而不是存儲(chǔ)編輯器的全量數(shù)據(jù)快照,并且需要維護(hù)兩個(gè)棧來處理數(shù)據(jù)轉(zhuǎn)移。此外我們還需要在此基礎(chǔ)上實(shí)現(xiàn)擴(kuò)展,例如自動(dòng)組合、操作合并、協(xié)同處理等。

      這里的自動(dòng)組合指的是用戶進(jìn)行高頻連續(xù)操作時(shí),我們需要將其合并為一個(gè)操作。操作合并則是指我們可以通過API來實(shí)現(xiàn)合并,例如用戶上傳圖片后,執(zhí)行了其他輸入操作,然后上傳成功后產(chǎn)生的操作,最后這個(gè)操作應(yīng)該合并到上傳圖片的這個(gè)操作上。協(xié)同處理則是需要遵循一個(gè)原則,即我們僅能撤銷屬于自己的操作,而不能撤銷其他人協(xié)同過來的操作。

      input模塊是處理輸入的模塊,輸入是編輯器的核心操作之一,我們需要處理輸入法、鍵盤、鼠標(biāo)等輸入操作。輸入法的交互處理是需要非常多的兼容處理,例如輸入法還存在候選詞、聯(lián)想詞、快捷輸入、重音等等。甚至是移動(dòng)端的輸入法兼容更麻煩,在draft中還單獨(dú)列出了移動(dòng)端輸入法的兼容問題。

      舉個(gè)目前比較常見的例子,ContentEditable無法真正阻止IME的輸入,這就導(dǎo)致了我們無法真正阻止中文的輸入。在下面的這個(gè)例子中,輸入英文和數(shù)字是不會(huì)有響應(yīng)的,但是中文卻是可以正常輸入的,這也是很多編輯器選擇自繪選區(qū)和輸入的原因之一,例如VSCode、釘釘文檔等。

      <div contenteditable id="$1"></div>
      <script>
        const stop = (e) => {
          e.preventDefault();
          e.stopPropagation();
        };
        $1.addEventListener('beforeinput', stop);
        $1.addEventListener('input', stop);
        $1.addEventListener('keydown', stop);
        $1.addEventListener('keypress', stop);
        $1.addEventListener('keyup', stop);
        $1.addEventListener('compositionstart', stop);
        $1.addEventListener('compositionupdate', stop);
        $1.addEventListener('compositionend', stop);
      </script>
      

      model模塊是用來映射DOM視圖和狀態(tài)模型的關(guān)系,這部分是視圖層和數(shù)據(jù)模型的橋梁,在很多時(shí)候我們需要通過DOM來獲取狀態(tài)模型,同樣也會(huì)需要通過狀態(tài)模型在獲取對應(yīng)的DOM視圖。這部分就是利用WeakMap來維護(hù)映射,以此來實(shí)現(xiàn)狀態(tài)的同步。

      perform模塊是封裝了針對數(shù)據(jù)模型執(zhí)行變更的基礎(chǔ)模塊,由于構(gòu)造基本的delta操作會(huì)比較復(fù)雜,例如執(zhí)行屬性marks的變更,是需要過濾掉\n的這個(gè)op,反過來對行屬性的操作則是需要過濾掉普通文本op。因此需要封裝這部分操作,來簡化執(zhí)行變更的成本。

      plugin模塊實(shí)現(xiàn)了編輯器的插件化機(jī)制,插件化是非常有必要的,理論上而言普通文本外的所有格式都應(yīng)該由插件來實(shí)現(xiàn)。那么這里的插件化主要是提供了基礎(chǔ)的插件定義和類型,管理了插件的生命周期,以及諸如按方法調(diào)用分組、方法調(diào)度優(yōu)先級(jí)等能力。

      rect模塊是用來處理編輯器的位置信息,在很多時(shí)候我們需要根據(jù)DOM節(jié)點(diǎn)來計(jì)算位置,并且需要提供節(jié)點(diǎn)在編輯器的相對位置,特別是很多附加能力中,例如虛擬滾動(dòng)的視口鎖定、對比視圖的虛擬圖層、評(píng)論能力的高度定位等等。此外,選區(qū)的位置信息也是很重要的,例如浮動(dòng)工具欄的彈出位置。

      ref模塊是實(shí)現(xiàn)了編輯器的位置轉(zhuǎn)移引用,這部門其實(shí)是利用了協(xié)同的transform來處理的索引信息,類似于slatePathRef。舉個(gè)例子,當(dāng)用戶上傳圖片后,此時(shí)可能會(huì)進(jìn)行其他的內(nèi)容插入操作,此時(shí)圖片的索引值會(huì)發(fā)生變化,而使用ref模塊則可以拿到最新的索引值。

      schema模塊是用來定義編輯器的數(shù)據(jù)應(yīng)用規(guī)則,我們需要在此處定義數(shù)據(jù)屬性需要處理的方法,例如加粗的屬性marks需要在輸入后繼續(xù)繼承加粗屬性,而行內(nèi)代碼inline類型則不需要繼續(xù)繼承,類似于圖片、分割線則需要被定義為獨(dú)占整行的Void類型,MentionEmoji等則需要被定義為Embed類型。

      selection模塊是用來處理選區(qū)的模塊,選區(qū)是編輯器的核心操作基準(zhǔn),我們需要處理選區(qū)同步、選區(qū)校正等等。實(shí)際上選區(qū)的同步是非常復(fù)雜的事情,從瀏覽器的DOM映射到選區(qū)模型本身就是需要精心設(shè)計(jì)的事情,而選區(qū)的校正則是需要處理非常多的邊界情況。

      在先前我們也提到了相關(guān)的問題,以下面的DOM結(jié)構(gòu)為例,如果我們要表達(dá)選區(qū)折疊在4這個(gè)字符左側(cè)時(shí),同樣會(huì)出現(xiàn)多種表達(dá)可以實(shí)現(xiàn)這個(gè)位置,這實(shí)際上就會(huì)很依賴瀏覽器的默認(rèn)行為。因此這樣就需要我們自己來保證這個(gè)選區(qū)的映射,以及在非常規(guī)狀態(tài)下的校正邏輯。

      // <span>123</span><b><em>456</em></b><span>789</span>
      { node: 123, offset: 3 }
      { node: <em></em>, offset: 0 }
      { node: <b></b>, offset: 0 }
      

      state模塊維護(hù)了編輯器的核心狀態(tài),在實(shí)例化編輯器時(shí)傳遞基本數(shù)據(jù)后,我們后續(xù)維護(hù)的內(nèi)容就變成了狀態(tài),而不是最開始傳遞的數(shù)據(jù)內(nèi)容。我們的狀態(tài)變更方法同樣會(huì)在此處實(shí)現(xiàn),特別是Immutable/Key的狀態(tài)維護(hù),我們需要保證狀態(tài)的不可變性,以此來減少重復(fù)渲染。

                                   |-- LeafState
                   |-- LineState --|-- LeafState
                   |               |-- LeafState            
      BlockState --|
                   |               |-- LeafState
                   |-- LineState --|-- LeafState
                                   |-- LeafState
      

      Delta

      Delta模塊封裝了編輯器的數(shù)據(jù)模型,我們需要基于數(shù)據(jù)模型來描述編輯器的內(nèi)容,以及編輯器內(nèi)容的變更。除此之外,還封裝了數(shù)據(jù)模型的諸多操作,例如composetransforminvertdiffIterator等等。

      Delta
       ├── attributes
       ├── delta
       ├── mutate
       ├── utils
       └── ...
      

      這里的Delta實(shí)現(xiàn)是基于Quill的數(shù)據(jù)模型改造的,Quill的數(shù)據(jù)模型設(shè)計(jì)非常優(yōu)秀,特別是封裝了基于OT的操作變換等方法。但是設(shè)計(jì)上還是存在不方便的地方,因此參考了EtherPad的數(shù)據(jù)實(shí)現(xiàn),在此基礎(chǔ)上改造了部分實(shí)現(xiàn),我們后續(xù)會(huì)詳細(xì)講述數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)。

      此外需要注意的是,我們的Delta實(shí)現(xiàn)最主要的是用來描述文檔以及變更,相當(dāng)于一種序列化和反序列化的實(shí)現(xiàn)。上邊也提到了在初始化編輯器之后,我們維護(hù)的數(shù)據(jù)就變成了內(nèi)建的狀態(tài),而非最初初始化的數(shù)據(jù)內(nèi)容。因此很多方法在控制器層面上,都會(huì)有單獨(dú)的設(shè)計(jì),例如immutable的狀態(tài)維護(hù)。

      attributes模塊維護(hù)了針對文本描述屬性的操作,我們在這里簡化了屬性的實(shí)現(xiàn),即AttributeMap類型定義了為<string, string>的類型。而具體的模塊中則定義了composeinverttransformdiff等方法,以此來實(shí)現(xiàn)屬性的合并、反轉(zhuǎn)、變換、差異等操作。

      delta模塊實(shí)現(xiàn)了整個(gè)編輯器的數(shù)據(jù)模型,delta通過ops實(shí)現(xiàn)了線形結(jié)構(gòu)的數(shù)據(jù)模型。ops的結(jié)構(gòu)主要包括三種操作,insert用來描述插入文本、delete用來描述刪除文本、retain用來描述保留文本/移動(dòng)指針,以及在此基礎(chǔ)上的composetransform等等方法。

      mutate模塊則實(shí)現(xiàn)了immutabledelta模塊實(shí)現(xiàn),并且獨(dú)立了\n作為獨(dú)立的op。最初的控制器設(shè)計(jì)實(shí)現(xiàn)是基于數(shù)據(jù)變更實(shí)現(xiàn)的,后續(xù)將其改造為原始狀態(tài)的維護(hù),因此這部分實(shí)現(xiàn)移動(dòng)到了delta模塊中,因此這部分可以直接對應(yīng)編輯器的狀態(tài)維護(hù),可以用于單元測試等等。

      utils模塊則封裝了對于op以及delta的輔助方法,clone的相關(guān)方法實(shí)現(xiàn)了諸如opdelta等深拷貝以及對等方法,當(dāng)然由于我們新的設(shè)計(jì)則無需引入lodash的相關(guān)方法。此外還實(shí)現(xiàn)了一些數(shù)據(jù)的判斷以及格式化方法,例如數(shù)據(jù)的起始/結(jié)束字符串判斷、分割\n的方法等等。

      React

      React模塊實(shí)現(xiàn)了視圖層的適配器,提供了基本的TextVoidEmbed等類型的節(jié)點(diǎn),以及相關(guān)的渲染模式,相當(dāng)于封裝了符合Core模式的調(diào)度范式。并且還提供了相關(guān)包裝的HOC節(jié)點(diǎn),以及Hooks等方法,以此來實(shí)現(xiàn)插件的視圖層擴(kuò)展。

      React
       ├── hooks
       ├── model
       ├── plugin
       ├── preset
       └── ...
      

      hooks模塊實(shí)現(xiàn)了獲取編輯器實(shí)例的方法,方便在React組件中獲取編輯器實(shí)例,當(dāng)然這依賴于我們的Provider組件。此外還實(shí)現(xiàn)了readonly的狀態(tài)方法,這里的只讀狀態(tài)維護(hù)本身是維護(hù)在插件中的,但是后來將其提取到了React組件中,這樣能更容易切換編輯/只讀狀態(tài)。

      model模塊實(shí)現(xiàn)了編輯器內(nèi)建的數(shù)據(jù)模型,實(shí)際上是對應(yīng)了Core層的State,即Block/Line/Leaf的數(shù)據(jù)模型,這其中除了DOM節(jié)點(diǎn)需要遵循的模式外,還實(shí)現(xiàn)了諸如臟DOM檢測的方式等。此外這里還存在了特殊的EOL節(jié)點(diǎn),是個(gè)特殊的LeafModel,會(huì)根據(jù)策略調(diào)度行尾節(jié)點(diǎn)的渲染。

      plugin模塊實(shí)現(xiàn)了編輯器的插件化機(jī)制,這里的插件化主要是擴(kuò)展了基礎(chǔ)的插件定義和類型,例如在Core中定義的插件方法類型返回值是any,在這里我們需要將其定義為具體的ReactNode類型。此外,這里還實(shí)現(xiàn)了渲染時(shí)的插件,即沒有在核心層維護(hù)狀態(tài)的類型,主要是Wrap類型的節(jié)點(diǎn)插件化。

      preset模塊預(yù)設(shè)了編輯器對外暴露的API組件,諸如編輯器的ContextVoidEmbedEditable組件等等,主要是提供構(gòu)建編輯器視圖的基礎(chǔ)組件,以及插件層的組件擴(kuò)展等。當(dāng)然還封裝了很多交互實(shí)現(xiàn),例如自動(dòng)聚焦、選區(qū)同步、視圖刷新適配器等等。

      Utils

      Utils模塊實(shí)現(xiàn)了諸多通用的工具函數(shù),主要是處理編輯器內(nèi)的通用邏輯,例如防抖、節(jié)流等等,也有處理DOM結(jié)構(gòu)的輔助方法,還有事件分發(fā)的處理方法、事件綁定的裝飾器等,以及諸如列表的操作、國際化、剪貼板操作等等。

      Utils
       ├── debounce.ts
       ├── decorator.ts
       ├── dom.ts
       └── ...
      

      總結(jié)

      在這里我們實(shí)現(xiàn)了簡單的編輯器MVC架構(gòu)示例,然后在此基礎(chǔ)上自然而然地抽象出了編輯器的核心模塊、數(shù)據(jù)模型、視圖層、工具函數(shù)等,并且將其做了簡單的敘述。在后續(xù)我們會(huì)描述編輯器的數(shù)據(jù)模型設(shè)計(jì),介紹我們的Delta數(shù)據(jù)結(jié)構(gòu)方法,以及在編輯器中的相關(guān)應(yīng)用場景。數(shù)據(jù)結(jié)構(gòu)是非常重要的設(shè)計(jì),因?yàn)榫庉嬈鞯暮诵牟僮鞫际腔跀?shù)據(jù)模型的,若不能夠理解數(shù)據(jù)結(jié)構(gòu)的設(shè)計(jì),則會(huì)導(dǎo)致難以理解編輯器的很多操作模型。

      每日一題

      參考

      posted @ 2025-04-15 10:22  WindRunnerMax  閱讀(1730)  評(píng)論(1)    收藏  舉報(bào)
      ?Copyright    @Blog    @WindRunnerMax
      主站蜘蛛池模板: 日韩a∨精品日韩在线观看| xx性欧美肥妇精品久久久久久| 亚洲精品麻豆一区二区| 91老肥熟女九色老女人| 日本一区二区不卡精品| 国产成人精品av| 久久久久夜夜夜精品国产| 色一伦一情一区二区三区| 暖暖 在线 日本 免费 中文| 免费观看欧美猛交视频黑人| 韩国三级网一区二区三区| 少妇被粗大的猛烈xx动态图| 日本污视频在线观看| 精品国产成人a在线观看| 久青草国产综合视频在线| 久久日韩精品一区二区五区| 昔阳县| 国产老熟女视频一区二区| 黄色A级国产免费大片视频| 无码人妻丰满熟妇奶水区码| 国产嫩草精品网亚洲av| 四虎国产精品久久免费地址| 成av人电影在线观看| 久久精品人妻无码一区二区三区| 蜜臀精品一区二区三区四区| 91国在线啪精品一区| 在线天堂中文新版www| 三人成全免费观看电视剧高清| 亚洲av无码成人精品区一区| brazzers欧美巨大| 色综合久久久久综合99| 蜜芽久久人人超碰爱香蕉| 人妻少妇88久久中文字幕 | 亚洲国产精品日韩av专区| 西西人体44WWW高清大胆| 无码精品人妻一区二区三区中| 搡老熟女老女人一区二区| 国产精品麻豆成人AV电影艾秋 | 国产高清在线精品一区二区三区| 日韩精品一区二区三区中文| 男女激情一区二区三区|