🐝

TiptapのコンテンツとReactを同期する | 画像リスト機能やコメント機能への応用編

に公開

こんにちは、ベンチプレスの重量が上がらなくなったきりさわです。以前エディター内のコンテンツとReactコンポーネントを同期する方法を紹介した記事を書きました。この内容に関連して追記したいことを思いついたのでそれについて書きます。

応用パターンについて

前述した記事では「エディター上のイベントを監視できるonUpdateオプション(など)でsetStateしてReactコンポーネントを更新しよう」という内容を書きました。
Tiptapのエディター上の内容が更新されたらReactコンポーネントを更新するという同期方法は自然な方法かと思いますが、Reactコンポーネント側で行った更新をTiptapの更新も考慮して整合性を保つ場合に一工夫必要になります。例えば以下のようなパターンで画像リストを例に挙げます。

要件自体少し複雑なので最初に完成したアプリケーションを示します(ソースコード)。左側に表示されるのがTiptapのエディターで右側に表示されるのがReactコンポーネントの画像リストです。

https://tiptap-sync-editor-document-with-image-list-component-kh5w.vercel.app/

  1. 「本文に画像を挿入」を押してエディター上に画像を挿入できる。エディター上に挿入された画像は右側の画像リストに表示される

  2. エディター上から画像を消すと画像リストからも画像が消える

  3. エディター上から画像削除後にCmd+Z(Undo操作)した場合、エディター上のコンテンツが戻り、削除して消えていた画像がエディター上と画像リストに復活する。その後、Cmd+Shift+Z(Redo操作)した場合、Cmd+Zで復活させていた画像がエディター上と画像リストから消える

  4. 本文に挿入された画像は必ず画像リストに存在する

  5. 画像リストからはエディター上で挿入された画像は消せない

  6. 「画像リストに画像を追加」を押して画像リストに画像を追加できる

  7. 画像リストから追加したが画像は画像リストから削除できる

1〜4はエディター側の操作、5〜7は画像リスト側の操作になってます。4に関してTiptapの機能を活用すれば画像リストからエディター上の画像を消すことは可能ですが、別のTipsになるのでこの記事では触れない&実装していません。
この記事では主に3に関する要件の実現方法が見所になると思います。単に本文中の画像を消したら画像リストからもその画像を消す実装をするとシンプルです。しかし、Undo・Redo操作があるため、この操作により本文で復活した画像を自動的に画像リストに復活、逆も然りで本文で消えた画像を自動で画像リストから削除をする必要があります。単に画像リストから画像を消しただけだと本文側で画像が復活した時に消えた画像を復元できなくなります。

実装の設計

前提としてバックエンドには本文と画像リストの画像一覧がそれぞれのカラムに保存される構成を想定しています。以下は、初回ロード時にバックエンドからのレスポンスとして受け取ることを期待しているデータ構造です。
contentが本文の内容でimagesは画像リストの画像一覧です。imagesには本文中でアップロードされた画像と画像リスト側でアップロードされた画像が全て存在している状態です。

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/main.tsx#L15-L21

処理全体の動きは以下のようになります。

  1. レスポンスを受け取る
  2. レスポンスデータの本文(content)から本文中に存在する画像を全て取得する
  3. 画像一覧(レスポンスのimages)と2で抽出した本文画像を比較して画像リスト側の画像にマークをつける(imageViewModel[])
  4. 本文中の画像を保持するプロパティ(editorBodyImages)と画像履歴imagesHistory)を保持するプロパティを持つオブジェクト(imageCollection)を用意する
  5. 3で取得したimageViewModel[]を画像履歴としたimageCollectionをStateの初期値にする

ここまでが初回ロード時にやることです。そしてこの後からは初回ロード後にエディターと画像リストで発生するインタラクションに応じた動きです。

  1. 本文側でエディター上のコンテンツに変化があった時、本文中に存在する画像を全て取得してimageCollectioneditorBodyImagesにsetStateする
  2. 「本文に画像を挿入」ボタンを押して本文中に画像を挿入した時、imagesHistoryに挿入した画像をsetStateする
    8.「画像リストに画像を追加」を押して画像リストに画像を追加した時、imagesHistoryに画像をsetStateする、この時に画像リスト側からアップロードしたことがわかるようにフラグでマークする。
  3. 「画像を削除」を押して画像リストから画像を削除した時、imagesHistoryから削除した画像を削除する

これでインタラクションに伴う状態遷移が整いました。あとはimageCollectionから画像リストに表示する画像一覧を計算するだけです。

  1. imageCollectionのimagesHistoryからeditorBodyImagesに存在する画像と画像リスト側でアップロードした画像を取得する

ポイントはエディター上で挿入した画像はブラウザーがリロードされるまでStateに残しておくことです。imageCollectionのimagesHistoryにはエディター上で挿入した画像は消えることはなく残り続けます。対して、画像リスト側で追加した画像は追加・削除のタイミングでStateから削除します。imageCollectionのeditorBodyImagesには現在のエディター上に存在する画像が反映されているため、imagesHistoryをeditorBodyImagesでフィルターすることで画像リストに表示する画像一覧が割り出せます。

