# [React + Draft.js] 學習筆記 ###### tags `React` `前端筆記` `plugin` <div style="display: flex; justify-content: flex-end"> > created: 2023/12/07 </div> ## 前言 記錄目前在工作上遇到的需求:實作一個可以新增圖片及影片的編輯器,而且也可以正確地在網頁上渲染編輯時的內容(意思就是發送完後其他人在網頁上可以看到同樣的東西)。設計師在設計時是以 Dcard 為範本發想功能,因此只要有 Dcard 發文編輯器的基本功能即可。 ## 為什麼用 Draft.js? 只是在找範例的時候發現這篇 [打造簡潔有力版面的小秘訣:Dcard 編輯器實作經驗分享](https://medium.com/dcardlab/%E6%89%93%E9%80%A0%E7%B0%A1%E6%BD%94%E6%9C%89%E5%8A%9B%E7%89%88%E9%9D%A2%E7%9A%84%E5%B0%8F%E7%A7%98%E8%A8%A3-dcard-%E7%B7%A8%E8%BC%AF%E5%99%A8%E5%AF%A6%E4%BD%9C%E7%B6%93%E9%A9%97%E5%88%86%E4%BA%AB-cdd0e65ed178) > Dcard 的編輯器從一開始 Next 改版之後,我們就使用 Draft.js,這是一個 Facebook 開源的 editor,但因為內部用了 immutable.js 所以 library 非常的肥大。 發現 Dcard 一開始也用 Draft.js 實作,所以就決定直接選擇這個套件了。至於為什麼不學 Dcard 不使用 Draft.js 呢?因為開發的時程有限,所以還是先乖乖用開源的套件實作,待日後有時間回頭好好消化 Dcard 分享的技術,再回來動手玩玩。 ## 需要先知道的概念 1. Draft.js 使用 [Immutable.js](https://github.com/immutable-js/immutable-js) 處理 Draft.js 內的物件,和 React 處理 state 的方式相符,更新 state 時是直接得到新的 state 2. [EditorState](https://draftjs.org/docs/api-reference-editor-state/) 是管理編輯器的最頂層物件(所以每一個編輯器一定都會有一個 EditorState) 3. [ContentState](https://draftjs.org/docs/api-reference-content-state/) 包含編輯器內所有的 `content`(想像成編輯器內使用者輸入的文字等等) 所以 `EditorState` 為編輯器的頂層物件,每個編輯器都會有一個,然後 `EditorState` 有 `ContentState`,可以取得使用者於編輯器內輸入的資料。 因為 Draft.js 是使用 Immutable.js 處理物件的,因此若單純用 `console.log(editorState)` 會看不到實際的物件屬性: ![截圖 2023-12-07 15.24.06](https://hackmd.io/_uploads/SyuQ7gJUa.png) 想要查看 `EditorState` 有什麼東西,可以使用 `editorState.toJS()`: ![截圖 2023-12-07 15.25.52](https://hackmd.io/_uploads/Skm9meJLa.png) (可以發現在 `EditorState` 內有 `currentContent` 的這個屬性,這個屬性就是 `ContentState`) 而文件說明使用 `EditorState.getCurrentContent()` 取得 `ContentState`,但也是要記得使用 `.toJS()` 轉成一般的物件,才就可以看到裡面有什麼東西: ![截圖 2023-12-07 15.30.05](https://hackmd.io/_uploads/B115Nx1L6.png) > The most common use for the `ContentState` object is via `EditorState.getCurrentContent()`, which provides the `ContentState` currently being rendered in the editor. ## 編輯器重要的基底屬性(`blockMap` 及 `entityMap`) 由上方的圖片可以知道,`ContentState` 中包含 `blockMap` 及 `entityMap` 這兩個物件屬性,而這兩個屬性就是建構出編輯器顯示資料重要資訊。 ### `blockMap` 1. 是一個物件,可以想像是一個巨大的 hashMap 2. 保存著每一筆 `ContentBlock` ![截圖 2023-12-07 15.41.28](https://hackmd.io/_uploads/ryjEPgyLa.png) (編輯器目前輸入的資料) ![截圖 2023-12-07 15.43.00](https://hackmd.io/_uploads/Sku9PgyL6.png) (目前得出的物件) ### 那麼什麼是 `ContentBlock`? 由上圖可以得知,目前輸入為第一行 `test1` 第二行 `test2`,因此只要使用者換行後都會產生新的 `ContentBLock` 並保存在 `blockMap` 之內。 從下方的範例可以更清楚地知道,因為輸入分成四行,所以就會有四個 `ContentBlock`,==就算是空白也是會佔一個。== ![](https://www.wukai.me/asset/images/2019-07-21-draft-editor-03.png) (*[ref. 从插入图片功能的实现来介绍 Draft.js 富文本编辑器](https://www.wukai.me/2019/07/21/draftjs-editor-tutorial-1/)*) ### `ContentBlock` 包含什麼資訊? 每個 `ConentBlock` 包含了: ![截圖 2023-12-07 18.24.58](https://hackmd.io/_uploads/rkRtpGkLT.png) ![截圖 2023-12-07 18.27.46](https://hackmd.io/_uploads/HyP40zJIa.png) 1. `key`: 代表這個 block 的 `key` 2. `type`: 代表這個 block 是何種類型(如 `'unstyled'` 或者 `atomic`) 3. `text`: 代表這個 block 中輸入的文字 4. `entityRanges`: `{ offset: number, length: number, key: number }[]` 若 `type` 為 `atmoic` 通常會有這組陣列,供開發者回去 `ContentState` 中的 `entityMap` 查找對應資料 ![截圖 2023-12-07 18.32.37](https://hackmd.io/_uploads/HJEP1mkU6.png) (`entityRanges` 中記錄: `{ offset: 0, lenght: 1, key: 0 }`) ![截圖 2023-12-07 18.32.48](https://hackmd.io/_uploads/SJSwJQ1IT.png) (那麼在 `ContentState` 的 `entityMap` 就會找到對應的資訊) :::info 根據文件,可以用的 `type` 為: - unstyled(純文字) - paragraph - header-one - header-two - header-three - header-four - header-five - header-six - unordered-list-item - ordered-list-item - blockquote - code-block - atomic(圖片元素,預設會是 `<figure>` tag,要返回圖片須自行新增 `<img>` tag) ::: ### `entityMap` 是什麼? `entityMap` 保存每一個 `Entity`,而所謂的 `Entity` 則是保存各別 `ContentBlock` 額外的資料,比如說圖片的網址或者連結的 URL 等等。 > An entity is an object that represents metadata for a range of text within a Draft editor. It has three properties: - **type**: A string that indicates what kind of entity it is, e.g. `'LINK'`, `'MENTION'`, `'PHOTO'`. - **mutability**: Not to be confused with immutability a la `immutable-js`, this property denotes the behavior of a range of text annotated with this entity object when editing the text range within the editor. This is addressed in greater detail below. - **data**: An optional object containing metadata for the entity. For instance, a `'LINK'` entity might contain a `data` object that contains the `href` value for that link. 每個 `Entity` 有三個屬性: 1. `type`: 代表該 `Entity` 是什麼(比方說 `LINK`, `IMAGE`, `VIDEO`) 2. `mutability`: `MUTABLE`(可以更改), `IMMUTABLE`(不可更改) 3. `data`: 一個可選的屬性,可以把該 `Entity` 額外的資料放在這裡,比如說 `IMAGE` 圖片的 `data` 就可以這樣塞 `{ src: ...., alt: .... }` ## 實作插入圖片 必須參照 `EditorState` 中的 `ContentState` 格式更新每個 `ContentBlock`。 > 思路: > 插入 `<img />` 標籤在編輯器內 -> 所以得要在 block 中新增對應的 `Entity` 保存 `<img />` 需要的資料(比如說 `src, alt`)-> 找到更新 `EditorState` 的辦法(讓編輯器可以正確顯示) ### 建立 `Entity` #### `.contentState().createEntity()`: 建立 `Entity` `.createEntity()` 會回傳新的 `ContentState` ```typescript= // [.ref createEntity()](https://draftjs.org/docs/api-reference-content-state/#createentity) createEntity( type: DraftEntityType, mutability: DraftEntityMutability, data?: Object ): ContentState ``` 所以實際建立一個 `<img />` 需要的 `Entity` 應為: ```javascript= const contentStateWithEntity = editorState.getCurrentContent().createEntity('IMAGE', 'IMMUTABLE', { src: /* img 需要的網址 URL */ }) ``` 所以這時候的 `contentStateWithEntity` 是一個有新增 `Entity` 的 `ContentState`。 #### 取得新的 `ContentState` 後必須更新頂層的 `EditorState` 因為編輯器的頂層是由 `EditorState` 管理的,但又因為 Draft.js 是由 Immutable.js 更新資料,因此要更新 `EditorState` 的話勢必得更新整個物件,所以文件有提供 `AtomicBlockUtils` 模組,裡面有 API 可以供開發者更新 atomic,並且拿回傳的 `EditorState` 更新(替換)當前的編輯器 `EditorState`。 > The `AtomicBlockUtils` module is a static set of utility functions for atomic block editing. > In each case, these methods accept `EditorState` objects with relevant parameters and return `EditorState` objects. ```typescript= // [ref. insertAtomicBlock()](https://draftjs.org/docs/api-reference-atomic-block-utils/#insertatomicblock) insertAtomicBlock: function( editorState: EditorState, entityKey: string, character: string ): EditorState ``` 所以將函有圖片 `Entity` 的 `ContentState` 更新至當前的 `EditorState` 應為: ```javascript= /* STEP 1: 建立一個 block 需要的 Entity -> 也就是 <img /> 需要的資料 */ const contentStateWithEntity = editorState.getCurrentContent().createEntity('IMAGE', 'IMMUTABLE', { src: reader.result }) /* STEP 2: 使用 AtomicBlockUtils.insertAtomicBlock 回傳的 EditorState 更新當前編輯器的 EditorState */ setEditorState( // AtomicBlockUtils.insertAtomicBlock() 會回傳新的 EditorState AtomicBlockUtils.insertAtomicBlock( // 拿當前的 EditorState + 建立一個帶有剛才新增 Entity 的 ContantState EditorState.set(editorState, { currentContent: contentStateWithEntity }), // 使用 ContentState.getLastCreatedEntityKey() API 取得剛建立的 Entity contentStateWithEntity.getLastCreatedEntityKey(), ' ' ) ) ``` ![截圖 2023-12-08 14.05.52](https://hackmd.io/_uploads/H1PIfVxUT.png) (若成功更新,從 `EditorState` 取得 `ContentState` 就會得到類似上圖的 `ContentState`) 可以發現在 blocks 中其一的 block (type: `atomic`) 有一組 `entityRanges[]`: ```javascript= { "key": "9bkif", "text": " ", "type": "atomic", "depth": 0, "inlineStyleRanges": [], "entityRanges": [ { "offset": 0, "length": 1, "key": 0 } ], "data": {} } ``` 然後在 `entityMap` 中有對應 `key` 的 `Entity`: ```javascript= { "type": "IMAGE", "mutability": "IMMUTABLE", "data": { "src": "<img /> src 的網址" } } ``` ## 在編輯器內顯示插入的圖片 現在已經成功更新 `EditorState` 了,現在得要解決「顯示」。 編輯器的資料顯示是由 `EditorState` 中的 `ContentState`(函數個 `ContentBlocks` 及 `Entities`),所以 Draft.js 的 `<Edtior />` 元件提供另一個 `props`,供開發者定參照 `ContentBlock` 渲染出客製化的元件(要不然就是渲染 `EditorBlock` 預設的元件): :::info 只有 `editorState` 及 `onChange` 是必填的 `props`,其他的都是 optional(可以參考 [Editor Component](https://draftjs.org/docs/api-reference-editor/#blockrendererfn) 查看 `<Editor />` 元件其他的 `props`) ::: ```javascript= <Editor ref={editor} editorState={editorState} onChange={setEditorState} placeholder='Write something!' /* 使用客製化渲染元件的 blockRendererFn */ blockRendererFn={myBlockRenderer} /> ``` ### `blockRendererFn()` 參照 `ContentBlock` 資訊渲染客製化的元件 ```typescript= blockRendererFn?: (block: ContentBlock) => ?Object ``` `blockRendererFn` 會接收每一個 `ContentBlock`,下列為文件範例,若 `ContentBlock` type 為 `'atomic'` 則渲染 `<MediaComponent />`: ```javascript= function myBlockRenderer(contentBlock) { const type = contentBlock.getType(); if (type === 'atomic') { return { component: MediaComponent, editable: false, props: { foo: 'bar', }, }; } } // Then... import { Editor } from 'draft-js'; class EditorWithMedia extends React.Component { ... render() { return <Editor ... blockRendererFn={myBlockRenderer} />; } } ``` ```javascript= { /* 需要顯示的客製化元件 */ component: MediaComponent, // 注意不是 <MediaComponent /> 這樣子會導致 MediaComponent 被叫用,這裡傳該元件即可 /* 根據文件,建議一律填 false */ editable: false, /* 屆時傳遞給 component 的 props */ props: { .... } } ``` > [...] the optional `props` object includes props that will be passed through to the rendered custom component via the `props.blockProps` sub property object. > 要注意的是到時候在元件內取用 props 時,要多包一層 `blockProps` ```javascript= { // ... /* 當前的 BlockContent */ block: ..., /* 當前的 ContentState */ contentState: ... //... /* 這裡為 component 要取得 props blockProps: { ... } // ... } // 所以實際元件要這樣子拿 props const MediaComponent = ({ blockProps }) => { .... } ### 找到渲染客製化元件的手段後,得先找到客製化元件渲染的資料來源為何(比如說 `<img src=''>`) 因為 `Entity` 用來保存每個 `ContentBlock` 的額外資料,因此必須回 `ContentState` 撈取對應 `ContentBlock` 的 `Entity`(在插入圖片 `Block` 時是以當前的 `EditorState` 更新,因此回去查找是可以找到的)。 所以官網的範例可以擴充成: ```javascript= /* STEP 1: 定義 blockRendererFn 接受的 props function,且該 function 有當前遍歷的 ContentBlock 為 parameter */ const myBlockRenderer(contentBlock) { /* STEP 2: 從當前的 ContentBlock 取得其 type */ const type = contentBlock.getType(); // 若 type === 'atomic' 代表需要渲染為媒體相關的標籤(如 <img />) if (type === 'atomic') { return { component: MediaComponent, editable: false, props: { foo: 'bar' } }; } } /* 實際要渲染的元件 */ const MediaComponent = ({ blockProps, contentState, block }) => { // 取得傳入的 props console.log(blockProps.foo) // 'bar' /* STEP 3: 從當前的 ContentBlock 取得對應的 Entity key */ const currentBlockEntityKey = block.getEntityAt(0) /* STEP 4: 從當前的 ContentState 撈取指定 key 的 Entity */ const currentContentBlockEntity = contentState.getEntity(currentBlockEntityKey) /* STEP 5: 取得指定 Entity 的 資料 */ const currentEntityData = currentContentBlockEntity.getData() return ( <img src={currentEntityData.src} alt="hello" /> ) } // 再傳客製化的 renderer function props <Editor ... blockRendererFn={myBlockRenderer} /> ``` 從當前的 `ContentBlock` 取得 `type`,符合特定的條件再渲染客製化的元件,再藉由 `component` 封裝,讓其渲染的元件可以讀取到當前的 `ContentBlock` 及 `ContentState` 等資訊。 ## 送出資料 ## 從後端取得的資料顯示 ## 參考資料 1. [淺談 React.js 中好用的富文本編輯器:Draft.js](https://medium.com/andy-blog/%E6%B7%BA%E8%AB%87-react-js-%E4%B8%AD%E5%A5%BD%E7%94%A8%E7%9A%84%E5%AF%8C%E6%96%87%E6%9C%AC%E7%B7%A8%E8%BC%AF%E5%99%A8-draft-js-398f53798e51) 2. [从插入图片功能的实现来介绍 Draft.js 富文本编辑器](https://www.wukai.me/2019/07/21/draftjs-editor-tutorial-1/) 3. [Entities](https://draftjs.org/docs/advanced-topics-entities/#internaldocs-banner) 4. [EditorState](https://draftjs.org/docs/api-reference-editor-state/) 5. [ContentBlock](https://draftjs.org/docs/api-reference-content-block/#internaldocs-banner) 6. [Custom Block Components](https://draftjs.org/docs/advanced-topics-block-components) 7. [初探draft-js](https://zhuanlan.zhihu.com/p/612512816) 8. [Rich text editing on the web: Getting started with Draft.js](https://dev.to/rose/rich-text-editing-on-the-web-getting-started-with-draft-js-2f68) -> 超詳細的教學