⌨️

既存の React コンポーネントで作る、Tiptap WYSIWYG エディタ part1

に公開

例えばこの Zenn のようななんらかの文書投稿サービスがあり、そこ既存の文書の表示と同じような見た目の WYSIWYG エディタを導入しようと思った場合、これはどう実現できるでしょう?
今回は既存の文書の表示に React を使っている(フロントエンドで文書そのものか文書の中間表現を React コンポーネントにマッピングしている)場合に、そのコンポーネントを使って Tiptap でノードやマークを自作し、WYSIWYG エディタを実装してみる話です。
かなり専門的な内容になるかと思いますが、お付き合いいただけると幸いです。

1. Tiptap と Lexical の比較

今回のようなケースに限らず、既存のライブラリに対して拡張を自作しようとする場合は、それがしやすい技術選定をすることが重要です。
WYSIWYG エディタの実装では TiptapLexical が候補に上がることが多いので、まずはこれらでどうノードを実装するのか軽く見ていきましょう。
今回であれば、React コンポーネントを表示に使う方法なども確認しておきたいです。

Lexical でのノードの扱い

https://zenn.dev/mktu/articles/7d547829f330c9

Lexical でどのようにノード(やそれをエディタ上に挿入するコマンド)を実装するのかについては、上記の記事が詳しいです。
ノードやコマンドの実装の流れを書いていくと、だいたいこんな感じのようです。

  1. React のコンポーネントを用意する(これは共通)
  2. DecoratorNode を継承したノードを作る
    • decorate() というメソッドで React のコンポーネントを返す & ノードが持つ属性を props としてコンポーネントに渡す
    • LexicalEditor コンポーネントの customNodes でこのノードを登録する
  3. ノードをエディタに挿入するためのコマンドとコンポーネントを作る
    • createCommand() でコマンドの名前を決める
    • ノードの生成処理を書くための React コンポーネントを生やす
    • ↑ の useEffect()registerCommand() を呼んでノードの生成処理を書く
    • ↑ のコンポーネントを LexicalEditor の子コンポーネントとして配置する

コードにするとざっとこんな感じになるはずで、(payload) => { /* 中略 */ } としているノードの生成処理が割と大変そうな雰囲気でした。

export class CustomHeading extends DecoratorNode {
  /* 中略 */

  decorate() {
    return <Heading /* 中略 */ />;
  }
}

export const INSERT_HEADING_COMMAND =
  createCommand("INSERT_HEADING_COMMAND");

export const HeadingRegister = () => {
  const [editor] = useLexicalComposerContext();
  useEffect(() => {
    return editor.registerCommand(
      INSERT_HEADING_COMMAND,
      (payload) => { /* 中略 */ },
      COMMAND_PRIORITY_EDITOR,
    );
  }, [editor]);

  return null;
};

const Editor = () => {
  return (
    <LexicalEditor
      /* 中略 */
      customNodes={[CustomHeading]}
    >
      <HeadingRegister />
    </LexicalEditor>
  );
};

Tiptap でのノードの扱い

Tiptap はドキュメントに Node views with React というページがあったので、それを参考に見ていきます。
ノードやコマンドの実装の流れを書いていくと、だいたいこんな感じのようです。

  1. React のコンポーネントを用意する(これは共通)
  2. Node.create() でノードを作る
    • addNodeView() というメソッドで ReactNodeViewRenderer(Component) のようにコンポーネントを渡す
    • ノードが持つ属性をどうコンポーネントに渡すかは後述
    • useEditor()extensions でこのノードを登録する
  3. ノードをエディタに挿入するためのコマンドとコンポーネントを作る
    • ノードに addCommands() メソッドを実装してコマンドを定義する
    • React のコンポーネントを NodeViewWrapper コンポーネントでラップしたコンポーネントを生やす
    • ↑ により、ノードが持つ属性は props.node.attrs のように React コンポーネントに渡せるようになる

コードにするとざっとこんな感じになるはずです。
Tiptap は標準?のコンポーネントも拡張とほぼ同じように定義されているので、細かい部分は Heading の実装 が参考になるでしょう。

export const CustomHeading = Node.create({
  /* 中略 */

  addNodeView() {
    return ReactNodeViewRenderer(WrappedHeading);
  },

  addCommands() {
    return {
      setHeading:
        (attributes) =>
        ({ commands }) => {
          return commands.setNode(
            this.name,
            attributes,
          );
        },
    };
  },
});

