📌

Draft.js + TaiwindCSS + Next.jsでリッチテキストエディタ

2022/03/09に公開

はじめに

TailwindCSS & Next.jsなプロジェクトでDraft.jsを使いたかったのでサンプル実装してみました。

https://github.com/shibuyamio/draft-js-next

  • Draft.js v0.11.0
  • Next.js v12.1.0
  • TailwindCSS v3.0.23
  • TypeScript

元々react-draft-wysiwygを使っていたのですが、しばらくアップデートされていないので諦めて素のDraft.jsを直接使うことにしました。

あと多分TailwindCSSとDraft.jsのラッパーを共存させようとするとスタイルがバッティングすると思います。

Draft.js

Facebook制のエディタフレームワークです。すごく簡単にWysiwygエディタが実装できます。

https://draftjs.org/

日本語では以下のような記事があるようです。

https://www.wantedly.com/companies/wantedly/post_articles/28285
https://qiita.com/samayotta/items/309f28b9da5a99b6e38f

Draft.jsを使ったエディタ実装もOSSでいくつか提供されています。インスタントにエディタをアプリに追加できます。更新が止まってるものも多いので注意。

https://github.com/jpuri/react-draft-wysiwyg

https://github.com/globocom/megadraft

https://github.com/springload/draftail/

v0.11.0からAPIが新しくなっていますので注意してください。新APIの実装例を探しましたがあまり情報がなかった。

構成

Next.js & TailwindCSS & TypeScriptにしました。
フロントエンドのことはよくわからないですが多分鉄板な気がします(?

こんなかんじのエディタをNext.jsに装備します。

動かす

導入

Next.jsでプロジェクトを作ります。

yarn create next-app --typescript

TailwindCSSをインストールします。

yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

以下のプラグインをインストールしました。@tailwindcss/formsは直接エディタには必要ないかも。

yarn add -D @tailwindcss/forms @tailwindcss/typography

tailwind.config.jsに追記します。

tailwind.config.js
module.exports = {
  content: [
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
};

global.cssを編集します。

global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

Draft.jsをインストールします。

yarn add draft-js

Editor Componentの作成

Draft.jsを使ってエディタコンポーネントを作っていきます。

components/Editor/index.ts
import { Editor as DraftEditor, EditorState } from "draft-js";
import "draft-js/dist/Draft.css";
import { useState } from "react";

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

  return <DraftEditor editorState={editorState} onChange={setEditorState} />;
};

export default Editor;

Draft.jsはSSRに対応していないため、そのままpage内にimportするとエラーになります。

Nextのdynamicを使って動的にimportします。

pages/index.tsx
import dynamic from "next/dynamic";

const Editor = dynamic(import("../components/Editor/index"), { ssr: false });

export default function Home() {
  return (
    <div className='container'>
      <Editor />
    </div>
  );
}

これだけで超シンプルなエディタが表示されます。何もスタイルが適用されていないので最初真っ白でコンポーネントが表示されてないのかと思いました。

スタイリング

TailwindCSSで見た目を整えます。Editorコンポーネントに直接スタイルを指定できないので、divで囲ってスタイルを書いていきます。

components/Editor/index.tsx

...

<div
  className="shadow-sm border border-gray-300 rounded-md sm:text-sm overflow-scroll h-[500px] p-3 prose prose-stone"
  onClick={focusEditor}
>
  <Editor
    ref={editor}
    editorState={editorState}
    onChange={setEditorState}
    placeholder="Tell a story..."
  />
</div>

...

prose prose-stone でTailwindCSSのTypographyをエディタに適用できます。

h-[500px] overflow-scrollでエディタの縦500pxを超えたテキストはスクロール表示されるようにできます。数値はお好みで調整してください。

インラインスタイル適用UIの実装

Draft.jsはimmutableなEditorStateを提供します。シンプルなAPIとユーティリティが提供されているので、簡単にテキストを加工できます。

ここでは、選択したテキストを太字にするボタンを実装します。RichUtilを使って実装します。

components/Editor/index.tsx

import { Editor as DraftEditor, EditorState, RichUtils } from "draft-js";
import "draft-js/dist/Draft.css";
import { useState } from "react";

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

  const handleBold = () => {
    setEditorState(RichUtils.toggleInlineStyle(editorState, "BOLD"));
  };

  return (
    <div className='m-4 max-w-xl'>
      <div className='mb-1 pr-4 flex items-center'>
        <div className='ml-auto flex items-center space-x-5'>
          <button onClick={handleBold}>B</button>
        </div>
      </div>
      <div className='shadow-sm border border-gray-300 rounded-md sm:text-sm overflow-scroll h-[500px] p-3 prose prose-stone'>
        <DraftEditor
          editorState={editorState}
          onChange={setEditorState}
          placeholder='Tell a story...'
        />
      </div>
    </div>
  );
};

export default Editor;

こんなかんじになります。

Italic, Underlineも同様に実装できます。

ブロック要素適用UIの実装

インラインと同じようにして、ブロック要素も設定できます。スタイル無しだとp要素が適用されます。

const newEditorState = RichUtils.toggleBlockType(editorState, 'blockquote')

これで選択したブロックをBlockquote要素に設定します。

UIコンポーネントは自力で用意する必要があるので、TailwindCSSのコンポーネントからListboxを使った例を参考に作成しました。

components/Editor/BlockStyleControles.tsx
import { Listbox, Transition } from "@headlessui/react";
import { MenuAlt2Icon } from "@heroicons/react/solid";
import { EditorState, RichUtils } from "draft-js";
import { Dispatch, Fragment, SetStateAction, useEffect, useState } from "react";
function classNames(...classes: string[]) {
  return classes.filter(Boolean).join(" ");
}

