📑

【React】リッチテキストエディタ(Quill、Tiptap、Slate...)の考え方や前提知識

2024/08/13に公開7

概要

4年ほどProductionで使っていたリッチテキストエディタ(Quill on Nuxt.js v2)をTiptap on Next.jsに移行しました。
既存のQuillエディタの使い勝手をTiptapで再現しつつ、改善できるところは改善しつつ、既存の4年分のリッチテキストデータが正しく編集できるようにしなければいけませんでした。

本記事では移行の具体的なプロセスを解説しようと思っていたのですが、リッチテキストエディタは前提知識があまりに多いため、前提となる知識や考え方を解説しているだけでそこそこのボリュームになりました。そこで、一旦考え方や前提知識をまとめた、という体で公開します。

本記事を読んでから各ライブラリのDocsを読んだりカスタマイズを始めたら、少しハードルが下がっていることかと思います。

対象読者の例

  • リッチテキストエディタに興味がある
  • リッチテキストエディタの開発がどれくらい難しそうかなんとなく把握しておきたい
  • 同じ会社や近いチームにリッチテキストエディタの開発者がいて、そのうち関わることになるかもだから事前にキャッチアップしておきたい

筆者について

これまで実装経験のあるリッチテキストエディタ

リッチテキストエディタを魔改造して個人開発しているテストメーカーというサービスがあるので、よかったら遊んでみてね!

https://www.test-maker.app/

リッチテキストエディタ技術基礎

基礎知識

Contenteditable

ほぼすべてのWebベースのエディタはContenteditableを使って実装されています。

https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable

Contenteditable属性が付与されたDOMおよびそのChildrenは、ブラウザから編集可能になります。
また、Childrenの要素にてContenteditableをfalseにすると、そこだけ編集不可にすることも可能で、込み入ったエディタを実装する際は、特定のブロックだけ編集不可にすることで、特定のコマンドでDOM挿入できるが、簡単に中身を書き換えたりできないように実装したりします。

IME

相当カスタマイズの沼にハマらないと直面しませんが、HTML上で<input>でもないものを編集可能として扱うのはまあまあ罪深いことで、IMEで変換を確定した瞬間に変な挙動になる、といったバグを踏むことがあります。

onCompositionEndというイベントがあることを脳裏に置いておくと、いつか役に立ちます。

https://developer.mozilla.org/ja/docs/Web/API/Element/compositionend_event

代表例としてIMEを挙げておきましたが、たとえばCtrl+Zといった一般的なショートカットが一部効かなかったり、ブラウザ差異(FirefoxやSafariが特に)を踏んだり、入力部品を魔改造することはいろいろなリスクがついてきます。

まあアレです、海外製のちょっとアーリーなSaaSいじっていると、日本語の確定が変なことってよくあるじゃないですか。あれは大抵Contenteditable属性をTrueにしたDOMに対して、onCompositionEndをちゃんと実装せずに、onChangeとかonBlurとかだけ取り扱っているから変になっていたりします。

Contenteditable属性を付与したら編集可能になるよとは言ったものの、実際はリッチテキストエディタのライブラリを使うことで、内部的にContenteditable属性の付与から、IME等の挙動差異の吸収まで任せてしまう方が現実的です。ただ、ある程度魔改造をしていると自分でやってしまえという欲望に駆られることがあるので書いておきました。

HTMLを扱うことによる制限

