Reactで構築するアプリケーションにEditor機能を追加したいという時に利用できるライブラリの一つにDraft.jsがあります。Draft.jsはReactのためのRich Text Editor Framweworkでプラグインも公開されているので機能追加も簡単に行うことができます。実際のサービスに利用されていることからもReactでEditor機能を組み込みたいと考えている時に知っておいて損はないライブラリの一つです。本文書ではDraft.jsを使いこなす上で必要となる文字の書式設定、リンクの設定、画像の追加などシンプルなコードを利用して動作確認を行なっていきます。

Reactで利用できるRichText EditorにはSlate, lexicalTiptapなどがあります。

本文書ではReactのバージョンは18.3.1、draft-jsのバージョンは0.11.7を利用して動作確認を行っています。

現在はdraft-jsはメンテナンスモードに入っているので機能の追加などは行われません。
fukidashi

Reactプロジェクトの作成

npm create vite@latestコマンドを利用してReactのプロジェクトの作成を行います。


 % npm create vite@latest

> npx
> create-vite

✔ Project name: … react-draft-js
✔ Select a framework: › React
✔ Select a variant: › JavaScript

Scaffolding project in /Users/mac/Desktop/react-draft-js...

Done. Now run:

  cd react-draft-js
  npm install
  npm run dev

プロジェクトの作成が完了したらプロジェクトディレクトリに移動してnpm installコマンドを実行してJavaScriptのライブラリのインストールを行います。

draft-jsライブラリのインストール


% npm install draft-js

インストールが完了したらsrcフォルダにcomponentsフォルダを作成してMyEditor.jsxファイルを作成します。


const MyEditor = () => {
  return (
    <div>MyEditor</div>
  )
}

export default MyEditor

MyEditor.jsxファイルを作成後、App.jsxファイルの更新を行います。


import MyEditor from './components/MyEditor';

function App() {
  return (
    <div style={{ margin: '2em' }}>
      <h1>Draft.js</h1>
      <MyEditor />
    </div>
  );
}

export default App;

デフォルトで設定されているStyleの設定を無効にするためmain.jsxファイルのindex.cssファイルのimport文をコメントします。


import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
// import './index.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

MyEditorコンポーネントに記述した内容がブラウザに表示されるか”npm run dev”コマンドで開発サーバの起動を行います。

MyEditorコンポーネントの表示確認
MyEditorコンポーネントの表示確認

はじめてのDraft.js

Draft.jsのドキュメントに記載されているコードをMyEditor.jsxにコピー&ペーストとしてどうような動作になるのか確認します。


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

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

  return <Editor editorState={editorState} onChange={setEditorState} />;
}
export default MyEditor;

設定後、ブラウザ上には何も表示されず、ブラウザのデベロッパーツールのコンソールを確認すると以下のメッセージが表示されます。Node.jsのglobalオブジェクトがブラウザ環境で定義されていないために発生します。


Uncaught ReferenceError: global is not defined

この問題を解決するためにvite.config.jsファイルでglobalの設定を行います。


import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  define: {
    global: 'window',
  },
});

vite.config.jsファイルに設定を追加するとブラウザ上には”Draft.js”の文字列以外は何も表示されていない状態です。

ブラウザ上での確認
ブラウザ上での確認

ブラウザ上には入力するための目印を見つけることはできませんが”Draft.js”の文字列の下をクリックすると文字の入力を開始することができます。

文字の入力の確認
文字の入力の確認

Enterボタンを押すと改行を行うことができ、改行した場所にも続けて文字を入力することができます。ただ文字を入力するだけのメモのようなアプリケーションで利用したい場合であれば保存機能さえ追加できればこれだけの機能で十分かもしれません。

改行を行い文字の入力が可能
改行を行い文字の入力が可能

ブラウザに直接文字を入力できるのはなぜ?と疑問に持っている人もいるかと思います。ブラウザ上の要素で文字を入力できるは”contenteditable”属性をtrueに設定しているためです。Editorコンポーネントが表示している要素をデベロッパーツールの要素で確認するとcontentediable属性が”true”に設定されていることが確認できます。デベロッパーツール上からcontentediable属性の値をtrueからfalseに変更すると文字の入力ができなくなります。

contentediable属性の確認
contentediable属性の確認

PlaceHolderの設定

文字を入力する場所がわかるようにPlaceHolderを設定することができます。


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

function MyEditor() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  return (
    <Editor
      editorState={editorState}
      onChange={setEditorState}
      placeholder="ここから入力を行ってください。"
    />
  );
}
export default MyEditor;

ブラウザ上にはplaceholderに設定した文字列が表示されます。placeholderを設定することで入力する場所がわかるようになりました。

placeholderの設定の確認
placeholderの設定の確認

入力内容の保存と読み込み

入力した内容を取得する方法

入力した内容はuseState Hookで定義したeditorStateの中に保存されるのでどのように入力した値が入っているの確認します。

“保存”ボタンを追加してボタンをクリックするとeditorStateの内容が取得できるようにします。


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

function MyEditor() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const saveContent = () => {
    console.log(editorState);
  };
  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
      />
    </div>
  );
}
export default MyEditor;

“はじめてのDraft.js”を入力後、”保存”ボタンをクリックするとブラウザのコンソールでeditorStateの中身を確認することができます。

EditorStateの中身の確認
EditorStateの中身の確認

コンソールに表示されたオブジェクトから入力した文字列はcurrentContentオブジェクトを展開していくと確認することはできますが階層が深いのでどこに保存されているのか見つけるは非常に困難です。EditorStateはgetCurrentContentというメソッドを持っているので実行するとcurrentContentの中身のみ取得することができます。


const saveContent = () => {
  console.log(editorState.getCurrentContent());
};

コンソールに表示されるのはContentStateオブジェクトです。

currentContentの中身を取得
currentContentの中身を取得

blockMapを展開していくと入力した文字列を確認することができますがDraft.jsではContentStateを引数に設定するとrawのJavaScriptデータに変換してくれるconvertToRaw関数が提供されています。convertToRaw関数を利用することでデータベースなどに保存する形に変換することができます。


import { useState } from 'react';
import { convertToRaw, Editor, EditorState } from 'draft-js';
import 'draft-js/dist/Draft.css';
//略
const saveContent = () => {
  const contentState = editorState.getCurrentContent();
  const raw = convertToRaw(contentState);
  console.log(raw);
};

表示されているBlocksとEntityMapについての説明は本文書の後半で行っています。

convertRawの実行
convertRawの実行

convertToRaw関数で変換されたオブジェクトをJSON.stringifyを利用してJSONフォーマットのデータに変換することができます。この形であれば文字列なのでデータベースやlocalStorageにも保存することができます。

localStorageへの保存

データベースに保存することもできますがlocalStorageであればデータベースの設定も必要もないのでlocalStorageを使って保存の動作確認を行います。