const WrappedHeading = (
  props: ReactNodeViewProps<HTMLHeadingElement>,
) => {
  <NodeViewWrapper>
    <Heading level={props.node.attrs.level}>
      <NodeViewContent />
    </Heading>
  </NodeViewWrapper>;
};

const Editor = () => {
  const editor = useEditor({
    extensions: [/* 中略 */, CustomHeading],
    /* 中略 */
  });
  return <EditorContent editor={editor} />;
};

総じて、Tiptap と Lexical の大きな違いは、Tiptap では構文拡張や拡張機能みたいなものを「Node.create() が作る変数」という単位でまとめて扱うことができる点だと思います。
これは、今回のようにたくさん自作ノードを作る必要がある場合は扱いやすい特性です。

2. Tiptap をインストールする

それでは Tiptap をインストールしてみましょう。
以下のドキュメントでは、@tiptap/starter-kit という標準的なノードやマークの実装も入れる形になっていますが、今回のように自作ノードを作るのが実装のメインとなる場合は不要でしょう。

描画部分は addNodeView() で置き換えることになるし、ノードの属性(Tiptap では attributesattrs と呼ばれる)も既存の React コンポーネントの props に合わせて自分で定義することになるので、標準?の実装を見ながら書くという感じになるはずです。
Minimal setup for paragraphs & text only を参考にセットアップしていきましょう。
2行目で入れているものは、後から自作していきます。(Document も自作できると思いますが、どうせ書くことはそう変わらないので入れています。)

npm i @tiptap/react @tiptap/pm @tiptap/core @tiptap/extension-document
npm i @tiptap/extension-paragraph @tiptap/extension-text

これが最小の構成になると思います。
Next.js で動かす場合は、useEditor()immediatelyRender: false を渡しましょう。

import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import { EditorContent, useEditor } from "@tiptap/react";

const Editor = () => {
  const editor = useEditor({
    extensions: [Document, Paragraph, Text],
  });
  return <EditorContent editor={editor} />;
};

