淺談 React.js 中好用的富文本編輯器:Draft.js

Andy Chen
Andy的技術分享blog
12 min readMay 31, 2021

前言

最近筆者在研究如何在網頁上進行文章編輯,原本是用 Editor.js 這個套件,但後來考量到 Editor.js 的動線真的不是那麼人性化,畢竟 Editor.js 如果要做文字樣式上的變化必須要用滑鼠選取反白後才會有個 tooltip 的樣式顯示可編輯的樣式,像下圖這樣:

Editor.js 編輯文字 Demo

謎之聲:medium 也是用這樣的設計,害我一開始真的不知道要去哪邊改變字體大小😂

為了讓整體看起來更像一個內容管理系統,於是筆者選擇了 Draft.js

Draft.js 基本介紹

Draft.js 是 facebook 做的富文本編輯器,既然是 facebook 做的所以一定可以跟 React.js 結合的非常完美,而 Draft.js 也繼承了 React.js 的設計理念,用了各種 state 來控制整個編輯區塊的內容,而 Draft,js 中所有的物件是利用 Immutable.js 進行處理,好處是可以確保每次產生的物件都會是新的物件也符合 React 更新 state 的原理,但壞處就是 Immutable.js 產生的物件非常難閱讀,不過這些都是 Draft.js 內部會處理的,讀者只要了解整個 Draft.js 的觀念即可,接下來筆者就來介紹兩個非常重要的 state:editorState 以及 contentState

  • editorState

editorState 是用來控制編輯器的 state,白話來說就是每個編輯器都會有一個屬於自己的 editorState,有了 editorState 才能儲存在編輯器中的所有內容,這是一個 top-level 的物件,產生的方法也很簡單,引用 Draft.js 中的 EditorState,並利用 createEmpty 這個方法,即可產生一個全新的 editorState 物件,範例如下:

import { Editor, EditorState} from 'draft-js';const editorState = EditorState.createEmpty();<Editor editorState={editorState} />
  • contentState

contentState 是用來取得編輯器內容的 state,通常都是用來取得內容的詳細資訊,像是文字內容、文字樣式、超連結網址、圖片位置等等,只要是你在編輯器內做事情都會被 contentState 記錄起來,而 contentState 會產生兩個非常重要的物件分別是 contentBlock 以及 entityMap這兩個物件也是非常重要的觀念,下面的內容都會依序介紹,要取得 contentState 也非常容易,只要透過 editorStategetCurrentContent 這個方法就可以取得 contentState 了,範例如下:

import { Editor, EditorState} from 'draft-js';const editorState = EditorState.createEmpty();const contentState = editorState.getCurrentContent();

Draft.js 基本用法

講完 state 後接下來就要介紹幾個 Draft.js 中非常重要的物件,在上面的內容中其實已經不小心透露出來了,就是 contentBlock 以及 entityMap。

  • contentBlock

contentBlock 簡單來說就是每個內容區塊,當我們換行之後在 contentState 內都會有一個新的物件產生,每個物件其實就是一個 contentBlock,接下來就來介紹幾個常用的 contentBlock 的值。

  • type:簡單來說就是這個區塊是什麼樣的區塊,每種區塊所對應到的 html tag 也是不一樣的,下面會介紹幾個常用的 type。

unstyled:純文字,預設會是 <p> tag。

paragraph:純文字,預設會是 <p> tag,現在比較少用,基本上純文字的 type 會以上面的 unstyled 為主。

header-one:標題一,預設會是 <h1> tag,此外還有 header-two 到 header-six 用法也一樣,其對應到的 tag 為 <h2> 到 <h6>。

unordered-list-item:無序排列,預設會是 <ul> tag。

ordered-list-item:有序排列,預設會是 <ol> tag。

blockquote:引言元素,預設會是 <blockquote> tag。

code-block:程式碼元素,預設會是 <code> tag。

atomic:圖片元素,預設會是 <figure> tag,要返回圖片須自行新增 <img> tag。

  • text

用來取得該區塊的所有文字內容。

  • entityRanges

用來取得該區塊的 entity,會是一個陣列,陣列裡面會包含要取得該區塊 entity 的物件,每個物件都會有一個名為 key 的 key,透過這個 key 就可以知道要取得 entityMap 陣列中的第幾個資料了。

inlineStyleRanges:用來取得該區塊的文字樣式,會是一個陣列,陣列裡面會包含該區塊文字樣式的物件,其中 offset 就是代表這段文字內容從第幾個字元開始有特定樣式, length 代表從 offset 提供的起點開始一共有多少個字元會有特定樣式,最後 style 則代表這段文字的特定樣式。

  • entityMap

entityMap 主要是用來存放文章內容中各區塊的中繼資料,舉凡圖片位置、超連結連結網址等等都是存放在 entityMap 中,而 entityMap 會是一個陣列裡面包含許多個物件,每個物件一共有三個屬性:

type:哪種型態的 entity,有 IMAGE、LINK等等型態。

mutability:此 entity 是否可以修改,可修改的值會是 MUTABLE 不可修改會是 IMMUTABLE,像 IMAGE 通常都是不能修改的只能刪除,而 LINK 是可以修改目標連結。

data:此 entity 的資料,像 IMAGE 會有此 IMAGE 的 url而 LINK 會有 href 等等,這些資料都會存在 data 物件中。

Decorator 中文名稱叫修飾子,修飾子簡單來說就是讓一些一定會有固定樣式的文字透過自己客製化的方法顯示出該文字的固定樣式,舉例來說:像 hashtag 一定會是藍色字並且有底線,這時候就可以寫一個 Decorator 讓這些 hashtag 固定保持藍色字並且有底線,就不用每次還要在 onChange 的時候去判斷有沒有 hashtag 可以省不少力氣。

