React専用リッチテキストエディタライブラリ「Draft.js」の実践的Tips集

React専用リッチテキストエディタライブラリ「Draft.js」の実践的Tips集

昨年、弊社でフロントエンド開発を担当した案件で Draft.js を活用してリッチテキストエディタの実装をおこないました。

そもそも、Draft.js は React 上でリッチテキストエディタを構築するライブラリで、同種のライブラリのなかでは随一の人気を誇ります。

使ったことがなければ意識もしませんが、開発元である Facebook の投稿フォームや、Twitterのツイートのフォームなどは、実はこの Draft.js を使って実装されています(なので、よく見ると<input>タグではなく、<div>タグになっています)。
ツイート内で「@」や「#」を付けたら自動でアカウントへのリンクやハッシュタグのリンクになったりしますが、普通の<input>タグでそれらを実装するのはきわめて困難であり、そうしたリッチな処理を実現するために Draft.js が使われているのです。

Twitterのツイートのフォームは Draft.js が使われている

しかしながら、Draft.js は世界的に見ればメジャなライブラリですが、日本語の情報が多いとは決して言い切れません。ですので、すこしでも特殊な実装をする際に頼れる情報がほとんどない、という状況に多々遭遇します。

そこで今回は、 同じ轍を踏んで欲しくないという思いを込め、Draft.js を使ったリッチテキストエディタの実装において、実践的なケースをいくつかご紹介します。

なお、Draft.js の基本的な解説については、以下のスライドがわかりやすいので、知りたい方は参照してみてください。
React.js, Draft.jsで作る リッチテキストエディタ開発入門

最もミニマムな構成

Draft.js を用いてリッチテキストエディタを構成するうえで最もミニマムな構成は以下のようになります。この例のように、 Draft.js では useState を用い、エディタの状態を示すイミュータブル変数 editorState および、状態変更関数 setEditorState でエディタ内部の状態を逐次変化させていきます。

import React, { useState } from "react";
import {Editor, EditorState} from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

function EditorApp() {
  const [editorState, setEditorState] = useState(
    () => EditorState.createEmpty(),
  );

  return (
    <div className="wrapper">
     <Editor 
       editorState={editorState}
       onChange={setEditorState} 
     />
    </div>
  );
}

export default EditorApp;

<Editor> としてラップした子要素のなかに、contenteditable="true" かつ、 role="textbox" な div 要素を持ち、そのなかで直接テキストを入力していくかたちになります。

新たに行を作成しテキストを入力すると、ユニークなオフセットキーを持つ div 要素が生成され、その配下に実際のテキストを含む span 要素が入ります。つまり、ブロックごとに管理をしていくイメージになります。

基本的な構成、使い方はこのようになります。各要素はユニークなキーやクラスを持つので、エディタ内でなにか操作したり、スタイルを当てる際にはそれらの情報を参照します。

【ケース1】読み取り専用にしたい

「いきなり読み取り専用にすんのかい!」というツッコミはさておき、エディタを読み取り専用にしたい場面は少なくないと思います。

これはいたって単純で、Editor コンポーネントに readOnly というプロパティを設定すればOKです。

import React, { useState } from "react";
import {Editor, EditorState} from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

function EditorApp() {
  const [editorState, setEditorState] = useState(
    () => EditorState.createEmpty(),
  );

  return (
    <div className="wrapper">
     <Editor 
       editorState={editorState} 
       onChange={setEditorState} 
       readOnly={true} 
     />
    </div>
  );
}

export default EditorApp;

こうしたコンポーネントの props の情報やメソッドなどは、公式の API リファレンスにあがっていますので、なにか機能を追加する際はまずはこちらを見るといいでしょう。

なお、 Draft.js の型定義ファイルは以下にアップされています。型の情報はこちらの公式リポジトリを参考にされるとよいでしょう。

【ケース2】初期値を変更したい

ケース1では EditorState.createEmpty() というメソッドの返り値を editorState の初期値に設定していました。これは “Empty” というだけあって空の状態を生成するメソッドです。

なお、 editorState は、entityMap というテキスト装飾のためのメタ情報を格納したオブジェクトと、 blocks というブロックごとのデータを格納した配列から成ります。
ですので、EditorState.createEmpty() で生成される空の状態は以下のようになります。

{
    entityMap: {},
    blocks: [],
 }

もし、空の状態ではなく最初になにかテキストを含んだ状態にする(別のエディタの状態を復元したりする場合など)には、この初期値を例えば以下のように変更します。

import React, { useState } from "react";
import {Editor, EditorState, convertFromRaw} from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

const initData = convertFromRaw({
  entityMap: {},
  blocks: [
    {
      key: "xxxxxx", // ユニークなキー値
      text: "ここに初期テキストがはいります。", // 任意のテキスト
      type: "unstyled", // テキストのタイプ。初期値は "unstyled"
      depth: 0,
      entityRanges: [],
      inlineStyleRanges: [],
      data: {},
    },
  ],
})

const initState = EditorState.createWithContent(
  initData,
)

function EditorApp() {
  const [editorState, setEditorState] = useState(initState);

  return (
    <div className="wrapper">
     <Editor 
       editorState={editorState} 
       onChange={setEditorState} 
     /> 
    </div>
  );
}

