株式会社LITALICOでWebエンジニア(Rails)を担当しています、@YudaiTsukamotoです。
この記事は『LITALICO Advent Calendar 2017』1日目の記事です。
記念すべき1記事目は、Facebook謹製のリッチテキストエディタフレームワークのDraft.jsについて書こうと思います。

はじめに

弊社では、conobie, LITALICO(りたりこ)発達ナビ, U2Plusを始めとするメディアを中心としたWebサービスを複数運営しております。様々な歴史的な経緯で、記事編集画面をスクラッチで実装しているのですが、以下のような辛さを抱えています。

  • 記事UIパーツの修正・追加をする事が結構しんどい
  • 書いた見た目と表示される見た目が異なるので、いちいちプレビューを見ないといけない
  • Undo/Redoなどの履歴管理が辛い
  • コンテンツの順序を変更することが辛い
  • ...etc

上記の大部分の辛さは設計に起因するのですが、既存システムの改修や、新しくメディアを立ち上げ!といった場面で全てスクラッチで設計・実装することがだんだん馬鹿らしくなってきて、何か良い方法は無いかと模索しておりました。

そんな折、Wantedlyの技術ブログでDraft.jsの導入についての記事を発見し、Draft.jsによって現在抱えている辛さの大半を解決できるのでは?と思い、調査をしたのでそこで得た知見をまとめます。
「Draft.jsって簡単につかえるよ〜」という導入記事は比較的よく見かけますが、こってりした日本語での紹介記事はあまり見当たらず、せっかくなので「苦しんで覚えるC言語」にあやかって、こってりした記事にしてみようと思います。

※ 私自身も苦しんで覚えている最中なので、間違いなど有りましたらお気軽にコメント/編集リクエストをお願いします。
※ 苦しんで覚えるなので、なるべく説明を省略せずに書いているため文章量が多くなってしまっておりますがご了承ください。

この記事で作れるもの

社内ハッカソンでDraft.jsを使ってリッチテキストエディタ作ってみました。

アリーヴェデルチ.mov.gif

だいたいこの記事で書かれていることを実践すれば作れます。

Draft.jsとは

Facebook謹製のリッチテキストエディタを作るためのフレームワークで、以下の特徴を持っております。

  • ContentEditableなDOMの管理が簡単にできる
  • React, Immutable.jsをベースに構築されている
  • Inline, Block要素のスタイルを宣言的に定義できる
  • 独自のUIパーツをReactコンポーネントで作ることができる
  • ブラウザ間の差分を抽象化してくれる

Reactベースで作られている事もあり、リッチテキストエディタを簡単かつ堅牢な仕組みで実装できます。

ContentEditable

Draft.jsの説明の前に、ContentEditableとは何かを簡単に説明します。
(知っている・興味ないという方はこの節を読み飛ばしても構いません)

ContentEditableは、DOM要素を編集可能かどうかを示す、HTML5で標準定義されたグローバル属性で、mozillaのドキュメントには以下の様に書かれています。

contenteditable グローバル属性 は、ユーザーによる要素の編集が可能かを示す列挙型属性です。可能である場合、ブラウザーは要素のウィジェットを編集可能なものに変更します。

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable

ためしに、任意のWebページを開いて、bodyの開始タグを以下のように編集してみましょう。

<body contenteditable="true">
  <!-- 沢山のDOM -->
</body>

すると、以下の画像の様に、bodyタグ内のDOM要素をまるでテキストエディタの様に編集することができます。

contenteditable.mov.gif

ContentEditableのいいところ

上記の特性により、ContentEditableを利用することでWYSIWYGエディタを比較的簡単に作成することができます。

ちなみにWYSIWYGとは

WYSIWYG(アクロニム: ウィジウィグ)とは、コンピュータのユーザインタフェースに関する用語で、ディスプレイに現れるものと処理内容(特に印刷結果)が一致するように表現する技術。What You See Is What You Get(見たままが得られる)の頭文字をとったものであり、「is」を外したWYSWYG(ウィズウィグ)と呼ばれることもある。

https://ja.wikipedia.org/wiki/WYSIWYG
だそうです。

また、ContentEditable属性は殆どのブラウザで実装されています。
https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable

ContentEditable is Terrible

