<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          【W(wǎng)eb技術(shù)】1397- 深入淺出富文本編輯器

          共 16554字,需瀏覽 34分鐘

           ·

          2022-08-01 00:45

          編輯器介紹

          常見的富文本編輯器現(xiàn)實方式可以分成兩大類,分別是用 textarea 和 contenteditable 來實現(xiàn)。

          textarea

          結(jié)構(gòu)簡單使用方便,一些文本格式和復(fù)雜的樣式難以實現(xiàn),推薦僅在對編輯要求不高的場景使用。

          contenteditable

          將元素的 contenteditable 屬性設(shè)為 true時,該元素則成為了編輯器的主體。配合 document.execCommand 能夠?qū)崿F(xiàn)絕大多數(shù)功能,主流編輯器是基于 contenteditable 來設(shè)計的。

          但是單純依賴 contenteditable 直接產(chǎn)出 html 會帶來一些問題,例如相同的輸入在不同瀏覽器下的輸出可能不一致,相同的輸出在不同瀏覽器中展示存在差異,并且這些問題在移動端會被放大,同時 html 使用具有局限性,不方便在跨平臺間使用。

          因此更好的方案是制定一套數(shù)據(jù)結(jié)構(gòu) + 文檔模型,所有的輸入都經(jīng)過編輯器生成約定的產(chǎn)物,這樣在不同的平臺均可解析并且保證得到預(yù)期的效果。

          還有一類是以 Google docs 為主的編輯器,不使用 contenteditable ,而是基于 canvas 渲染[1],通過監(jiān)聽用戶輸入,模擬編輯器的運(yùn)行,此類編輯器實現(xiàn)成本極高且復(fù)雜。

          本文以 quill[2] 為例,介紹如何實現(xiàn)一個支持跨平臺渲染,且可以插入自定義模塊的富文本編輯器。

          基本概念

          delta[3]

          用于描述富文本內(nèi)容或內(nèi)容變換的數(shù)據(jù)結(jié)構(gòu),純 json 格式,能夠轉(zhuǎn)化成 js 對象后方便操作,基本格式如下,由一組 op 組成。

          op 是個 js 對象,可理解為對當(dāng)前內(nèi)容的一次變更,它主要有以下幾個屬性。

          insert: 插入,后面 【3.2 數(shù)據(jù)結(jié)構(gòu)】有介紹可能的值和對應(yīng)的含義

          retain: 值為 number 類型,保留相應(yīng)長度的內(nèi)容

          delete: 值為 number 類型,刪除相應(yīng)長度的內(nèi)容

          上面三個屬性必有且僅有一個出現(xiàn)在 op 對象中

          attributes: 可選,值為對象,可描述格式化信息

          如何理解內(nèi)容或內(nèi)容變換,舉個??,下面這段數(shù)據(jù)表示了內(nèi)容 “Grass the Green”,

          {
            ops: [
              { insert: 'Grass', attributes: { bold: true } },
              { insert: ' the ' },
              { insert: 'Green', attributes: { color: '#00ff00' } }
            ]
          }

          經(jīng)過下面一次 delta 內(nèi)容變換后新內(nèi)容為 “Grass the blue”。

          {
            ops: [
              // 接下來 5 個字符取消加粗并加上斜體格式
              { retain: 5, attributes: { bold: null, italic: true } },
              // 維持 5 個字符不變
              { retain: 5 },
              // 插入
              { insert: "Blue", attributes: { color: '#0000ff' },
              // 刪除后面 5 個字符
              { delete: 5 }
            ]
          }

          Delta 本質(zhì)上是一系列操作記錄,在渲染時可以看作記錄了從空白到目標(biāo)文檔的一個過程,而 HTML 是一個樹形結(jié)構(gòu),所以 Delta 的線性結(jié)構(gòu)相比 HTML 在業(yè)務(wù)使用上有天生優(yōu)勢。

          parchment[4]

          一種文檔模型,由 blots 組成,用來描述數(shù)據(jù),可以拓展自定義的數(shù)據(jù)。

          <p>
              一段文字加視頻的富文本內(nèi)容。
              <img src="xxx" alt="">
            </p>
            <p>
              <strong>加粗文本結(jié)尾。</strong>
          </p>

          parchment 與 blot 關(guān)系類似于 DOM 與 element node,上面一段 html 內(nèi)容使用 dom tree 和 parchment tree 描述分別如下圖所示。


          parchment 提供了幾種基礎(chǔ) blot,同時支持開發(fā)中根據(jù)需求拓展定義自己的 blot,后面會演示如何開發(fā)一個自定義的 blot。

          {
            // 基礎(chǔ)節(jié)點
            ShadowBlot,
            // 容器節(jié)點 => 基礎(chǔ)節(jié)點
            ContainerBlot,
            // 格式化節(jié)點 => 容器節(jié)點
            FormatBlot,
            // 葉子節(jié)點
            LeafBlot,
            // 編輯器根節(jié)點 => 容器節(jié)點
            ScrollBlot,
            // 塊級節(jié)點 => 格式化節(jié)點 
            BlockBlot,
            // 內(nèi)聯(lián)節(jié)點 => 格式化節(jié)點 
            InlineBlot,
            // 文本節(jié)點 => 葉子節(jié)點
            TextBlot,
            // 嵌入式節(jié)點 => 葉子節(jié)點
            EmbedBlot,
          }

          最后用一張圖了解下 quill 內(nèi)部的工作流程,其中開發(fā)者需要關(guān)注的業(yè)務(wù)層邏輯十分簡潔,可以通過手動輸入和 api 方式變更編輯器內(nèi)容,同時 editor-change 事件會輸出當(dāng)次操作和最新內(nèi)容對應(yīng)的 delta 數(shù)據(jù)。


          實際應(yīng)用

          數(shù)據(jù)流

          在業(yè)務(wù)中,基本數(shù)據(jù)流應(yīng)該如下圖所示,由編輯器生成 delta 數(shù)據(jù),之后由相應(yīng)平臺的解析器渲染成對應(yīng)的內(nèi)容。


          數(shù)據(jù)結(jié)構(gòu)

          良好的內(nèi)容數(shù)據(jù)結(jié)構(gòu)設(shè)計,在后續(xù)維護(hù)和跨平臺渲染時起到關(guān)鍵作用,我們可以將富文本內(nèi)容中依賴的媒體(圖片、視頻、自定義的格式)數(shù)據(jù)放到外層來,通過 id 關(guān)聯(lián),這樣日后拓展和渲染時會比較方便。

          interface ItemContent {
              // 富文本數(shù)據(jù),存儲著 delta-string
              text?: string;
              // 視頻
              videoList?: Video[];
              // 圖片
              imageList?: Image[];
               // 自定義的模塊,如投票、廣告卡片、問卷卡片等等
              customList?: Custom[];
          }

          其中編輯器輸出的是標(biāo)準(zhǔn) delta 數(shù)據(jù), 結(jié)構(gòu)如下所示,

          // 純文本, \n 代表換行
          {
              insert: string;
          },
           // 特殊類型的文本
          {
              insert: '超鏈接文本'
              attributes: {
                  // 文字顏色
                  color: string,
                  // 加粗
                  bold:  boolean,
                  // 超鏈接地址
                  link: string;
                  ...,
              }
          },
          // 有序無序列表
          {
              insert:  '\n',
              attributes: {
                list: 'ordered' | 'bullet'
              }
           },
          {
              insert: {
                  uploading: {
                      // 資源類型
                      type'image' | 'video' | 'vote' | 'and more...'
                      // 資源 id
                      uid: string
                  },
              },
          },
          // 圖片
          {
              insert: { image: '${image_uri}' }
          },
          // 視頻
          {
              insert: {
                  videoPoster: {
                     /** 視頻封面地址 */            url: string;
                     /** 視頻 id */            videoId: string;
                  }
              }
          },
          // 投票
          {
              insert: {
                  vote: {
                      voteId: string
                  }
              }
          },
          // 縮進(jìn),作用域內(nèi)所有文本向右縮進(jìn) indent 個單位;
          // 作用域:從當(dāng)前為起始位置向前回溯,遇到以下任意一種情況結(jié)束
          // 1、純文本 \n
          // 2、attributes的屬性含有indent并且indent值小于等于當(dāng)前值
          {
              insert:  '\n',
              attributes: {
                  indent: 1-8,
              }
          },

          圖片 / 視頻混排

          圖片上傳需要支持展示上傳中的狀態(tài),并且不應(yīng)該阻塞用戶的編輯,所以需要先使用一個占位元素,待上傳完成后將占位替換成真實圖片或視頻。

          自定義 blot

          自定義 blot 的好處是能夠?qū)⒄麄€的功能(例如圖表功能)封裝到一個 blot 中,這樣業(yè)務(wù)開發(fā)時可直接使用,而不用管每個功能是怎么實現(xiàn)的。下面以圖片視頻上傳態(tài)占位 blot 為例,演示如何自定義一個 blot。

          import Quill from 'quill';

          enum MediaType {
            Image = 'image',
            Video = 'video',
          }

          interface UploadingType {
            type: MediaType;
            // 唯一的 id,當(dāng)圖片或視頻上傳完成后,需要找到對應(yīng)的 uid 進(jìn)行替換
            uid: string;
          }

          export const BlockEmbed = Quill.import('blots/block/embed');

          class Uploading extends BlockEmbed {
            static _value: Record<string, UploadingType> = {};

            static create(value: UploadingType) {
              const ELEMENT_SIZE = 60;
              // blot 對應(yīng)的 dom 節(jié)點
              const node = super.create();
              this._value[value.uid] = value;
              node.contentEditable = false;
              node.style.width = `${ELEMENT_SIZE}px`;
              node.style.height = `${ELEMENT_SIZE}px`;
              node.style.backgroundImage = `url(占位圖地址)`;
              node.style.backgroundSize = 'cover';
              node.style.margin = '0 auto';
              // 用來區(qū)分對應(yīng)資源
              node.setAttribute('data-uid', value.uid);
              return node;
            }

            static value(v) {
              return this._value[v.dataset?.uid];
            }
          }

          Uploading.blotName = 'uploading';
          Uploading.tagName = 'div';

          export default Uploading;

          將自定義 blot 注冊到編輯器實例中,使用 quill 的 insertEmbed 來調(diào)用這個blot 即可。

          // editor.tsx
          Quill.register(VideoPosterBlot);

          quill.insertEmbed(1, 'uploading', {
            type'image',
            uid: 'xxx',
          });

          處理粘貼操作

          復(fù)制粘貼可以大幅提升編輯器效率,但是我們需要對剪切板中的視頻和圖片進(jìn)行特殊處理,將剪切板中的內(nèi)容轉(zhuǎn)化成自定義的格式,并自動上傳其中圖片和視頻。

          基本原理

          監(jiān)聽用戶的粘貼操作,讀取 paste event[5] 返回的 clipboardData[6] 數(shù)據(jù),二次加工后再插入編輯器中。

          target.addEventListener('paste', (event) =>  {
              const clipboardData = (event.clipboardData || window.clipboardData)
              const text = clipboardData.getData(
                'text',
              );
              const html = clipboardData.getData(
                'text/html',
              );
              
              /**
              * 業(yè)務(wù)邏輯
              */
              
              event.preventDefault();
          });

          clipboardData.items 是 DataTransferItem 的數(shù)組集合,它包含了本次粘貼操作的數(shù)據(jù)內(nèi)容。

          DataTransferItem 有兩個屬性分別是 kindtype,其中 kind 值通常是 string 類型,如果是文件類型的數(shù)據(jù)那么值為 filetype 值是 MIME 類型,常見的是 text/plain 和 text/html。

          處理圖片

          剪切板中的圖片來源分為兩大類,一是直接從文件系統(tǒng)中復(fù)制,這種情況我們

          從文件系統(tǒng)中復(fù)制

          從文件系統(tǒng)中復(fù)制粘貼后,能獲取到 File 對象,那么直接插入編輯器中,即可復(fù)用前面的圖片上傳邏輯。

          從網(wǎng)頁復(fù)制

          從上面右圖不難看出,從網(wǎng)頁中復(fù)制過來的內(nèi)容中包含 text/html 富文本類型,由于圖片可能是臨時地址,直接使用三方圖片地址不可靠,需要把 html 中圖片地址提取出來,下載后再上傳至我們自己的服務(wù)器中,圖片上傳模塊還能繼續(xù)復(fù)用上文的圖片混排。

          上文內(nèi)容的 dom 樹基礎(chǔ)結(jié)構(gòu)如圖所示,可以經(jīng)過后序遍歷將所有節(jié)點處理成數(shù)組結(jié)構(gòu),當(dāng)遇到節(jié)點為圖片時則調(diào)用上面的圖片混排邏輯。

          convert({ html, text }, formats = {}) {
              if (!html) {
                return new Delta().insert(text || '');
              }
              // 返回 HTMLDocument 對象
              const doc = new DOMParser().parseFromString(html, 'text/html');
              const container = doc.body;
              // key - node
              // value - matcher: (node, delta, scroll) => newDelta
              const nodeMatches = new WeakMap();
              // 返回兩個匹配器,分別處理 ELEMENT_NODE 和 TEXT_NODE ,將 dom 轉(zhuǎn)化成 Delta
              const [elementMatchers, textMatchers] = this.prepareMatching(
                container,
                nodeMatches,
              );
              
              return traverse(
                this.quill.scroll,
                container,
                elementMatchers,
                textMatchers,
                nodeMatches,
              );
          }


           function traverse(scroll, node, elementMatchers, textMatchers, nodeMatches) {
            // 節(jié)點為葉子節(jié)點即文本
            if (node.nodeType === node.TEXT_NODE) {
              return textMatchers.reduce((delta, matcher) =>  {
                return matcher(node, delta, scroll);
              }, new Delta());
            }
            if (node.nodeType === node.ELEMENT_NODE) {
              return Array.from(node.childNodes || []).reduce((delta, childNode) =>  {
                let childrenDelta = traverse(
                  scroll,
                  childNode,
                  elementMatchers,
                  textMatchers,
                  nodeMatches,
                );
                if (childNode.nodeType === node.ELEMENT_NODE) {
                  childrenDelta = elementMatchers.reduce((reducedDelta, matcher) =>  {
                    return matcher(childNode, reducedDelta, scroll);
                  }, childrenDelta);
                  childrenDelta = (nodeMatches.get(childNode) || []).reduce(
                    (reducedDelta, matcher) =>  {
                      return matcher(childNode, reducedDelta, scroll);
                    },
                    childrenDelta,
                  );
                }
                return delta.concat(childrenDelta);
              }, new Delta());
            }
            return new Delta();
          }

          上面例子中的數(shù)據(jù)可以轉(zhuǎn)化成以下 delta 數(shù)據(jù),視頻的處理方法與圖片類似,這里不再贅述。


          {
              ops: [
              {
                  insert: '說起艾冬梅這個名字,現(xiàn)在的年輕人可能不是很熟悉,但是她曾經(jīng)卻是家喻戶曉的人物,'
              },
              {
                  insert: '艾冬梅是我國著名的馬拉松運(yùn)動員'  ,         attribute: {
                      bold: true
                  },
              },
              {
                  insert: '。她出生于1981年,是個來自東北的姑娘,和很多普通的八零后一樣,她來自一個平凡的家庭,從小生活十分幸福,家境雖然不富裕,但艾冬梅依然是父母的掌上明珠。'
              },
              {
                 insert: {
                     image: {
                         url: 'xxx'
                     }
                 }
              },
              {
                  insert: '但是艾冬梅和其他人不同的是她從小就展現(xiàn)出了驚人的長跑天賦'  ,         attribute: {
                      bold: true
                  },
              },
              {
                  insert: ' , 1993年當(dāng)時艾冬梅還在念小學(xué),她在一次跑步比賽中獲得了一個十分優(yōu)秀的成績,在腳趾頭受傷的情況下打破了當(dāng)?shù)氐?000米項目記錄,遠(yuǎn)遠(yuǎn)超過了參賽的所有人。這讓很多人都十分震驚,于是艾冬梅順利地被齊齊哈爾體校選中。'
              }
             ]
          }

          解析數(shù)據(jù)

          在 web 場景下可以使用 quill-delta-to-html[7] 這個庫來做解析,如果是小程序,對于媒體元素(如:小程序中圖片必須要指定寬高[8])支持相對不太友好,需要自己解析,下面簡單介紹下如何渲染 delta 數(shù)據(jù)。

          由于 delta 是一個線性結(jié)構(gòu),轉(zhuǎn)化成 dom 時,需要構(gòu)建一棵樹,將塊級元素的子元素關(guān)聯(lián)到它的 children 中。

          上圖中的原數(shù)據(jù)經(jīng)過第一輪處理

          1. 純文本反規(guī)范化,將 abc\ndef\ng 格式轉(zhuǎn)化成 [abc, \n, def, \n, g]
          2. 將塊級元素的元信息,寫入第一個 op 中

          塊級元素的元信息包括:縮進(jìn),有序列表序號,【當(dāng)前元素所在塊級元素】在原數(shù)據(jù)中的起始與終止索引,【當(dāng)前元素所在塊級元素】在 dom 列表中的索引

          經(jīng)過上面轉(zhuǎn)化后原數(shù)據(jù)變成上圖中的格式,每個 op 都含有相應(yīng)的元數(shù)據(jù),接下要做的就是解析這些 op,將其轉(zhuǎn)化成 Element。

          對于自定義 blot 的渲染,我們可以封裝成組件(react 或 vue 組件,取決你使用什么框架),這樣業(yè)務(wù)功能和編輯器開發(fā)可解耦,不了解編輯器代碼的同學(xué)也能夠參與開發(fā)。

          小結(jié)

          至此,我們已經(jīng)了解開發(fā)編輯器的基本流程和需要重點關(guān)注的一些事項。如果業(yè)務(wù)中需要拓展一些功能卡片,如飛書文檔的各種應(yīng)用,可通過拓展 blot + 編寫對應(yīng)的組件來實現(xiàn)。此外還能夠通過編寫相應(yīng)平臺的解析器在非 web 場景的展示,輕松實現(xiàn)內(nèi)容跨平臺渲染。

          參考資料

          [1]

          基于 canvas 渲染: https://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html

          [2]

          quill: https://quilljs.com/docs/quickstart/

          [3]

          delta: https://quilljs.com/docs/delta/

          [4]

          parchment: https://github.com/quilljs/parchment

          [5]

          paste event: https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event

          [6]

          clipboardData: https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/clipboardData

          [7]

          quill-delta-to-html: https://www.npmjs.com/package/quill-delta-to-html

          [8]

          指定寬高: https://developers.weixin.qq.com/miniprogram/dev/component/image.html

          - END -

          ?

          ?

          瀏覽 52
          點贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  腋毛美女浴室大胆自慰 | 日本黄色片 | 亚洲精品一区中文字幕乱码 | 日韩人妻无码精品一区 | 午夜精品久久久久久久99蜜桃乐播 |