localStorageに保存するためにlocalStorageのsetItemメソッドを利用し第一引数には識別子のkeyを第二引数には保存するデータを指定します。キーは任意の名前をつけることができるのでここでは”test”としています。


const saveContent = () => {
  const contentState = editorState.getCurrentContent();
  const raw = convertToRaw(contentState);
  localStorage.setItem('test', JSON.stringify(raw, null, 2));
};

localStorageに保存したデータはデベロッパーツールのApplicationから確認することができます。Keyには”test”、ValueにはJSONデータが保存されていることが確認できます。

ローカルストレージに保存した内容の確認
ローカルストレージに保存した内容の確認

保存した内容の取り出し方法

localStorageに保存した内容を取り出す方法を確認します。localStraogeのgetItemメソッドの引数に取り出したいデータのKeyを指定するだけでそのKeyを持つデータを取得することができます。

“取得”ボタンを追加し、ボタンをクリックするとgetContentが実行されlocalStorage.getItemでlocalStorageに保存されているデータを取得しています。


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

function MyEditor() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  const getContent = () => {
    const raw = localStorage.getItem('test');
    console.log(raw);
  };

  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
        <button onClick={getContent}>取得</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
      />
    </div>
  );
}
export default MyEditor;

文字列をブラウザ上から入力して”保存”ボタンを入力した後に”取得”ボタンをクリックするとブラウザのデベロッパーツールのコンソールにはJSONデータ形式でデータが表示されます。」

ローカルストレージからのデータ取得
ローカルストレージからのデータ取得

JSON.parseを利用してローカルストレージから取り出したデータをオブジェクトに変換します。


const getContent = () => {
  const raw = localStorage.getItem('test');
  console.log(JSON.parse(raw));
};

convertToRaw関数を実行した時のオブジェクトと同じ値であることがわかります。

JSON.parseでオブジェクトに変換
JSON.parseでオブジェクトに変換

contentStateからrawのJavaScriptオブジェクトに変換するためにConvertToRawを利用しましたが今回はその逆でJavaScriptのオブジェクトからcontentStateに変換するためにConvertFromRaw関数を利用します。


import { useState } from 'react';
import { Editor, EditorState, convertToRaw, convertFromRaw } from 'draft-js';
import 'draft-js/dist/Draft.css';
//略」
const getContent = () => {
  const raw = localStorage.getItem('test');
  console.log(convertFromRaw(JSON.parse(raw)));
};

ContentStateに変換されることが確認できます。

contentStateへの変換
contentStateへの変換

さらにEditorStateが持つcreateWithContentメソッドを利用することでcontentStateをEditorStateに戻すことができます。


const getContent = () => {
  const raw = localStorage.getItem('test');
  const contentState = convertFromRaw(JSON.parse(raw));
  const newEditorState = EditorState.createWithContent(contentState);
  setEditorState(newEditorState);
};

“取得”ボタンを利用してlocalStorageからデータを取得していましたが、保存したデータはコンポーネントがマウントする際に取得したいのでuseEffect Hookを利用します。localStorageにtestというKeyを持つ場合のみlocalStorageから値を取得してeditorStateに設定します。


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

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

  useEffect(() => {
    const raw = localStorage.getItem('test');
    if (raw) {
      const contentState = convertFromRaw(JSON.parse(raw));
      const newEditorState = EditorState.createWithContent(contentState);
      setEditorState(newEditorState);
    }
  }, []);

  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
      />
    </div>
  );
}
export default MyEditor;

入力後に保存ボタンをクリックして入力したデータを保存しておけばブラウザを再読み込みしてもlocalStorageから保存したデータを読み込みブラウザ上に表示させることができるようになりました。

入力とデータの保存、読み込みの動作確認によりエディターとしての基本機能の設定方法を理解することができました。

文字の書式(スタイル)変更

これまでに入力した文字列は通常のテキスト文字で装飾も文字の太さも変更することができません。Draft.jsはRich Text Editorと呼ばれている通り文字の書式設定を変更することができます。

クリックイベントによる変更

例えばある一部の文字列のみ文字の太さを変更したい場合にはRichUtilsモジュールのtoggleInlineStyleメソッドを利用することができます。”太字”ボタンを追加してtoggleBold関数を追加します。

RichUtilsのtoggleInlineStyleメソッドの第一引数にはeditorState、第二引数にはinlieStyleを指定します。太文字にする場合のinlineStyleの値は”BOLD”です。


import { useState, useEffect } from 'react';
import { Editor, EditorState, convertToRaw, convertFromRaw,RichUtils } from 'draft-js';
import 'draft-js/dist/Draft.css';

function MyEditor() {
  const [editorState, setEditorState] = useState(() =>
    EditorState.createEmpty()
  );
//略
  const toggleBold = (event) => {
    event.preventDefault();
    setEditorState(RichUtils.toggleInlineStyle(editorState, 'BOLD'));
  };

  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
        <button onClick={toggleBold}>太字</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
      />
    </div>
  );
}
export default MyEditor;

設定後、カーソルを利用して太字にしたい文字列を選択します。

文字列を選択

選択後に”太字”ボタンをクリックします。文字が太字になっていることが確認できます。

選択した文字列が太字に
選択した文字列が太字に

toggleInlineStyleメソッドを利用しており再度太字になった文字列を選択して”太字”ボタンをクリックすると太字が解除されます。

toggleはOnであればOff, OffであればOnのように切り替えを行える仕組みなので通常の文字列で実行すると太文字になり、太文字で実行すると通常の文字列に切り替わります。
fukidashi

文字を太文字にしたい場合は大文字の”BOLD”をtoggleInlineStyleに設定しました。小文字の”bold”は利用できません。BOLDの他に文字が少し傾くイタリックに変更できる”ITALIC”や文字にアンダーラインをつける”UNDERLINE”や”CODE”などがあります。

toggleInlineStyleメソッドの第二引数をBOLDからITALICやUNDERLINEに変更してそれぞれの書式が反映されることを確認してください。

コマンドによる変更

EditorコンポーネントにhandleKeyCommand propsを設定することでコマンドの情報を取得することができます。コマンドの情報を取得できるだけではなく取得した情報を利用して書式を変更することができます。変更にはRichUtilsモジュールが持つhandleKeyCommandを利用します。Windowsであれば入力したテキストを選択して”Ctrl + B”のキーボタンを押すと文字が太文字になります。”Ctrl + +L”の場合はイタリックとなります。


import { useState, useEffect } from 'react';
import {
  Editor,
  EditorState,
  convertToRaw,
  convertFromRaw,
  RichUtils,
} from 'draft-js';
import 'draft-js/dist/Draft.css';

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

//略

  const handleKeyCommand = (command, editorState) => {
    const newState = RichUtils.handleKeyCommand(editorState, command);

    if (newState) {
      setEditorState(newState);
      return 'handled';
    }

    return 'not-handled';
  };

  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
        handleKeyCommand={handleKeyCommand}
      />
    </div>
  );
}
export default MyEditor;

