🐡

Draft.jsでのWYSIWYGエディタ作成

2022/12/16に公開

はじめに

Draft.jsはFacebook謹製のリッチテキストエディタを作るためのフレームワークです。
詳しい説明はこの記事が丁寧に書いて下さっているので割愛します。

https://qiita.com/YudaiTsukamoto/items/264f333e90a1edb818a3#はじめに

今回の記事は太字や下線などの文字装飾、文字サイズの変更、画像、リンクの挿入、文字揃えの機能を導入する方法のまとめになります。

デモ

全体

装飾などがない雛形としては以下のコードになります。
EditorState.createEmpty()で空白のeditorStateを作成し、それをuseStateで管理しています。

import * as React from 'react';
import './style.css';
import {
  Editor,
  EditorState,
} from 'draft-js';
import 'draft-js/dist/Draft.css';


export default function App() {
  const [editorState, setEditorState] = React.useState(() =>
    EditorState.createEmpty()
  );

  return (
    <div>
      <Editor
        placeholder="入力してください"
        editorState={editorState}
        onChange={setEditorState}
      />
    </div>
  );
}

文字装飾

Draft.jsに既に定まっている太字、コード、斜め字、打ち消し線、下線に関してはRichUtils.toggleInlineStyle を使うことでスタイルを変更することができます。
それぞれBOLD、CODE、ITALIC、STRIKETHROUGH、UNDERLINEを関数のinlineStyleとして渡すことでスタイルが適用された新しいEditorStateを生成されます。

toggleInlineStyle(
  editorState: EditorState,
  inlineStyle: string,
): EditorState

https://github.com/facebook/draft-js/blob/main/src/model/immutable/DefaultDraftInlineStyle.js

/** 文字装飾 */
const handleDecoration = (style: string) => {
  setEditorState(RichUtils.toggleInlineStyle(editorState, style));
};

デフォルトの機能だけではなくcustomStyleMapを使用することで、インライン要素の文字のスタイルを自由に変更することができます。


customStyleMapを使用したカスタマイズ

EditorのcustomStyleMapに対して、キーとスタイルが紐づいたオブジェクトを渡します。
このキーは上のtoggleInlineStyleの関数のinlineStyleに入れることでエディター上に反映されます。(BOLDなどと同じ)

/** スタイル変更用 */
const customStyleMap = {
  '16px': { fontSize: 16 },
  '20px': { fontSize: 20 },
  '24px': { fontSize: 24 },
  'red': { color: 'red' },
};

<button onMouseDown={() => handleDecoration('24px')}>24px</button>
<button onMouseDown={() => handleDecoration('red')}>red</button>

<Editor
  customStyleMap={customStyleMap}
/>
  

注意としてはtoggleInlineStyleを使った場合スタイルが追加されていくので、同じプロパティの場合複数の値が付与されていて、どれか1つだけがスタイルとして使われることになります。

その対策としてはその値を切り替える方式にすることです。
draft.jsのexamplesに色で同様のことをしているものがあります。

https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/color/color.html
https://codepen.io/Kiwka/pen/oBpVve?editors=0010

const toggleStyle = (style: string, e: React.MouseEvent<any>) => {
    e.preventDefault();
    const selection = editorState.getSelection();
    const nextContentState = Object.keys(customStyleMap).reduce(
      (contentState, fontSize) => {
        return Modifier.removeInlineStyle(contentState, selection, fontSize);
      },
      editorState.getCurrentContent()
    );

    let nextEditorState = EditorState.push(
      editorState,
      nextContentState,
      'change-inline-style'
    );
    const currentStyle = editorState.getCurrentInlineStyle();

    if (selection.isCollapsed()) {
      nextEditorState = currentStyle.reduce((state, style) => {
        return RichUtils.toggleInlineStyle(state, style);
      }, nextEditorState);
    }

    if (!currentStyle.has(style)) {
      nextEditorState = RichUtils.toggleInlineStyle(nextEditorState, style);
    }
    setEditorState(nextEditorState);
  };

もし現在のインラインで使われているスタイルが知りたい場合は下記のような関数を使うことで判別できます。

const hasStyle = (style: string) => {
    const currentStyle = editorState.getCurrentInlineStyle();
    return currentStyle.has(style);
  };

Block要素のスタイリング

RichUtils.toggleBlockType を使うことでブロック要素の種類やスタイルを変更することができます。

例えば、
setEditorState(RichUtils.toggleBlockType(editorState, 'header-one'));
とすると、ContentBlockのtypeがheader-oneとなり、そのブロックの要素はh1要素になります。

toggleBlockType(
  editorState: EditorState,
  blockType: string
): EditorState
/** ブロック装飾 */
const handleBlock = (block: string) => {
  setEditorState(RichUtils.toggleBlockType(editorState, block));
};

デフォルトのBlock type

https://draftjs.org/docs/advanced-topics-custom-block-render-map
https://github.com/facebook/draft-js/blob/main/src/model/immutable/DefaultDraftBlockRenderMap.js