場合によりますが、HTMLをそのままバックエンドで永続化する場合、特定エディタへのロックイン度合いは下がるものの、以下のような制限・制約が掛かります。

  • コンテンツをネイティブアプリで表示することが難しくなる
    • スマホアプリも展開しているのであれば、HTMLで生データを扱ってしまうと、ネイティブアプリでの描画に気を使ったり、Webviewで頑張るといった変化球が要求されます(とはいえ、ネイティブアプリで描画するにしても、JSONをParseしてネイティブアプリで扱える要素に変換する実装が必要ですが...)
  • セキュリティに気を使う
    • エディタライブラリ自体はセキュリティ対策していても、REST APIは直接叩けるから、XSS脆弱性のあるHTMLを投げられてしまう可能性があります。バックエンドでサニタイズを実行することで、HTMLを洗浄することが望ましいです
      • http://htmlpurifier.org/
      • ちなみにTiptapは芸が細かくて、PHP用のサニタイザー(を兼ねたモノ)を公開してくれています。個人的にはバックエンドまでTiptapにロックインされるのは好みではないですが場合によっては有力でしょう
  • 解析が辛い
    • HTMLじゃなくてJSONでもまあまあ辛いですが、RDBの1カラムにまるっとHTMLを突っ込む設計にした場合は、たとえば○○タグを使っている割合といった解析が辛くなります(頑張ればできるけども)
    • 文字数とかもSQL単体で取得するのが難しいですね。<b>hoge</b>で4文字になるわけなので。
  • SPAでの表示がdangerouslySetInnerHTMLほぼ一択になる
    • できれば避けたいですね。ロックイン度合いやBundle Sizeとのトレードオフですがライブラリ指定のJSON形式で読み書きし、ライブラリ側にJSONからHTMLへの変換を任せてしまうほうが、安全度は高いです

リッチテキストエディタを構成する技術要素

入力操作をDOMに変換するロジック

リッチテキストエディタ開発に向き合う上で思っておいて欲しいことは、「割とあらゆる動作を自分で規定していかないといけない」ということです。QuillやDraft.jsといったちょっと前の時代のリッチテキストエディタは規定されている動作の割合が大きいのですが、SlateやTiptapといった比較的最近のエディタは、そのあたりからカスタマイズ可能になっている代わりに、気がつくと自作地獄になったりするので、一応認識しておいた方がいいでしょう。

たとえば、「改行したときに<br>タグを入れるのか、それとも現在カーソルが所属しているブロック要素を空でInsertするのか」といったことすら、自分で定義できたりするし、一方でエディタのデフォルトの言うとおりにいつの間にかなっていたりするよ、ということです。

エディタに入力されたコンテンツをExportする

さて、エディタに入力された文字列はデータストアに保存する必要があります。

入力された太字や斜体などの装飾や、画像などの埋め込みも忠実に保存し、読み出し時に再現できる必要がありますが、第一候補としては、エディタ内に表現されたHTMLをそのままデータストアに保存する手段が挙げられます。

HTMLで読み書きするメリットは、

  • エディタライブラリが今後仮に変わっても、HTMLでさえ扱っていればまず大丈夫そうだろうといえること
  • HTMLをそのままReactのdangerouslySetInnerHTMLに突っ込むことで表示側もこなせちゃうところ

といったところです。

一方で、本記事をこのまま読んでいくと分かるかと思いますが、大抵のエディタは専用JSONがあり、たとえば太字は{ type: 'bold', content: '太字です' }で表現されていて、これをエディタライブラリで読み込むと<b>太字です</b>になる、みたいな感じで組まれていたりします。というかむしろこのJSONのほうが主体で、実は外部からHTML渡しても、内部でわざわざJSONに治してからレンダリングしている、と捉えた方が自然なことが多いです。

なので、JSONをデータストアに読み書きする発想もあります。メリットをまとめると、

  • データストアがRDB(MySQL、PostgresQL)の場合、JSON型が使えるので、解析がやりやすいと言えないこともない
  • セキュリティが割と固め。エディタ的にInvalidなJSONは表示すらままならなくなるため
  • JSONからHTMLにParseするロジックを入力画面と表示画面で共通化する傾向にあるため、結果として入力時のWYSIWYGと表示画面の見た目を一致させやすい

逆にデメリットは、表示するときにフロントエンドでParseすると、表示画面はパフォーマンスめっちゃ求められているのにParseのためにエディタライブラリをimportする羽目になり、Bundle Sizeが増大することです。なおこれが嫌ならバックエンドでParseしてHTMLに起こせばいいので、たいした問題ではないのですが。バックエンド言語のSDKをリッチテキストエディタライブラリ側が用意していないと地獄のParser自作になったり、同じParseロジックをバックエンド・フロントエンドで分散管理することになるので辛いですかね。

データストアからGETしたコンテンツをエディタ上でDOMに変換するロジック

さて、データストアにエディタ特化のJSONを入れている場合は、そのままエディタライブラリに突っ込めばすぐ編集可能になるのですが、HTMLを入れてしまっている場合は、HTMLをParseしてリッチテキストエディタ上に表現するロジックが必要になる場合があります。