export default EditorApp;

initData は、convertFromRaw()を使い、生のオブジェクトから ContentState へ変換した値です。
その後、ContentState から EditorState へ変換するメソッドである createWithContent() を使い、新たなステートを生成するという流れです。

(注):
ContentStateEditorState はどちらも “state” という単語を含んでおりややこしい概念なのですが、これらは異なっています。公式サイトの解説によると ContentState はエディタの状態を表すイミュータブルなレコードで、EditorState はトップレベルのステートオブジェクトであり、現在のテキストコンテンツの状態はもちろん、選択状態、ContentState で保持される変更のスタックなど、エディタに関するあらゆる情報を持つイミュータブルなレコードです。エディタ上で操作する上では基本的には EditorState を意識していれば大丈夫です。

【ケース3】キーバインドのカスタムをしたい

Draft.js ではキーバインドのカスタマイズが可能です(「キーバインド」とは、キーに対する機能の割り当てのことを指し示します)。この機能を使えば、例えば「Enter キーを押したら、アラートを出す」といった処理も可能になります。

具体的には、以下のように keyBindingFn プロパティに任意の関数を渡し、その関数内で実際の処理を行います。戻り値の型としては、 EditorCommand | null となっており、 Draft.js で定義されるコマンド(もしくは任意の string 値)もしくは null を返します。

import React, { useState } from "react";
import {Editor, EditorState, convertFromRaw, getDefaultKeyBinding } from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

function EditorApp() {
  const [editorState, setEditorState] = useState(
    () => EditorState.createEmpty(),
  );

  /**
   * カスタムキーバインディングの定義
   */
  const myKeyBindingFn = (e) => {

    if (e.key === "Enter") {
      alert("Enter!!!")
      
      return "disabled"
    }

    return getDefaultKeyBinding(e)
  }

  return (
    <div className="wrapper">
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        keyBindingFn={myKeyBindingFn}
      />
    </div>
  );
}

export default EditorApp;

カスタムキーバインドを柔軟に使うことによってエディタとしての操作の幅が広がります。
ニッチな例ですが、例えば十字キーの向きを変えたり、特定のキー入力の際は処理を無効化したり、操作ログとしてのタイムスタンプを生成するのに活用したり、、、といったことも可能です。

(注):
公式サイトの活用例では入力されたキーコードの取得の際に e.keyCode プロパティを使用していますが、2021年1月現在、同プロパティは非推奨となったので、e.key もしくは、 e.code を使うことをおすすめします。

【ケース4】任意のブロックを削除したい

Draft.js はデフォルトの状態で、キーボードの “Backspace” キーを入力すればブロックを削除できます。

ただ、ボタンなどそれ以外の操作によってブロックを削除したいケースもあるかと思います。その際、 Draft.js はイミュータブルな設計になっているので少し遠回りな処理が必要になります。

その場合、例えば以下のように引数に editorState と任意のブロックキーを渡すと、そのブロックを削除した結果の editorState を返すユーティリティ関数を作るなどするといいでしょう(そのまま使ってもらっても結構です)。

※初見のメソッドがいくつかありますが、すべて公式のリファレンスに記載されているものなので、適宜検索してみてください。

./removeBlock.js

/**
 * ブロックを削除する関数
 */
const removeBlock = (editorState: EditorState, blockKey: string) => {
  const contentState = editorState.getCurrentContent()
  const blockMap = contentState.getBlockMap()

  // 指定のキーのブロックを削除し、新たな blockMap を取得
  const newBlockMap = blockMap.remove(blockKey)
  const newContentState = contentState.merge({
    blockMap: newBlockMap,
  })

  // 任意のキーが削除された後の EditorState  
  const newEditorState = EditorState.push(
    editorState,
    newContentState,
    "remove-block",
  )

  // 新たな EditorState を返す
  // この後、使う側で setEditorState()で状態を更新すれば、エディタ上でブロックが削除される
  return newEditorState
}

export default removeBlock

先にも述べましたが、Draft.js では基本的に editorState の値を意識して処理を作っていくことが肝になります。

この場合は、任意のキーのブロックを削除し、その状態を新たな editorState として設定し、返却することで、削除の操作を実現しています。また逆にブロックを追加したい場合も、基本的には同様の手順を踏めば実現可能です。

【ケース5】特殊なブロックをレンダリングしたい

前提として、 Draft.js がデフォルトで用意しているブロックのタイプは以下のとおりです。
段落や見出し、リスト、引用など、エディタに必要な種類は一通りそろっています(ちなみに、特に設定していない場合は “unstyled” が割り当てられます)。

type CoreDraftBlockType =
 | 'unstyled'
 | 'paragraph'
 | 'header-one'
 | 'header-two'
 | 'header-three'
 | 'header-four'
 | 'header-five'
 | 'header-six'
 | 'unordered-list-item'
 | 'ordered-list-item'
 | 'blockquote'
 | 'code-block'
 | 'atomic';