こちらもデフォルトの機能だけではなくblockStyleFnを使用することで、クラス名を自由に変更することができます。


blockStyleFnを使った文字揃え

/** スタイル付与関数 */
const getBlockStyle = (block: ContentBlock) => {
  switch (block.getType()) {
    case 'left':
      return 'alignLeft';
    case 'center':
      return 'alignCenter';
    case 'right':
      return 'alignRight';
    default:
      return '';
  }
};

<button onMouseDown={() => handleBlock('left')}>left</button>
<button onMouseDown={() => handleBlock('center')}>center</button>
<button onMouseDown={() => handleBlock('right')}>right</button>

<Editor
   blockStyleFn={getBlockStyle}
/>

getBlockStyleはクラス名を付与しているため、CSSでスタイルを変更する必要があります。
クラス名を付与するところではtext-alignが効かなかったので1つ下のdivにスタイルを付与しています。

.alignRight div {
  text-align: right;
}

.alignCenter div {
  text-align: center;
}

.alignLeft div {
  text-align: left;
}


画像

画像の挿入箇所と画像の表示部分に分けて説明します。

挿入部分

画像の挿入に関してはボタン押下後にフォルダから画像を選択するパターンとドラッグ&ドロップで挿入するパターンがあります。

画像選択する箇所は別ですが、その後はどちらも画像をAPIに送信しURLを受け取り、そのURLの画像を挿入するという流れになります。
ボタンを押したらURLを入力するテキストボックスを表示するような実装でもいいかと思います。

以下がテキストボックスの例になります。

https://codesandbox.io/s/op8bolzq?file=/index.js
finallyでe.target.valueに空文字を代入していますが、これは同じ画像を挿入できるようにする対応になります。

画像を選択する方とドラッグ&ドロップでは型が違うため、注意が必要です。
今回はAPIの箇所は書いていませんが、APIに渡す値によってinsertImageの関数の引数などを変更してください。

ドラッグ&ドロップの場合はDrop時のcallbackである、handleDroppedFilesを使用する必要があります。

https://draftjs.org/docs/api-reference-editor/#handledroppedfiles

 /** ブロック装飾 */
  const handleBlock = (block: string) => {
    setEditorState(RichUtils.toggleBlockType(editorState, block));
  };

  /** 画像のアップロード、挿入 */
  const uploadImage: React.ChangeEventHandler<HTMLInputElement> =
    React.useCallback(
      async (e) => {
        try {
          // 画像のアップロード
          setIsUploading(true);

          if (!(e.target instanceof HTMLInputElement)) return;

          const file = e.target.files?.[0];
          if (!file) return;

          // 画像情報をinsertImageの関数に渡し、画像アップロードを行う
          await insertImage();
        } finally {
          setIsUploading(false);
          e.target.value = '';
        }
      },
      [editorState]
    );

  /** ドラッグ&ドロップでの画像挿入 */
  const handleDroppedFiles = (
    selection: SelectionState,
    blobs: Blob[]
  ): DraftHandleValue => {
    // blobsはuploadImageと同じ形式にしてinsertImageに渡す

    insertImage();
    return 'handled';
  };

  /** 画像挿入 */
  const insertImage = React.useCallback(() => {
    /**
     * APIに画像を送信する
     * APIには画像を渡してURLが帰ってくる想定
     * 今回はplacehold.jpの画像を使用する
     */

    // 画像の挿入
    const contentState = editorState.getCurrentContent();
    const contentStateWithEntity = contentState.createEntity(
      'image',
      'MUTABLE',
      { url: 'https://placehold.jp/600x600.png' } // ここを変えるようにする
    );
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    let nextEditorState = EditorState.set(editorState, {
      currentContent: contentStateWithEntity,
    });
    nextEditorState = AtomicBlockUtils.insertAtomicBlock(
      nextEditorState,
      entityKey,
      ' '
    );
    setEditorState(nextEditorState);
  }, [editorState]);
  
  // ドラッグ&ドロップ
  <Editor
    handleDroppedFiles={handleDroppedFiles}
  />

表示部分

blockRendererFnを使い画像用コンポーネントを表示するようにします。
editableをfalseにすることで、画像があるblockにフォーカスされないようになります。画像周りに文字を入力すると画像が消えたり文字が入力できない不具合があったりしたのでeditableはfalseの方がいいと思います。

このmediaは画像しか表示していませんが、挿入時とif文でのtypeを比べている箇所を変更することで他のメディア(音声や動画)も表示できるようになります。

type BlockComponentProps = {
  contentState: ContentState;
  block: ContentBlock;
};

const Media = (props: BlockComponentProps) => {
    const entityKey = props.block.getEntityAt(0);
    if (!entityKey) return null;
    const entity = props.contentState.getEntity(entityKey);
    const { url } = entity.getData();
    const type = entity.getType();

    let media;
    if (type === 'image') {
      media = <img src={url} alt="" className="image" />;
    }

    return media;
  };

  const blockRenderer = (block: ContentBlock) => {
    if (block.getType() === 'atomic') {
      return {
        component: Media,
	editable: false,
      };
    }

    return null;
  };