HTMLが入っているならそのままエディタ(のRootにあるcontenteditableなdivのchildren)に突っ込めばいいじゃないか!と思われるかも知れませんが、その発想は推奨しません。

何らかの理由で意図しないDOMがデータストアに混入した場合や、ある時点を境に新しい機能をエディタに追加したり逆に既存機能をサポート外にしたときなどに、HTMLをParseするレイヤーをカスタマイズ可能にしていないと、詰んでしまうからです。
具体的には、YouTube埋め込みをサポートしていたとして、あるタイミングでYouTubeがサービス終了したとします。そうすると、データストアのHTMLには<iframe src="https://youtube.com/hogehoge...という文字列がそのまま入っているわけで、そのままエディタに表示としてしまうとInvalidな状態になるからです。
もちろんこのときそのInvalidなiframeを許容するという仕様に倒すことも可能ではあるものの、実際はParse層をカスタムできるエディタのほうが、技術的な自由度が高いことが想像できると思います。

弊社の場合、Quillを使っていた時代は、HTMLをそのまま突っ込んでいましたが、Tiptap移行を機に、データストアのHTMLをエディタ上のHTMLに変換するレイヤーを明示的に実装しました。
たとえば太字は{ type: 'bold', content: '太字です' }で表現し、実際のDOMでは<b>太字です</b>になるようにTiptapで設定したとしましょう。しかし、Quillで読み書きした既存のHTMLは、太字を<span style={font-weight: 'bold'}>太字です</span>で表現していたとすると、このHTMLはTiptapのパーサーに無視されてしまいます。そこで、パーサーの実装を拡張して、spanタグかつstyle属性が指定されているものは、その内容に応じてTiptapで扱える太字(この例ではbタグ)に変換するといったロジックを追加する必要があります。

どこまで開発者がカスタマイズ可能か

ここまで話してきたように、リッチテキストエディタにおけるデータフローとは、入力をDOMに変換する、DOMを保存形式に変換する、保存形式をDOMに変換するといった変換処理の組合せによって成り立っています。

筆者がQuill、Tiptap、Slateといったライブラリを使って感じたところとしては、これらの変換部分をどの程度カスタマイズできるか?およびそれがどの程度やりやすいか?また、カスタマイズ部分をOSS化可能にすることでエコシステムをどの程度巻き込んでいるか?といったあたりが、拡張性やカスタマイズ性を考えるうえで重要でした。

筆者自身はQuillはあまりカスタマイズせずに若干無法地帯気味に使っていたこともあって深められていないのですが、TiptapやSlateはオリジナルで拡張を何種類か実装する程度には深められています。詳細はともかく肌感覚としては、Slateのほうが細かいところまでカスタムできる多種多様なAPIが提供されている一方、エコシステムが綺麗に巻き込めておらず、Plateというラッパーライブラリを結局噛まさないと現実的に厳しいところが玉に瑕でした。
大半のケースでは、Tiptapのほうがちょうど良い塩梅に感じました。

また、Slateはなかなかバージョンが0系から変わらなかったり、ラッパーライブラリであるPlateがほぼ個人メンテナンスで破壊的変更が多いことなど、いわゆるOSSとしての懸念点が目に付きます。リッチテキストエディタは普段使うライブラリより、群を抜いてコードリーディングや問題解決が難しい分、ライブラリ選定の際には、OSSとしての運営能力の高さをレビューした方が良いと思います(とんでもない量のIssueが来るし、無限のコンテンツ組合せがある分Reproduceも難しいです。Tiptapは一部有料機能を提供していますが、それくらい商売っけがないとやってられないと思います)。

実装にあたって気をつけること

よほど凡庸なエディタを作るという仕様でもなければ、多少自前でカスタマイズすることになるかと思います。

リッチテキストエディタのカスタマイズに取り組むにあたっては、これまで話した変換ロジックを意識するのが大事です。今自分が読んでいる・書いている実装が、どこからどこへの変換ロジックなんだっけ?というのが割と混乱しがちなので、ちゃんと切り分けながらプリミティブに実装していくのが良いと思います。

同時編集可能か

