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

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

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

      富文本編輯器剪貼板模塊基石-序列化與反序列化

      在富文本編輯器中,序列化與反序列化是非常重要的環節,其涉及到了編輯器的內容復制、粘貼、導入導出等模塊。當用戶在編輯器中進行復制操作時,富文本內容會被轉換為標準的HTML格式,并存儲在剪貼板中。而在粘貼操作中,編輯器則需要將這些HTML內容解析并轉換為編輯器的私有JSON結構,以便于實現跨編輯器內容的統一管理。

      描述

      我們平時在使用一些在線文檔編輯器的時候,可能會好奇一個問題,為什么我們能夠直接把格式復制出來,而不僅僅是純文本,甚至于說從瀏覽器中復制內容到Office Word都可以保留格式。這看起來是不是一件很神奇的事情,不過當我們了解到剪貼板的基本操作之后,就可以了解這其中的底層實現了。

      說到剪貼板的操作,在執行復制行為的時候,我們可能會認為復制的就是純文本,然而顯然光靠復制純文本我們是做不到上述的功能。所以實際上剪貼板是可以存儲復雜內容的,那么在這里我們以Word為例,當我們從Word中復制文本時,其實際上是會在剪貼板中寫入這么幾個key值:

      text/plain
      text/html
      text/rtf
      image/png
      

      看著text/plain是不是很眼熟,這顯然就是我們常見的Content-Type或者稱作MIME-Type,所以說我們是不是可以認為剪貼板是一個Record<string, string>的結構類型。但是別忽略了我們還有一個image/png類型,因為我們的剪貼板是可以直接復制文件的,所以我們常用的剪貼板類型就是Record<string, string | File>例如此時復制這段文字在剪貼板中就是如下內容

      text/plain
      例如此時復制這段文字在剪貼板中就是如下內容
      
      text/html
      <meta charset="utf-8"><strong style="...">例如此時復制這段文字</strong><em style="...">在剪貼板中就是如下內容</em>
      

      那么我們執行粘貼操作的時候就很明顯了,只需要從剪貼板里讀取內容就可以。例如我們從語雀復制內容到飛書中時,在語雀復制的時候會將text/plain以及text/html寫入剪貼板,在粘貼到飛書的時候就可以首先檢查是否有text/htmlkey,如果有的話就可以讀取出來,并且將其解析成為飛書自己的私有格式,就可以通過剪貼板來保持內容格式粘貼到飛書了。而如果沒有text/html的話,就直接將text/plain的內容寫到私有的JSON數據即可。

      此外,我們還可以考慮到一個問題,在上邊的例子中實際上我們是復制時需要將JSON轉到HTML字符串,在粘貼時需要將HTML字符串轉換為JSON,這都是需要進行序列化與反序列化的,是需要有性能消耗以及內容損失的,所以是不是能夠減少這部分消耗。通常來說如果是在應用內直接直接粘貼的話,可以直接通過剪貼板的數據直接compose到當前的JSON即可,這樣就可以更完整地保持內容以及減少對于HTML解析的消耗。例如在飛書中,會有docx/text的獨立clipboard key以及data-lark-record-data作為獨立JSON數據源。

      那么至此我們已經了解到剪貼板的工作原理,緊接著我們就來聊一聊如何進行序列化的操作。說到復制我們可能通常會想到clipboard.js,如果需要兼容性比較高的話(IE)可以考慮,但是如果需要在現在瀏覽器中使用的話,則可以直接考慮使用HTML5規范的API完成,在瀏覽器中關于復制的API常用的有兩種,分別是document.execCommand("copy")以及navigator.clipboard.write/writeText

      document.execCommand("selectAll");
      const res = document.execCommand("copy");
      console.log(res); // true
      
      const dataItems: Record<string, Blob> = {};
      for (const [key, value] of Object.entries(data)) {
        const blob = new Blob([value], { type: key });
        dataItems[key] = blob;
      }
      navigator.clipboard.write([new ClipboardItem(dataItems)])
      

      而對于序列化即粘貼行為,則存在document.execCommand("paste")以及navigator.clipboard.read/readText可用。但是需要注意的是execCommand這個API的調用總是會失敗,clipboard.read則需要用戶主動授權。關于這個問題,我們在先前通過瀏覽器擴展對可信事件的研究也已經有過結論,在擴展中即使保持清單中的clipboardRead權限聲明,也無法直接讀取剪貼板,必須要在Content Script甚至chrome.debugger中才可以執行。

      document.addEventListener("paste", (e) => {
        const data = e.clipboardData;
        console.log(data);
      });
      const res = document.execCommand("paste");
      console.log(res); // false
      
      navigator.clipboard.read().then(res => {
        for (const item of res) {
          item.getType("text/html").then(console.log).catch(() => null)
        }
      });
      

      當然這里并不是此時研究的重點,我們關注的是內容的序列化與反序列化,即在富文本編輯器的復制粘貼模塊的設計。當然這個模塊還會有更廣泛的用途,例如序列化的場景有交付Word文檔、輸出Markdown格式等,反序列的場景有導入Markdown文檔等。而我們對于這個模塊的設計,則需要考慮到以下幾個問題:

      1. 插件化,編輯器中的模塊本身都是插件化的,那么關于剪貼板模塊的設計自然也需要能夠自由擴展序列化/反序列化的格式。特別是在需要精確適配編輯器例如飛書、語雀等的私有格式時,需要能夠自由控制相關行為。
      2. 普適性,由于富文本需要實現DOM與選區MODEL的映射,因此生成的DOM結構通常會比較復雜。而當我們從文檔中復制內容到剪貼板時,我們會希望這個結構是更規范化的,以便粘貼到其他平臺例如飛書、Word等時會有更好的解析。
      3. 完整性,當執行序列化與反序列時,希望能夠保持內容的完整性,即不會因為這個的過程而丟失內容,這里相當于對性能做出讓步而保持內容完整。而對于編輯器本身的格式則關注性能,由于實際注冊的模塊一致,希望能夠直接應用數據而不需要走整個解析過程。

      那么本文將會以slate為例,處理嵌套結構的剪貼板模塊設計,并且以quill為例,處理扁平結構的剪貼板模塊設計。并且以飛書文檔的內容為例,分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,分類型進行序列化與反序列化的設計。

      嵌套結構

      slate的基本數據結構是樹形結構的JSON類型,相關的DEMO實現都在https://github.com/WindRunnerMax/DocEditor中。我們先以標題與加粗的格式為例,描述其基礎內容結構:

      [
        { children: [{ text: "Editor" }], heading: { type: "h1", id: "W5xjbuxy" } },
        { children: [{ text: "加粗", bold: true }, { text: "格式" }] },
      ];
      

      實際上slate的數據結構形式非常類似于DOM結構的嵌套格式,甚至于DOM結構與數據結構是完全一一對應的,例如在渲染Embed結構中的零寬字符渲染時也會在數據結構中存在。因此在實現序列化與反序列化的過程中,理論上我們是可以直接實現其JSON結構完全對應為DOM結構的轉換。

      然而完全對應的情況只是理想情況下,富文本編輯器對于內容的實際組織形式可能會多種多樣,例如實現引用塊結構時,外層包裹的blockquote標簽可能是數據結構本身存在,也可能是渲染時根據行屬性動態渲染的,這種情況下就不能直接從數據結構的層面上將其序列化為完整的HTML

      // 結構渲染
      [
        {
          blockquote: true,
          children:[
            { children: [{ text: "引用塊 Line 1" }] },
            { children: [{ text: "引用塊 Line 2" }] },
          ]
        }
      ];
      
      // 動態渲染
      [
        { children: [{ text: "引用塊 Line 1" }], blockquote: true },
        { children: [{ text: "引用塊 Line 2" }], blockquote: true },
      ];
      

      此外,我們實現的編輯器必然是需要插件化的,在剪貼板模塊中我們無法準確得知插件究竟是如何組織數據結構的。而在富文本編輯器中有著不成文的規矩,我們寫入剪貼板的內容需要是盡可能規范化的結構,否則就無法跨編輯器粘貼內容。因此我們如果希望能夠保證規范化的數據,就需要在剪貼板模塊提供基本的序列化與反序列化的接口,而具體的實現則歸于插件本身處理。

      那么基于這個基本理念,我們首先來看序列化的實現,即JSON結構到HTML的轉換過程。先前我們也提到了,對于編輯器本身的格式則關注性能,由于實際注冊的模塊一致,希望能夠直接應用數據而不需要走整個解析過程,因此我們還需要在剪貼板中額外寫入application/x-doc-editorkey,用來直接存儲Fragment數據。

      {
        "text/plain": "Editor\n加粗格式",
        "text/html": "<h1 id=\"W5xjbuxy\">Editor</h1><div data-line><strong>加粗</strong>格式</div>",
        "application/x-doc-editor": '[{"children":[{"text":"Editor"}],"heading":{"type":"h1","id":"W5xjbuxy"}},{"children":[{"text":"加粗","bold":true},{"text":"格式"}]}]',
      }
      

      我們接下來需要設想下如何將內容寫入到剪貼板,以及實際觸發的場景。除了常見的Ctrl+C來觸發復制行為外,用戶還有可能希望通過按鈕來觸發復制行為,例如飛書就可以通過工具欄復制整個行/塊結構,因此我們不能直接通過OnCopy事件的clipboardData來寫數據,而是需要主動觸發額外的Copy事件。

      前邊也提到了navigator.clipboard.write同樣可以寫入剪貼板,調用這個API是不需要真正觸發Copy事件的,但是當我們使用這個方法寫入數據的時候,可能會拋出異常。此外這個API必須要在HTTPS環境下才能使用,否則會完全沒有這個函數的定義。

      在下面的例子中需要焦點在document上,需要在延遲時間內點擊頁面,否則會拋出DOMException。而即使當我們焦點在頁面上,執行后同樣會拋出DOMException,從拋出的異常來看是因為application/x-doc-editor類型不被支持。

      (async () => {
        await new Promise((resolve) => setTimeout(resolve, 3000));
        const params = {
          "text/plain": "Editor",
          "text/html": "<span>Editor</span>",
          "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
        }
        const dataItems = {};
        for (const [key, value] of Object.entries(params)) {
          const blob = new Blob([value], { type: key });
          dataItems[key] = blob;
        }
        // DOMException: Type application/x-doc-editor not supported on write.
        navigator.clipboard.write([new ClipboardItem(dataItems)]);
      })();
      

      因為這個API不支持我們寫入自定義的類型,因此我們就需要主動觸發Copy事件來寫入剪貼板,雖然我們同樣可以將這個字段的數據作為HTML的某個屬性值寫入text/html中,但是我們這里還是將其獨立出來處理。那么以同樣的數據,我們使用document.execCommand寫入剪貼板的方式就需要新建textarea元素來實現。

      const data = {
        "text/plain": "Editor",
        "text/html": "<span>Editor</span>",
        "application/x-doc-editor": '[{"children":[{"text":"Editor"}]}]',
      }
      const textarea = document.createElement("textarea");
      textarea.addEventListener("copy", event => {
        for (const [key, value] of Object.entries(data)) {
          event.clipboardData && event.clipboardData.setData(key, value);
        }
        event.stopPropagation();
        event.preventDefault();
      });
      textarea.style.position = "fixed";
      textarea.style.left = "-999px";
      textarea.style.top = "-999px";
      textarea.value = data["text/plain"];
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand("copy");
      document.body.removeChild(textarea);
      

      當然這里我們能夠很明顯地看到由于textarea.select,我們原本的編輯器焦點會丟失。因此這里我們還需要注意,在執行復制的時候需要記錄當前的選區值,在寫入剪貼板之后先將焦點置于編輯器,之后再恢復選區。

      接下來我們來處理插件化的定義,這里的Context非常簡單,只需要記錄當前正在處理的Node以及當前已經處理過后的html節點即可。而在插件中我們需要實現serialize方法,用來將Node序列化為HTMLwillSetToClipboard則是Hook定義,當即將寫入剪貼板時會被調用。

      // packages/core/src/clipboard/utils/types.ts
      /** Fragment => HTML */
      export type CopyContext = {
        /** Node 基準 */
        node: BaseNode;
        /** HTML 目標 */
        html: Node;
      };
      
      // packages/core/src/plugin/modules/declare.ts
      abstract class BasePlugin {
        /** 將 Fragment 序列化為 HTML  */
        public serialize?(context: CopyContext): void;
        /** 內容即將寫入剪貼板 */
        public willSetToClipboard?(context: CopyContext): void;
      }
      

      既然我們的具體轉換是在插件中實現的,那么我們主要的工作就是調度插件的執行了。為了方便處理數據,我們這里就不使用Immutable的形式來處理了,我們的Context對象是整個調度過程中保持一致的,即插件中我們所有的方法都是原地處理的。那么調度的方式就直接通過plugin組件調度,調用后從context中獲取html節點即可。

      // packages/core/src/plugin/modules/declare.ts
      public call<T extends CallerType>(key: T, payload: CallerMap[T], type?: PluginType) {
        const plugins = this.current;
        for (const plugin of plugins) {
          try {
            // @ts-expect-error payload match
            plugin[key] && isFunction(plugin[key]) && plugin[key](payload);
          } catch (error) {
            this.editor.logger.warning(`Plugin Exec Error`, plugin, error);
          }
        }
        return payload;
      }
      
      const context: CopyContext = { node: child, html: textNode };
      this.plugin.call(CALLER_TYPE.SERIALIZE, context);
      value.appendChild(context.html);
      

      那么重點的地方就是我們設計的serialize調度方法,我們這里的核心思想是: 當處理到文本行時,我們創建一個空的Fragment節點作為行節點,然后迭代每個文本值,取出當前行的每個Text值創建文本節點,以此創建context對象,然后調度PLUGIN_TYPE.INLINE級別的插件,將序列化后的HTML節點插入到行節點中。

      // packages/core/src/clipboard/modules/copy.ts
      if (this.reflex.isTextBlock(current)) {
        const lineFragment = document.createDocumentFragment();
        current.children.forEach(child => {
          const text = child.text || "";
          const textNode = document.createTextNode(text);
          const context: CopyContext = { node: child, html: textNode };
          this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.INLINE);
          lineFragment.appendChild(context.html);
        });
      }
      

      然后針對每個行節點,我們同樣需要調度PLUGIN_TYPE.BLOCK級別的插件,將處理過后的內容放置于root節點中,并將內容返回。這樣我們就完成了最基本的文本行的序列化操作,這里我們在DOM節點上加入了額外的標識,這樣可以幫助我們在反序列化的時候能夠冪等地處理。

      // packages/core/src/clipboard/modules/copy.ts
      const root = rootNode || document.createDocumentFragment();
      // ...
      const context: CopyContext = { node: current, html: lineFragment };
      this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
      const lineNode = document.createElement("div");
      lineNode.setAttribute(LINE_TAG, "true");
      lineNode.appendChild(context.html);
      root.appendChild(lineNode);
      

      在基本的行結構處理完成后,還需要關注外層的Node節點,這里的數據處理方式與行節點類似。但是這里需要注意的是,這里是遞歸的結構處理,那么這里的JSON結構執行順序就是深度優先遍歷,即先處理文本節點以及行節點,然后再處理外部的塊結構,由內而外地處理,由此來保證整個DOM樹形結構的處理。

      // packages/core/src/clipboard/modules/copy.ts
      if (this.reflex.isBlock(current)) {
        const blockFragment = document.createDocumentFragment();
        current.children.forEach(child => this.serialize(child, blockFragment));
        const context: CopyContext = { node: current, html: blockFragment };
        this.plugin.call(CALLER_TYPE.SERIALIZE, context, PLUGIN_TYPE.BLOCK);
        root.appendChild(context.html);
        return root as T;
      }
      

      而對反序列化的處理則相對簡單,Paste事件是不可以隨意觸發的,必須要由用戶的可信事件來觸發。那么我們就只能通過這個事件來讀取clipboardData中的值,這里需要關注的數據除了先前復制的key,還有files文件字段需要處理。對于反序列化,我們同樣需要在插件中具體實現,同樣是需要原地修改的Context定義。

      // packages/core/src/clipboard/utils/types.ts
      /** HTML => Fragment */
      export type PasteContext = {
        /** Node 目標 */
        nodes: BaseNode[];
        /** HTML 基準 */
        html: Node;
        /** FILE 基準 */
        files?: File[];
      };
      
      /** Clipboard => Context */
      export type PasteNodesContext = {
        /** Node 基準 */
        nodes: BaseNode[];
      };
      
      // packages/core/src/plugin/modules/declare.ts
      abstract class BasePlugin {
        /** 將 HTML 反序列化為 Fragment  */
        public deserialize?(context: PasteContext): void;
        /** 粘貼的內容即將應用到編輯器 */
        public willApplyPasteNodes?(context: PasteNodesContext): void;
      }
      

      這里的調度形式與序列化類似,如果剪貼板中存在application/x-doc-editorkey,則直接讀取這個值。如果存在文件需要處理,則調度所有插件處理,否則則需要讀取text/html的值,如果不存在的話就直接讀取text/plain內容,同樣構造JSON應用到編輯器中。

      // packages/core/src/clipboard/modules/paste.ts
      const files = Array.from(transfer.files);
      const textDoc = transfer.getData(TEXT_DOC);
      const textHTML = transfer.getData(TEXT_HTML);
      const textPlain = transfer.getData(TEXT_PLAIN);
      if (textDoc) {
        // ...
      }
      if (files.length) {
        // ...
      }
      if (textHTML) {
        // ...
      }
      if (textPlain) {
        // ...
      }
      

      這里的重點是對于text/html的處理,也就是反序列化將HTML節點轉換為Fragment節點,這里的處理方式與序列化類似,同樣是需要遞歸地處理數據。首先需要對HTML使用DOMParser對象進行解析,然后深度優先遍歷由內而外處理每個節點,具體的實現依然需要調度插件來處理。

      // packages/core/src/clipboard/modules/paste.ts
      const parser = new DOMParser();
      const html = parser.parseFromString(textHTML, TEXT_HTML);
      
      // ...
      const root: BaseNode[] = [];
      // NOTE: 結束條件 `Text`、`Image`等節點都會在此時處理
      if (current.childNodes.length === 0) {
        if (isDOMText(current)) {
          const text = current.textContent || "";
          root.push({ text });
        } else {
          const context: PasteContext = { nodes: root, html: current };
          this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
          return context.nodes;
        }
        return root;
      }
      const children = Array.from(current.childNodes);
      for (const child of children) {
        const nodes = this.deserialize(child);
        nodes.length && root.push(...nodes);
      }
      const context: PasteContext = { nodes: root, html: current };
      this.plugin.call(CALLER_TYPE.DESERIALIZE, context);
      return context.nodes;
      

      接下來我們將會以slate為例,處理嵌套結構的剪貼板模塊設計。并且以飛書文檔的內容為源和目標,分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,在上述基本模式的調度下,分類型進行序列化與反序列化的插件實現。

      行內結構

      行內結構指的是加粗、斜體、下劃線、刪除線、行內代碼塊等行內的結構樣式,這里以加粗為例來處理序列化與反序列化。在序列化行內結構部分,我們只需要判斷如果是文本節點,就為其包裹一層strong節點,注意的是我們需要原地處理。

      // packages/plugin/src/bold/index.tsx
      export class BoldPlugin extends LeafPlugin {
        public serialize(context: CopyContext) {
          const { node, html } = context;
          if (node[BOLD_KEY]) {
            const strong = document.createElement("strong");
            // NOTE: 采用`Wrap Base Node`加原地替換的方式
            strong.appendChild(html);
            context.html = strong;
          }
        }
      }
      

      反序列化這部分我們也需要前提處理,我們還需要先處理純文本的內容,這是公共的處理方式,即所有節點都是文本節點時,我們需要加入一級行節點。并且還需要對數據進行格式化,理論上我們應該對所有的節點都過濾一次Normalize,但是這里就簡單地處理空節點數據。

      // packages/plugin/src/clipboard/index.ts
      export class ClipboardPlugin extends BlockPlugin {
        public deserialize(context: PasteContext): void {
          const { nodes, html } = context;
          if (nodes.every(isText) && isMatchBlockTag(html)) {
            context.nodes = [{ children: nodes }];
          }
        }
      
        public willApplyPasteNodes(context: PasteNodesContext): void {
          const nodes = context.nodes;
          const queue: BaseNode[] = [...nodes];
          while (queue.length) {
            const node = queue.shift();
            if (!node) continue;
            node.children && queue.push(...node.children);
            // FIX: 兜底處理無文本節點的情況 例如 <div><div></div></div>
            if (node.children && !node.children.length) {
              node.children.push({ text: "" });
            }
          }
        }
      }
      

      對于內容的處理則是判斷出HTML節點存在加粗的格式后,對當前已經處理的Node節點樹中所有的文本節點實現加粗操作,這里同樣需要原地處理數據。這里我們還封裝了applyMark的方法,用來處理所有的文本節點格式。其實這里有趣的是,因為我們的目標是構造整個JSON,我們就不需要關注使用slateTransform模塊操作Model

      // packages/plugin/src/clipboard/utils/apply.ts
      export class BoldPlugin extends LeafPlugin {
        public deserialize(context: PasteContext): void {
          const { nodes, html } = context;
          if (!isHTMLElement(html)) return void 0;
          if (isMatchTag(html, "strong") || isMatchTag(html, "b") || html.style.fontWeight === "bold") {
            // applyMarker packages/plugin/src/clipboard/utils/apply.ts
            context.nodes = applyMarker(nodes, { [BOLD_KEY]: true });
          }
        }
      }
      

      段落結構

      段落結構指的是標題、行高、文本對齊等結構樣式,這里則以標題為例來處理序列化與反序列化。序列化段落結構,我們只需要Node是標題節點時,構造相關的HTML節點,將本來的節點原地包裝并賦值到context即可,同樣采用嵌套節點的方式。

      // packages/plugin/src/heading/index.tsx
      export class HeadingPlugin extends BlockPlugin {
        public serialize(context: CopyContext): void {
          const element = context.node as BlockElement;
          const heading = element[HEADING_KEY];
          if (!heading) return void 0;
          const id = heading.id;
          const type = heading.type;
          const node = document.createElement(type);
          node.id = id;
          node.setAttribute("data-type", HEADING_KEY);
          node.appendChild(context.html);
          context.html = node;
        }
      }
      

      反序列化則是相反的操作,判斷當前正在處理的HTML節點是否為標題節點,如果是的話就將其轉換為Node節點。這里同樣需要原地處理數據,與行內節點不同的是,需要使用applyLineMarker將所有的行節點加入標題格式。

      // packages/plugin/src/heading/index.tsx
      export class HeadingPlugin extends BlockPlugin {
        public deserialize(context: PasteContext): void {
          const { nodes, html } = context;
          if (!isHTMLElement(html)) return void 0;
          const tagName = html.tagName.toLocaleLowerCase();
          if (tagName.startsWith("h") && tagName.length === 2) {
            let level = Number(tagName.replace("h", ""));
            if (level <= 0 || level > 3) level = 3;
            // applyLineMarker packages/plugin/src/clipboard/utils/apply.ts
            context.nodes = applyLineMarker(this.editor, nodes, {
              [HEADING_KEY]: { type: `h` + level, id: getId() },
            });
          }
        }
      }
      

      組合結構

      組合結構在這里指的是引用塊、有序列表、無序列表等結構樣式,這里則以引用塊為例來處理序列化與反序列化。序列化組合結構,同樣需要Node是引用塊節點時,構造相關的HTML節點進行包裝。

      // packages/plugin/src/quote-block/index.tsx
      export class QuoteBlockPlugin extends BlockPlugin {
        public serialize(context: CopyContext): void {
          const element = context.node as BlockElement;
          const quote = element[QUOTE_BLOCK_KEY];
          if (!quote) return void 0;
          const node = document.createElement("blockquote");
          node.setAttribute("data-type", QUOTE_BLOCK_KEY);
          node.appendChild(context.html);
          context.html = node;
        }
      }
      

      反序列化同樣是判斷是否為引用塊節點,并且構造對應的Node節點。這里與標題模塊不同的是,標題是將格式應用到相關的行節點上,而引用塊則是在原本的節點上嵌套一層結構。

      // packages/plugin/src/quote-block/index.tsx
      export class QuoteBlockPlugin extends BlockPlugin {
        public deserialize(context: PasteContext): void {
          const { nodes, html } = context;
          if (!isHTMLElement(html)) return void 0;
          if (isMatchTag(html, "blockquote")) {
            const current = applyLineMarker(this.editor, nodes, {
              [QUOTE_BLOCK_ITEM_KEY]: true,
            });
            context.nodes = [{ children: current, [QUOTE_BLOCK_KEY]: true }];
          }
        }
      }
      

      嵌入結構

      嵌入結構在這里指的是圖片、視頻、流程圖等結構樣式,這里則以圖片為例來處理序列化與反序列化。序列化嵌入結構,我們只需要Node是圖片節點時,構造相關的HTML節點進行包裝。與之前的節點不同的是,此時我們不需要嵌套DOM節點了,將獨立節點原地替換即可。

      // packages/plugin/src/image/index.tsx
      export class ImagePlugin extends BlockPlugin {
        public serialize(context: CopyContext): void {
          const element = context.node as BlockElement;
          const img = element[IMAGE_KEY];
          if (!img) return void 0;
          const node = document.createElement("img");
          node.src = img.src;
          node.setAttribute("data-type", IMAGE_KEY);
          node.appendChild(context.html);
          context.html = node;
        }
      }
      

      對于反序列化的結構,判斷當前正在處理的HTML節點是否為圖片節點,如果是的話就將其轉換為Node節點。與先前的轉換不同的是,我們此時不需要嵌套結構,只需要固定children為零寬字符占位即可。實際上這里還有個常用的操作是,粘貼圖片內容通常需要將原本的src轉儲到我們的服務上,例如飛書的圖片就是臨時鏈接,在生產環境中需要轉儲資源。

      // packages/plugin/src/image/index.tsx
      export class ImagePlugin extends BlockPlugin {
        public deserialize(context: PasteContext): void {
          const { html } = context;
          if (!isHTMLElement(html)) return void 0;
          if (isMatchTag(html, "img")) {
            const src = html.getAttribute("src") || "";
            const width = html.getAttribute("data-width") || 100;
            const height = html.getAttribute("data-height") || 100;
            context.nodes = [
              {
                [IMAGE_KEY]: {
                  src: src,
                  status: IMAGE_STATUS.SUCCESS,
                  width: Number(width),
                  height: Number(height),
                },
                uuid: getId(),
                children: [{ text: "" }],
              },
            ];
          }
        }
      }
      

      塊級結構

      塊級結構指的是高亮塊、代碼塊、表格等結構樣式,這里則以高亮塊為例來處理序列化與反序列化。高亮塊則是飛書中比較定制的結構,本質上是Editable結構的嵌套,這里的兩層callout嵌套結構則是為了兼容飛書的結構。序列化塊級結構在slate中跟引用結構類似,在外層直接嵌套組合結構即可。

      // packages/plugin/src/highlight-block/index.tsx
      export class HighlightBlockPlugin extends BlockPlugin {
        public serialize(context: CopyContext): void {
          const { node: node, html } = context;
          if (this.reflex.isBlock(node) && node[HIGHLIGHT_BLOCK_KEY]) {
            const colors = node[HIGHLIGHT_BLOCK_KEY]!;
            // 提取具體色值
            const border = colors.border || "";
            const background = colors.background || "";
            const regexp = /rgb\((.+)\)/;
            const borderVar = RegExec.exec(regexp, border);
            const backgroundVar = RegExec.exec(regexp, background);
            const style = window.getComputedStyle(document.body);
            const borderValue = style.getPropertyValue(borderVar);
            const backgroundValue = style.getPropertyValue(backgroundVar);
            // 構建 HTML 容器節點
            const container = document.createElement("div");
            container.setAttribute(HL_DOM_TAG, "true");
            container.classList.add("callout-container");
            container.style.border = `1px solid rgb(` + borderValue + `)`;
            container.style.background = `rgb(` + backgroundValue + `)`;
            container.setAttribute("data-emoji-id", "balloon");
            const block = document.createElement("div");
            block.classList.add("callout-block");
            container.appendChild(block);
            block.appendChild(html);
            context.html = container;
          }
        }
      }
      

      反序列化則是判斷當前正在處理的HTML節點是否為高亮塊節點,如果是的話就將其轉換為Node節點。這里的處理方式同樣與引用塊類似,只是需要在外層嵌套一層結構。

      // packages/plugin/src/highlight-block/index.tsx
      export class HighlightBlockPlugin extends BlockPlugin {
        public deserialize(context: PasteContext): void {
          const { nodes, html: node } = context;
          if (isHTMLElement(node) && node.classList.contains("callout-block")) {
            const border = node.style.borderColor;
            const background = node.style.backgroundColor;
            const regexp = /rgb\((.+)\)/;
            const borderColor = border && RegExec.exec(regexp, border);
            const backgroundColor = background && RegExec.exec(regexp, background);
            if (!borderColor || !backgroundColor) return void 0;
            context.nodes = [
              {
                [HIGHLIGHT_BLOCK_KEY]: {
                  border: borderColor,
                  background: backgroundColor,
                },
                children: nodes,
              },
            ];
          }
        }
      }
      

      扁平結構

      quill的基本數據結構是扁平結構的JSON類型,相關的DEMO實現都在https://github.com/WindRunnerMax/BlockKit中。我們同樣以標題與加粗的格式為例,描述其基礎內容結構:

      [
        { insert: "Editor" },
        { attributes: { heading: "h1" }, insert: "\n" },
        { attributes: { bold: "true" }, insert: "加粗" },
        { insert: "格式" },
        { insert: "\n" },
      ];
      

      序列化的調度方案與slate類似,我們同樣需要在剪貼板模塊提供基本的序列化與反序列化的接口,而具體的實現則歸于插件本身處理。針對序列化的方法,也是按照基本行遍歷的方式,優先處理Delta結構的的文本,再處理行結構的格式。但是由于delta的數據結構是扁平的,因此我們不能直接遞歸處理,而是應該循環到EOL時將當前行的節點更新為新的行節點。

      // packages/core/src/clipboard/modules/copy.ts
      const root = rootNode || document.createDocumentFragment();
      let lineFragment = document.createDocumentFragment();
      const ops = normalizeEOL(delta.ops);
      for (const op of ops) {
        if (isEOLOp(op)) {
          const context: SerializeContext = { op, html: lineFragment };
          this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
          let lineNode = context.html as HTMLElement;
          if (!isMatchBlockTag(lineNode)) {
            lineNode = document.createElement("div");
            lineNode.setAttribute(LINE_TAG, "true");
            lineNode.appendChild(context.html);
          }
          root.appendChild(lineNode);
          lineFragment = document.createDocumentFragment();
          continue;
        }
        const text = op.insert || "";
        const textNode = document.createTextNode(text);
        const context: SerializeContext = { op, html: textNode };
        this.editor.plugin.call(CALLER_TYPE.SERIALIZE, context);
        lineFragment.appendChild(context.html);
      }
      

      反序列化的整體流程則與slate更加類似,因為我們同樣都是以HTML為基準處理數據,深度遞歸遍歷優先處理葉子節點,然后以處理過的delta為基準處理額外節點。只不過這里我們最終輸出的數據結構會是扁平的,這樣的話就不需要特別關注Normalize的操作。

      // packages/core/src/clipboard/modules/paste.ts
      public deserialize(current: Node): Delta {
        const delta = new Delta();
        // 結束條件 Text Image 等節點都會在此時處理
        if (!current.childNodes.length) {
          if (isDOMText(current)) {
            const text = current.textContent || "";
            delta.insert(text);
          } else {
            const context: DeserializeContext = { delta, html: current };
            this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
            return context.delta;
          }
          return delta;
        }
        const children = Array.from(current.childNodes);
        for (const child of children) {
          const newDelta = this.deserialize(child);
          delta.ops.push(...newDelta.ops);
        }
        const context: DeserializeContext = { delta, html: current };
        this.editor.plugin.call(CALLER_TYPE.DESERIALIZE, context);
        return context.delta;
      }
      

      此外,對于塊級嵌套結構的處理,我們的處理方式可能會更加復雜,但是在當前的實現中還并沒有完成,因此暫時還處于設計階段。序列化的處理方式類似于下面的流程,與先前結構不同的是,當處理到塊結構時,直接調用剪貼板的序列化模塊,將內容嵌入即可。

                                    | --  bold ··· <strong> -- |
                       | -- line -- |                          | -- <div> ---|
                       |            | --  text ··· <span> ---- |             |
                       |                                                     |
      root -- lines -- | -- line -- leaves ··· <elements> --------- <div> ---| -- normalize -- html
                       |                                                     |
                       | -- codeblock -- ref(id) ··· <code> ------- <div> ---|
                       |                                                     |
                       | -- table -- ref(id) ··· <table> ---------- <div> ---|
      

      反序列化的方式相對更復雜一些,因為我們需要維護嵌套結構的引用關系。雖然本身經過DOMParser解析過后的HTML是嵌套的內容,但是我們的基準解析方法目標是扁平的Delta結構,然而blocktable等結構的形式是需要嵌套引用的結構,這個id的關系就需要我們以約定的形式完成。

                                        | -- <b> -- text ··· text|r -- bold|r -- |
                | -- <align> -- <h1> -- |                                        | -- head|r -- align|r -- |
                |                       | -- <a> -- text ··· text|r -- link|r -- |                         |
      <body> -- |                                                                                          | -- deltas 
                |                       | -- <u> -- text ··· text|r -- unl|r --- |                         |
                | -- <code> -- <div> -- |                                        | -- block|id -- ref|r -- |
                                        | -- <i> -- text ··· text|r -- em|r ---- |
      

      接下來我們將會以delta數據結構為例,處理扁平結構的剪貼板模塊設計。同樣分別以行內結構、段落結構、組合結構、嵌入結構、塊級結構為基礎,在上述基本模式的調度下,分類型進行序列化與反序列化的插件實現。

      行內結構

      行內結構指的是加粗、斜體、下劃線、刪除線、行內代碼塊等行內的結構樣式,這里以加粗為例來處理序列化與反序列化。序列化行內結構部分基本與slate一致,從這里開始我們采用單元測試的方式執行。

      // packages/core/test/clipboard/bold.test.ts
      it("serialize", () => {
        const plugin = getMockedPlugin({
          serialize(context) {
            if (context.op.attributes?.bold) {
              const strong = document.createElement("strong");
              strong.appendChild(context.html);
              context.html = strong;
            }
          },
        });
        editor.plugin.register(plugin);
        const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
        const root = editor.clipboard.copyModule.serialize(delta);
        const plainText = getFragmentText(root);
        const htmlText = serializeHTML(root);
        expect(plainText).toBe("HelloWorld");
        expect(htmlText).toBe(`<div data-node="true"><strong>Hello</strong>World</div>`);
      });
      

      反序列化部分則是判斷當前正在處理的HTML節點是否為加粗節點,如果是的話就將其轉換為Delta節點。

      // packages/core/test/clipboard/bold.test.ts
      it("deserialize", () => {
        const plugin = getMockedPlugin({
          deserialize(context) {
            const { delta, html } = context;
            if (!isHTMLElement(html)) return void 0;
            if (isMatchHTMLTag(html, "strong") || isMatchHTMLTag(html, "b") || html.style.fontWeight === "bold") {
              // applyMarker packages/core/src/clipboard/utils/deserialize.ts
              applyMarker(delta, { bold: "true" });
            }
          },
        });
        editor.plugin.register(plugin);
        const parser = new DOMParser();
        const transferHTMLText = `<div><strong>Hello</strong>World</div>`;
        const html = parser.parseFromString(transferHTMLText, "text/html");
        const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
        const delta = new Delta().insert("Hello", { bold: "true" }).insert("World");
        expect(rootDelta).toEqual(delta);
      });
      

      段落結構

      段落結構指的是標題、行高、文本對齊等結構樣式,這里則以標題為例來處理序列化與反序列化。序列化段落結構,我們只需要Node是標題節點時,構造相關的HTML節點,將本來的節點原地包裝并賦值到context即可,同樣采用嵌套節點的方式。

      // packages/core/test/clipboard/heading.test.ts
      it("serialize", () => {
        const plugin = getMockedPlugin({
          serialize(context) {
            const { op, html } = context;
            if (isEOLOp(op) && op.attributes?.heading) {
              const element = document.createElement(op.attributes.heading);
              element.appendChild(html);
              context.html = element;
            }
          },
        });
        editor.plugin.register(plugin);
        const delta = new MutateDelta().insert("Hello").insert("\n", { heading: "h1" });
        const root = editor.clipboard.copyModule.serialize(delta);
        const plainText = getFragmentText(root);
        const htmlText = serializeHTML(root);
        expect(plainText).toBe("Hello");
        expect(htmlText).toBe(`<h1>Hello</h1>`);
      });
      

      反序列化則是相反的操作,判斷當前正在處理的HTML節點是否為標題節點,如果是的話就將其轉換為Node節點。這里同樣需要原地處理數據,與行內節點不同的是,需要使用applyLineMarker將所有的行節點加入標題格式。

      // packages/core/test/clipboard/heading.test.ts
      it("deserialize", () => {
        const plugin = getMockedPlugin({
          deserialize(context) {
            const { delta, html } = context;
            if (!isHTMLElement(html)) return void 0;
            if (["h1", "h2"].indexOf(html.tagName.toLowerCase()) > -1) {
              applyLineMarker(delta, { heading: html.tagName.toLowerCase() });
            }
          },
        });
        editor.plugin.register(plugin);
        const parser = new DOMParser();
        const transferHTMLText = `<div><h1>Hello</h1><h2>World</h2></div>`;
        const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
        const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
        const delta = new Delta()
          .insert("Hello")
          .insert("\n", { heading: "h1" })
          .insert("World")
          .insert("\n", { heading: "h2" });
        expect(rootDelta).toEqual(MutateDelta.from(delta));
      });
      

      組合結構

      組合結構在這里指的是引用塊、有序列表、無序列表等結構樣式,這里則以引用塊為例來處理序列化與反序列化。序列化組合結構,我同樣需要Node是引用塊節點時,構造相關的HTML節點進行包裝。在扁平結構下類似組合結構的處理方式會是渲染時進行的,因此序列化的過程與先前標題一致。

      // packages/core/test/clipboard/quote.test.ts
      it("serialize", () => {
        const plugin = getMockedPlugin({
          serialize(context) {
            const { op, html } = context;
            if (isEOLOp(op) && op.attributes?.quote) {
              const element = document.createElement("blockquote");
              element.appendChild(html);
              context.html = element;
            }
          },
        });
        editor.plugin.register(plugin);
        const delta = new MutateDelta().insert("Hello").insert("\n", { quote: "true" });
        const root = editor.clipboard.copyModule.serialize(delta);
        const plainText = getFragmentText(root);
        const htmlText = serializeHTML(root);
        expect(plainText).toBe("Hello");
        expect(htmlText).toBe(`<blockquote>Hello</blockquote>`);
      });
      

      反序列化同樣是判斷是否為引用塊節點,并且構造對應的Node節點。這里與標題模塊不同的是,標題是將格式應用到相關的行節點上,而引用塊則是在原本的節點上嵌套一層結構。反序列化的結構處理方式也類似于標題處理方式,由于在HTML的結構上是嵌套結構,在應用時在所有行節點上加入引用格式。

      // packages/core/test/clipboard/quote.test.ts
      it("deserialize", () => {
        const plugin = getMockedPlugin({
          deserialize(context) {
            const { delta, html } = context;
            if (!isHTMLElement(html)) return void 0;
            if (isMatchHTMLTag(html, "p")) {
              applyLineMarker(delta, {});
            }
            if (isMatchHTMLTag(html, "blockquote")) {
              applyLineMarker(delta, { quote: "true" });
            }
          },
        });
        editor.plugin.register(plugin);
        const parser = new DOMParser();
        const transferHTMLText = `<div><blockquote><p>Hello</p><p>World</p></blockquote></div>`;
        const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
        const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
        const delta = new Delta()
          .insert("Hello")
          .insert("\n", { quote: "true" })
          .insert("World")
          .insert("\n", { quote: "true" });
        expect(rootDelta).toEqual(MutateDelta.from(delta));
      });
      

      嵌入結構

      嵌入結構在這里指的是圖片、視頻、流程圖等結構樣式,這里則以圖片為例來處理序列化與反序列化。序列化嵌入結構,我們只需要Node是圖片節點時,構造相關的HTML節點進行包裝。與之前的節點不同的是,此時我們不需要嵌套DOM節點了,將獨立節點原地替換即可。

      // packages/core/test/clipboard/image.test.ts
      it("serialize", () => {
        const plugin = getMockedPlugin({
          serialize(context) {
            const { op } = context;
            if (op.attributes?.image && op.attributes.src) {
              const element = document.createElement("img");
              element.src = op.attributes.src;
              context.html = element;
            }
          },
        });
        editor.plugin.register(plugin);
        const delta = new Delta().insert(" ", {
          image: "true",
          src: "https://example.com/image.png",
        });
        const root = editor.clipboard.copyModule.serialize(delta);
        const plainText = getFragmentText(root);
        const htmlText = serializeHTML(root);
        expect(plainText).toBe("");
        expect(htmlText).toBe(`<div data-node="true"><img src="https://example.com/image.png"></div>`);
      });
      

      對于反序列化的結構,判斷當前正在處理的HTML節點是否為圖片節點,如果是的話就將其轉換為Node節點。同樣的,這里還有個常用的操作是,粘貼圖片內容通常需要將原本的src轉儲到我們的服務上,例如飛書的圖片就是臨時鏈接,在生產環境中需要轉儲資源。

      // packages/core/test/clipboard/image.test.ts
      it("deserialize", () => {
        const plugin = getMockedPlugin({
          deserialize(context) {
            const { html } = context;
            if (!isHTMLElement(html)) return void 0;
            if (isMatchHTMLTag(html, "img")) {
              const src = html.getAttribute("src") || "";
              const delta = new Delta();
              delta.insert(" ", { image: "true", src: src });
              context.delta = delta;
            }
          },
        });
        editor.plugin.register(plugin);
        const parser = new DOMParser();
        const transferHTMLText = `<img src="https://example.com/image.png"></img>`;
        const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
        const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
        const delta = new Delta().insert(" ", { image: "true", src: "https://example.com/image.png" });
        expect(rootDelta).toEqual(delta);
      });
      

      塊級結構

      塊級結構

      塊級結構指的是高亮塊、代碼塊、表格等結構樣式,這里則以塊結構為例來處理序列化與反序列化。這里的嵌套結構還沒有實現,因此這里僅僅是實現了上述deltas圖示的測試用例,主要的處理方式是當存在引用關系時,主動調用序列化的方式將其寫入到HTML中。

      it("serialize", () => {
        const block = new Delta().insert("inside");
        const inside = editor.clipboard.copyModule.serialize(block);
        const plugin = getMockedPlugin({
          serialize(context) {
            const { op } = context;
            if (op.attributes?._ref) {
              const element = document.createElement("div");
              element.setAttribute("data-block", op.attributes._ref);
              element.appendChild(inside);
              context.html = element;
            }
          },
        });
        editor.plugin.register(plugin);
        const delta = new Delta().insert(" ", { _ref: "id" });
        const root = editor.clipboard.copyModule.serialize(delta);
        const plainText = getFragmentText(root);
        const htmlText = serializeHTML(root);
        expect(plainText).toBe("inside\n");
        expect(htmlText).toBe(
          `<div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div>`
        );
      });
      

      反序列化則是判斷當前正在處理的HTML節點是否為塊級節點,如果是的話就將其轉換為Node節點。這里的處理方式則是,深度優先遍歷處理節點內容時,若是出現block節點,則生成id并放置于deltas中,然后在ROOT結構中引用該節點。

      it("deserialize", () => {
        const deltas: Record<string, Delta> = {};
        const plugin = getMockedPlugin({
          deserialize(context) {
            const { html } = context;
            if (!isHTMLElement(html)) return void 0;
            if (isMatchHTMLTag(html, "div") && html.hasAttribute("data-block")) {
              const id = html.getAttribute("data-block")!;
              deltas[id] = context.delta;
              context.delta = new Delta().insert(" ", { _ref: id });
            }
          },
        });
        editor.plugin.register(plugin);
        const parser = new DOMParser();
        const transferHTMLText = `<div data-node="true"><div data-block="id"><div data-node="true">inside</div></div></div>`;
        const html = parser.parseFromString(transferHTMLText, TEXT_HTML);
        const rootDelta = editor.clipboard.pasteModule.deserialize(html.body);
        deltas[ROOT_BLOCK] = rootDelta;
        expect(deltas).toEqual({
          [ROOT_BLOCK]: new Delta().insert(" ", { _ref: "id" }),
          id: new Delta().insert("inside"),
        });
      });
      

      每日一題

      參考

      posted @ 2025-05-07 10:18  WindRunnerMax  閱讀(604)  評論(1)    收藏  舉報
      ?Copyright    @Blog    @WindRunnerMax
      主站蜘蛛池模板: 在线成人国产天堂精品av| 国产成人乱色伦区| 国产色无码专区在线观看| 成人做爰69片免费看网站野花| 97精品人妻系列无码人妻| 亚洲精品天天影视综合网| 116美女极品a级毛片| 亚洲成人精品在线伊人网| 人妻精品动漫h无码| 日本熟妇XXXX潮喷视频| 国产成人亚洲精品日韩激情| 玖玖在线精品免费视频| 亚洲国产精品毛片av不卡在线| 国产高清自产拍av在线| 国产在线精品一区二区中文| 亚洲永久精品日韩成人av| 猫咪社区免费资源在线观看| 亚洲人成电影在线天堂色| 色婷婷五月综合久久| 日韩有码中文字幕av| 亚洲AVAV天堂AV在线网阿V| 欧美老熟妇乱子伦牲交视频| 亚洲av无码成人影院一区| 怡春院久久国语视频免费| 国产精品一区二区三区蜜臀| 女人与牲口性恔配视频免费| 国产短视频精品一区二区| 亚洲色大成网站WWW永久麻豆| 成人网站免费观看永久视频下载| 国产精品成人网址在线观看| 中文字幕有码无码AV| 国产又色又爽又黄的| 亚洲成AV人片在线观高清| 亚洲欧美综合一区二区三区| 亚洲深夜精品在线观看| 武陟县| 国产精品视频免费一区二区| 鲁鲁网亚洲站内射污| 亚洲国产午夜精品理论片妓女| 国产精品黄色片| 91麻精品国产91久久久久|