上記の様にリッチテキストエディタを作るために頼もしいContentEditableですが、ContentEditableが抱える辛さをメジャーなブログシステムのエンジニアが記事にしています。

Mediumエンジニアブログ : Why ContentEditable is terrible
LINEエンジニアブログ : LINE BLOGアプリ開発で contenteditable と戦った話

ブログではContentEditableの辛さを以下のように説明していました。

ContentEditableのAPIの機能不足(仕様がバグ?)

例えば、「Baggins」という文字列をHTMLタグで表現する場合以下の様に無数にパターンがありますが、エディタ側でこれらのパターンは全て同等でなければいけません。「Baggins」という文字列を編集する場合はこれらのパターンを全て同じにする必要がありますが、すべての異なるDOM構造を編集アクションで書くことは、ContentEditableの標準APIでは容易ではありません。

  • <strong><em>Baggins</em></strong>
  • <em><strong>Baggins</strong></em>
  • <em><strong>Bagg</strong><strong>ins</strong></em>
  • <em><strong>Bagg</strong></em><strong><em>ins</em></strong>

また、ContentEditableではDOMを直接編集するようになっているため、実装によっては目に見えない文字や空のspanタグがHTMLにはいりこみ、2つのContentEditable要素が完全に異なった動作をする可能性があります(後述のカーソル管理の辛さを解消するために、わざと実装することが推奨されていて、それもまた辛い)。

こうなると、ユーザー体験を損ねたり、エンジニアもデバッグが困難になる可能性があります。

カーソル管理が辛い

例えば、「his name was <strong><em>Baggins</em></strong>」という文章で、Begginsの前にカーソルをあわせる場合、以下の3つの状態を意識して目的のカーソル位置を管理する必要があります。

  • strong開始タグの直前
  • em開始タグの直前
  • em開始タグの直後

Draft.js is ステキ

上記に挙げられるContentEditableの辛さを、以下の方法でDraft.jsはうまく解消してくれます。

  • Draft.jsは、ContentEditableなDOMを管理するReactコンポーネントを提供する
    • ContentEditable管理の複雑性をうまく抽象化している
    • DOMをいじるのではなく、コンポーネントのstateを更新してDOMをレンダリングするReactの仮想DOMのいいところを活かしている
  • Draft.jsは、プレーンなテキストとその装飾情報を持つStateオブジェクトで管理する
    • カーソル位置・選択範囲はプレーンテキストに内でカーソルが選択開始/終了した位置を管理すればよい

Draft.jsの基本要素

ここからが、Draft.jsの説明です。
Draft.jsは大きく以下の2つの要素にわけられます

  • Editorコンポーネント
    • ContentEditableなDOMを管理するコンポーネント
  • EditorStateオブジェクト
    • 文章やカーソル状態などEditor全体の状態を表すトップレベルのstateオブジェクト

まずはそれぞれについて説明します。

Editor

EditorコンポーネントはContentEditableなDOM要素を管理するReactコンポーネントです。
EditorStateオブジェクト(後述)をpropsとして持ち、下の例ではonChangeハンドラによってEditorStateが更新されるようになっています。


class MyEditor extends React.Component {
  constructor(props) {
    super(props);
    this.state = {editorState: EditorState.createEmpty()};
    this.onChange = editorState => this.setState({editorState});
  }

  render() {
    return(
      <Editor editorState={this.state.editorState} onChange={this.onChange} />
    );
  }
}

以下の例のように、素のReactでinputやtextareaタグを扱うときと同じようなインターフェースで使用することができます。


class MyInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};
    this.onChange = (evt) => this.setState({value: evt.target.value});
  }
  render() {
    return <input value={this.state.value} onChange={this.onChange} />;
  }
}

EditorState

EditorStateは文章やカーソル状態などEditor全体の状態を表すトップレベルのstateオブジェクトです。
EditorStateは大きくわけると以下の5つの情報を持ちます。

  1. 現在の入力されているtext contentの情報(ContentState)
  2. コンテンツのスタイリングのルール
  3. 現在のカーソル位置・選択範囲の情報(SelectionState)
  4. Undo/Redoのスタック
  5. 最新のコンテンツに対して行った変更の種類

EditorStateの構造と各要素を理解することで、Draft.jsで何ができるのかが明らかになります。

