🍉

TiptapとReactの統合について

2024/08/04に公開

こんにちは、順調に胸筋と積読の厚みが増してきているきりさわです。Tiptapを触っていて書き残しておきたいことが溜まり続けている状況をなんとかしようとなんとなく書き殴る感じの記事です。

TiptapのコンテンツとReact

ReactとTiptap(prosemirror)でviewを更新するフローは完全に別です。Reactはstateを更新することでviewを更新しますがこのReactのフローとは別にTiptap(prosemirror)には固有のフローがあります。
以下のようにuseStateの値をTiptapのコンテンツとして渡し、setContentしてもエディターのコンテンツは更新されません。

function App() {
  const [content, setContent] = useState("<p>Hello World!</p>");

  const editor = useEditor({
    extensions,
    content,
  });

  return (
    <>
      <EditorContent editor={editor} />
      <button
        type="button"
        onClick={() => {
          setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

Tiptapのコンテンツを更新してviewに反映させるためにはTiptapから提供されるメソッドを使う必要があります。

https://tiptap.dev/docs/editor/api/commands/content

const content = "<p>Hello World!</p>";

function App() {
  const editor = useEditor({
    extensions,
    content,
  });

  return (
    <>
      <EditorContent editor={editor} />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

Reactとprosemirrorの統合はTiptapが行なっているため、自分でエディターのコンテンツを操作する場合はTiptapから提供されるメソッド(それかeditorインスタンスやprosemirror pluginからprosemirrorのAPIを直接触る)で操作をする必要があります。
一番最初に挙げた例ではReactのstateの値をエディターの初期値にセットしているだけでstateのセッターはエディターのコンテンツの更新に関与できません。

エディター内のコンテンツとReactのコンポーネントを同期させるにはどうするか

TiptapのコンテンツとReactのコンポーネントを同期させたい場合があります。例えば、エディターで編集しているコンテンツの文字数や画像枚数を取得してエディターの外でReactのコンポーネントにより表示したい場合があるかと思います。
文字数に関しては、CharacterCount Extensionという公式のExtensionがあります。
https://tiptap.dev/docs/editor/extensions/functionality/character-count#page-title

上記公式Docsのサンプルコードを見てみるとコンポーネントでeditor.storage.characterCount.characters()を実行して文字数を更新しています。注意が必要なのはuseEditorの再レンダリングでcharacters()がレンダリングごとに再実行されることによって文字数を表示するviewが更新されているということです。Tiptapのソースコードを見てもtransactionのたびに際レンダリングさせているようです。
editorインスタンスを参照するだけで参照しているコンポーネントが再レンダリングされるように見えますが、以下のようなパターンは再レンダリングされません。

const extensions = [StarterKit, CharacterCount];
const content = "<p>Hello World!</p>";

const CharacterCounter = memo(({ editor }: { editor: Editor }) => {
  return <p>{editor?.storage.characterCount.characters()}</p>;
});

function App() {
  const editor = useEditor({
    extensions,
    content,
  });

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <CharacterCounter editor={editor} />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

親コンポーネントのAppはeditorの再レンダリングによる影響を受けますが、子コンポーネントのCharacterCounterはeditorインスタンスの参照は変わっていないため再レンダリングされず結果的に文字数を表示するviewが更新されません。(CharacterCounterコンポーネントはReact.memoでコンポーネントをメモ化していますが、メモ化を外すと親コンポーネント(App)の再レンダリングに引っ張られる形で文字数も更新されるようになります。)
以下のようにeditorインスタンスをglobal stateにセットして任意のコンポーネントでそのeditorインスタンスを参照しつつコンテンツの文字数が更新されたら参照しているコンポーネントを再レンダリングすることもできません。

const extensions = [StarterKit, CharacterCount];

const content = "<p>Hello World!</p>";

const CharacterCounter = memo(() => {
  const editor = useGetTiptapEditorGlobalState();

  return <p>{editor?.storage.characterCount.characters()}</p>;
});

function App() {
  const editor = useEditor({
    extensions,
    content,
  });
  const setTipTapEditor = useSetTiptapEditorGlobalState();

  useEffect(() => {
    if (editor === null) return;

    setTipTapEditor(editor);
  }, [editor, setTipTapEditor]);

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <CharacterCounter />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

editor(コンテンツの内容)が更新されたら再レンダリングされて子コンポーネントも更新されるという動きは自然ですが、コンポーネントの階層が深くなった場合中間のmemo化されたコンポーネントなどによって、useEditorによる再レンダリングをリーフコンポーネントまで伝搬させるのは難しくなります。またパフォーマンス的にどうしてもeditorによる再レンダリングを防ぎたい場合があるかもしれません。(もちろんそもそもネストを深くしないなど設計によって一定解決できる部分もあります。あと上記の例の場合、React Compilerによってmemo化されて動かなくなるみたいなことは起きるのか気になりました)

この問題?に対してはonTransactiononUpdateなどエディターのイベントをフックできるオプションが役に立ちます。
以下のようにonUpdateで文字数を保存するstate(charCount)にエディターの文字数をセットすることでエディターのコンテンツが変わるたびにcharCountを参照するコンポーネントを再レンダリングさせることができます。

const CharacterCounter = memo(({ charCount }: { charCount: number }) => {
  return <p>{charCount}</p>;
});

function App() {
  const [charCount, setCharCount] = useState(0);
  const editor = useEditor({
    extensions,
    content,
    onUpdate({ editor }) {
      setCharCount(editor.storage.characterCount.characters());
    },
  });

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <CharacterCounter charCount={charCount} />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
    </>
  );
}

onTransactionはコンテンツの更新だけでなくエディター内のカーソルの位置が変わったりフォーカスがされたりされなかったりした時にも動きます。文字数はコンテンツが変わったときに変わるため今回はonUpdateを使用しています。

自前でコンテンツの内容を読み取る

文字数の場合は公式のExtensionがありましたが、例えばエディター内の画像枚数を取得して画像枚数のバリデーションをReactから行いたい場合があると思います。この場合もonUpdatesetStateすることでエディターのコンテンツとReactを同期させることができます。そしてもう一つエディター内のコンテンツから画像枚数を取得する方法が必要なテクニックとしてあります。画像枚数をsetStateするタイミングで画像枚数をどうやって計算するのかという部分です。

下準備として以下のように公式の画像Extensionを使い「画像を追加する」ボタンを押すとエディターに画像が配置されるコードを用意します。画像枚数を表示するImageCounterには固定値をとりあえず入れておきます。

const extensions = [StarterKit, CharacterCount, ImageExtension];

const content = "<p>Hello World!</p>";

const CharacterCounter = memo(({ charCount }: { charCount: number }) => {
  return <p>文字数:{charCount}</p>;
});

const ImageCounter = ({ imageCount }: { imageCount: number }) => {
  return <p>画像枚数:{imageCount}</p>;
};

function App() {
  const [charCount, setCharCount] = useState(0);
  const editor = useEditor({
    extensions,
    content,
    onUpdate({ editor }) {
      setCharCount(editor.storage.characterCount.characters());
    },
  });

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <CharacterCounter charCount={charCount} />
      <ImageCounter imageCount={1} />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
      <button
        type="button"
        onClick={() => {
          editor
            .chain()
            .focus()
            .setTextSelection(0)
            .setImage({ src: "https://placehold.jp/150x150.png" })
            .run();
        }}
      >
        画像を追加する
      </button>
    </>
  );
}

画像枚数をカウントするために以下のようにします。collectImageCountがポイントです。editor.state.doc.decendantsでエディター内のdocに存在するNodeを全て走査して画像のNodeを見つけたらカウントを足していくことでコンテンツに存在する画像枚数を算出しています。

function collectImageCount(editor: Editor) {
  let imageCount = 0;

  editor.state.doc.descendants((node) => {
    if (node.type.name === ImageExtension.name) {
      imageCount++;
    }
  });

  return imageCount;
}

function App() {
  const [charCount, setCharCount] = useState(0);
  const [imageCount, setImageCount] = useState(0);

  const editor = useEditor({
    extensions,
    content,
    onUpdate({ editor }) {
      setCharCount(editor.storage.characterCount.characters());
      setImageCount(collectImageCount(editor));
    },
  });

  if (editor === null) return false;

  return (
    <>
      <EditorContent editor={editor} />
      <CharacterCounter charCount={charCount} />
      <ImageCounter imageCount={imageCount} />
      <button
        type="button"
        onClick={() => {
          editor?.commands.setContent("<p>Update content!</p>");
        }}
      >
        コンテンツを更新
      </button>
      <button
        type="button"
        onClick={() => {
          editor
            .chain()
            .focus()
            .setTextSelection(0)
            .setImage({ src: "https://placehold.jp/150x150.png" })
            .run();
        }}
      >
        画像を追加する
      </button>
    </>
  );
}

まとめ

ということでスクラップみたいな記事でした。ソースコードは以下のRepositoryにあります。後半の画像のコードはfeature/add-imageブランチにあります。

https://github.com/kirikirisu/tiptap-rendering-sandbox

Discussion