既然不用自己在 onChange 的時候去判斷一些文字上的樣式,就代表 Decorator 勢必要綁定在 editorState 中,這樣 Draft.js 才會根據 editorState 去做文字的樣式修飾

  • compositeDecorator

compositeDecorator 顧名思義就是用來組合 Decorators 的方法,compositeDecorator 會傳入一個陣列,陣列中每個元素都是一個物件,每個物件都必須要包含兩個 key:strategy 以及 component。

strategy:需回傳一個 function,此 function 一共會有三個參數,分別是 contentBlock、callback、contentState,

component:需回傳一個 component 進行 Decorator 的繪製。

Draft.js 進階用法

有了上面的基礎觀念後其實就可以開始設計 Draft.js 編輯器了,但有時候總是會遇到一些需求是要幫編輯器做一些客製化設計,這時候筆者就要來介紹一些比較進階的用法。

key bindings 看名稱也知道是根據鍵盤上的按鍵來進行一些客製化的動作,當然 Draft.js 也有 default 的 key Bindings function 叫 getDefaultKeyBinding 裡面有各種 Default 的 key bindings command,如果要針對其他的按鍵進行客製化動作則要自己寫一個 function 並且在編輯器元件中當成是 props 傳入,範例如下:

// 首先我們先寫一個 custom key binding function 
import { getDefaultKeyBinding, KeyBindingUtil } from 'draft-js';
const { hasCommandModifier } = KeyBindingUtil;
function customKeyBinding(e) {
if (e.keyCode === 83 && hasCommandModifier(e)) {
// keyCode 83 為 s 按鍵
return 'save';
}
return getDefaultKeyBinding(e);
};

上面這邊寫了一個簡單的 function 並回傳了相對應的客製 command,筆者姑且先稱之為 save 這個 command,之後就可以在編輯器處理到此 command 的時候做客製化的動作了。

// 接下來就是要設計 key command 的行為
function handleKeyCommand(command) {
if (command === 'save') {
// do something to save editorState
return 'handled';
}
return 'not-handled';
};

這邊要注意的是 handleKeyCommand 在結束了該 command 的動作後需回傳 handled 這樣編輯器才知道已經完成了,反之如果失敗的話就回傳 not-handled

這邊筆者只是單純介紹 key bindings 因此只寫個粗略的方法。

其實 custom block rendering 最主要的功能就是定義一個 custom block render map,在 Immutable.js 中有一個用來產生物件的方法叫:Map,利用這個方法就可以很輕鬆的定義出一套 block render map,其實 Draft.js 自己也有定義好 default 的 render map,例如當 block type 為 header-one 的時候要使用 h1 這個元素,想要了解更多 default 的 render map 可以參考這個網站,至於 custom block render map 寫法也很簡單如下:

const blockRenderMap = Immutable.Map({
blockquote: {
element: 'blockquote',
wrapper: <CustomComponentOfBlockquote />,
},
unstyled: {
element: 'p',
wrapper: <CustomComponentOfParagraph />,
},
});
<Editor blockRenderMap={blockRenderMap} />

基本上 custom block render map 裡面可以設定兩個比較重要的 key 分別是 element 以及 wrapper

  • elementelement 顧名思義就是這段 block 要用哪個 html tag 包裝起來。
  • wrapperwrapper 則是實作 block 的顯示元件,所以假如我今天 element 先用 div 定義起來,在 wrapper props.children 就會是一個以 div 為主的 container。

但假如讀者是直接用這種方式直接將自己的 custom block render map 當成 props 傳進去 editor 裡面,是會直接覆蓋掉原本 Draft.js 預設好的 block rende map,因此這邊筆者推薦用另一種方式來處理這段,利用官方提供的 Draft.DefaultDraftBlockRenderMap.merge() 方式來做合併的動作,這樣就只會覆蓋掉客製化的部分,其餘的都是根據 default 的 render map 來實作。

const extendedBlockRenderMap = Draft.DefaultDraftBlockRenderMap.merge(customBlockRenderMap)<Editor blockRenderMap={extendedBlockRenderMap} />

custom block component 最主要的功能就是產生一個 render function,這個 function 會接受到 contentBlock 的參數,如果看到這段忘記什麼是 contentBlock 也可以把文章往上滑,上面都有詳細的介紹,透過 contentBlock 內建的方法就可以輕鬆的得到此 block 的型別,也就可以因此去客製化各個 type 的元件,這也是筆者最常使用的方法,畢竟 function 能做的事情還是比較多也比較彈性,寫法也很簡單如下。

const handleBlockRender = contentBlock => {
const type = contentBlock.getType();
switch(type) {
case 'header-one':
return {
component: RenderHeader,
editable: false,
props: {
level: 1
}
}
default:
return null;
}
}

小結

Draft.js 的觀念真的太多了,光是把官方文件閱讀完就花了好多時間,但不得不說網頁能做的事情越來越多了,連完整的編輯器都可以透過網頁來實現,看來前端工程師會越來越沒有極限了XDD

如果對於文章有任何問題歡迎留言給筆者,感謝大家的閱讀 🙏,之後還有更多閒情逸致的時候再來寫一篇文章來介紹一些自己在 Draft.js 的客製化中寫的 utils 吧XD

--

--

Andy Chen
Andy的技術分享blog

嗨嗨我是Andy,用嘴巴工作的工程師😂,喜歡學習不同領域的內容,專長為網頁開發,歡迎大家跟我聊技術~