今回は、ContentStateと、コンテンツのスタイリングのルールについて一部紹介します。

ContentState

この記事で一番大事な部分です。

ContentStateはEditor内の文章全体の情報を持ち、その情報は大きく分けると以下の2つです。

  • ContentBlock(詳しくは後述)
    • HTMLのブロック要素に対応する要素。
    • DOMではなく、文章のプレーンテキストや、テキストの装飾情報が格納されている
  • Entity(詳しくは後述)
    • テキストや装飾情報以外の補足情報が入っている(例えばaタグのhrefに渡すurl等)

以下は、ContentStateを生のJSONでexportしたものです。
ContentBlockやEntityはそれぞれの要素の数分Arrayで格納されています(blocs, entityMap)。

スクリーンショット 2017-11-30 18.06.05.png

上記のContentStateを元にEditorコンポーネントがレンダリングした文章はこちらです。

スクリーンショット 2017-11-30 19.02.16.png

このように、ContentStateの状態を変更させることで、Editor内の文章を編集・装飾することが可能になります。また、ContentState自体の更新は直接行うのではなく、専用のAPIが用意されていますがそれについては後ほど説明します。

ContentBlock

ContentBlockはHTMLのブロック要素に対応する要素で、ContentStateの中でEditor内のブロックの数だけ配列(blocks)として格納されます。

localhost_8080.png

また、ContentBlockは以下の情報を持ちます

  • text
    • ContentBlock内のプレーンテキスト
  • type
    • headerやparagraphやlist等のBlock要素の種類を表すtype
    • デフォルトで幾つか用意されている他、プログラマが自由に定義することもできる(後述)
  • inlineStyleRanges
    • textの装飾情報が記述されており、offsetとlengthプロパティによって装飾範囲を指定
    • styleプロパティによって、どのスタイルを適用するかを決定している。
    • styleプロパティの値は、デフォルトで幾つか用意されている他、プログラマが自由に定義することもできる(後述)
  • enityRanges
    • textへの補足情報が記述されており、inlineStyleRangesと同様offsetとlengthプロパティで範囲を指定
    • keyプロパティによってお、entityMapの中からどのentityを適用させるかを決定している(Entityの節で詳しく説明)
  • key
    • ContentBlockの識別ID
  • depth
    • list要素等のインデントの深さを表す。通常0

したがって、以下のContentBlockは

  • ブロック要素がdiv(unstyledはデフォルトではdiv要素)
  • offsetが7で長さが4の範囲について、'BOLD'というスタイルを適用

という状態を表しています。

スクリーンショット 2017-11-30 19.32.02.png

その結果以下Editorコンポーネント内でレンダリングされることで、以下の文章が画面に出力されます。

スクリーンショット 2017-11-30 19.34.02.png

Entity

Entityは、ContentBlockのtextに対してメタデータを付与する仕組みです。
例えば、テキストリンクを実現する場合、以下のように、aタグ内のプレーンテキストと、href属性に渡すurlが必要になります。このurlというプレーンテキストに対するメタデータをEntityの仕組みによってContentBlockのtextの任意の場所に付与することができます。


<a href="https://hoge.com">テキストリンク</a>

Entityは、ContentState内にEntityの数分だけ配列(entityMap)で格納されます。

localhost_8080.png

また、Entityは以下の情報を持ちます。

  • data
    • textに付与するメタデータをJavascriptObject形式で持ちます
    • ex) data: {url: "https://qiita.com"}
  • mutability
    • 対象となるtextの範囲をeditorで編集した時の挙動を決定する属性
    • 値は以下の3つ
      • immutable
        • textからentityアノテーションを取り除かない限りテキストを編集することができない
        • 例えばメンションや画像パーツ等のentityはこれに該当する
      • mutable
        • textを自由に編集することができる
        • 例えばテキストリンクなどが該当する
      • segmented
        • immutableと基本同じだが、削除時の挙動をカスタマイズできる
    • Immutable.jsと混同しそうだが、全く別の概念。Facebookもこれについては懸念しており、命名を変更する可能性があるらしい
  • type
    • エンティティの種類を決める文字列。'LINK'や'MENTION'などプログラマが必要に応じて定義することができる
    • entityのtypeに対してレンダリングするReactコンポーネントを指定することができる(後述)

