PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

React 対応 WYSIWYG エディタ Draft.js を試してみた

こんにちは。SaaSプロダクト開発部の千葉です。

今回は、最近試した「React 対応の WYSIWYG エディタで HTML 文字列を(class などの属性値を維持しながら)編集する」方法をまとめていきたいと思います。

WYSIWYG エディタとは

今回の要件は、HTML 文字列を「編集前と編集後で class などの属性値を維持しながら編集する」ことです。

この条件を満たすために、WYSIWYG エディタが使えればユーザにとって直感的かなと思い、ライブラリを使って試作してみることにしました。

WYSIWYG エディタとは「編集中の見た目 = 完成後の見た目」になるエディタです。

What You See Is What You Get(見たままが得られる)の頭文字をとって「ウィジウィグ」と読みます。

ja.wikipedia.org

React 対応 WYSIWYG エディタの選定

WYSIWYG エディタのライブラリは Draft.jsQuill.jsTiptap など色々ありましたが、今回は React に対応していてかつ、人気の高い Draft.js を使ってみることにしました。

ライブラリの数値周りは以下のような感じです。

npm trends (2023/05/27現在)

特徴
Draft.js
  • 2番人気
  • facebook でも採用されている
  • 初期値に HTML 文字列を設定できる
  • 編集バーをカスタマイズできる
  • Quill.js
  • 1番人気
  • スター数が最も多い
  • Slack や Salesforce でも採用されている
  • react-quill を使う(公式ライブラリではないかつ更新頻度が低い)
  • 初期値に HTML 文字列を設定できる
  • 編集バーをカスタマイズできる
  • Tiptap
  • 1番若い
  • GitLab や Wix でも採用されている
  • スターの伸び率が高い
  • 初期値に HTML 文字列を設定できる
  • 編集バーをカスタマイズできる
  • Draft.js で HTML 文字列を出力する基本的な実装

    基本的な実装は簡単で、以下のように実装します。

    import {
      ContentState,
      convertFromHTML,
      convertToRaw,
      Editor,
      EditorState,
    } from 'draft-js';
    import draftToHtml from 'draftjs-to-html';
    import React, { useState } from 'react';
    
    // 初期値に例えば '<div class="test">text</div>' などHTML文字列を指定する想定。
    type Props = {
      content?: string;
    };
    
    const EditorContent: React.FC<Props> = ({ content = '' }) => {
      const [editorState, setEditorState] = useState(() => {
        const blocksFromHTML = convertFromHTML(content);
        // 初期化する。
        const contentState = ContentState.createFromBlockArray(
          blocksFromHTML.contentBlocks,
          blocksFromHTML.entityMap,
        );
        return EditorState.createWithContent(contentState);
      });
    
      const handleEditorChange = (newEditorState: EditorState) => {
        setEditorState(newEditorState);
      };
    
      const handleGetText = () => {
        const contentState = editorState.getCurrentContent();
        // HTML文字列に変換する。
        const htmlText = draftToHtml(convertToRaw(contentState));
        // コンソールに表示して確認する。
        console.log(htmlText);
      };
    
      return (
        <div>
          <Editor editorState={editorState} onChange={handleEditorChange} />
          <button onClick={handleGetText}>Get!!</button>
        </div>
      );
    };
    
    export default React.memo(EditorContent);
    

    解説

    入力
    HTML 文字列を Draft.js の内部の RawDraftContentState の RawDraftContentBlock に変換して、エディタ上で WYSIWYG になるようにします。

    出力
    Draft.js から派生したライブラリ draftjs-to-html を使って RawDraftContentBlock を HTML 文字列に変換し、編集した文字列を出力します。

    RawDraftContentBlock はブロックレベルコンテンツと同じような概念です。Draft.js で1つの要素を構成するのに必要なメタデータを指します。

    createFromBlockArray() は初期値を指定する関数です。HTML をエディタに表示させないために使います。
    初期値を指定する関数は他にもあり、createFromText()を使うと HTML もエディタ上に表示されます。また、初期値が空の場合は createEmpty() を使います。

    draftToHtml は RawDraftContentBlock を HTML 文字列に変換するために使います。変換できるタグは現状10個です。HTML タグを除いた文字列に変換する場合は contentState.getPlainText() を使います。

    基本的な実装での課題

    上記の基本的な実装のみだと、満たしたい要件のうち「編集前と編集後で class などの属性値を維持しながら編集する」ことを満たせませんでした。

    draftjs-to-html は変換できるタグが少なく、タグそのものや属性が消えたり、div タグが p タグに変換されたりしました。
    また、改行すると p タグで囲われてしまいます。

    今回は「編集前と編集後で class などの属性値を維持しながら編集」したいので、Draft.js のこの仕様は課題になりました。

    Draft.js を深くいじって解決

    Draft.js への入力と出力の方法を変えると、ある程度は解決しました。

    import {
      Editor,
      EditorState,
      convertToRaw,
      convertFromRaw,
      DraftBlockType,
      RawDraftEntityRange,
      RawDraftContentBlock,
    } from 'draft-js';
    
    import React, { FC, useState } from 'react';
    
    type Props = {
      content?: string;
    };
    
    type NoEndTagElType = {
      tagName: string; // 親要素のタグ
      childrenTotal: number; // 子要素の総数
      childrenCount: number; // インサート済みの子要素
    };
    
    const EditorContent: FC<Props> = ({ content = '' }) => {
      const blocks: {
        data: {
          children: HTMLCollection; // Exportするときに、親要素の終了タグを追加する判定に使う
          class: string; // Exportするときに、class属性をつける
        };
        depth: number;
        entityRanges: Array<RawDraftEntityRange>;
        text: string; // エディタに表示する文言
        type: DraftBlockType; // どのタグでtextを囲むか
      }[] = [];
      const exportHtmlEls: string[] = [];
      const noEndTagEls: NoEndTagElType[] = []; // 終了タグを追加していない親要素のリスト
    
      // タグをRawDraftContentBlockのtypeに変換する。
      const getDraftjsTagType = (tag: string) => {
        if (tag === 'h3') return 'header-three';
        if (tag === 'li') return 'unordered-list-item';
        return tag;
      };
    
      // RawDraftContentBlockのtypeをタグに変換する。
      const getHtmlTagName = (tag: string) => {
        if (tag === 'header-three') return 'h3';
        if (tag === 'unordered-list-item') return 'li';
        return tag;
      };
    
      // RawDraftContentBlockを作成する。
      const createBlocks = (htmlElements: HTMLCollection, isRepeat: boolean) => {
        Array.from(htmlElements).forEach((htmlElement) => {
          if (htmlElement) {
            const isCreateChildrenBlock =
              Array.from(htmlElement.children).length > 0;
    
            const block = {
              data: {
                children: htmlElement.children,
                class: htmlElement.className,
              },
              depth: isRepeat ? 1 : 0,
              text: '',
              type: getDraftjsTagType(htmlElement.tagName.toLowerCase()),
              entityRanges: [],
            };
            if (isCreateChildrenBlock) {
              // 親要素のRawDraftContentBlockの追加
              // 文字列は子要素で表示するので空文字にする
              block.text = '';
              blocks.push(block);
    
              // 子要素のRawDraftContentBlockの追加
              createBlocks(htmlElement.children, true);
            } else {
              // RawDraftContentBlockの追加
              block.text = htmlElement.innerHTML;
              blocks.push(block);
            }
          }
        });
      };
    
      // HTML文字列をHTMLに変換する。
      const convertToElement = (html: string) => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');
        return doc.body.children;
      };
    
      // RawDraftContentBlockからHTML文字列に変換する。
      const convertToElementString = (block: RawDraftContentBlock) => {
        const childrenElLength = Array.from(block?.data?.['children']).length;
        const parentEl = noEndTagEls?.[noEndTagEls.length - 1];
    
        // class属性がある場合はclass属性を追加する。
        const _class = block?.data?.['class']
          ? ` class="${block?.data?.['class']}"`
          : '';
        const tag = getHtmlTagName(block.type);
    
        const baseElement = `<${tag}${_class}>${block.text}`;
    
        const pushStartTag = childrenElLength > 0;
        const pushLastChild =
          parentEl && parentEl?.childrenTotal - 1 === parentEl?.childrenCount;
        const pushChild =
          parentEl && parentEl?.childrenTotal - 1 > parentEl?.childrenCount;
    
        if (pushStartTag) {
          // 親要素の場合 開始タグの追加
          noEndTagEls.push({
            tagName: tag,
            childrenTotal: childrenElLength,
            childrenCount: 0,
          });
    
          exportHtmlEls.push(baseElement);
        } else if (pushLastChild) {
          // 最後の子要素の場合
          parentEl.childrenCount = parentEl?.childrenCount + 1;
    
          // 子要素の追加
          exportHtmlEls.push(`${baseElement}</${tag}>`);
          // 親要素の終了タグの追加
          pushParentEndTag(parentEl);
        } else if (pushChild) {
          parentEl.childrenCount = parentEl.childrenCount + 1;
    
          // 子要素の追加
          exportHtmlEls.push(`${baseElement}</${tag}>`);
        } else {
          // 要素の追加
          exportHtmlEls.push(`${baseElement}</${tag}>`);
        }
      };
    
      // convertToElementString内で呼んで、親要素の終了タグを追加する。
      const pushParentEndTag = (parentEl: NoEndTagElType) => {
        if (parentEl && parentEl?.childrenTotal - 1 > parentEl?.childrenCount)
          return;
    
        if (parentEl?.tagName) {
          exportHtmlEls.push(`</${parentEl.tagName}>`);
        }
    
        // 終了タグを追加したので親要素の終了タグを追加していないリストから削除する。
        noEndTagEls.pop();
    
        const nextParentEl = noEndTagEls[noEndTagEls.length - 1];
        if (nextParentEl) {
          // 1つ上に親要素がある場合はもう一度終了タグを追加する可能性がある。
          nextParentEl.childrenCount = nextParentEl?.childrenCount + 1;
          pushParentEndTag(nextParentEl);
        }
      };
    
      // 初期化する。
      const htmlElement = convertToElement(content);
      createBlocks(htmlElement, false);
      const initialContent = {
        blocks,
        entityMap: {},
      };
      const blocksFromHTML = convertFromRaw(initialContent);
      const [editorState, setEditorState] = useState(() =>
        EditorState.createWithContent(blocksFromHTML),
      );
    
      // 編集後のHTML文字列を出力する。
      const handleGetText = () => {
        const contentState = editorState.getCurrentContent();
        const rawContentState = convertToRaw(contentState);
    
        // RawDraftContentBlockをHTML文字列に変換する。
        rawContentState.blocks.forEach((block) => convertToElementString(block));
    
        // エディタに表示しているHTML文字列を出力する。
        console.log(exportHtmlEls.join('\n'));
      };
    
      return (
        <div>
          <Editor editorState={editorState} onChange={setEditorState} />
          <button onClick={handleGetText}>Get!!</button>
        </div>
      );
    };
    
    export default React.memo(EditorContent);
    

    解説

    入力
    HTML 文字列を RawDraftContentBlock にマッピングすることでメタデータを保持し、エディタで WYSIWYG になるようにします。
    RawDraftContentBlock の data にメタデータを渡せるので、class と children を渡すようにしました。

    出力
    RawDraftContentBlock をメタデータを元に自前で HTML 文字列に変換することで draftjs-to-html では消えてしまったタグや class 属性を残すようにしました。

    突貫で書いたコードですが、色々自前で実装する必要がありました。
    Draft.js は、この他にも DraftBlockType を上書きしてエディタ上の表示を変えられるなど、カスタマイズ性は高そうです。

    まとめ

    色々試してみましたが、最終的には WYSIWYG エディタは断念し react-ace を採用しました。

    HTML をエディタで直接編集、プレビューボタンをつけて表示の最終確認を行えるエディタです。

    Draft.js を使ったテストでは class のみ考慮していますが、全ての属性を考慮する必要があったり、考慮する点が多く、今回はそこまでの時間をかけずに実装したかったので Draft.js の採用は諦めました。

    そもそも、WYSIWYG エディタの性質上、class の指定やタグの種類などは本来 WYSIWYG エディタにとっては関心がない部分で、そこを気にする場合は WYSIWYG エディタを使ってはいけない気もしました。

    とはいえ、なにかやりようはあった気がするので、また折を見て触ってみたいと思います!