<Editor
  blockRendererFn={blockRenderer}
/>

公式のソースコードが参考になります

https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/media/media.html


リンク

リンク付与の流れとしては以下になります

  • リンクにしたい文字を選択
  • ボタンを押したらリンク用のテキストボックスを表示する
  • リンクを別タブで開くかどうかを選択する
  • テキストボックスに入力されたリンクを付与する

リンクの付与の際にエンティティとデコレーターという機能を使用します。

https://draftjs.org/docs/advanced-topics-entities
https://draftjs.org/docs/advanced-topics-decorators/
https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/link/link.html

デコレーターはコンテンツの中から条件に当てはまる箇所を探し、指定されたコンポーネントでレンダリングするようになっています。
下のコードでは、findLinkEntitiesでtypeがlinkになっているものを探し、該当の箇所にLinkのコンポーネントで表示をしています。

The decorator concept is based on scanning the contents of a given ContentBlock for ranges of text that match a defined strategy, then rendering them with a specified React component.

  /** リンクの検索 */
  const findLinkEntities = (
    contentBlock: ContentBlock,
    callback: any,
    contentState: ContentState
  ) => {
    contentBlock.findEntityRanges((character) => {
      const entityKey = character.getEntity();
      return (
        entityKey !== null &&
        contentState.getEntity(entityKey).getType() === 'LINK'
      );
    }, callback);
  };

  /** エディタ上でのリンク表示用 */
  const Link = (props: DraftDecoratorComponentProps) => {
    const { url, isOpenNewTab } = props.contentState
      .getEntity(props.entityKey || '')
      .getData();
    return (
      <a
        href={url}
        className="link"
        target={isOpenNewTab ? '_blank' : '_self'}
        rel="noopener noreferrer"
      >
        {props.children}
      </a>
    );
  };

  /** リンクの付与 */
  const handleLink = () => {
    const contentState = editorState.getCurrentContent();
    const contentStateWithEntity = contentState.createEntity(
      'LINK',
      'MUTABLE',
      { url: urlValue, isOpenNewTab: isOpeningInNewTab }
    );
    const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
    let nextEditorState = EditorState.set(editorState, {
      currentContent: contentStateWithEntity,
    });

    nextEditorState = RichUtils.toggleLink(
      nextEditorState,
      nextEditorState.getSelection(),
      entityKey
    );
    onCloseLinkModal();
    setEditorState(nextEditorState);
  };

  /** リセット用関数 */
  const onCloseLinkModal = () => {
    setIsShowURLInput(false);
    setIsOpeningInNewTab(false);
    setUrlValue('');
  };

  /** デコレータ */
  const decorator = new CompositeDecorator([
    {
      strategy: findLinkEntities,
      component: Link,
    },
  ]);
  
  // 作成したdecoratorはcreateEmptyの引数にする
  const [editorState, setEditorState] = React.useState(() =>
    EditorState.createEmpty(decorator)
  );
<button className="button" onClick={() => setIsShowURLInput((e) => !e)}>
  url
</button>

{isShowURLInput && (
  <div className="urlContainer">
    <input
      className="urlInput"
        value={urlValue}
	onChange={(e) => setUrlValue(e.target.value)}
	type="text"
    />
    <label className="target">
      <input
        type="checkbox"
        id="target"
	name="target"
        checked={isOpeningInNewTab}
	onChange={() => setIsOpeningInNewTab((e) => !e)}
      />
      <div>別URLで開く</div>
    </label>
    <button onClick={handleLink}>Confirm</button>
  </div>
)}


readonly

EditorにreadOnlyのフラグを渡すだけで読み込み専用にすることができます

<Editor
  readOnly
/>

参考記事

整理できてないですが参考記事です。
drafs.jsの公式ソースコードがかなり参考になります。

https://draftjs.org/docs/getting-started
https://zenn.dev/trictrac/articles/4377beaf0953856b7a0e#動かす
https://qiita.com/mottox2/items/9534f8efb4b09093a304
https://github.com/facebook/draft-js/blob/main/src/model/immutable/DefaultDraftInlineStyle.js
https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/media/media.html
https://www.wantedly.com/companies/wantedly/post_articles/306245"
https://gist.github.com/bultas/981cde34b3d1a2cb9b558ca9467bca77
https://medium.com/@ibraheems.ali95/text-alignment-in-draftjs-and-using-with-statetohtml-caecd0138251
https://codesandbox.io/s/draft-js-text-editor-0xg24?file=/src/DraftJs/rich.css:447-458"
https://qiita.com/YudaiTsukamoto/items/264f333e90a1edb818a3
https://draftjs.org/docs/api-reference-rich-utils/#togglelink
https://stackoverflow.com/questions/62321505/how-to-add-link-in-draft-js-no-plugins
https://codesandbox.io/s/nz8fj?file=/src/index.js
https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/link/link.html
https://github.com/facebook/draft-js/blob/main/src/model/decorators/DraftDecorator.js
https://so99ynoodles.com/blog/make-wysiwyg-editor-with-draft-js

Discussion