“Ctrl + B”を押した場合にはcommandには”BOLD”の文字列が含まれています。

Key Bindingの設定

handleKeyCommandを利用してコマンドによる書式の変更を行うことができました。デフォルト以外で設定されているキーを登録して処理を行う方法を確認します。ここではMac環境で”Command + B”ボタンをクリックすると保存処理が行われます。


import { useState, useEffect } from 'react';
import {
  Editor,
  EditorState,
  convertToRaw,
  convertFromRaw,
  getDefaultKeyBinding,
  KeyBindingUtil,
} from 'draft-js';
import 'draft-js/dist/Draft.css';

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

  const { hasCommandModifier } = KeyBindingUtil;

  const myKeyBindingFn = (e) => {
    if (e.keyCode === 83 && hasCommandModifier(e)) {
      return 'myeditor-save';
    }
    return getDefaultKeyBinding(e);
  };

  useEffect(() => {
    const raw = localStorage.getItem('test');
    if (raw) {
      const contentState = convertFromRaw(JSON.parse(raw));
      const newEditorState = EditorState.createWithContent(contentState);
      setEditorState(newEditorState);
    }
  }, []);

  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    console.log('raw', raw);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  const handleKeyCommand = (command) => {
    if (command === 'myeditor-save') {
      saveContent();
      return 'handled';
    }
    return 'not-handled';
  };
  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
        handleKeyCommand={handleKeyCommand}
        keyBindingFn={myKeyBindingFn}
      />
    </div>
  );
}
export default MyEditor;

keyBindingFn propsにmyKeyBindingFn関数を指定しています。Key Codeの83は”S”のキーを表しています。hasCommandModifierでCtrlキーを識別しています。入力領域でCtrl + Sキーを押すと”myeditor-save”が戻されます。”myeditor-save”は任意の名前をつけることができます。

“myeditor-save”は”Ctrl + B”キーを実行した時の”BOLD”に対応するのでhandleKeyCommand propsに指定したhandleKeyCommandで取得することができ、引数のcommandに”myeditor-save”が入っている場合にはsaveContent関数が実行され、ここではlocalStorageに入力内容が保存されます。

Block Typeの変更

Block Typeと言われても最初は何かわからないと思うので、2行文字列を入力し保存ボタンを押してブラウザのコンソールにconvertToRaw関数を実行した後の値を表示させます。


const saveContent = () => {
  const contentState = editorState.getCurrentContent();
  const raw = convertToRaw(contentState);
  console.log('raw',raw);
  localStorage.setItem('test', JSON.stringify(raw, null, 2));
};

2行入力しているのでblocksの配列は2つの要素を持っています。その要素の中にtypeというプロパティが含まれており、どちらにも’unstyled’と設定されています。これがこれから変更を行うBlock Typeです。

blocksの確認
blocksの確認

デフォルトでは確認した通り、’unstyled’ が設定されています。このtypeを変更することで’unstyled’からh1タグへ変更することが可能となります。

ブラウザのデベロッパーツールで要素を確認しておくと下記のような設定になっています。Block Typeを変更することでどのような変化があるのか確認していきましょう。

unstyled時の要素の確認
unstyled時の要素の確認

Block Typeで変更できるタイプについてはドキュメントのContentBlockに記載されています。文字が小さいですが、unstyled以外にparagraphやheader-one, header-twoなどがあることが確認できます。

contetBlockの一覧
contetBlockの一覧

実際にh1タグに変更する方法を確認するために”h1″ボタンを追加してhandleHeaderOne関数を設定します。


import { useState, useEffect } from 'react';
import {
  Editor,
  EditorState,
  convertToRaw,
  convertFromRaw,
  RichUtils,
} from 'draft-js';
import 'draft-js/dist/Draft.css';

function MyEditor() {
//略
  const toggleHeaderOne = (event) => {
    event.preventDefault();
    setEditorState(RichUtils.toggleBlockType(editorState, 'header-one'));
  };

  return (
    <div>
      <div>
        <button onClick={saveContent}>保存</button>
        <button onClick={toggleBold}>太字</button>
        <button onClick={toggleHeaderOne}>h1</button>
      </div>
      <Editor
        editorState={editorState}
        onChange={setEditorState}
        placeholder="ここから入力を行ってください。"
      />
    </div>
  );
}
export default MyEditor;

h1タグを付与したい文字列のどこかにカーソルを合わせて”h1″ボタンをクリックします。カーソルを合わせた文字列全体がh1タグに設定されます。

h1タグへの変更
h1タグへの変更

デベロッパーツールで要素を確認するとh1タグが設定されていることが確認できます。”unstlyed”の時の要素を再確認するとdivタグであることがわかり、divタグからh1タグに変わっていることがわかります。

h1タグが設定されているかの確認
h1タグが設定されているかの確認

contentToRaw関数の変換後の値も確認しておきます。typeを確認すると”unstyled”から”header-one”に更新されていることがわかります。

typeの確認
typeの確認

Block Typeを変更することでどのような変化があるのか理解することができました。

Inline Toobarの設定

draft-jsにはプラグインが存在しプラグインを活用することで機能を追加することができます。どのようなプラグインが存在するのかはこちらから確認することができます。

draft-js-pluginsのページ
draft-js-pluginsのページ

プラグインのインストール

プラグインを利用するためには@draft-js-plugins/editorをインストールする必要があり、その後個別のプラグインをインストールしていくことになります。


 % npm install @draft-js-plugins/editor

Inline Toolbarを利用するためにプラグイン@draft-js-plugins/inline-toolbarのインストールを行います。


 % npm install @draft-js-plugins/inline-toolbar

デフォルトのInline Toolbar設定

これまでは太文字ボタン、h1ボタンを追加することで文字のスタイルやBlock Typeの変更を行うことができました。Inline Toolbarのプラグインを追加することで文字の入力途中にポップアップで書式のToolbarを表示させることができます。Inline Toolbarを設定すると下記のようにポップアップで表示されます。

Inline Toolbarとは
Inline Toolbarとは

Inline ToolbarのPluginsのドキュメントのSimple Inline Toolbar Exampleを参考に設定を行います。インストールしたpluginsはEditorコンポーネントのplugins propsを使って渡します。


import { useEffect, useMemo, useState } from 'react';
import Editor, { createEditorStateWithText } from '@draft-js-plugins/editor';
import createInlineToolbarPlugin from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';

const text =
  'In this editor a toolbar shows up once you select part of the text …';

const MyEditor = () => {
  const [plugins, InlineToolbar] = useMemo(() => {
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [[inlineToolbarPlugin], inlineToolbarPlugin.InlineToolbar];
  }, []);

  const [editorState, setEditorState] = useState(() =>
    createEditorStateWithText('')
  );

  useEffect(() => {
    setEditorState(createEditorStateWithText(text));
  }, []);

  const onChange = (value) => {
    setEditorState(value);
  };

  return (
    <div>
      <Editor editorState={editorState} onChange={onChange} plugins={plugins} />
      <InlineToolbar />
    </div>
  );
};

