Draft.jsでWYSIWYGエディターを実装する

2021年3月14日


WYSIWYGエディターとは

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

マークダウンエディターとは違って、書いたときに見えるものがそのまま作成後に表示されるエディターらしいです。

完成品#

result

draft-js-plugins-editor#

カスタマイズ性が高いけど難しい Draft.js を簡単に使用するため誕生したライブラリーです。 メンテナンスされたりされなかったりしているので、使用する際は気をつけてください。 また、複数のプラグインを一緒に使うとコンフリクトする場合もあります。

インストール#

terminal
1yarn add draft-js
2yarn add -D @types/draft-js

Draft.js はtTypeScriptで書かれてないので、@types/draft-js を一緒にインストールしました。

tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import { Editor, EditorState } from "draft-js";
5import "draft-js/dist/Draft.css";
6
7const TextEditor = () => {
8 const [editorState, setEditorState] = React.useState<EditorState>(
9 EditorState.createEmpty()
10 );
11 return <Editor editorState={editorState} onChange={setEditorState} />;
12};
13
14export default TextEditor;

useState の初期化の時、EditorState.createEmpty() をしていますが、 EditorState.createWithContent で初期値を変更することも可能です。

これで基本的な contenteditable コンポーネントが作られました。編集を無効にしたい場合は readOnly をpropsとして渡せばOKです。

太字や斜字、H1/H2/H3などを実装する#

Draft.js では基本的な inlineStyles とそれに対する command, Block を用意していて、 RichUtils からそれらを使用することができます。Draft.js のデータ構造については こちらにqiita記事で説明されていたので省略いたします。

キーボード操作で太字や斜字などが適用できるようにする#

tsx
1/** TextEditor.tsx */
2
3...
4
5import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";
6
7...
8
9const TextEditor = () => {
10
11 ...
12
13 const handleKeyCommand = (command: DraftEditorCommand) => {
14 const newState = RichUtils.handleKeyCommand(editorState, command);
15 if (newState) {
16 setEditorState(newState);
17 return "handled";
18 }
19 return "not-handled";
20 };
21
22 return (
23 <Editor
24 editorState={editorState}
25 onChange={setEditorState}
26 handleKeyCommand={handleKeyCommand}
27 />
28 );
29};

RichUtilshandleKeyCommand を呼び出しております。 Command + B で太字にできることを確認してみてください。詳しい内容は省きますが、handleKeyCommandkeyBindingFn と組み合わせることで、カスタムのコマンドを作ることもできます。(例えば Command + S で保存ができるようにするなど)

ツールバーを作る#

tsx
1/** TextEditor.tsx */
2
3...
4
5const TextEditor = () => {
6
7 ...
8
9 const handleTogggleClick = (e: React.MouseEvent, inlineStyle: string) => {
10 e.preventDefault();
11 setEditorState(RichUtils.toggleInlineStyle(editorState, inlineStyle));
12 };
13
14 const handleBlockClick = (e: React.MouseEvent, blockType: string) => {
15 e.preventDefault();
16 setEditorState(RichUtils.toggleBlockType(editorState, blockType));
17 };
18
19 return (
20 <div>
21 <button onMouseDown={(e) => handleBlockClick(e, "header-one")}>H1</button>
22 <button onMouseDown={(e) => handleBlockClick(e, "header-two")}>H2</button>
23 <button onMouseDown={(e) => handleBlockClick(e, "header-three")}>H3</button>
24 <button onMouseDown={(e) => handleBlockClick(e, "unstyled")}>Normal</button>
25 <button onMouseDown={(e) => handleTogggleClick(e, "BOLD")}>bold</button>
26 <button onMouseDown={(e) => handleTogggleClick(e, "ITALIC")}>italic</button>
27 <button onMouseDown={(e) => handleTogggleClick(e, "STRIKETHROUGH")}>strikthrough</button>
28 <button onMouseDown={(e) => handleBlockClick(e, "ordered-list-item")}>Ordered List</button>
29 <button onMouseDown={(e) => handleBlockClick(e, "unordered-list-item")}>Unordered List</button>
30 <Editor editorState={editorState} onChange={setEditorState} handleKeyCommand={handleKeyCommand} />
31 </div>
32 );
33};
34
35export default TextEditor;