リッチテキストエディタの同時編集は、稀によくある要件定義だと思うのですが、かなり難しいので、よほどその機能がサービスの中核では無い限り、軽い気持ちで入れようと思わない方がおすすめです。
(Firestoreなら簡単に同時アクセスできてリアルタイム更新できるよ!とかそういった話じゃ無くて、DOMなりJSONなりを同時編集して、コンフリクトしないorしても軽症で済ませながら絶えずマージしていくアルゴリズムのほうがヤバい、という話です)

もし関わる必要が生じた方は、以下のような文献やライブラリを一通り見てみると良いと思います。

※CRDTっていう単語さえ知っていたらある程度調べられると思うが、実際に実装で試行錯誤する経験をめっちゃ積まないと理論と結果が接地しなくて辛いタイプのやつ

軽くリッチテキストエディタ実装の実例を示す

最後に、軽く実装例を置いておきます。リッチテキストエディタのカスタマイズでだいたい引っかかる印象なのが、「画像のアップロード&埋め込み機能」です。

tiptap×画像のアップロードを例にして説明します。

入力操作をDOMに変換する①inputタグのonChangeで画像アップロードを呼び出す

uploadImage関数は、自社バックエンドAPIか何かに接続して、画像の実ファイルをアップロードし、アップロードが完了したらアクセス可能なURLを返す非同期関数とします。これは日頃React Hook Formなりのライブラリとの統合のために普通に実装している関数でいいです。

      <input
        name={'image-upload'}
        id={'image-upload'}
        ref={fileRef}
        type={'file'}
        hidden
        accept={'image/*'}
        onChange={(e) => {
          if (!e.target.files) {
            return
          }
          uploadImage({
            body: {
              image: e.target.files[0]!,
            },
          })
            .then((res) => insertImageElementIntoEditor(editor, res.imageUrl))
            .catch(() => undefined) // TODO: 画像アップロードエラー時の処理があればここに
        }}
      />

入力操作をDOMに変換するロジック②画像URLをimgタグに変換&挿入

insertImageElementIntoEditorがアップロード完了後に呼ばれているのですが、たとえば以下のような実装をします。

import type { Editor } from '@tiptap/react'

const insertImageElementIntoEditor = (editor: Editor, url: string) => editor.chain().focus().setImage({ src: url }).run()

この処理はエディタライブラリによってまちまちですが、要は自前実装から返ってきたURLを、エディタ側の関数を呼び出すことでエディタ内部の画像要素に置換して挿入するわけです。

だいたいの仕様では、フォーカスが現在当たっているところに挿入するでしょうから、focus()を実行した後にチェインしてsetImageといった関数を実行すると、画像が挿入されます。

このとき、setImageのように特定の値をエディタ内DOMに置換する処理は、tiptapのようにカスタマイズ前提のライブラリではデフォルトで実装されておらず、ExtensionやPluginといった外部の実装を当て込むことで関数が使えるようになります。

tiptapの場合は、以下のようにeditorインスタンス生成時にExtensionを指定できます。

import { Image } from '@tiptap/extension-image'

// 中略

  const editor = useEditor({
    extensions: [
      Image,
      // others
    ],
  })

こうすることでsetImage関数が使えるようになるわけです。多かれ少なかれ、たいていのリッチテキストエディタはこのような思想になっています。

極端な話、「リッチテキストエディタ内にハートボタンがあり、ハートボタンを押すとhttps://example.com/heart.pngが挿入される」という要件であれば

<button onClick={() => insertImageElementIntoEditor(editor, 'https://example.com/heart.png')} />

で実装できちゃいます。ユーザーが何を操作するかと、その結果どんなDOMが構築されるかをそれぞれ切り離した設計になっているので、自由度がめっちゃ高いわけですね。

エディタに入力されたコンテンツをExportする

普通、エディタにはonChangeといったイベントハンドラが実装されているので、そこ経由でHTMLなりJSONなり好きな形式でデータを受け取れます。

