WYSIWYGエディターとは
WYSIWYG(アクロニム: ウィジウィグ)とは、コンピュータのユーザインタフェースに関する用語で、ディスプレイに現れるものと処理内容(特に印刷結果)が一致するように表現する技術。What You See Is What You Get(見たままが得られる)の頭文字をとったものであり、「is」を外したWYSWYG(ウィズウィグ)と呼ばれることもある。
マークダウンエディターとは違って、書いたときに見えるものがそのまま作成後に表示されるエディターらしいです。
完成品#
draft-js-plugins-editor#
カスタマイズ性が高いけど難しい Draft.js
を簡単に使用するため誕生したライブラリーです。
メンテナンスされたりされなかったりしているので、使用する際は気をつけてください。
また、複数のプラグインを一緒に使うとコンフリクトする場合もあります。
インストール#
1yarn add draft-js2yarn add -D @types/draft-js
Draft.js
はtTypeScriptで書かれてないので、@types/draft-js
を一緒にインストールしました。
1/** TextEditor.tsx */23import React from "react";4import { Editor, EditorState } from "draft-js";5import "draft-js/dist/Draft.css";67const TextEditor = () => {8 const [editorState, setEditorState] = React.useState<EditorState>(9 EditorState.createEmpty()10 );11 return <Editor editorState={editorState} onChange={setEditorState} />;12};1314export default TextEditor;
useState
の初期化の時、EditorState.createEmpty()
をしていますが、
EditorState.createWithContent
で初期値を変更することも可能です。
これで基本的な contenteditable
コンポーネントが作られました。編集を無効にしたい場合は readOnly
をpropsとして渡せばOKです。
太字や斜字、H1/H2/H3などを実装する#
Draft.js
では基本的な inlineStyles
とそれに対する command
, Block
を用意していて、
RichUtils
からそれらを使用することができます。Draft.js
のデータ構造については
こちらにqiita記事で説明されていたので省略いたします。
キーボード操作で太字や斜字などが適用できるようにする#
1/** TextEditor.tsx */23...45import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";67...89const TextEditor = () => {1011 ...1213 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 };2122 return (23 <Editor24 editorState={editorState}25 onChange={setEditorState}26 handleKeyCommand={handleKeyCommand}27 />28 );29};
RichUtils
の handleKeyCommand
を呼び出しております。
Command + B
で太字にできることを確認してみてください。詳しい内容は省きますが、handleKeyCommand
と keyBindingFn
と組み合わせることで、カスタムのコマンドを作ることもできます。(例えば Command + S
で保存ができるようにするなど)
ツールバーを作る#
1/** TextEditor.tsx */23...45const TextEditor = () => {67 ...89 const handleTogggleClick = (e: React.MouseEvent, inlineStyle: string) => {10 e.preventDefault();11 setEditorState(RichUtils.toggleInlineStyle(editorState, inlineStyle));12 };1314 const handleBlockClick = (e: React.MouseEvent, blockType: string) => {15 e.preventDefault();16 setEditorState(RichUtils.toggleBlockType(editorState, blockType));17 };1819 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};3435export default TextEditor;
基本的な機能は RichUtils
に入っているので、inlineStyles
の変更には toggleInlineStyle
、Block
のタイプの変更には toggleBlockType
メソッドを使います。
取り消しとやり直しボタンを作る#
1/** TextEditor.tsx */23...4 <button5 disabled={editorState.getUndoStack().size <= 0}6 onMouseDown={() => setEditorState(EditorState.undo(editorState))}>7 undo8 </button>9 <button10 disabled={editorState.getRedoStack().size <= 0}11 onMouseDown={() => setEditorState(EditorState.redo(editorState))}>12 redo13 </button>14...
EditorState
の undo
と redo
を使います。
リンクを追加する#
リンクを正しくレンダリングするためには、Editor
側に decorator
を足す必要があります。
1/** Link.tsx */23import { CompositeDecorator, DraftDecoratorComponentProps } from "draft-js";45export 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};1314export 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: Link23 }24]);
CompositeDecorator
を使用し strategy
でそのエンティティの種類を調べ、component
でそれをレンダリングするコンポーネントを渡します。DraftDecoratorComponentProps
は残念ながら @types/draft-js
には入っておらず、Draft.js
内で flow
のタイプとして定義されていたものを参考に拡張しました。面倒くさいなら any
で良いかもしれません。(原本はこちら)
1/** module.d.ts */23import 'draft-js';45declare 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
を使って実装します。
1/** TextEditor.tsx */23...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: link14 });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
の初期化時に渡してあげれば大丈夫です。
1/** TextEditor.tsx */23import React from "react";4import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";5import "draft-js/dist/Draft.css";6import { linkDecorator } from "./Link";78const TextEditor = () => {9 const [editorState, setEditorState] = React.useState<EditorState>(EditorState.createEmpty(linkDecorator));1011 ...1213 return (14 <div>15 ...16 <button17 {/* 選択された範囲がない場合、disabledにする */}18 disabled={editorState.getSelection().isCollapsed()}19 onMouseDown={(e) => {20 e.preventDefault();21 handleAddLink();22 }}>23 link24 </button>25 ...26 );27};
画像を挿入する#
画像の挿入には様々な方法が考えられます。基本的なURLの追加から、コピペ、ドラッグ・アンド・ドロップなど。今回はURLを指定して追加する方法 を紹介します。コピペやドラッグ・アンド・ドロップは Editor
のpropsである handlePastedFiles
や handleDroppedFiles
を活用すれば対応できると思います。今回は AtomBlockUtils
を使います。
1/** TextEditor.tsx */23...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: contentStateWithEntity14 });15 return setEditorState(AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, " "));16 };17...
画像はカスタムな Block
なので、Editor
の blockRendererFn
を使う必要があります。Media
をレンダーする関数、mediaBlockRenderer
を作ります。画像だけでなく、動画やその他カスタムなブロックにも対応できます。
1/** Media.tsx */23import React from 'react';4import { ContentBlock, ContentState } from 'draft-js';56interface BlockComponentProps {7 contentState: ContentState;8 block: ContentBlock;9}1011export 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};1617const Media = (props: BlockComponentProps) => {18 const entity = props.contentState.getEntity(props.block.getEntityAt(0));19 const type = entity.getType();2021 let media = null;22 if (type === 'image') {23 media = <Image {...props} />;24 }2526 return media;27};2829export const mediaBlockRenderer = (block: ContentBlock) => {30 if (block.getType() === 'atomic') {31 return {32 component: Media,33 editable: false,34 };35 }36 return null;37};
1/** TextEditor.tsx */23...4 <Editor5 editorState={editorState}6 onChange={setEditorState}7 handleKeyCommand={handleKeyCommand}8 blockRendererFn={mediaBlockRenderer}9 />10...
文字数の制限を設ける#
文字数は editorState.getCurrentContent().getPlainText().length
で取得できますが、
制限文字数を指定してそれ以上は記入できなくする要件があるかもしれません。
この場合はhandleBeforeInput
や handlePastedText
を使うことで実現できます。
こちらにサンプルコードがあるので、
ここでは割愛させて頂きます。
データを保存 & 読み込みする#
Draft.js
ではJSONとしてデータを変換する機能があり、
draft-js-export-html
などのライブラリーを使えば、
HTML形式に変換も可能です。
今回はエディターのデータをJSONに変換し、 localStorage
に保存しています。
1/** TextEditor.tsx */23import React from "react";4import {5 ...6 convertToRaw,7 convertFromRaw8} from "draft-js";910...1112const TEXT_EDITOR_ITEM = "draft-js-example-item";1314const TextEditor = () => {15 const data = localStorage.getItem(TEXT_EDITOR_ITEM);1617 const initialState = data18 ? EditorState.createWithContent(convertFromRaw(JSON.parse(data)), linkDecorator)19 : EditorState.createEmpty(linkDecorator);2021 const [editorState, setEditorState] = React.useState<EditorState>(initialState);2223 const handleSave = () => {24 const data = JSON.stringify(convertToRaw(editorState.getCurrentContent()));25 localStorage.setItem(TEXT_EDITOR_ITEM, data);26 };2728 ...2930 return (31 <div>32 ...33 <Editor34 editorState={editorState}35 onChange={setEditorState}36 handleKeyCommand={handleKeyCommand}37 blockRendererFn={mediaBlockRenderer}38 />39 <button40 onClick={(e) => {41 e.preventDefault();42 handleSave();43 }}>44 save45 </button>46 </div>47 );48};
保存ボタンを押したら、リロードしても内容が保存されていました。 スタイリングは省きましたが、これで大体の実装は完了です。お疲れ様でした。