3. 最初の自作ノードを実装する(Text

先ほどの最小構成の extensions から、どれか1つでもノードを消すと実行時にエラーが出るようになると思います。
Tiptap は最低でもこれらの種類のノードを必要とします。
ここからまずは Text を置き換えていきましょう。

Text の実装を見る

Text は先ほど @tiptap/extension-text で入れていたので、実装を見にいってみましょう。
全ての?拡張には簡単なドキュメントも用意されています: Text extension

https://github.com/ueberdosis/tiptap/blob/v3.17.1/packages/extension-text/src/text.ts

namegroup については Tiptap のドキュメントにも説明があります。
Tiptap は ProseMirror というライブラリの上に構築されており、各ノードの性質を決まったフォーマットで指定する必要があります。
実装を確認しつつ、Tiptap や ProseMirror のドキュメントも読んで設定していきましょう。

https://tiptap.dev/docs/editor/core-concepts/schema

Text を実装する

ドキュメントに書かれているように、Text はやや特殊なノードで、通常どのノードも実装すべきとされている(理由は後述)parseHTML()renderHTML() がありません。
Node.create() がとるものとその意味は、以下のドキュメントにまとまっています。
(これについては次の章以降でより詳しく見ていきます。)

https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/node

自作する場合は Markdown 関係の実装は不要なので、こうなるでしょう。

import { Node } from "@tiptap/core";

export const Text = Node.create({
  name: "text",
  group: "inline",
});

これを先ほどの Text と入れ替えると、動作していることが確認できるはずです。
これで最初の自作ノードの実装は完了です 🎉

4. 基礎的な自作ノードを実装する(Paragraph

次は Paragraph を実装してみます。

Paragraph の実装を見る

Paragraph の実装は @tiptap/extension-paragraph です: Paragraph extension

https://github.com/ueberdosis/tiptap/blob/v3.17.1/packages/extension-paragraph/src/paragraph.ts

以下の記事も参考になりました。

https://zenn.dev/karintou/articles/6733f12f974c79

とりあえず Text の場合と同じように、name, group, content は必要でしょう。
これらと同じくらい重要なメソッドに、parseHTML()renderHTML() があります。

エディタ上の要素のコピーとペースト

parseHTML()renderHTML() は、簡単にはエディタ上の要素が(例えばクリップボードなどに)コピーされた場合のテキスト表現を定義するのが renderHTML() で、ペーストされた場合のエディタでの扱いを定義するのが parseHTML() です。

まず renderHTML() のドキュメントの内容を見て見ましょう(以下で引用)。
renderHTML() の目的は、エディタ上でのノードやマークの識別子の払い出しと、他のエディタとの互換性のための HTML 表現の出力(シリアライズ)です。

The renderHTML method is used to define how the mark is rendered as HTML. It should return an array representing the mark's HTML representation.
This will be used during copy events to render the mark as HTML.

次に parseHTML() のドキュメントの内容を見て見ましょう(以下で引用)。
parseHTML() の目的は、払い出された識別子や他のエディタ(や web 上)の HTML から、エディタ上でノードやマークを作ること(デシリアライズ)です。

The parseHTML method is used to define how the mark is parsed from HTML. It should return an array of objects representing the mark's attributes.
This will be used during paste events to parse the HTML content into a mark.

この2つを実装することで、エディタ上で要素をコピー & ペーストできるようになります。
識別子の払い出しと読み取りについては、最低限エディタ内でそれぞれのノードやマークが識別できればいいため、例えば data-* 属性 を識別子とする実装も可能です。
この方法であれば、既存の React コンポーネントが実際にどんな HTML タグを作るのかを自作ノードが知っている必要がなくなり、Tiptap 関連の実装とコンポーネントの実装を疎にできます。
(Tiptap の標準?の実装を参考にそのまま書くと、{ tag: "p" } のように parseHTML() で HTML タグを直接指定することなり、綺麗に分離できません。)

Paragraph を実装する

実装はこのようになるでしょう。
ParagraphComponentp ではなく div を返す実装であっても、エディタ上では data-editor-node="p" な要素であれば Paragraph として扱われるので、React の実装に関係なくノードを定義できています。

import { Node, mergeAttributes } from "@tiptap/core";
import {
  ReactNodeViewRenderer,
  NodeViewWrapper,
  NodeViewContent,
} from "@tiptap/react";

export const Paragraph = Node.create({
  name: "paragraph",
  group: "block",
  content: "inline*",

  parseHTML() {
    return [
      // React が実際に吐く HTML タグを Tiptap の実装が知っていて欲しくない
      // サービス内では data 属性を使って、React の実装と切り離してノードを扱う
      { tag: '[data-editor-node="p"]' },
      // 外部サイトからペーストされたときのための設定
      { tag: "p" },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      "p", // 外部サイトとの互換性のために一般的なタグも指定しておく
      mergeAttributes(HTMLAttributes, {
        "data-editor-node": "p",
      }),
      0,
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(WrappedParagraph, {
      // NodeViewContent を span で作らせるための設定
      contentDOMElementTag: "span",
    });
  },
});

const WrappedParagraph = () => {
  return (
    <NodeViewWrapper>
      <ParagraphComponent>
        {/* @ts-expect-error */}
        <NodeViewContent as="span" />
      </ParagraphComponent>
    </NodeViewWrapper>
  );
};

renderHTML() で配列の要素に 0 が混ざっていますが、これは「ここに Paragraph の子要素(例えば Text)が入ってきますよ」という意味です。(引用元

The number zero (pronounced “hole”) is used to indicate the place where a node's child nodes should be inserted. If it occurs in an output spec, it should be the only child element in its parent node.

renderHTML()HTMLAttributes は Tiptap 側で作れられる属性で、これを mergeAttributes() という補助関数で自分がつけたい属性とマージします。

ReactNodeViewRenderer() のオプションと、<NodeViewContent as="span" />as の指定は、HTML の仕様違反を防ぐための調整です。
NodeViewContent はデフォルトでは div で作られる(参照)ため、ParagraphComponent の実体が p タグである場合、フレージングコンテンツ(いわゆるインライン要素)の中にフローコンテンツ(いわゆるブロック要素)が作られることになり、ブラウザ上でもエラーが出ます。
as="span" も必要で機能しますが、なぜか型エラーになるので一旦エラーを抑制しています。

おわりに

今回はこれらの内容について書きました。

  • Tiptap と Lexical のノードの扱いの違い
  • Tiptap で自作ノードを作っていく上でのセットアップ
  • Tiptap の実装やドキュメントの場所の紹介
  • 実際に Text と Paragraph を実装してみる
  • parseHTML()renderHTML() の概念的な説明
  • React の実装に関係なくノードを定義する方法

実装の内容はこちらにもまとめています。

https://github.com/ahuglajbclajep/react-twin-wysiwyg-editor

元気があれば他のノードの実装についても書いてみたいです。
自分もまだまだ勉強中ですが、この記事が Tiptap を触る方の一助になれば幸いです!

Discussion