export default MyEditor;
@draft-js-plugins/inline-toolbar/lib/plugin.cssをimportしなければInline Toolbarは表示されません。
fukidashi

Inline Toolbarを利用することで簡単に書式を変更することができるようになります。下記ではInline Toolbarを利用して文字列の”toolbar shows”をBold, Italic, underlineの設定を行っています。設定も解除も簡単に行えます。設定を行っている書式のボタンの色が変わります。

Inline Toolbarによる書式の変更
Inline Toolbarによる書式の変更

Inline Toolbarのカスタマイズ

デフォルトの設定では設定できる書式が限定されていますがカスタイズを行うことでBlock Typeの変更も可能になります。


import { useState, useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import Editor, { createEditorStateWithText } from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';

const text =
  'In this editor a toolbar shows up once you select part of the text …';

const MyEditor = () => {
  const [plugins, InlineToolbar] = useMemo(() => {
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [[inlineToolbarPlugin], inlineToolbarPlugin.InlineToolbar];
  }, []);

  const [editorState, setEditorState] = useState(() =>
    createEditorStateWithText('')
  );

  useEffect(() => {
    setEditorState(createEditorStateWithText(text));
  }, []);

  const onChange = (value) => {
    setEditorState(value);
  };

  return (
    <div>
      <Editor editorState={editorState} onChange={onChange} plugins={plugins} />
      <InlineToolbar>
        {(externalProps) => (
          <>
            <ItalicButton {...externalProps} />
            <BoldButton {...externalProps} />
            <UnderlineButton {...externalProps} />
            <Separator {...externalProps} />
            <HeadlineOneButton {...externalProps} />
            <HeadlineTwoButton {...externalProps} />
            <HeadlineThreeButton {...externalProps} />
          </>
        )}
      </InlineToolbar>
    </div>
  );
};

export default MyEditor;

@draft-js-plugins/buttonsからButtonコンポーネントのimportを行い、importしたButtonコンポーネントをInlineToolbarタグの中で設定しています。

設定後ブラウザ上でInline Toolbarを確認すると書式だけではなくBlock Typeの変更ボタンも表示されていることが確認できます。書式とBlock Typeボタンの間の縦線はSeparatorコンポーネントを利用しています。

カスタマイズしたInline Toolbar
カスタマイズしたInline Toolbar

実際にH1, H2などのボタンを押して変更が行われることを確認してください。

その他のボタンについてはnode_modulesフォルダの@draft-js-plgins/buttons/lib/componentsの中に含まれています。ボタンがどのように設定されているかは@draft-js-plgins/buttons/libのindex.esm.jsファイルに記述されています。

リンクボタンの追加

Inline Toolbarの中にリンクボタンを追加することができますがリンクボタンを追加するためには追加のプラグインのインストールが必要になります。


 % npm install @draft-js-plugins/anchor

インストールしたanchorプラグインの設定を行います。


import { useEffect, useMemo, useState } from 'react';
import Editor, { createEditorStateWithText } from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';
import createLinkPlugin from '@draft-js-plugins/anchor';
import '@draft-js-plugins/anchor/lib/plugin.css';

const text =
  'In this editor a toolbar shows up once you select part of the text …';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

  const [editorState, setEditorState] = useState(() =>
    createEditorStateWithText('')
  );

  useEffect(() => {
    setEditorState(createEditorStateWithText(text));
  }, []);

  const onChange = (value) => {
    setEditorState(value);
  };

  return (
    <div>
      <Editor editorState={editorState} onChange={onChange} plugins={plugins} />
      <InlineToolbar>
        {(externalProps) => (
          <>
            <ItalicButton {...externalProps} />
            <BoldButton {...externalProps} />
            <UnderlineButton {...externalProps} />
            <Separator {...externalProps} />
            <HeadlineOneButton {...externalProps} />
            <HeadlineTwoButton {...externalProps} />
            <HeadlineThreeButton {...externalProps} />
            <LinkButton {...externalProps} />
          </>
        )}
      </InlineToolbar>
    </div>
  );
};

export default MyEditor;

ブラウザで確認するとH3ボタンの横にリンクボタンが追加されていることを確認することができます。

Inline Toolbarへのリンクの追加
Inline Toolbarへのリンクの追加

リンクボタンをクリックすると入力フォームが表示されます。

リンク入力フォームの表示
リンク入力フォームの表示

フォームに入力を行うことでリンクの設定を行うことができますがPlaceholderを設定することができます。PlaceholderはcreateLinkPluginの引数で設定します。


const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });

ブラウザで確認するとPlaceHolderに設定した文字列が表示されることが確認できます。

リンクの入力フォームのplaceholderの設定
リンクの入力フォームのplaceholderの設定

リンクを設定することができても文字列が入力できる状態ではリンクをクリックすることはできません。リンクをクリックしてページを移動するためにはEditorコンポーネントのreadonly propsをtrueに変更する必要があります。デフォルトではfalseに設定されておりtrueに変更することで入力ができなくなりリンクも有効となります。


<Editor
  editorState={editorState}
  onChange={onChange}
  plugins={plugins}
  readOnly={true}
/>

localStorageの保存の設定とuseState Hookでreadonlyを定義してEditorのreadonlyのpropsを切り替えることでリンクの設定の動作確認を行うことができます。


import { useEffect, useMemo, useState } from 'react';
import { EditorState, convertFromRaw, convertToRaw } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';
import createLinkPlugin from '@draft-js-plugins/anchor';
import '@draft-js-plugins/anchor/lib/plugin.css';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

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

  const [readonly, setReadOnly] = useState(false);

  useEffect(() => {
    const raw = localStorage.getItem('test');
    if (raw) {
      const contentState = convertFromRaw(JSON.parse(raw));
      const newEditorState = EditorState.createWithContent(contentState);
      setEditorState(newEditorState);
    }
  }, []);

  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  const onChange = (value) => {
    setEditorState(value);
  };

  return (
    <div>
      <div>
        {!readonly && <button onClick={saveContent}>保存</button>}
        {readonly ? (
          <button onClick={() => setReadOnly(false)}>Edit</button>
        ) : (
          <button onClick={() => setReadOnly(true)}>ReadOnly</button>
        )}
      </div>
      <Editor
        editorState={editorState}
        onChange={onChange}
        plugins={plugins}
        readOnly={readonly}
      />
      <InlineToolbar>
        {(externalProps) => (
          <>
            <ItalicButton {...externalProps} />
            <BoldButton {...externalProps} />
            <UnderlineButton {...externalProps} />
            <Separator {...externalProps} />
            <HeadlineOneButton {...externalProps} />
            <HeadlineTwoButton {...externalProps} />
            <HeadlineThreeButton {...externalProps} />
            <LinkButton {...externalProps} />
          </>
        )}
      </InlineToolbar>
    </div>
  );
};