基本的な機能は RichUtils に入っているので、inlineStyles の変更には toggleInlineStyleBlock のタイプの変更には toggleBlockType メソッドを使います。


取り消しとやり直しボタンを作る#

tsx
1/** TextEditor.tsx */
2
3...
4 <button
5 disabled={editorState.getUndoStack().size <= 0}
6 onMouseDown={() => setEditorState(EditorState.undo(editorState))}>
7 undo
8 </button>
9 <button
10 disabled={editorState.getRedoStack().size <= 0}
11 onMouseDown={() => setEditorState(EditorState.redo(editorState))}>
12 redo
13 </button>
14...

EditorStateundoredo を使います。

リンクを追加する#

リンクを正しくレンダリングするためには、Editor 側に decorator を足す必要があります。

tsx
1/** Link.tsx */
2
3import { CompositeDecorator, DraftDecoratorComponentProps } from "draft-js";
4
5export const Link = (props: DraftDecoratorComponentProps) => {
6 const { url } = props.contentState.getEntity(props.entityKey).getData();
7 return (
8 <a rel="noopener noreferrer" target="_blank" href={url}>
9 {props.children}
10 </a>
11 );
12};
13
14export const linkDecorator = new CompositeDecorator([
15 {
16 strategy: (contentBlock, callback, contentState) => {
17 contentBlock.findEntityRanges((character) => {
18 const entityKey = character.getEntity();
19 return entityKey !== null && contentState.getEntity(entityKey).getType() === "LINK";
20 }, callback);
21 },
22 component: Link
23 }
24]);

CompositeDecorator を使用し strategy でそのエンティティの種類を調べ、component でそれをレンダリングするコンポーネントを渡します。DraftDecoratorComponentProps は残念ながら @types/draft-js には入っておらず、Draft.js 内で flow のタイプとして定義されていたものを参考に拡張しました。面倒くさいなら any で良いかもしれません。(原本はこちら

ts
1/** module.d.ts */
2
3import 'draft-js';
4
5declare module 'draft-js' {
6 export interface DraftDecoratorComponentProps {
7 blockKey: any;
8 children?: ReactNode;
9 contentState: ContentState;
10 decoratedText: string;
11 dir?: any;
12 end: number;
13 entityKey?: string;
14 offsetKey: string;
15 start: number;
16 }
17}

そして リンクのエンティティを追加 するメソッドを作ります。 今回は簡単に prompt を使って実装します。

tsx
1/** TextEditor.tsx */
2
3...
4const handleAddLink = () => {
5 const selection = editorState.getSelection();
6 const link = prompt("Please enter the URL of your link");
7 if (!link) {
8 setEditorState(RichUtils.toggleLink(editorState, selection, null));
9 return;
10 }
11 const content = editorState.getCurrentContent();
12 const contentWithEntity = content.createEntity("LINK", "MUTABLE", {
13 url: link
14 });
15 const newEditorState = EditorState.push(editorState, contentWithEntity, "apply-entity");
16 const entityKey = contentWithEntity.getLastCreatedEntityKey();
17 setEditorState(RichUtils.toggleLink(newEditorState, selection, entityKey));
18 };
19...

後は作った decorator を、EditorState の初期化時に渡してあげれば大丈夫です。

tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";
5import "draft-js/dist/Draft.css";
6import { linkDecorator } from "./Link";
7
8const TextEditor = () => {
9 const [editorState, setEditorState] = React.useState<EditorState>(EditorState.createEmpty(linkDecorator));
10
11 ...
12
13 return (
14 <div>
15 ...
16 <button
17 {/* 選択された範囲がない場合、disabledにする */}
18 disabled={editorState.getSelection().isCollapsed()}
19 onMouseDown={(e) => {
20 e.preventDefault();
21 handleAddLink();
22 }}>
23 link
24 </button>
25 ...
26 );
27};

画像を挿入する#

画像の挿入には様々な方法が考えられます。基本的なURLの追加から、コピペ、ドラッグ・アンド・ドロップなど。今回はURLを指定して追加する方法 を紹介します。コピペやドラッグ・アンド・ドロップは Editor のpropsである handlePastedFileshandleDroppedFiles を活用すれば対応できると思います。今回は AtomBlockUtils を使います。

tsx
1/** TextEditor.tsx */
2
3...
4 const handleInsertImage = () => {
5 const src = prompt("Please enter the URL of your picture");
6 if (!src) {
7 return;
8 }
9 const contentState = editorState.getCurrentContent();
10 const contentStateWithEntity = contentState.createEntity("image", "IMMUTABLE", { src });
11 const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
12 const newEditorState = EditorState.set(editorState, {
13 currentContent: contentStateWithEntity
14 });
15 return setEditorState(AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, " "));
16 };
17...

