TK-NOTE

技術的なことや趣味、読んだ本についての感想などを投稿してきます。

【react-quill】簡単にリッチテキストエディタを実装する

Cover Image for 【react-quill】簡単にリッチテキストエディタを実装する
React

react-quill とは何か?

Quillとは、オープンソースのWYSIWYGエディタをWeb上で実現するためのjsプラグインです。
QuillをReact上で使えるようにしたパッケージがreact-quillです。

react-quillの使い方

react-quillのオプションは以下の通りです。

  • id: 指定するとDOM要素に当該IDが付与されます
  • className: 指定するとDOM要素に当該ClassNameが付与されます
  • value: エディタに表示する値。「HTMLを含む文字列、Quill Delta インスタンス、または Deltaを表すプレーン オブジェクト」を指定できるみたい。
  • defaultValue: エディタに表示する初期値
  • readOnly: trueを設定すると編集できなくなります。
  • placeholder: エディタが空の時に表示する文字列。動的に変更したい時はrefsとdata-attributesを使え、とのこと。
  • modules: エディターのツールバーなどに適用するモジュール
  • formats: 編集中に有効にするフォーマットを配列で指定する。デフォルトでは全て有効になっています。
  • style: CSSに適用するスタイル。キャメルケースで指定すること。
  • theme: 適用するテーマ名。デフォルトは「snow」
  • tabIndex: エディタがフォーカスする順番
  • bounds: ポップアップの位置を制限するために Quill によって使用されるセレクターまたは DOM 要素。デフォルトは document.body
  • children: デフォルトの代わりに編集領域として使用される要素。textareaは指定できない
  • onChange: 編集後に新しい内容で実行されるコールバック
  • onChangeSelection: 新しい選択値で実行されるコールバック
  • onFocus: フォーカスされた時に実行するコールバック
  • onBlur: フォーカスを失った時に実行するコールバック
  • onKeyPress: キーを押して離した時に実行するコールバック
  • onKeyDown: キーを押して離す前に実行するコールバック
  • onKeyUp: キーを話す時に実行するコールバック
  • preserveWhitespace: true の場合、デフォルトの div タグの代わりに pre タグがエディター領域に使用されます


react-quill でブログ管理システムを作ってみる

さっそくブログ管理システムを作ってみたいと思います。

まず、プロジェクトの雛形を作成します。vite を使用してJavaScript,Reactを選択し、プロジェクト名はreact-quill-sampleとします。

npm create vite@latest
Need to install the following packages:
  create-vite@4.2.0
Ok to proceed? (y) yProject name: … react-quill-sampleSelect a framework: › ReactSelect a variant: › JavaScript

Scaffolding project in /react-quill-sample...

Done. Now run:


ディレクトリを移動して、インストール、ローカルホストを立ち上げます。

cd react-quill-sample
npm install
npm run dev


必要なパッケージをインストールします。CSSはBootstrapを、react-quillで作成したHTMLを直接埋め込むためにhtml-react-parserを使用します。

npm install --save react-quill html-react-parser bootstrap reactstrap


次にBootstrapとreact-quillのCSSファイルのセットアップを行います。
不要なCSSファイルの読み込みを削除するのと以下の2行でsrc/main.jsxでCSSファイルの読み込みを行います。

import "react-quill/dist/quill.snow.css";
import "bootstrap/dist/css/bootstrap.css";

src/main.jsxのファイル全体は以下のようになります。これでセットアップは完了です。
./src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "react-quill/dist/quill.snow.css";
import "bootstrap/dist/css/bootstrap.css";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);


次にContextAPIでブログを管理するためにContextProviderを定義します。ブログの内容はid, title, content, createdAtとなります。
./src/BlogInfoProvider.jsx

import { createContext, useState } from "react";

export const BlogInfoContext = createContext();

export const BlogInfoProvider = ({ children }) => {
  const [blogs, setBlogs] = useState([]);
  const [id, setId] = useState();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [registerModal, setRegisterModal] = useState(false);
  const toggleRegisterModal = () => setRegisterModal(!registerModal);
  const [editModal, setEditModal] = useState(false);
  const toggleEditModal = () => setEditModal(!editModal);

  const addBlog = () => {
    const lastBlog = blogs[blogs.length - 1];
    const id = lastBlog ? lastBlog.id + 1 : 1;
    const newBlog = {
      id,
      content,
      title,
      createdAt: new Date(),
    };
    setBlogs([...blogs, newBlog]);
    toggleRegisterModal();
    setId();
    setTitle("");
    setContent("");
  };

  const updateBlog = () => {
    const newBlog = {
      id,
      content,
      title,
    };
    const newBlogs = blogs.map((blog) => {
      if (blog.id === id) {
        return { ...blog, ...newBlog };
      } else {
        return blog;
      }
    });
    setBlogs(newBlogs);
    toggleEditModal();
    setTitle("");
    setContent("");
  };

  const deleteBlog = (i) => {
    if (confirm("本当に削除しますか?")) {
      const newBlogs = [...blogs];
      newBlogs.splice(i, 1);
      setBlogs(newBlogs);
    }
  };
  return (
    <BlogInfoContext.Provider
      value={{
        blogs,
        setBlogs,
        title,
        setTitle,
        content,
        setContent,
        registerModal,
        setRegisterModal,
        toggleRegisterModal,
        editModal,
        setEditModal,
        addBlog,
        deleteBlog,
        updateBlog,
        setId,
      }}
    >
      {children}
    </BlogInfoContext.Provider>
  );
};