export default MyEditor;

ReadOnlyがtrueの場合は Draft.jsの文字列に設定したhttps://draftjs.org/のリンクをクリックするとdraft.jsの公式ホームページが表示されます。

readonlyがtrueでリンクをクリック可能
readonlyがtrueでリンクをクリック可能

Themeの設定

Inline Toolbarを設定する際に@draft-js-plugins/inline-toolbar/lib/plugin.cssをimportしなければInline Toolbarが表示されないという説明をしました。

plugin.cssの中身を確認すると下記のclassが設定されています。


.bpsgbes{display:inline-block;}
.b181v2oy{background:#fbfbfb;color:#888;//略}
.a9immln{background:#efefef;color:#444;}.a9immln svg{fill:#444;}
.tukdd6b{left:50%;-webkit-transform:translate(-50%) s・・略}
.s1o2cezu{display:inline-block;//略}

上記のclassがどこに適用されているか確認するためにデベロッパーツールでInlineToolbarの要素を確認します。class名を確認するとInlineToolbarコンポーネントの場所の要素で利用されていることがわかります。

InlineToolbarの要素の確認
InlineToolbarの要素の確認

@draft-js-plugins/inline-toolbar/lib/plugin.cssをMyEditor.jsファイルから削除してApp.cssファイルにplugin.cssに記述されていたコードをコピー&ペーストしてMyEditor.jsでimportすることで同じスタイルが適用されるのでInline Toolbarのスタイルに変化はありません。


.bpsgbes {
  display: inline-block;
}
.b181v2oy {
  background: #fbfbfb;
  color: #888;
  font-size: 18px;
  border: 0;
  padding-top: 5px;
  vertical-align: bottom;
  height: 34px;
  width: 36px;
}
//略

import { useEffect, useMemo, useState } from 'react';
import { EditorState, convertFromRaw, convertToRaw } from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
//import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import '../App.css'
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';
import createLinkPlugin from '@draft-js-plugins/anchor';
import '@draft-js-plugins/anchor/lib/plugin.css';
;
//略

InlineToolbarのスタイルを変更したい場合はApp.cssファイルのclassで設定されている値を変更することで異なるスタイルに変更することができます。

例えばb181v2oyクラスはbutton要素に設定されているクラスなのでApp.cssのb181v2oyクラスのbackgroundのカラーを#fbfbfbからブラックに変更してみましょう。


.b181v2oy {
  background: black; //default #fbfbfb
  color: #888;
  font-size: 18px;
  border: 0;
  padding-top: 5px;
  vertical-align: bottom;
  height: 34px;
  width: 36px;
}

設定通り背景色がブラックになっていることが確認できます。

InlineToolbarの背景色を変更
InlineToolbarの背景色を変更

このようにApp.cssを更新することでデフォルト値とは異なるスタイルに変更することができます。

デフォルトで設定されているclassを利用してスタイルの設定を変更しましたが、Draft.jsのプラグインにはthemeを設定することでデフォルトに設定されているclassを上書きすることができます。つまり好きな名前のclassを設定することができます。

createInlineToolbarPluginの引数でthemeの設定を行います。InlineToolbarのthemeではtoolbarSytlesとbuttonStylesの設定を行うことができます。名前の通りtoolbarに関するclassとtoolbarの中にあるbuttonに関するclassです。


const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
  const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
  const inlineToolbarPlugin = createInlineToolbarPlugin({
    theme: {
      toolbarStyles: {
        toolbar: 'inline-toobar',
      },
      buttonStyles: {
        button: 'inline-toolbar-button',
        buttonWrapper: 'inline-toolbar-button-wrapper',
        active: 'inline-toolbar-button-active',
      },
    },
  });
  return [
    [inlineToolbarPlugin, linkPlugin],
    inlineToolbarPlugin.InlineToolbar,
    linkPlugin.LinkButton,
  ];
}, []);

設定後にブラウザで要素を確認します。toolbarStylesで設定したinline-toolbar、buttonStylesで設定したinline-toolbar-buttonに書き換わっていることが確認できます。

class名の上書き
class名の上書き

これらのclassを設定することでInlneToobarのスタイルを変更することができます。他のプラグインでも同様の方法でスタイルを変更することが可能です。

toolbarStylesとbuttonStylesの両方を設定する必要があります。toolbarStylesのみだとエラーが発生します。
fukidashi

画像の表示

ファイルをデスクトップからドラッグ&ドロップした場合にドロップしたファイル情報を取得できるように”handleDroppedFiles” propsが用意されています。Editorコンポーネントに設定します。


<Editor
  editorState={editorState}
  onChange={onChange}
  plugins={plugins}
  readOnly={readonly}
  handleDroppedFiles={handleDroppedFiles}
/>

handleDroppedFiles関数を追加してファイルの情報が取得できるのか確認します。ファイルの情報はhandleDroppedFiles関数の第二引数に保存されているのでコンソールに表示させます。第一引数のselectionにはファイルをドロップした場所のBlockのキーやBlock内のどの位置にドロップされたかの情報が含まれています。


const handleDroppedFiles = (selection, files) => {
  console.log(files);
};

ファイルをドロップしていいのはEditorコンポーネントの領域です。それ以外の領域にドロップするとブラウザのデフォルトの動作でドロップした画像がそのままブラウザ上に表示されます。入力されている文字の上にドロップするようにしてください。

下記のようにドロップしたファイル情報が表示されます。

ドロップしたファイルの情報
ドロップしたファイルの情報

通常であればドロップしたファイルをバックエンドサーバなどに送信してファイルの保存を行います。その場合は保存したファイルのURLを受け取りその情報を利用して画像の表示を行うという流れになるかと思います。

ここではデフォルトでpublicフォルダに保存されているlogo192.pngを利用するためURLはlogo192.pngとします。サーバでの画像の保存が完了してURLのlogo192.pngが戻されたと考えてください。


const handleDroppedFiles = (selection, files) => {
  console.log(files);
  //サーバに保存する処理 画像のURLが戻される
  insertImage('logo192.png');
};

insertImage関数を利用してeditStateへの画像の追加を行います。


const insertImage = (url) => {
  const contentState = editorState.getCurrentContent();
  const contentStateWithEntity = contentState.createEntity(
    'image',
    'IMMUTABLE',
    { src: url }
  );
  const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
  const newEditorState = EditorState.set(editorState, {
    currentContent: contentStateWithEntity,
  });
  onChange(
    AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, ' ')
  );
};

実際に”はじめてのDraft.jsです。”の文字列の後ろに画像ファイルをドロップすると空間が追加されますが画像は表示されません。

ファイルをDrop
ファイルをDrop