したがって、例えば以下のContentBlockとEntityの組み合わせは、

  • ContentBlock内のtextのoffsetが0, lengthが8の範囲に対して、keyが0のentityをマッピングする
  • key0に対応するentityは以下の状態を持つ
    • メタデータとしてurl属性に"https://facebook.com"という値をもつ
    • "LINK"というtypeが指定されている
    • mutableなので、テキストを自由に編集可能

という状態を表しています。

localhost_8080.png

その結果以下Editorコンポーネント内でレンダリングされることで、以下の文章が画面に出力されます。

スクリーンショット 2017-11-30 20.09.46.png

以上がEditorStateの中核となるContentStateの概要でした。
Draft.jsがどうやって文章とその装飾を管理しているのかが、ContentStateの構造を理解することでよく分かりますので、わからなかった方はもう一度読み直してみてください。

コンテンツのスタイリング

ContentStateは構造理解が中心なので、ちょっと複雑でしたがこの節からは実装の話になるので少し気楽に構えてください。
Draft.jsでは、Block要素単位、Inline要素単位でスタイリングの定義方法がそれぞれ用意されていますので、それぞれ別個に説明しようと思います。

RichUtils

スタイリング定義方法の前に、テキストにリッチなスタイルを適用するための便利なAPIを提供するRichUtilsmoduleの紹介をして、いかに簡単にDraft.jsがテキストの装飾を行っているかを説明しようと思います。

前述の通り、Editor内の文章の装飾情報はContentStateが管理しているのですが、RichUtilsはこのContentState(厳密にはEditorState)を操作するトップレベルのAPIを提供しています。

例えば、toggleInlineStyleというメソッドは以下のシグネチャで定義されたメソッドで、現在エディタで選択されている範囲のテキストのinlineStyleを変更するメソッドです。このinlineStyleと言うのはContentBlockのinlineStyleRangesで出てきた"BOLD"等のstyle属性のことです。

toggleInlineStyle(
  editorState: EditorState,
  inlineStyle: string,
): EditorState

このtoggleInlineStyleメソッドを、特定のbutton要素が押された時に呼び出される様に実装したものが以下のコードです。

class MyEditor extends React.Component {
  // …

  _onBoldClick() {
    this.onChange(RichUtils.toggleInlineStyle(this.state.editorState, 'BOLD'));
  }

  render() {
    return (
      <div>
        <button onClick={this._onBoldClick.bind(this)}>Bold</button>
        <Editor
          editorState={this.state.editorState}
          handleKeyCommand={this.handleKeyCommand}
          onChange={this.onChange}
        />
      </div>
    );
  }
}

このように、ContentStateの値を直接いじらず、RichUtilsモジュールのAPIを経由してContentStateを変更し、プログラマが任意の状況・任意のタイミングでテキストの装飾を行うことができるのです。

※ 厳密にはDraft.js内のstateオブジェクトは全てImmutableなオブジェクトなので直接値を変更することはできず、変更時は常に新しいオブジェクトを生成しているのですが細かいのでこの話は割愛します。

勿論Inlineのスタイルだけではなく、Blockのスタイルを変更するメソッドもありますし、カスタムキーマップを実現するメソッドも用意されているので、この記事を読み終わった後にでも公式APIドキュメントを読んでみてください。

Inline要素のスタイリング

インライン要素のスタイリングは、文字色、フォントサイズやフォントファミリー等の文字単位のスタイリングの話や、ハッシュタグやメンション機能など、特定のフォーマットで入力した文字に対して指定したスタイルを適用させるなど、様々あります。
この節では、それらのスタイリング方法を一つひとつ説明します。

Custom Style Map

Draft.jsではインラインの装飾は、ContentBlock内の以下のstyleプロパティ指定されている文字列で決まるということを前に説明しました。

{
  blocks: {
    [
      0: {
      // ...
      inlineStyleRanges:
        [
          0: {offset: 9, length: 4, style: "BOLD"}
        ]
      }
    ]
  },
  // ...
},

では、styleプロパティで指定されているこの"BOLD"によってHTMLがどうなっているのかを確認してみましょう。

localhost_8080.png

細かい部分は置いておいて、inline styleとしてfont-weight:bold;が記述されていることがわかります。

