Draft.js製テキストエディタの内容をFireStoreに保存する

はじめに

Draft.js製テキストエディタに入力されたデータをFireStoreに保存する手順を記録します。

正確には、今回エディタのコンポーネントを取ってきているライブラリはDraft.jsではなく、react-draft-wysiwygというDraft.jsをさらに拡張させたライブラリになります。

ですが今回やりたいことであるデータの書き込みをする限りではエディタのコンポーネントに限っては2つのライブラリ間で何か特別な機能の差異があるわけではないので、実装する際は好きな方を使用してもらって問題ありません(Draft.jsのエディタでも今回やりたいことができるのは確認済み)。

ちなみに私がreact-draft-wysiwygを使っているのは、ツールバーをめちゃくちゃ簡単にエディタにくっつけられるからです。

また、「Draft.js製エディタの内容をDBに書き込む」ことについては、Wantedlyのエンジニアの方が執筆されている以下の記事をとても参考にさせていただきました。

www.wantedly.com

なのでほとんど上記の記事を読めばOKなのですが、上記の記事では、簡単のためlocalStorageへの保存までで説明を終えているため、本記事では実際にDB(今回はCloud FireStore)への書き込み処理を行うまでを取り扱おうと思います。

前提

  • Draft.jsもしくはreact-draft-wysiwygで入力可能なエディタを作成済
  • 環境変数などを用いてfirebaseのinitializeAppが完了済
  • firestore.rules作成済

環境

  • react 17.0.1
  • typescript 4.5.4
  • gatsby 4.3.0
  • @emotion/react 11.7.0
  • @mui/material 5.2.7
  • draft.js 0.11.7
  • react-draft-wysiwyg 1.14.7

コード全文

import React, { useState } from "react"
import firebase from "../firebase"

import { css } from "@emotion/react"
import { Button } from "@mui/material"
import { Editor } from "react-draft-wysiwyg"
import {
  EditorState,
  convertToRaw,
  convertFromRaw,
  ContentState,
} from "draft-js"
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css"

const db = firebase.firestore()
const save = async (data: string) => {
  await db.collection("articles").doc().set({ content: data })
}

const Article = () => {
  const initData = convertFromRaw({
    entityMap: {},
    blocks: [
      {
        key: "key",
        text: "",
        type: "unstyled",
        depth: 7,
        entityRanges: [],
        inlineStyleRanges: [],
        data: {},
      },
    ],
  })

  const initState = EditorState.createWithContent(initData)
  const [editorState, setEditorState] = useState(initState)

  const handleChange = (state: EditorState) => {
    setEditorState(state)
  }

  const onSave = async (contentState: ContentState) => {
    const object = convertToRaw(contentState)
    const data = JSON.stringify(object)
    console.log(data)
    await save(data)
  }

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    await onSave(editorState.getCurrentContent())
  }

  return (
    <form onSubmit={handleSubmit}>
      <div
        onClick={focus}
        css={css`
          box-sizing: border-box;
          border: 1px solid #ddd;
          cursor: text;
          padding: 16px;
          border-radius: 2px;
          margin-bottom: 2em;
          box-shadow: inset 0px 1px 8px -3px #ababab;
          background: #fefefe;
          height: 200px;
        `}
      >
        <Editor
          editorState={editorState}
          onEditorStateChange={handleChange}
          placeholder="書きたいことを入力してください"
          toolbar={{
            options: ["inline", "blockType", "list", "textAlign", "link"],
            inline: {
              options: ["bold", "strikethrough"],
            },
            blockType: {
              inDropdown: false,
              options: ["H2"],
            },
            list: {
              options: ["unordered"],
            },
            textAlign: {
              options: ["center"],
            },
            link: {
              options: ["link"],
            },
          }}
        />
      </div>
      <Button type="submit" variant="contained">
        保存
      </Button>
    </form>
  )
}

export default Article

手順

1. FireStoreにデータを保存するための関数作成

FirebaseをinitializeAppしているファイルからfirebaseというnamespaceをimportします。 そしてそれをもとにデータ保存用の関数を書いていきます。

具体的には、そのnamespaceをもとにFireStoreの今回書き込みをするドキュメントのパスを指定し、そこにdataを投入するという形です。

ちなみにcollectionが持つdocメソッドは引数を入れずに使用すると、勝手にドキュメントのidを作成してくれるので便利です。

import firebase from "../firebase"

const db = firebase.firestore()
const save = async (data: string) => {
  await db.collection("articles").doc().set({ data })
}

またnamespaceであるfirebaseをexportしているファイルはこんな感じです。 import "firebase/compat/firestore"がないとFireStoreへはアクセスできないので注意です。

import firebase from "firebase/compat/app"
import "firebase/compat/auth"
import "firebase/compat/firestore"

const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGE_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID,
  measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID,
}

firebase.initializeApp(firebaseConfig)

export const auth = firebase.auth()
export default firebase

2. テキストエディタに入力されたデータをJSONに変換する関数作成

関数にはContentStateというクラスを型にした引数を入れます。

ContentStateとは、Draft.jsのエディタの状態を管理しているEditorStateというクラスの中で、入力された文章の内容を保持する役割を持つクラスです。これはDraft.jsが提供してくれています。

convertToRawはContentStateが持つデータ構造をよりシンプルなものに変換するための関数です。こちらもDraft.jsで提供してくれているのでimportしましょう。