ドロップしたファイルを表示させるためのpluginのimageをインストールします。


 % npm install @draft-js-plugins/image

インストールしたImageのプラグインの設定を行います。


//略
import createImagePlugin from '@draft-js-plugins/image';
import '@draft-js-plugins/image/lib/plugin.css';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    const imagePlugin = createImagePlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin, imagePlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);
//略

先ほど何も表示されていなかった場所に画像が表示されるようになります。

Imageプラグイン追加後に画像表示
Imageプラグイン追加後に画像表示

Drag&Dropによる画像の表示も行えるようになりました。動作したコードは下記の通りです。


import { useEffect, useMemo, useState } from 'react';
import {
  EditorState,
  convertFromRaw,
  convertToRaw,
  AtomicBlockUtils,
} from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';
import createLinkPlugin from '@draft-js-plugins/anchor';
import '@draft-js-plugins/anchor/lib/plugin.css';
import createImagePlugin from '@draft-js-plugins/image';
import '@draft-js-plugins/image/lib/plugin.css';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    const imagePlugin = createImagePlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin, imagePlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

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

  const [readonly, setReadOnly] = useState(false);

  useEffect(() => {
    const raw = localStorage.getItem('test');
    if (raw) {
      const contentState = convertFromRaw(JSON.parse(raw));
      const newEditorState = EditorState.createWithContent(contentState);
      setEditorState(newEditorState);
    }
  }, []);

  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    console.log(raw);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  const onChange = (value) => {
    setEditorState(value);
  };

  const handleDroppedFiles = (selection, files) => {
    console.log(files);
    //サーバに保存する処理 画像のURLが戻される
    insertImage('logo192.png');
  };

  const insertImage = (url) => {
    const contentState = editorState.getCurrentContent();
    const contentStateWithEntity = contentState.createEntity(
      'image',
      'IMMUTABLE',
      { src: url }
    );
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    const newEditorState = EditorState.set(editorState, {
      currentContent: contentStateWithEntity,
    });
    onChange(
      AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, ' ')
    );
  };

  return (
    <div>
      <div>
        {!readonly && <button onClick={saveContent}>保存</button>}
        {readonly ? (
          <button onClick={() => setReadOnly(false)}>Edit</button>
        ) : (
          <button onClick={() => setReadOnly(true)}>ReadOnly</button>
        )}
      </div>
      <Editor
        editorState={editorState}
        onChange={onChange}
        plugins={plugins}
        readOnly={readonly}
        handleDroppedFiles={handleDroppedFiles}
      />
      <InlineToolbar>
        {(externalProps) => (
          <>
            <ItalicButton {...externalProps} />
            <BoldButton {...externalProps} />
            <UnderlineButton {...externalProps} />
            <Separator {...externalProps} />
            <HeadlineOneButton {...externalProps} />
            <HeadlineTwoButton {...externalProps} />
            <HeadlineThreeButton {...externalProps} />
            <LinkButton {...externalProps} />
          </>
        )}
      </InlineToolbar>
    </div>
  );
};

export default MyEditor;

input要素を利用したファイルを選択した場合でも同様にファイルの表示は可能です。input要素の追加を行いtypeをfileとしてonChagneイベントでhandleFileを設定します。


<input type="file" onChange={handleFile} />

handle関数を追加します。


const handleFile = (e) => {
  console.log(e.target.files);
  //サーバに保存する処理 画像のURLが戻される
  insertImage('logo512.png');
};

イベントから取得できるファイル情報はhandleDroppedFilesで取得したファイル情報と同じであることが確認できます。

選択したファイルの情報
選択したファイルの情報

入力のカーソルが入力領域にない場合は一番下に画像が追加されます。

Linkの設定

Inline Toolborのプラグインとして追加したanchorとは異なり、文字列を打っている最中にURLを入力すると自動でリンクを貼ってくれるプラグインlinkifyの設定を行います。

linkifyプラグインのインストールを行います。


 % npm install @draft-js-plugins/linkify

インストール後はプラグインの設定を行います。


//略
import createLinkifyPlugin from '@draft-js-plugins/linkify';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    const imagePlugin = createImagePlugin();
    const linkifyPlugin = createLinkifyPlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin, imagePlugin, linkifyPlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

設定後https://まではリンクの設定は行われませんがdを入力すると自動でリンクが貼られます。

リンクの自動設定
リンクの自動設定

URLを入力後、スペースを開けるとリンクの設定は完了します。

リンクの設定の確認
リンクの設定の確認

ReadOnlyに切り替えると設定したリンクボタンをクリックすることができ、リンク先に移動します。

BlocksとEntityMapの理解

convertToRaw関数を実行した時に表示されていた下記の情報の理解を深めるためにここまでに設定したMyEditor.jsを利用してコンテンツを作成してみましょう。

convertRawの実行
convertRawの実行

利用するMyEditor.jsのコードは画像の設定を行った後のものです。


import { useEffect, useMemo, useState } from 'react';
import {
  EditorState,
  convertFromRaw,
  convertToRaw,
  AtomicBlockUtils,
} from 'draft-js';
import Editor from '@draft-js-plugins/editor';
import createInlineToolbarPlugin, {
  Separator,
} from '@draft-js-plugins/inline-toolbar';
import '@draft-js-plugins/inline-toolbar/lib/plugin.css';
import {
  ItalicButton,
  BoldButton,
  UnderlineButton,
  HeadlineOneButton,
  HeadlineTwoButton,
  HeadlineThreeButton,
} from '@draft-js-plugins/buttons';
import createLinkPlugin from '@draft-js-plugins/anchor';
import '@draft-js-plugins/anchor/lib/plugin.css';
import createImagePlugin from '@draft-js-plugins/image';
import '@draft-js-plugins/image/lib/plugin.css';

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    const imagePlugin = createImagePlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      [inlineToolbarPlugin, linkPlugin, imagePlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

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

  const [readonly, setReadOnly] = useState(false);

  useEffect(() => {
    const raw = localStorage.getItem('test');
    if (raw) {
      const contentState = convertFromRaw(JSON.parse(raw));
      const newEditorState = EditorState.createWithContent(contentState);
      setEditorState(newEditorState);
    }
  }, []);

  const saveContent = () => {
    const contentState = editorState.getCurrentContent();
    const raw = convertToRaw(contentState);
    console.log('raw', raw);
    localStorage.setItem('test', JSON.stringify(raw, null, 2));
  };

  const onChange = (value) => {
    setEditorState(value);
  };

  const handleDroppedFiles = (selection, files) => {
    console.log(files);
    //サーバに保存する処理 画像のURLが戻される
    insertImage('logo192.png');
  };

  const insertImage = (url) => {
    const contentState = editorState.getCurrentContent();
    const contentStateWithEntity = contentState.createEntity(
      'image',
      'IMMUTABLE',
      { src: url }
    );
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    const newEditorState = EditorState.set(editorState, {
      currentContent: contentStateWithEntity,
    });
    onChange(
      AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, ' ')
    );
  };

  return (
    <div>
      <div>
        {!readonly && <button onClick={saveContent}>保存</button>}
        {readonly ? (
          <button onClick={() => setReadOnly(false)}>Edit</button>
        ) : (
          <button onClick={() => setReadOnly(true)}>ReadOnly</button>
        )}
      </div>
      <Editor
        editorState={editorState}
        onChange={onChange}
        plugins={plugins}
        readOnly={readonly}
        handleDroppedFiles={handleDroppedFiles}
      />
      <InlineToolbar>
        {(externalProps) => (
          <>
            <ItalicButton {...externalProps} />
            <BoldButton {...externalProps} />
            <UnderlineButton {...externalProps} />
            <Separator {...externalProps} />
            <HeadlineOneButton {...externalProps} />
            <HeadlineTwoButton {...externalProps} />
            <HeadlineThreeButton {...externalProps} />
            <LinkButton {...externalProps} />
          </>
        )}
      </InlineToolbar>
    </div>
  );
};