次にmain.jsxで先ほど作成したBlogInfoProviderAppコンポーネントを囲います。
./src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "react-quill/dist/quill.snow.css";
import "bootstrap/dist/css/bootstrap.css";
import { BlogInfoProvider } from "./BlogInfoProvider";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <BlogInfoProvider>
      <App />
    </BlogInfoProvider>
  </React.StrictMode>
);



次にブログの一覧を表示するためのコンポーネントを作成します。
一覧画面にはID、タイトル、内容、作成日時を表示します。対象の記事を削除、更新するためのボタンも設置します。
./src/BlogList.jsx

import React, { useContext } from "react";
import { Button, Table } from "reactstrap";
import parse from "html-react-parser";
import Modal from "./Modal";
import { BlogInfoContext } from "./BlogInfoProvider";

function BlogList() {
  const {
    setTitle,
    setContent,
    blogs,
    deleteBlog,
    editModal,
    toggleEditModal,
    updateBlog,
    setEditModal,
    setId,
  } = useContext(BlogInfoContext);
  const handleEditModal = (i) => {
    setTitle(blogs[i].title);
    setContent(blogs[i].content);
    setId(i + 1)
    setEditModal(!editModal);
  };
  return (
    <Table>
      <thead>
        <tr>
          <th>#</th>
          <th>タイトル</th>
          <th>内容</th>
          <th>作成日時</th>
          <th>更新</th>
          <th>削除</th>
        </tr>
      </thead>
      <tbody>
        {blogs.map((blog, i) => (
          <tr key={i}>
            <th scope="row">{blog.id}</th>
            <td>{blog.title}</td>
            <td>{parse(blog.content)}</td>
            <td>{blog.createdAt.toLocaleString()}</td>
            <td>
              <Button color="primary" onClick={() => handleEditModal(i)}>
                更新
              </Button>
              <Modal
                modal={editModal}
                toggle={toggleEditModal}
                onClick={() => updateBlog()}
              />
            </td>
            <td>
              <Button color="danger" onClick={() => deleteBlog(i)}>
                削除
              </Button>
            </td>
          </tr>
        ))}
      </tbody>
    </Table>
  );
}

export default BlogList;


次は記事を作成するためのフォームのモーダル実装です。ここでreact-quillを使用します。使用箇所は以下の部分です。

<ReactQuill
  id="content"
  theme="snow"
  value={content}
  onChange={setContent}
/>


Modalコンポーネントは記事の新規作成時と、既存記事の更新時に呼ばれるコンポーネントです。
./src/Modal.jsx

import React, { useContext } from "react";
import {
  Button,
  Modal as BootstrapModal,
  ModalHeader,
  ModalBody,
  ModalFooter,
  Label,
  Input,
  Form,
  FormGroup,
} from "reactstrap";
import ReactQuill from "react-quill";
import { BlogInfoContext } from "./BlogInfoProvider";

function Modal({ modal, toggle, onClick }) {
  const { title, setTitle, content, setContent } = useContext(BlogInfoContext);
  return (
    <BootstrapModal isOpen={modal} toggle={toggle}>
      <ModalHeader toggle={toggle}>
        ブログ記事を追加する
      </ModalHeader>
      <ModalBody>
        <Form>
          <FormGroup>
            <Label for="title">タイトル</Label>
            <Input
              id="title"
              name="title"
              placeholder="タイトルを入力"
              type="text"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
          </FormGroup>
          <FormGroup>
            <Label for="content">内容</Label>
            <ReactQuill
              id="content"
              theme="snow"
              value={content}
              onChange={setContent}
            />
          </FormGroup>
        </Form>
      </ModalBody>
      <ModalFooter>
        <Button color="secondary" onClick={toggle}>
          キャンセル
        </Button>
        <Button color="primary" onClick={onClick}>
          保存
        </Button>
      </ModalFooter>
    </BootstrapModal>
  );
}

export default Modal;


最後にApp.jsxを実装します。
./src/App.jsx

import { Button } from "reactstrap";
import Modal from "./Modal";
import BlogList from "./BlogList";
import { BlogInfoContext } from "./BlogInfoProvider";
import { useContext } from "react";

function App() {
  const { toggleRegisterModal, addBlog, registerModal } =
    useContext(BlogInfoContext);
  return (
    <>
      <h2>ブログ管理システム</h2>
      <Button color="primary" onClick={toggleRegisterModal}>
        ブログ記事を書く
      </Button>
      <Modal
        modal={registerModal}
        toggle={toggleRegisterModal}
        onClick={addBlog}
      />
      <BlogList />
    </>
  );
}

export default App;