このように、先程指定していた"BOLD"という文字列に対応するinline styleがDraft.jsではデフォルト用意されており、以下のようなJavascriptオブジェクトの形式で定義されています。このオブジェクトのことをStyle Mapといいます。

const styleMap = {
  'BOLD': {
    fontWeight: 'bold',
  }
}

そして、EditorコンポーネントのcustomStyleMap propにstyleMapオブジェクトを渡すことで、デフォルトのStyle Mapとマージすることができます。
このCustomeStyleMapの仕組みによって、inlineの複雑なstylingを簡単に実現することができます。

以下の例は、"STRIKETHROUGH"という名前のkeyを持つstyle mapを新しく追加する例です。

const styleMap = {
  'STRIKETHROUGH': {
    textDecoration: 'line-through',
  }
};

class MyEditor extends React.Component {
  // ...
  render() {
    <Editor
      customStyleMap={styleMap}
      editorState={this.state.editorState}
    />
  }
}

後は、RichUtilsなどを使って任意の状況でtoggleInlineStyleメソッドを実行するなどして、定義したstyle Mapを適用することができます。
勿論デフォルトで定義されているstyle mapをオーバーライドすることも可能です。

Decorators

Decoratorsは独自に定義したパターンにマッチする文字列をContentBlockから検索して、マッチした部分を指定したReactコンポーネントで置き換えてくれる仕組みです。
Decorators自体は、文字列を検索して、適用範囲をReactコンポーネントに置き換えるcallbackを実行するstrategy functionと、置き換えるReactコンポーネントの2つの属性を持ったJavascriptオブジェクトです。このdecoratorのオブジェクトをEditorStateをイニシャライズする際に引数に指定することで、定義したDecoratorのパターンを実現することができます。


const decorator = {
  strategy: strategyFunction,
  component: myComponent,
};

Decoratorsで実現できる具体的な例は、FacebookやSlack等で実装されているハッシュタグやメンション、絵文字等があります。

それでは、ハッシュタグ機能を実現するための具体的な実装を見てみましょう。

ハッシュタグを検索する正規表現に合致するテキストをcontentBlockから検索して、該当する文字列に対してcallbackを実行するメソッドを用意します。
このcallbackの引数には、合致する文字列の始まりと終わりのオフセット値を渡すようにします。


function hashtagStrategy(contentBlock, callback, contentState) {
  const HASHTAG_REGEX = /\#(?!\,)[\S]+/g;
  findWithRegex(HASHTAG_REGEX, contentBlock, callback);
}

function findWithRegex(regex, contentBlock, callback) {
  const text = contentBlock.getText();
  let matchArr, start;
  while((matchArr = regex.exec(text)) !=== nill) {
    start = matchArr.index;
    callback(start, start + matchArr[0].length);
  }
}

次に、マッチした文字列を置き換えるReactコンポーネントを用意します


const HashtagSpan = props => {
  return (
    <span
      data-offset-key={props.offsetKey}
    >
      {props.children}
    </span>
  );
};

最後に、CompositeDecoratorというdecoratorを束ねるクラスをイニシャライズし、EditorStateを生成する際に引数にdecoratorsを渡せば完了です。

class MyEditor extends React.Component {
  constructor(props) {
    const decorator = new CompositeDecorator([
      {
        strategy: hashtagStrategy,
        component: HashtagSpan,
      }
    ]);

    this.state = {editorState: EditorState.createEmpty(decorator)};
    // ...
  }

  // ...
}

実際に作ったものがこちら

hashタグ.mov.gif

Decoratorsのステキなところは、strategyとcomponentが分離されていて、デザインとロジックがきれいに切り分けられていて変更がし易いところですね。

Block要素のスタイリング

Inline要素のスタイリングは、FacebookやSlack等のメッセンジャーの投稿フォーム等を作る時にとても威力を発揮する機能が多かったですが、Block要素のスタイリングでは、記事中の画像や引用文等などのブロック要素が沢山登場するコンテンツに最も威力を発揮します。
これもまた一つ一つスタイリングの方法を紹介していこうと思います。

BlockStyling

Draft.jsでは、Block typeと呼ばれる値を用いてブロック要素の種類を表現しています。
Block typeとは、ContentBlockのtype属性で指定される値のことです。

{
  blocks: {
    [
      0: {
      // ...
      type: 'header-one',
      }
    ]
  },
  // ...
},