export default MyEditor;

ブラウザ上で以下のコンテンツを作成します。

BlocksとEntity Mapを理解するためのコンテンツ
BlocksとEntity Mapを理解するためのコンテンツ

MyEditor.jsでは”保存”ボタンをクリックするとsaveContent関数が実行されconvertToRaw関数を実行した後のrawのデータを表示するように設定を行っています。

表示されているBlocksを見ると配列の要素が表示されているコンテンツの1行1行に対応していることがわかります。blocks配列の要素の0番目は一番上の”初めてのDraft.js”に対応しています。各Blockには重複しないキーが設定されていることもわかります。

blocksの内容の確認
blocksの内容の確認

typeには”header-one”, “unstyled”, “atomic”の3つが確認できます。”header-one”はh1タブであること表しており、”unstyled”はデフォルトのtype, atomicは画像に対応します。画像を2つ表示させているのでtypeば”atomic”は2つ存在します。

blocks配列の番号1の要素を展開してみてみましょう。inlineStyleRangesには配列に2つ要素が入っています。styleを見ると”BOLD”と”ITALIC”とあるようにinlineStyleRangesには変更した書式の情報が含まれていることがわかります。0番目の要素のoffsetは書式の変更の開始場所で0から数えて21番目から始まり4文字分がBOLDの書式が設定されていることを表しています。lengthは文字の長さに対応します。ITALICが設定された1番目の要素を見ると0から数えて27番目から始まり5文字分にITALICの書式が設定されていることを表しています。

配列番号2の要素の中身
配列番号2の要素の中身

次はtypeに”atomic”を持つ要素番号4の内容を確認します。今度はentityRangesの配列に1つの要素が入っています。offset, lenght, keyを持つオブジェクトですが画像についても情報は見つかりません。

type atomicの確認
type atomicの確認

typeに”atomic”を持つ要素番号8の内容と比較してみましょう。entityRangesを見ると先ほどとは異なりkeyが2に設定されていることがわかります。

もう一つのtypeに"atomi"を持つBlock
もう一つのtypeに”atomi”を持つBlock

ここでentityMapの配列を確認してみましょう。配列番号0と2には画像の情報を持つことがわかります。atomic typeのentityRangesのkeyの値とentityMapの配列番号の値が対応することが理解できます。この対応からBlocks配列の要素番号4,8の場所には画像が表示されることになります。

entityMapsの中身を確認
entityMapsの中身を確認

entityMap配列の要素番号1にはリンク情報が入っていることがわかります。リンクの設定を行ったblocks配列の要素番号5の内容を確認してみましょう。entityRangesのkeyに1が設定されており、entityMapの配列の要素番号1と対応していることがわかります。画像の場合はoffsetは0でlengthは1でしたが、リンクの場合は0から数えて10番目から3文字分にリンクが貼られているのでoffsetは10でlengthは3に設定されています。

Blocks配列の要素番号5の内容を確認
Blocks配列の要素番号5の内容を確認

これでBlocksとEntityMapsの関係とどのような情報が保存されるかを理解することができました。

Custom Block Componentsの作成方法

先ほどの説明でBlocksの理解が深まったと思うので、画像の表示に利用していたプラグインを利用せずに画像を表示する方法を設定していきます。

imageのプラグインの設定を削除しておきます。


略
// import createImagePlugin from '@draft-js-plugins/image';
略

