# [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) -> 超詳細的教學