Waka blog.

更新が止まっているreact-quillを使用せずQuillを直接Reactアプリで使用する

はじめに

WYSIWYGエディタを実装しているライブラリは色々ありますが、その中でもQuillは老舗で現在も開発が続いている有力なライブラリです。

そのQuillをReactに統合したものがreact-quillになります。

使い方は非常に簡単でスター数も多い有力なライブラリだったのですが、残念ながら2年前から更新が途絶えており、現時点で更新が再開される様子もありません。

その間Quill本体はメジャーバージョンが2系にアップデートされました。その結果、バージョン1系の脆弱性を解消できなかったり、Quill用のプラグイン(quill-image-compress等)との組み合わせでエラーが発生したりと様々な弊害が出ています。

そこで、react-quillの使用を諦め、QuillそのものをReactで使用する方法を試そうと思います。

Quill公式のサンプル

Quill公式サイトにはPlaygroundが公開されており、その中にReactでの使用例も含まれています。

こちらを参考に進めていきます。

import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react';

// Editor is an uncontrolled React component
const Editor = forwardRef(
  ({ readOnly, defaultValue, onTextChange, onSelectionChange }, ref) => {
    const containerRef = useRef(null);
    const defaultValueRef = useRef(defaultValue);
    const onTextChangeRef = useRef(onTextChange);
    const onSelectionChangeRef = useRef(onSelectionChange);

    useLayoutEffect(() => {
      onTextChangeRef.current = onTextChange;
      onSelectionChangeRef.current = onSelectionChange;
    });

    useEffect(() => {
      ref.current?.enable(!readOnly);
    }, [ref, readOnly]);

    useEffect(() => {
      const container = containerRef.current;
      const editorContainer = container.appendChild(
        container.ownerDocument.createElement('div'),
      );
      const quill = new Quill(editorContainer, {
        theme: 'snow',
      });

      ref.current = quill;

      if (defaultValueRef.current) {
        quill.setContents(defaultValueRef.current);
      }

      quill.on(Quill.events.TEXT_CHANGE, (...args) => {
        onTextChangeRef.current?.(...args);
      });

      quill.on(Quill.events.SELECTION_CHANGE, (...args) => {
        onSelectionChangeRef.current?.(...args);
      });

      return () => {
        ref.current = null;
        container.innerHTML = '';
      };
    }, [ref]);

    return <div ref={containerRef}></div>;
  },
);

Editor.displayName = 'Editor';

export default Editor;

forwardRefを使用して親コンポーネントにエディタの参照を持たせて、ref.currentで入力値を取得する想定みたいです。

ちょっとそのままだと使いづらいので、後から記載するサンプルコードではonChangeに渡したコールバック関数で入力したテキストを取得できる形に実装します。

propsの内容をuseRefでラップして使用する方法は、useEffectの依存関係を減らしながら最新の状態を保てる良い方法なので真似しようと思います。

実装

上記Quilllのサンプルを参考に以下のように実装しました。


type Props = {
  defaultValue?: string;
  onChange: (content: string) => void;
}

const QuillWysiwygEditor: FC<Props> = ({ defaultValue, onChange }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const defaultValueRef = useRef<string | undefined>(defaultValue);
  const onChangeRef = useRef<(content: string) => void>(onChange);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const editorContainer = container.appendChild(document.createElement("div"));

    const quill = new Quill(editorContainer, {
      theme: "snow",
    });

    if (defaultValueRef.current) {
      const delta = quill.clipboard.convert({ html: defaultValueRef.current });
      quill.setContents(delta);
    }

    const handleTextChange = () => {
      onChangeRef.current?.(quill.root.innerHTML);
    };

    quill.on(Quill.events.TEXT_CHANGE, handleTextChange);

    return () => {
      container.innerHTML = "";
      quill.off(Quill.events.TEXT_CHANGE, handleTextChange);
    };
  }, []);

  useLayoutEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  return <div ref={containerRef} />;
};

export default QuillWysiwygEditor;

Quillのサンプルにあったrange取得関連はノイズになるので削りました。

初回マウント時のみのuseEffectでQuillのインスタンスを生成し、デフォ値とonChangeイベントのハンドラの設定を行っています。(アンマウント時の解除も忘れず)

デフォ値は実用性を考慮してstringで受け取って、コンポーネント内でquill.Delta型に変換しています。

onChangeRefへのonChangeの設定はuseLayoutEffectで行います。これによってuseEffectの依存配列を減らすことができます。

エディタのカスタマイズをしたい場合はQuillインスタンス生成時のオプションに渡すことで実現します。

以上です。

react + quillでgoogle検索を行うと、大抵react-quillを使用した記事に行き着いてしまうので最初は難儀しましたが、最終的にはとてもシンプルな形で実装できてよかったです。