const MyEditor = () => {
  const [plugins, InlineToolbar, LinkButton] = useMemo(() => {
    const linkPlugin = createLinkPlugin({ placeholder: 'http://...' });
    // const imagePlugin = createImagePlugin();
    const inlineToolbarPlugin = createInlineToolbarPlugin();
    return [
      // [inlineToolbarPlugin, linkPlugin, imagePlugin],
      [inlineToolbarPlugin, linkPlugin],
      inlineToolbarPlugin.InlineToolbar,
      linkPlugin.LinkButton,
    ];
  }, []);

EditorコンポーネントにはblockRendererFn propsがあり、設定することで表示されているすべてのBlockの情報をアクセスすることができます。


<Editor
  editorState={editorState}
  onChange={onChange}
  plugins={plugins}
  readOnly={readonly}
  handleDroppedFiles={handleDroppedFiles}
  blockRendererFn={blockRenderer} //追加
/>

blockRendererFnに設定したblockRenderer関数を追加します。引数からBlockの情報を受け取ることができます。


const blockRenderer = (contentBlock) => {
  console.log(contentBlock);

   return null;
};

ブラウザ上に以下の内容が表示されており、画像の設定を行っていないので表示されていませんが”Custom Block Components….”の下に画像の情報を持つBlockが含まれています。

ブラウザで表示されている内容
ブラウザで表示されている内容

設定を行うとコンソールには複数のContentBlockが表示されますが中身を確認するとブラウザ上に表示された内容であることがわかります。

Blockにはtypeがあったことを思い出してください。画像の場合はtypeが”atomic”なので分岐を利用することで”atomic” typeのブロックのみ取り出すことができます。

BlockのtypeはgetTypeメソッドを利用して取得することができます。


const blockRenderer = (contentBlock) => {
  if (contentBlock.getType() === 'atomic') {
    console.log(contentBlock);
  }
  return null;
};

atomicの時だけCustom Block Componentsを表示させたい場合は以下のように行うことができます。


const blockRenderer = (contentBlock) => {
  if (contentBlock.getType() === 'atomic') {
    return {
      component: ImageComponent,
      editable: false,
    };
  }
}

returnのオブジェクトに含まれるImageComponentを追加します。


const ImageComponent = () => {
  return <div>画像</div>;
};

ブラウザで確認します。設定した通りtypeに”atomic”を持つBlockがある場合にはImageComponentで設定された”画像”の文字が表示されることが確認できます。

Custom Block Componentの表示
Custom Block Componentの表示

分岐を行わなかったどうなるのかと気になる人もいるかと思うのでifの分岐を削除してみましょう。


const blockRenderer = (contentBlock) => {
    return {
      component: ImageComponent,
      editable: false,
   };
}

予想できたと思いますがBlock分、画像という文字が表示されます。一つだけずれている画像には本当の画像ブロックでfigureタグでラップされています。

全ブロックをCustom Block Componentに
全ブロックをCustom Block Componentに

最終の目的は画像の文字を表示させるのではなく画像そのものを表示させる必要があります。

画像を表示させるためには画像のsrcの値が必要となります。srcが取得できれば下記のようにImageComponentの中でimgタグを利用することで文字ではなく画像を表示させることができます。


const ImageComponent = () => {
  //srcを取得するための処理
  return <img src={src} />;
};

atomicという分岐をいれていましたがacmicを持つBlockにはLINKもあったのでさらに分岐が必要となります。その分岐に利用するのが画像のBlockに対応するEntityが持つtypeの値です。typeの値には”image”が含まれていればそのBlockは画像のBlockであることが判明します。

Entiryが持つtypeの取得方法を確認していきます。

blockRenderer関数の第一引数はBlockでしたが第二引数には_refが入っており、_refからgetEditorStateを取り出し実行するとeditorStateを取得することができます。editorStateのgetCurrentContentメソッドを利用することでcontentStateが取得できます。contentBlockのgetEntityAt()メソッドでentityのkeyの値を取り出すことができるのでその値を利用してcontentStateに含まれるEntityの中から取得したkeyを持つentityを取り出し(contentState.getEntity(entity))とgetTypeメソッドでtypeを取得します。


const blockRenderer = (contentBlock, _ref) => {
  const getEditorState = _ref.getEditorState;

  if (contentBlock.getType() === 'atomic') {
    const contentState = getEditorState().getCurrentContent();
    const entity = contentBlock.getEntityAt(0);
    if (!entity) return null;
    const type = contentState.getEntity(entity).getType();
    if (type === 'image' || type === 'IMAGE') {
      return {
        component: ImageComponent,
        editable: false,
      };
    }
  }

  return null;
};

ImageComponentはpropsでcontentBlockとcontentStateを持っているので先ほどと同様の方法でentityを取得し今度はgetDataでEntityが持つdataを取得します。dataにはsrcが含まれていたことを確認しておきます。

entityMapsの中身を確認
entityMapsの中身を確認

const ImageComponent = ({ block, contentState }) => {
  const data = contentState.getEntity(block.getEntityAt(0)).getData();
  return <img src={data.src} alt={data.src} />;
};

ブラウザを確認するとCustom Block Componentで画像を表示することができました。

Customコンポーネントで画像の表示
Customコンポーネントで画像の表示

必要な情報を取得するためにどの関数を利用するのかやそれぞれの関数がどのような引数を持っているのかはドキュメントを読む必要がありますがBlockとEntityの構造が理解できていればCustom Block Componentの作成はそれほど難しくはないということが理解できたのではないでしょうか。

PDFの表示

画像以外のファイルも表示したいという要望があった場合にCustom Block Componentの作成方法が理解できていれば簡単に機能の追加を行うことができます。

PDFがドロップされた場合にはCustom Block Componentで作成したImageComponentではなくPdfComponentを利用できるように先にPdfComponentを作成しておきます。PdfComponentでは、imageタグではなくobjectタグを利用してブラウザ上でPDFが表示できるようにしておきます。


const PdfComponent = ({ block, contentState }) => {
  const data = contentState.getEntity(block.getEntityAt(0)).getData();
  return (
    
      PDF Document
    
  );
};

ファイルをドロップした時にどのフォーマットのファイルなのか確認するために拡張子をチェックします。PDFの場合は拡張子がpdf、画像の場合はとりあえずgifかjpgかpngとしておきます。

handleDroppedFiles関数を更新して拡張子を取得できるようにしておきます。split関数でファイル名から”.”を区切り文字としてわけ、配列が戻されるのでその最後の要素をpop関数で取り出しています。サーバにファイルをアップロードする処理が必要ですがここでは省略してurlが戻されることを想定してそのままurlにファイル名を利用します。そのためプロジェクトのpublicフォルダにはドロップするファイルと同じファイル名のファイルを保存しておく必要があります。insertImage関数をinsertMedia関数に変更します。引数には拡張子とurlを渡します。


const handleDroppedFiles = (selection, files) => {
  files.forEach((file) => {
    const ext = file.name.split('.').pop();
    //サーバに保存する処理 画像のURLが戻される
    const url = file.name;
    insertMedia(ext, url);
  });
};

insertMedia関数では渡された拡張子の値(ext)を利用してswith関数でtypeの値を設定します。typeはEntityのtypeに利用しています。画像の場合はimage、PDFの場合はpdfとしています。動画の場合やCSV, EXCEL, Wordなどによってtypeを分けたい場合はここで行うことになります。


const insertMedia = (ext, url) => {
  let type;
  
  switch (true) {
    case ['png', 'jpg', 'gif'].includes(ext):
      type = 'image';
      break;
    case ext === 'pdf':
      type = 'pdf';
      break;
    default:
      throw Error('対応していない拡張子です。');
  }

  const contentState = editorState.getCurrentContent();

  const contentStateWithEntity = contentState.createEntity(
    type,
    'IMMUTABLE',
    { src: url }
  );

  const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
  const newEditorState = EditorState.set(editorState, {
    currentContent: contentStateWithEntity,
  });
  onChange(
    AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, ' ')
  );
};

これでPDFファイルをドロップするとEntity Mapにtypeが”pdf”として登録されます。

PDFをドロップした後のEntity Map
PDFをドロップした後のEntity Map

保存されたPDFを表示させるためにblockRenderer関数を更新します。typeがimageの場合はImageComponent、typeがpdfの場合はPdfComponentが戻されます。


const blockRenderer = (contentBlock, _ref) => {
  const getEditorState = _ref.getEditorState;

  if (contentBlock.getType() === 'atomic') {
    const contentState = getEditorState().getCurrentContent();
    const entity = contentBlock.getEntityAt(0);
    if (!entity) return null;
    const type = contentState.getEntity(entity).getType();
    switch (true) {
      case type === 'image':
        return {
          component: ImageComponent,
          editable: false,
        };
      case type === 'pdf':
        return {
          component: PdfComponent,
          editable: false,
        };
      default:
        return null;
    }
  }
};

実際に動作確認を行います。ここではpublicファイルに保存されているファイルと同じ名前のファイルをドロップすると下記のように画像、PDFを表示できるようになりました。

画像とPDFを表示
画像とPDFを表示

まとめ

データベースへのコンテンツの保存やサーバへの画像の保存を行っていませんが初めてDraft.jsを利用した人でもDraft.jsの利用方法の理解が深まったのではないでしょうか。