画像はカスタムな Block なので、EditorblockRendererFn を使う必要があります。Media をレンダーする関数、mediaBlockRenderer を作ります。画像だけでなく、動画やその他カスタムなブロックにも対応できます。

tsx
1/** Media.tsx */
2
3import React from 'react';
4import { ContentBlock, ContentState } from 'draft-js';
5
6interface BlockComponentProps {
7 contentState: ContentState;
8 block: ContentBlock;
9}
10
11export const Image = (props: BlockComponentProps) => {
12 const { block, contentState } = props;
13 const { src } = contentState.getEntity(block.getEntityAt(0)).getData();
14 return <img src={src} alt={src} role="presentation" />;
15};
16
17const Media = (props: BlockComponentProps) => {
18 const entity = props.contentState.getEntity(props.block.getEntityAt(0));
19 const type = entity.getType();
20
21 let media = null;
22 if (type === 'image') {
23 media = <Image {...props} />;
24 }
25
26 return media;
27};
28
29export const mediaBlockRenderer = (block: ContentBlock) => {
30 if (block.getType() === 'atomic') {
31 return {
32 component: Media,
33 editable: false,
34 };
35 }
36 return null;
37};
tsx
1/** TextEditor.tsx */
2
3...
4 <Editor
5 editorState={editorState}
6 onChange={setEditorState}
7 handleKeyCommand={handleKeyCommand}
8 blockRendererFn={mediaBlockRenderer}
9 />
10...

文字数の制限を設ける#

文字数は editorState.getCurrentContent().getPlainText().length で取得できますが、 制限文字数を指定してそれ以上は記入できなくする要件があるかもしれません。

この場合はhandleBeforeInputhandlePastedText を使うことで実現できます。 こちらにサンプルコードがあるので、 ここでは割愛させて頂きます。

データを保存 & 読み込みする#

Draft.js ではJSONとしてデータを変換する機能があり、 draft-js-export-html などのライブラリーを使えば、 HTML形式に変換も可能です。 今回はエディターのデータをJSONに変換し、 localStorage に保存しています。

tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import {
5 ...
6 convertToRaw,
7 convertFromRaw
8} from "draft-js";
9
10...
11
12const TEXT_EDITOR_ITEM = "draft-js-example-item";
13
14const TextEditor = () => {
15 const data = localStorage.getItem(TEXT_EDITOR_ITEM);
16
17 const initialState = data
18 ? EditorState.createWithContent(convertFromRaw(JSON.parse(data)), linkDecorator)
19 : EditorState.createEmpty(linkDecorator);
20
21 const [editorState, setEditorState] = React.useState<EditorState>(initialState);
22
23 const handleSave = () => {
24 const data = JSON.stringify(convertToRaw(editorState.getCurrentContent()));
25 localStorage.setItem(TEXT_EDITOR_ITEM, data);
26 };
27
28 ...
29
30 return (
31 <div>
32 ...
33 <Editor
34 editorState={editorState}
35 onChange={setEditorState}
36 handleKeyCommand={handleKeyCommand}
37 blockRendererFn={mediaBlockRenderer}
38 />
39 <button
40 onClick={(e) => {
41 e.preventDefault();
42 handleSave();
43 }}>
44 save
45 </button>
46 </div>
47 );
48};

保存ボタンを押したら、リロードしても内容が保存されていました。 スタイリングは省きましたが、これで大体の実装は完了です。お疲れ様でした。

サンプルコード