デフォルトで、見出しや引用文やコードブロックやlistアイテムなどに対応するBlock typeが幾つか用意されており、これらのBlock typeには最低限のcssが定義されています。

実際には、この最低限のcssだけでは無機質なので別途cssを当ててスタイリングすることになるのですが、その際に直接htmlタグ名を指定するのは流石に横着すぎるので、Editorでレンダリングされる各Block要素に、任意のクラス名を当てたくなります。

h1 {
  font-size: 18px;
}

Draft.jsは勿論それをサポートしており、EditorコンポーネントのblockStyleFn propに、各Block typeにどんなclass名を当てるかを返すメソッドを渡すことで実現することができます。

以下のコードによって、Block typeがheader-one(つまりh1タグ)の場合に、クラス名に"header1"がついた状態でレンダリングされるようになります。

function myBlockStyleFn(contentBlock) {
  const type = contentBlock.getType();
  if (type === 'header-one') {
    return 'header1'
  }
}

<Editor
  blockStyleFn={myBlockStyleFn}
  editorState={this.state.editorState}
/>

Custom Block Rendering

先程説明したBlock typeには、実際にはHTML要素と1対1に対応されています。このBlock typeとHTMLの要素との対応関係をBlockRenderMapと呼びます。

BlockRenderMapは、EditorコンポーネントからDOMをレンダリングする際にも使われますが、逆にDOMからCotentStateに変換(convertFromHTMLメソッドを使う)したり、Copy and Pasteされる際にもこのBlockRenderMapが参照されます。

Draft.jsがデフォルトで用意しているBlockRenderMapは以下のとおりです。

HTML element Draft block type
<h1 /> header-one
<h2 /> header-two
<h3 /> header-three
<h4 /> header-four
<h5 /> header-five
<h6 /> header-six
<blockquote /> blockquote
<figure /> atomic
<div /> unstyled

このデフォルトで用意されているBlockRenderMapをオーバーライドしたりマージする仕組みがDraft.jsには用意されており、EditorコンポーネントのBlockRenderMap propに、自分で定義したBlockRenderMapを渡してあげればできます。
またpropsに渡すBlockRenderMapはImmutable Mapを渡して上げる必要があります。

以下のコードは、デフォルトのBlockRenderMapにsectionタグに対応するMapオブジェクトを追加しています。

const blockRenderMap = Immutable.Map({
  'section': {
    element: 'section',
  },
});

const extendedBlockRenderMap = Draft.DefaultDraftBlockRenderMap.merge(blockRenderMap);

class MyEditor extends React.Component {
  render() {
    return (
      <Editor
        blockRenderMap={extendedRenderMap}
      />
    );
  };
}

また、HTML elementからDraft Block typeに変換する際に、複数のhtmlエレメントを対象にしたいという場合は、aliasedElementsを指定することで解決できます。

'unstyled': {
  element: 'div',
  aliasedElements: ['p'],
}

これによって、Draft.jsが管理することができるBlock typeと対応するHTML要素を追加・変更することができるようになりました。

Custom Block Components

シンプルな構造のBlock要素へのスタイリングはこれまでのやり方で実現できますが、画像や動画のパーツなど、複雑なデータ構造とUIを持つブロック要素を取り扱いたい場合は、Custom Block Componentsという仕組みを使って実現します。

Custom Block Componentsとは、指定したBlock typeのContenBlockのレンダリングにReactコンポーネントを指定することができる仕組みです。
EditorコンポーネントのblockRenderFn propに、Block typeに対応したReact Component等の情報を返す様なメソッドを定義することでこの仕組みを利用することができます。

具体的に、画像のコンポーネントをEditorから作成する機能のコードを見てみましょう。

まず、blockRenderFn propに渡すメソッドを作成します。
メソッドの返り値はJavascriptオブジェクトで、それぞれのkeyの意味は以下のとおりです。

  • component
    • レンダリングするコンポーネント
  • editable
    • レンダリングするコンポーネントがcontentEditableかどうかを指定する
    • コンポーネントが画像など、文字列情報ではない場合はこの属性はfalseにする
  • props
    • デフォルトで渡されるprops以外に別途propsを渡したい場合に指定する属性
    • コンポーネントからはthis.props.blockPropsというように呼び出すことができる