データ構造をよりシンプルにしたあとは、そのオブジェクトをstringに変換します。 そして最後に1で作成した関数の引数としてそのデータを入れてあげます。

import {
  convertToRaw,
  ContentState,
} from "draft-js"

 const onSave = async (contentState: ContentState) => {
    const object = convertToRaw(contentState)
    const data = JSON.stringify(object)
    console.log(data)
    await save(data)
  }

脇道にはそれますが、引数の型に当てているContentStateはクラスとしてimportしてきています。 それなのに型として使うことができ、かつ型を当てた引数がクラスのメソッドとか使えちゃうのはどういうことなのでしょうか?

ここについては、厳密には違うかもしれませんが以下の記事にそれらしいことが書いてありました。

qiita.com

TypeScriptでは、クラスを定義すると同時に同名の型も定義されます。 この例では、クラスFooを定義したことで、Fooという型も同時に定義されました。Fooというのは、クラスFooのインスタンスの型です。

つまりTypeScriptでは、クラスを定義すると同名の型を勝手に定義してくれて、その型はクラスのインスタンスの型になるので、上記に挙げたことが可能になっているようです。

そこまで自信はないので、何か認識が誤っていることに気づいた方はご指摘いただけると幸いです🙏

3. 2の関数のトリガーとなるボタン作成、およびデータ送信のための関数とform作成

ボタンはMaterial UIで作成します。 ボタンが押されたらformが連動するようformの中にonSubmitを設け、そこにはhandleSubmitを入れます。

handleSubmitではpreventDefaultをしてページのリロードを防いだのち、2の関数を呼び出しています。 そしてその引数には、useStateのフックで管理しているeditorStateという状態の、さらにgetCurrentContentというメソッドを使用したデータを入れています。getCurrentContentはエディタに実際に入力されている文章データが取得するためのメソッドです。

import { Button } from "@mui/material"

const Article = () => {
    const initData = convertFromRaw({
    entityMap: {},
    blocks: [
      {
        key: "key",
        text: "",
        type: "unstyled",
        depth: 7,
        entityRanges: [],
        inlineStyleRanges: [],
        data: {},
      },
    ],
  })

  const initState = EditorState.createWithContent(initData)
  const [editorState, setEditorState] = useState(initState)
    const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault()
        await onSave(editorState.getCurrentContent())
     }

  return (
    <form onSubmit={handleSubmit}>
      <div
        onClick={focus}
        css={css`
          box-sizing: border-box;
          border: 1px solid #ddd;
          cursor: text;
          padding: 16px;
          border-radius: 2px;
          margin-bottom: 2em;
          box-shadow: inset 0px 1px 8px -3px #ababab;
          background: #fefefe;
          height: 200px;
        `}
      >
        <Editor
          editorState={editorState}
          onEditorStateChange={handleChange}
          placeholder="書きたいことを入力してください"
          toolbar={{
            options: ["inline", "blockType", "list", "textAlign", "link"],
            inline: {
              options: ["bold", "strikethrough"],
            },
            blockType: {
              inDropdown: false,
              options: ["H2"],
            },
            list: {
              options: ["unordered"],
            },
            textAlign: {
              options: ["center"],
            },
            link: {
              options: ["link"],
            },
          }}
        />
      </div>
      <Button type="submit" variant="contained">
        保存
      </Button>
    </form>
  )
}

4. firestore.rulesの変更

以下結構ゆるめに書いています。rulesはもっとこう書かないといけない、というご指摘がある方はご助言いただけますと幸いです。

isValidArticleで、dataがちゃんとFireStoreに投げた通信の中に入っているか、かつその型がstringかどうかを調べます。 isAuthUserというログインしているかをチェックする関数も設けてはいますが、内容的にはこの記事にはあまり関係しないのでミニマムで実装される場合はなくても大丈夫です(このエディタを何かのサービスに組み込むときには必要です)。

FireStoreはホワイトルール形式なので、〜という場合はcreateを許可する、という風に書きます。 以下ではisValidArticleとisAuthUserがそれぞれtrueであれば許可すると書いています。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthenticated() {
      return request.auth != null;
    }

    function isUserAuthenticated(userId) {
      return userId == request.auth.uid;
    }

    function isAuthUser(userId) {
      return isAuthenticated() && isUserAuthenticated(userId);
    }

    function isValidArticle(article) {
      return article.size() == 1
      && 'data' in article && article.data is string;
    }

    match /articles/{docId} {
      allow create: if isAuthUser(request.auth.uid) && isValidArticle(request.resource.data);
    }
  }
}

5. done 🙌

こちらが出来上がった画面です。 f:id:rinda_1994:20220123140023p:plain 実際にエディタにデータを入力して、保存ボタンを押してみましょう! firestoreを見にいくと、実際に入力した文章データがJSONで保存されているのがわかります(スクショの右あたりに"text":"ほげほげ"が入っている)。 f:id:rinda_1994:20220123140038p:plain

このtextというデータだけをFireStoreに保存しないのは、JSONのままの方が、エディタの内容を復元しやすいと思っているためです(まだ試していないですが)。 おそらくは、DBからJSONをgetしたのち、2で行っているconvertToRaw()とJSON.stringify()の逆の処理を行なってやれば復元できると思われます。

所感

Draft.jsエディタのDBへの書き込み処理は、基本的なところかもしれないですが、個人的にはつまづいたポイントでした。 Draft.jsの情報ソースは割と少ないので、困っている方の一助になれば幸いです。