export const RichTextEditor: FC<Props> = ({
  text,
  onChange,
}) => {
  const editor = useEditor({
    onUpdate: ({ editor: instance }) => {
      onChange(instance.getHTML())
    },
  })

ここまで実装したらReact Hook Formなどとの統合もイメージできますよね。

      <Controller
        name={'description'}
        render={({ field }) => (
          <Field label={'内容'}>
            <div className={styles.body}>
              <RichTextEditor
                text={field.value}
                onChange={field.onChange}
              />
            </div>
          </Field>
        )}
      />

データストアからGETしたコンテンツをエディタ上でDOMに変換する

最後に、書き込んだコンテンツを今度は編集したいときに再構築できるのか、という話ですが、たとえばtiptapであればExtensionは読み書き両方のロジックを内包しているので、Extensionを実装するないし利用する時点で読み書き両方対応完了しています。

ですが、本記事のきっかけにもなった、別のエディタライブラリの移行では簡単にはいきません。移行後のライブラリで記入したモノが移行後のライブラリで表示されるのは当たり前ですが、移行前のライブラリで記入されたあらゆるDOM構造が、移行後のライブラリで表示可能であることは簡単に断言できません。

実際、画像埋め込みが旧エディタライブラリのものを新ライブラリで表示しようとしたら何も表示されず、いろいろ調べた結果、inlineという特定のプロパティをTrueにしてExtensionを使うことで対応できました。

  // @see https://github.com/ueberdosis/tiptap/issues/1206#issuecomment-825851955
  Image.configure({ inline: true }),

補足

画像をアップロードしている間に、「アップロード中です」といった文言を入力欄に入れたい場合は、uploadImage関数の実行前にeditorに対して文字等のInsertを実行し、アップロード完了後にそれを差し替える形の操作を行うイメージで実装できます。そういうときも、それぞれ望むDOM構造やそれらをInsertしたりReplaceする関数をドキュメントから探して実装していく感じです。

まとめ

このように適宜Extensionを拡張したりカスタムすることで、読み書きの内容を必要に応じて最適化できます。
移行作業という文脈においてはここがダントツで難しいので、この問題にもし直面することになった方は急にExtensionとかPluginとかのロジックを読み解くことになって面食らうかも知れません。

私がtiptapでこの問題に直面したときは、とにかく既存Extensionのコードを読みつつ公式Doc読みつつ実コード読みつつ高速で学習ループを回して、トライアンドエラーの数で押し通しました。

この辺の具体的なプロセスやコード片は、また気が向いたら執筆しようと思うので、もし知りたい方は本記事にLikeなりコメントなりお願いします!

Discussion

Mikihiro SaitoMikihiro Saito

Vueですが、Tinymceの有料化に伴ってQuillに移行する経験はしました
その時もTinymceにロックインしてしまっていた関係でユーザーにも色々妥協して頂いて移行しました。

特にリッチテキストエディタはOSSのメンテコストも高そうなのでしょうがないだろうな、と思いつつ有料エディタの場合、エディタへのアクセス数が肥大化するようなプロダクトは要注意ですね。
かなり費用はお高くなってしまいます><

okkunokkun

ナレッジの共有ありがとうございます!私のチームでもdraft.jsから別のライブラリへの移行を検討しており大変参考になりました。

永続化する際にtiptapのJSON形式で保存していると思うのですが、DBの中はQuillのHTMLとtiptapのJSONの2種類が混在していますか?

今後さらに別のライブラリへ移行する必要がある場合、HTMLと tiptapのJSONの両方の変換ロジックが必要かなと思いまして、考慮したことがあれば伺いたいです🙇‍♂️

meijinmeijin

いえ、tiptapからHTMLで出力、保存しています。ざっくり説明すると、どちらかというと、tiptapのパーサーロジックをカスタムしてQuillの方に合わせました!影響範囲広くなりすぎそうだったので!

meijinmeijin

完璧に合わせたわけではないので、厳密には移行タイミングからHTMLの内容は微妙に異なります。その辺は表示側のデグレ含め気合いでテストして乗り切りました(乗り切ったことにしました)

okkunokkun

ご回答ありがとうございます!移行コストや互換性を考慮した結果、HTMLでの永続化を選択した感じですかね?

記事の中では永続化の際にHTMLとJSONのメリデメに触れつつも、JSONでの永続化のメリットの方が大きいように感じたので!

meijinmeijin

そうです!ちなみに個人開発ではJSON保存でやってます。個人開発ならエディタロックインしてもあまり困らないですしね。
基本的には解析可能なデータのほうが何するにしても望ましいからJSONを起点に考えつつ、まあ戦い方はあるからやむを得ずHTMLにすることもあるよね、という立場です

okkunokkun

大変参考になりました!ありがとうございます。