const BLOCK_TYPES = [
  { label: "スタイルなし", style: "unstyled" },
  { label: "H1", style: "header-one" },
  { label: "H2", style: "header-two" },
  { label: "H3", style: "header-three" },
  { label: "H4", style: "header-four" },
  { label: "H5", style: "header-five" },
  { label: "H6", style: "header-six" },
  { label: "Blockquote", style: "blockquote" },
  { label: "リスト", style: "unordered-list-item" },
  { label: "番号付きリスト", style: "ordered-list-item" },
  { label: "Code Block", style: "code-block" },
];

type Props = {
  editorState: EditorState;
  setEditorState: Dispatch<SetStateAction<EditorState>>;
};

type OptionProps = {
  blockStyle: {
    label: string;
    style: string;
  };
};

const BlockStyleOption = ({ blockStyle }: OptionProps) => (
  <Listbox.Option
    className={({ active }) =>
      classNames(
        active ? "bg-gray-100" : "bg-white",
        "cursor-default select-none relative py-2 px-3"
      )
    }
    value={blockStyle}
  >
    <div className="flex items-center">
      <span className="block font-medium truncate">{blockStyle.label}</span>
    </div>
  </Listbox.Option>
);

const BlockStyleControles = ({ editorState, setEditorState }: Props) => {
  const [blockStyle, setBlockStyle] = useState(BLOCK_TYPES[0]);

  useEffect(() => {
    setEditorState(RichUtils.toggleBlockType(editorState, blockStyle.style));
  }, [blockStyle]);

  return (
    <Listbox
      as='div'
      value={blockStyle}
      onChange={setBlockStyle}
      className='flex-shrink-0'
    >
      {({ open }) => (
        <>
          <Listbox.Label className='sr-only'>Select Style</Listbox.Label>
          <div className='relative'>
            <Listbox.Button className='relative inline-flex items-center rounded-full py-2 px-2 bg-gray-50 text-sm font-medium text-gray-500 whitespace-nowrap hover:bg-gray-100 sm:px-3'>
              <MenuAlt2Icon
                className='text-gray-500 flex-shrink-0 h-5 w-5 sm:-ml-1'
                aria-hidden='true'
              />
              <span className='hidden truncate sm:ml-2 sm:block'>
                {blockStyle.label === null ? "Style..." : blockStyle.label}
              </span>
            </Listbox.Button>

            <Transition
              show={open}
              as={Fragment}
              leave='transition ease-in duration-100'
              leaveFrom='opacity-100'
              leaveTo='opacity-0'
            >
              <Listbox.Options className='absolute right-0 z-10 mt-1 w-52 bg-white shadow max-h-56 rounded-lg py-3 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm'>
                {BLOCK_TYPES.map((type) => (
                  <BlockStyleOption key={type.label} blockStyle={type} />
                ))}
              </Listbox.Options>
            </Transition>
          </div>
        </>
      )}
    </Listbox>
  );
};

export default BlockStyleControles;

こんなかんじになります。

ブロックスタイルのカスタマイズ

proseを適用しただけではBlockquote要素にスタイルが適用されなかったので、自前で用意してやる必要があります。

Draft.jsではblockStyleFnEditorコンポーネントに渡してやることで、各ブロック要素にクラスを設定できます。

function myBlockStyleFn(contentBlock: ContentBlock) {
  const type = contentBlock.getType();

  switch (type) {
    case "blockquote":
      return "px-4 py-2 border-l-4 bg-neutral-100 text-neutral-600 border-neutral-300 quote not-italic";
  }

  return "";
}

こんなかんじでTailwindCSSのスタイルを設定しておいて、

...

<Editor
  editorState={editorState}
  onChange={setEditorState}
  placeholder="Tell a story..."
  blockStyleFn={myBlockStyleFn}
/>

こんなかんじで渡してやります。

TailwindCSSのTypographyをカスタマイズしたい場合は、以下のようにスタイルを追加してください。

    ...
    case "header-one":
      return "text-4xl";
    ...

先程適用したBlockquoteはこんな感じになります。

ショートカットの設定

Draft.jsの公式ドキュメント通りに設定しました。

https://draftjs.org/docs/quickstart-rich-styling

components/Editor/index.tsx
  const handleKeyCommand = (
    command: EditorCommand,
    editorState: EditorState
  ) => {
    const newState = RichUtils.handleKeyCommand(editorState, command);
    if (newState) {
      setEditorState(newState);
      return "handled";
    }

    return "not-handled";
  };
  
  ...

  return (
        <Editor
          editorState={editorState}
          onChange={setEditorState}
          handleKeyCommand={handleKeyCommand}
          placeholder="Tell a story..."
        />)
  ...

これだけでCtrl+Bなどの基本的なショートカットキーが使えるようになります。

まとめ

ちゃんとしたエディタを作ろうと思うと色々大変だと思いますが、ちょこっとリッチテキストを組み込みたい時にはこのくらいの実装でなんとかなりそうな気がします。(なるといいなあ)

ここまでのコードはこちらにあります。もし何かの参考になれば幸いです。

https://github.com/shibuyamio/draft-js-next

この後はfirebaseにstateを突っ込んで保存しています。
あまり真面目に実装してないですが、JSON.stringify(convertToRaw(editorState.getCurrentContent()))して保存するだけでなんとなかりそうな気がしています。

あとはreact-hook-formと接続したいのですが、validationで苦労しそうで若干尻込みしているところです。このあたり実装進めたらまた備忘録として記事にするかもしれません。

Discussion