例えば、これら各タイプを持つブロックに対して、デフォルトでプロパティや値を変更したい場合は、 blockRendererFn プロパティを活用します。このプロパティに渡した関数内で定義されたオプション値で各ブロックがレンダリングされていきます。

以下の例では、”blockquote” タイプのブロックにおいては、 editable: false 、つまり編集不可なブロックとして扱うように設定しています。

import React, { useState } from "react";
import { Editor, EditorState} from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

function EditorApp() {
  const [editorState, setEditorState] = useState(
    () => EditorState.createEmpty(),
  );

  const myBlockRenderer = (block) => {
    if (block.getType() === "blockquote") {
      return {
        editable: false,
      }
    }

    return null
  }

  return (
    <div className="wrapper">
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        blockRendererFn={myBlockRenderer}
      />
    </div>
  );
}

export default EditorApp;

blockRendererFn を駆使すれば、ブロックに特定の値を持たせたり、独自のコンポーネントを表示したりすることが可能です。

また、同様の blockStyleFn では、ブロックごとにユニークなスタイルを定義することも可能です。

【ケース6】画像を表示したい

Draft.js では、ブロック内で画像の表示も可能です。

画像を表示したり、テキストにリンク要素を追加するには、 editorState に含まれる entityMap にメタ情報を追加します。

export type RawDraftContentState = {
    blocks: Array<RawDraftContentBlock>;
    entityMap: { [key: string]: RawDraftEntity };
};

また、RawDraftEntity は以下の型で定義されています。

export type RawDraftEntity = {
    type: DraftEntityType;
    mutability: DraftEntityMutability;
    data: ?{ [key: string]: any };
};

Entity 周りのデータ構造については詳しく述べませんが、公式サイトのこちらのページに記載されているので参照してみてください。

実際に、 entityMap に画像のソースを追加してエディタ内で画像を表示するコードは以下のようになります。

import React, { useState } from "react";
import { convertFromRaw, Editor, EditorState} from 'draft-js';
import 'draft-js/dist/Draft.css';
import './App.css'

const initData = convertFromRaw({
  blocks: [
    {
      key: "16d03",
      text: "なんでもないテキスト。",
      type: "unstyled",
      depth: 0,
      inlineStyleRanges: [],
      entityRanges: [],
      data: {},
    },
    {
      key: "98pea",
      text: "https://dummyimage.com/100x100/000/fff",
      type: "atomic",
      depth: 0,
      inlineStyleRanges: [],
      entityRanges: [
        {
          offset: 0,
          length: 1,
          key: 0,
        },
      ],
      data: {},
    },
    {
      key: "16d04",
      text: "なんでもないテキスト。。。。。",
      type: "unstyled",
      depth: 0,
      inlineStyleRanges: [],
      entityRanges: [],
      data: {},
    },
    {
      key: "98peb",
      text: "https://dummyimage.com/100x100/fff/000",
      type: "atomic",
      depth: 0,
      inlineStyleRanges: [],
      entityRanges: [
        {
          offset: 0,
          length: 1,
          key: 1,
        },
      ],
      data: {},
    },
  ],
  entityMap: {
    0: {
      type: "image",
      mutability: "IMMUTABLE",
      data: { src: "https://dummyimage.com/100x100/000/fff" },
    },
    1: {
      type: "image",
      mutability: "IMMUTABLE",
      data: { src: "https://dummyimage.com/100x100/fff/000" },
    },
  },
});

const initState = EditorState.createWithContent(initData)

function EditorApp() {
  const [editorState, setEditorState] = useState(initState);

  const Image = (props) => {
    return <img src={props.src} alt="" />;
  };

  const Media = (props) => {
    const entity = props.contentState.getEntity(props.block.getEntityAt(0));
    const { src } = entity.getData();
    const type = entity.getType();

    let media;
    if (type === "image") {
      media = <Image src={src} />;
    }

    return media;
  };

  const myBlockRenderer = (block) => {
    if (block.getType() === "atomic") {
      return {
        component: Media,
        editable: false,
      };
    }

    return null
  }

  return (
    <div className="wrapper">
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        blockRendererFn={myBlockRenderer}
      />
    </div>
  );
}

export default EditorApp;

関数 myBlockRenderer は、ケース5で用いたブロックのカスタマイズの応用になります。”atomic” というタイプのブロックにおいては、編集不可な <Media> というコンポーネントを描画することを指定しています。

かつ、 <Media> コンポーネントにおいて、entitytype が “image” であった場合に、<img> タグを返して画像を描画するようにしています。例えば、この type が “link” の場合にはリンク付きのテキストにするなどのカスタムも可能です。

おわりに

今回は Draft.js を使ったリッチテキストエディタの実装における実践的なケースをいくつかご紹介しました。きわめて需要の限られる内容にはなっているかと思いますが、少しでも参考になれば幸いです。

なお、ミニマムな構成で実装したものをこちらのリポジトリにアップしています。

参考

Draft.js
facebook/draft-js: A React framework for building text editors.
30分で出来るDraft.js+React.js リッチエディタ作成入門
React.js, Draft.jsで作る リッチテキストエディタ開発入門 – Speaker Deck
苦しんで覚えるDraft.js -リッチテキストエディタをシュッと作る-