TiptapとReactの統合について
こんにちは、順調に胸筋と積読の厚みが増してきているきりさわです。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から提供されるメソッドを使う必要があります。
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があります。
上記公式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化されて動かなくなるみたいなことは起きるのか気になりました)
この問題?に対してはonTransaction
やonUpdate
などエディターのイベントをフックできるオプションが役に立ちます。
以下のように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から行いたい場合があると思います。この場合もonUpdate
でsetState
することでエディターのコンテンツと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
ブランチにあります。
Discussion