function myBlockRender(contentBlock) {
  const type = contentBlock.getType();
  if (type === 'atomic') {
    return {
      component: ImageComponent,
      editable: false,
      props: {
        foo: 'bar',
      },
    };
  }
}

次に、レンダリングするコンポーネントを作成します。
Custom Block Componentsを利用する場合、そのComponentを構成するために必要な情報をEntityから取得する事が多いです。
今回の例も、対応するContentBlockに関連付けられているEntity(block要素なのでentityは一つしか紐付かない)を取得して、Entityに格納されているメタデータを取り出しています。

class ImageComponent extends React.Component {
  render() {
    const {block, contentState} = this.props;
    const {foo} = this.props.blockProps;
    const data = contentState.getEntity(block.getEntityAt(0)).getData();
    // レンダリングするDOMをここで記述
  }
}

最後に、Editorコンポーネントのpropsに先程作ったmyBlockRendererメソッドを渡して完成です。

実際に作ったものがこちら

画像デルチ.mov.gif

独自のUIパーツをReactコンポーネントとして作成して、それを使ってテキストエディットすることができるのはDraft.jsの強力な機能の一つだと思っています。


以上でDraft.jsの説明は終了です。
断片的に実装方法を説明しましたが、コード全体を通して理解したいという方は、draft.jsのgithubのリポジトリにexamplesがあるのでそれを参考にしてみてください。

Draft.jsのいいところ

全体を通してみて、Draft.jsのいいなと思うところを列挙してみます。

  • Facebookもプロダクションで利用しているという安心感が多少ある
  • 少ないコード量で、リッチテキストエディタが要求する機能のほとんどを実現できる
  • Reactのエコシステムにシームレスに導入ができ、堅牢で安定感のあるエディタ開発を実現できる
    • Reduxとの相性も当然良いのもすごく◎
  • データの永続化を行う場合は、ContentStateをJSON化したデータを保存することが推奨されているが、そのJSONをパースして様々なプラットフォーム上で利用できるデータ構造に変換できる
    • Web,スマホアプリ,デスクトップアプリなどやろうと思えばクロスプラットフォームで利用できる

Draft.jsの苦しいところ

逆にDraft.jsここが辛いなというところも列挙してみます。

  • 日本で使っている人が少ない(?)からか、日本語資料がほぼ無い
  • 公式ドキュメントとDraft.jsのソースコード以外に信頼できる文献がほぼ無い(発見できなかっただけかも)
  • React, Immutable.jsに強く依存している。良くも悪くもFacebookと共に生きていく必要がある。
    • Reactが好きじゃないと多分いつか辛くなる

その他

今回紹介できませんでしたが、Draft.jsは色々他にも機能や周辺ライブラリがあるので興味のある方は公式APIドキュメントなどを見て調べてみたください

  • キーバインドのカスタマイズ
  • Focusの管理
  • Alignの管理
  • ContentBlockに対するインタラクティブなアクション
    • ex) 画像幅をマウスで調整するなど
  • ContentStateオブジェクト <=> HTML,MardDown,etc
  • draft-js-plugins
  • ...etc

おわりに

思いの外こってり具合が加速しすぎてしまいました。節ごとに記事にしても良いボリュームですが、ザーッと目を通した後にドキュメントを読むと内容がすっと入ってくるような記事にできたらと思ってなるべく網羅的に書きました。リッチテキストエディタを検討中の方の参考に少しでもなれたら幸いです。

実際にドキュメントとソースコードをあさりながら、Draft.jsを試してみて、今私達が抱えている辛さをかなりの割合で解決してくれそうなことがわかりました。ハッカソンで作成した成果物も500行程度のごく少量のコードで多くの機能を実現でき、非常に生産性が高いフレームワークだなと実感しました。まだプロダクションのサービスで運用しているわけではないので運用時の辛さの知見は少ないことと、類似ツールと比較しての調査はできていないので引き続き調査しつつ、社内ツールなどから小さく始めていきたいと思っています。

また、普段Ruby, Railsばかり触っているのでフロントエンド周りの技術を勉強するのは純粋に楽しかったです。

明日の『LITALICO Advent Calendar 2017』は@klriutsaさんの「JSON処理をわかりやすくするGemを作った」についての記事です. お楽しみに.