Draft.js + TaiwindCSS + Next.jsでリッチテキストエディタ
はじめに
TailwindCSS & Next.jsなプロジェクトでDraft.jsを使いたかったのでサンプル実装してみました。
- 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エディタが実装できます。
日本語では以下のような記事があるようです。
Draft.jsを使ったエディタ実装もOSSでいくつか提供されています。インスタントにエディタをアプリに追加できます。更新が止まってるものも多いので注意。
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
に追記します。
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
};
global.css
を編集します。
@tailwind base;
@tailwind components;
@tailwind utilities;
Draft.jsをインストールします。
yarn add draft-js
Editor Componentの作成
Draft.jsを使ってエディタコンポーネントを作っていきます。
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します。
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で囲ってスタイルを書いていきます。
...
<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
を使って実装します。
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
を使った例を参考に作成しました。
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ではblockStyleFn
をEditor
コンポーネントに渡してやることで、各ブロック要素にクラスを設定できます。
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の公式ドキュメント通りに設定しました。
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
などの基本的なショートカットキーが使えるようになります。
まとめ
ちゃんとしたエディタを作ろうと思うと色々大変だと思いますが、ちょこっとリッチテキストを組み込みたい時にはこのくらいの実装でなんとかなりそうな気がします。(なるといいなあ)
ここまでのコードはこちらにあります。もし何かの参考になれば幸いです。
この後はfirebaseにstateを突っ込んで保存しています。
あまり真面目に実装してないですが、JSON.stringify(convertToRaw(editorState.getCurrentContent()))
して保存するだけでなんとなかりそうな気がしています。
あとはreact-hook-formと接続したいのですが、validationで苦労しそうで若干尻込みしているところです。このあたり実装進めたらまた備忘録として記事にするかもしれません。
Discussion