この実装は、画像リストから画像を消す実装をした場合に文側で画像が復活した時に消えた画像を復元できなくなる問題を解決します。また、エディター上で画像が消えた時の処理もシンプルに実装できます。最初に思いつく実装は本文中の画像が消えたらその画像を画像リストから消すという手続き的な処理になるかと思いますが、前述した画像を復元できない問題に加えて、本文からどの画像が消えたのか計算する必要あります。Tiptapでこれを簡単に行う方法(本文で消えたNodeを割り出すメソッド)はないためこの部分でも計算が必要になります。エディター上で画像が消えたことを算出するのではなく、エディター上に存在する画像と挿入された画像を記録しておき最終的に画像リストとして表示する画像一覧を算出することで実装がシンプルになります。

実装

実装はどうなっているのか見ていきます。

前節の1から5はコード上では以下の部分です。

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/main.tsx#L15-L26

バックエンドを実装するのが面倒だったので仮実装としてレスポンスをreponseとして定数で定義しています。getImageCollectionは2,3,4の具体的な実装です。

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/get-image-collection.ts#L6-L37

最初にgetAllDocumentImageで本文中に存在する画像を全て取得します。DOMParserでimg要素を全て探してsrcの値を抽出しています。
次にresponseのimagesを走査して、本文中に存在する画像だった場合はisUploadedFromImageListというフラグをfalseに設定し、それ以外は画像リストからアップロードされていることになるためtrueに設定します。このようにフロントエンド側で扱いやすいように加工したデータという意味(諸説あり)で画像のURLにフラグを付与したデータ構造をImageViewModelという名前の型にしています。レスポンスから返された画像を全て走査して最終的にImageViewModel[]をimagesHistoryに紐付けています。

続いて6の実装です。

  1. 本文側でエディター上のコンテンツに変化があった時、本文中に存在する画像を全て取得してimageCollectionのeditorBodyImagesにsetStateする

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/App.tsx#L25-L30

onUpdateにより本文の内容が変わるたびにエディター上に存在する全ての画像取得&editorBodyImagesにsetStateしています。エディター上の画像はextractImagesFromTiptapEditorContentという関数でeditor.state.doc.forEachによりエディターのNodeを全て走査して取得しています。

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/hooks/useSetTiptapEditorImages.ts#L5-L24

続いて7の実装です。

7.「本文に画像を挿入」ボタンを押して本文中に画像を挿入した時、imagesHistoryに挿入した画像をsetStateする

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/App.tsx#L33-L41

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/hooks/use-set-image-history.ts#L6-L15

画像のアップロード処理は本題ではないので実装していません。generateRandomPlaceholdUrlでランダムな色とURLの画像を作る仮実装をしています。本文中に挿入するためisUploadedFromImageListフラグをfalseとして生成したImageViewModelをimagesHistoryにsetStateしています。本文への画像の挿入はeditorインスタンスから生やしているsetImageメソッドで挿入しています。

続いて8の実装です。

8.「画像リストに画像を追加」を押して画像リストに画像を追加した時、imagesHistoryに画像をsetStateする、この時に画像リスト側からアップロードしたことがわかるようにフラグでマークする。

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/App.tsx#L43-L49

7とほぼ一緒ですが、isUploadedFromImageListをtrueとしています。

続いて9の実装です。

  1. 「画像を削除」を押して画像リストから画像を削除した時、imagesHistoryから削除した画像を削除する

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/App.tsx#L51-L53

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/hooks/use-set-image-history.ts#L17-L30

削除する画像をidで指定してそのidでフィルターしたimagesHistoryをセットしています。今回は分かりやすいようにuuidで画像ごとに一意の値を付与していますが、URLも一意なのでURLでも良いと思います。

最後に10の実装です。

  1. imageCollectionのimagesHistoryからeditorBodyImagesに存在する画像と画像リスト側でアップロードした画像を取得する

https://github.com/kirikirisu/tiptap-sync-editor-document-with-image-list-component/blob/main/src/hooks/use-image-list.ts#L3-L30

imagesHistoryを走査しています。isUploadedFromImageListがtrueだった場合は画像リストに表示することは確定なので早期continueしています。それ以外の画像は本文からアップロードされたことになりますが、エディター上に存在する画像のみを表示するためeditorBodyImagesでフィルターしています。

これで実装が完成しました。

まとめ

今回画像リスト機能を例にして解説をしました。画像リストは要件自体複雑だったため実際の利用シーンは限られているかもしれません。しかし、この記事で紹介した方法を使えばTiptapでコメント機能を比較的シンプルに実装することができます。コメント機能は他に特筆すべきことがあるため今回は例にあげませんでしたが気が向いたら書くかもしれません。

Discussion