🧑‍💻

TiptapとNext.jsでしずかなインターネットのエディターを作ろう

2024/03/23に公開

tiptapでエディターを作ろう

こんにちは、いつまで経っても洗濯機の柔軟剤と洗剤の投入口を間違えているきりさわです。
この記事は、しずかなインターネットのエディタを作ってみようというtiptapのチュートリアル的な記事です。
しずかなインターネットのエディタ機能を再現したコードは以下になります。

https://github.com/kirikirisu/re-shizu-editor

https://re-shizu-editor.vercel.app/

CSSや細い挙動などは適当な部分がありますがチュートリアルということでお手柔らかに🙏。意外と簡単にReactでエディタが作れるということを知ってもらえればと思います。

再現する機能

以下の機能を再現してみます。tiptapのAPIの使い方など細かい部分は機能ごとに実装する過程で紹介します。

  • 見出し
  • 小見出し
  • リスト
  • 番号付きリスト
  • 引用
  • リンク
  • 強調
  • 画像
  • 文字数カウント

TiptapのExtension

Extensionはtiptapの主要な概念なので最初にざっくりと説明をします。
tiptapはHTMLを読み込んでエディタ上の機能を表現します(Jsonでもできそうですが今回はHTMLで表現する方法を紹介します)。HTMLのタグと属性にエディタの機能的な表現を紐づけており、エディタで使用するタグを必ず読み込む必要があります。
コードで説明するためにドキュメントのGetting started にある最もシンプルな例を見てみます。

'use client'

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

const Tiptap = () => {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌎️</p>',
  })

  return (
    <EditorContent editor={editor} />
  )
}

export default Tiptap

公式が提供しているStarterKitというExtensionを使うことでpタグによる段落機能やulタグによるリスト機能などエディタで定番の機能は使えるようになります。
Extensionの説明のためにStarterKitに含まれているParagraph Extension を見てみます。

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

parseHTMLで読み込むタグを指定していて、今回のparagraphの場合はpタグを読み込んでいます。指定方法は、CSSセレクタが使えます。CSSセレクタが使えるのでclassにhogeとつけたpタグとhugaとつけたpタグを別々のExtensionで読み込んで別の機能とすることもできます。また配列が返せるので高度な例だと複数のタグを読み込んで一つの機能としてtiptap内で扱うこともできます。

  parseHTML() {
    return [
      { tag: 'p' },
    ]
  },

そして、renderHTMLでエディタから吐き出されるHTMLを定義します。

  renderHTML({ HTMLAttributes }) {
    return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  },

renderHTMLの戻り値は DOMOutputSpec型で配列でHTML構造を表現していきます。

このHTMLをrenderしたいとすると
<div id="wrapper" class="wrapper"><div class="inner"><p>hoge</p></div></div>

こうなります。
return ["div", { id: "wrapper", class: "wrapper" }, ["div", { class: "inner" }, ["p", 0]]];

0 はホールと呼ばれていてpタグの内側にある要素がそのまま入ってきます。(他のExtensionでparseHTMLされない要素の場合は消されます。)

このようにtiptapでは必ずExtensionでparseHTMLとrenderHTMLをしてHTML表現をtiptapの内部表現にする必要があります。(parseHTMLされなかったHTML要素は消されます。)
Extension内では他にもショートカットキーなど色々な設定ができたり、Schemaを定義して細かい挙動を制御できたりします。

Extensionを用いることで、ブラウザがHTMLをDOMにしてJSから操作しやすいようにしているようにtiptapは独自の内部表現へとパースしてエディタ上で発生する操作を扱いやすくしています。

余談ですが、厳密にはtiptapのコアライブラリであるprosemirrorが扱う表現へと透過的にパースしていて、エディタ上ではユーザーが何か操作をしてから画面のUIを変えるまでのライフサイクルはprosemirrorのライフサイクルに依存しています。ExtensionのaddNodeViewReactのコンポーネントをレンダリングすることもできます。(prosemirrorとreactのライフサイクルはすごく似ていますが、全く別物なのでreactからprosemirrorのライフサイクルに介入しようとしたりするとすごく渋かったりします。)
余談の余談ですが、tiptapはprosemirrorのラッパーなのでtiptapでよくわからない概念が出てきた時はprosemirrorの方でググったりすると理解しやすいかもしれないです。

余談が長くなりましたが、ESLintのプラグインやExpressのミドルウェア的な感じでtiptapはExtensionにより機能を拡張していくという部分が重要な部分です。

標準的な機能にスタイルを当てる

リスト・番号付きリスト・引用・強調は、機能的にはStarterKitでほぼ実装されているので以下のようにStarterKitをExtensionに追加してCSSで見た目を整えるだけです。

const Tiptap = () => {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌎️</p>',
  })

  return (
    <EditorContent editor={editor} />
  )
}

(リスト機能を例として説明しますが、番号付きリスト・引用・強調も同じです。)
リスト機能は**<ul><li></li></ul>**というHTML構造が吐き出されます。これはStarterKitに含まれているBulletListExtensionListItemExtensionのrenderHTMLで定義されています。
吐き出されるHTML構造は以下のようにしてuseEditorのonUpdateのタイミングでeditor.getHTMLを出力することでデバックしやすくなります。

const editor = useEditor({
  extensions,
  content,
  onUpdate({ editor }) {
    console.log(editor.getHTML());
  },
});

吐き出されたHTMLにCSSをあてることでエディタ上の見た目も変えられます。なんの変哲もないCSSです。

ul {
  padding-left: 1.2em;
  line-height: 1.85;

  li {
    list-style: disc;
  }
}

ということでよくあるエディタの機能については、振る舞いは公式のExtensionで実装されていてあとはCSSでオリジナリティを出すという感じで実装できてしまいます。
さらにExtensionを拡張してマークダウン形式のキーバインドを変えたり、吐き出すHTMLも変えたりして細かい挙動を制御することもできます。次の章でExtensionを上書きする例を見てみます。

公式のExtensionを上書きして挙動を変える

見出し・小見出しも公式のExtensionで振る舞いがほぼ完結していますがしずかなインターネットと挙動を揃えるために少し変えたいところがあります。

しずかなインターネットでは見出しを作った時に生成されるタグを制限しています。

#一つでh2タグが生成される

#二つでh3タグが生成される

#三つだとh3タグが生成される

#四つだとhタグは生成されない

GIFが思いの外わかりにくかったのと作るのが面倒(2MB以下にするために挙動ごと区切らないといけない)だったので、StarterKitに含まれるExtensionのデフォルトの挙動は以下のように画像一枚で示します。上記と同じ操作で見出しを作った時に生成されるHTMLを見るとh1, h2, h3, h4 となっています。このデフォルトの挙動はドキュメントでも示されています。

エディタで編集した記事を公開する際にHTMLのセマンティック、SEO的にタイトルをh1として設定して本文はh2・h3だけを使いたい場合も出でくるかと思います。こういった場合などはExtensionを以下のように上書きすることで吐き出すHTMLや挙動を制御することできます。

const extensions = [
  StarterKit.configure({ heading: { levels: [2, 3] } }),
];

これでしずかなインターネットと挙動を合わせることができました。
ということでExtensionを拡張することで機能の挙動を変える例を紹介しました。

Extensionを一から作って画像機能を再現する

ここまでは公式のExtensionを使って機能を整えてきました。この章ではExtensionを一から作って静かなインターネットの画像機能を再現してみます。

一応公式でも画像のExtensionは提供されていますが、機能的にしずかなインターネットの画像とは違いが多いのと勉強のために自作していきます。(今回の画像機能のように公式のExtensionとやりたいことに違いが多い場合、一応頑張って公式のExtensionを拡張して挙動を再現することもできますが頑張らないといけないですし、自作した方が早いです。公式のExtensionはドキュメントにPlaygroundが用意されているのでそこで試してから判断するのが良いと思います。)

https://tiptap.dev/docs/editor/api/nodes/image

最終的な画像Extensionは以下のようになります。次の章からこのExtensionの実装を見ていきます。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/ImageExtension.tsx

ImageExtension

まずはpaseHTMLを見ていきますが、その前にしずかなインターネットで画像を挿入すると以下のようなHTMLが作られることを確認します。

文字にするとこんなんですね。

<figure
  className="e-image ProseMirror-selectednode"
  data-natural-width="8124"
  data-natural-height="5419"
  data-size="default"
  data-style="default"
  contenteditable="false"
  draggable="true"
>
  <img
    src="https://r2.sizu.me/users/20599/post-images/7w4tuzvdukimzszkk7bb.jpeg"
    width="8124"
    height="5419"
    alt=""
  />
</figure>

このHTMLをそのままcontentに入れてリロードしてみます。

...

const extensions = [
  StarterKit.configure({ heading: { levels: [2, 3] } }),
];

// しずかなインターネットの画像機能のHTMLをそのまま入れてみる
const content = `<figure class="e-image ProseMirror-selectednode" data-natural-width="8124" data-natural-height="5419" data-size="default" data-style="default" contenteditable="false" draggable="true"><img src="https://r2.sizu.me/users/20599/post-images/7w4tuzvdukimzszkk7bb.jpeg" width="8124" height="5419" alt=""></figure>`;

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

  if (!editor) return false;

...

エディタには何も表示されないと思います。(<p>hoge</p>とか<ul><li>hello</li><li>huga</li></ul>とかを入れた場合は表示されると思います。)これはfigureとimgタグをパースするExtensionがないためです。
ということでまずはparseHTMLを見てみます。

  parseHTML() {
    return [
      {
        tag: "figure.e-image",
        getAttrs: (element) => {
          if (typeof element === "string") return false;

          const imageElement = element.querySelector("img");

          return {
            src: imageElement?.getAttribute("src") ?? null,
            width: imageElement?.getAttribute("width") ?? null,
            height: imageElement?.getAttribute("height") ?? null,
            alt: imageElement?.getAttribute("alt") ?? null,
          };
        },
      },
    ];
  },

tag は CSSセレクタなので figure.e-image はe-imageというクラスを持つfigureを指定しています。これでfigureを読み込めるようになりました。
もう一つgetAttrsを指定しています。これは子要素のimgタグの属性を取得するために実装します。コールバックの引数からtagで読み込んだ要素を参照できるため、javascriptで子要素のimgタグから属性の値を取得します。
tagで指定したタグの属性は自動で取得されて、子要素のタグの属性は自動では取得されないのでこのようにgetAttrsを使って自分で取得する必要があります。

続いてrenderHTMLを見てみます。

  renderHTML({ HTMLAttributes }) {
    const {
      "data-natural-width": naturalWidth,
      "data-natural-height": naturalHeight,
      "data-size": size,
      "data-style": style,
      src,
      alt,
      height,
      width,
    } = HTMLAttributes as ImageAttributes;

    return [
      "figure",
      {
        class: "e-image",
        "data-natural-width": naturalWidth,
        "data-natural-height": naturalHeight,
        "data-size": size,
        "data-style": style,
      },
      [
        "img",
        {
          src,
          alt,
          height,
          width,
        },
      ],
    ];
  },

吐き出すHTMLを実装しています。基本的には一つのExtensionでパースしたHTML構造をそのままrenderHTMLで吐き出します。こうすることでパースするHTMLと吐き出すHTMLが一対一で紐付くので管理しやすくなります。
HTMLAttributesにはparseHTMLでパースした属性の値が入っており、それをrenderHTMLでも使うことで読み込んだHTMLと全く同じHTMLを出力できます。こうすることで何度リロードしてHTMLを読み込んだとしてもHTMLを保ったままtiptap上で扱うことができます。逆に何かパース・レンダーし忘れているタグ・属性があったとすると読み込んだ時は存在していたはずの要素が消えたりもします。

画像のExtensionを見るとaddAttributesという関数が実装されているかと思います。ここでrenderHTMLで参照していたHTMLAttributesを返すことができます。今回はparseHTMLのgetAttrsで属性を取得していました。getAttrsでパースしてreturnしていた属性をこのaddAttributesでも定義しないとHTMLAttributesからパースした属性を参照できません。
ドキュメントではこのaddAttributesで属性を取得する方法も紹介されています。

  addAttributes() {
    return {
      "data-natural-width": {
        default: null,
      },
      "data-natural-height": {
        default: null,
      },
      "data-size": {
        default: "default",
      },
      "data-style": {
        default: "default",
      },
      src: {
        default: null,
      },
      alt: {
        default: null,
      },
      width: {
        deafult: null,
      },
      height: {
        default: null,
      },
    };
  },

ここまで自前でparseHTML, renderHTMLをする方法を見てきました。figureとimgをパース・レンダーできるようになったのでエディタ上に画像を表示できるようになりました。

次はaddCommandsを見てみます。次のような見た目をしています。

addCommands() {
  return {
    setImage:
      (options) =>
      ({ commands }) => {
        return commands.insertContent({
          type: this.name,
          attrs: options,
      });
    },
  };
},

tiptapはエディタ上で行う操作をCommandsとして提供してくれます。

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

エディタ操作をするための便利関数ファクトリーみたいなもので、エディタ上で選択されている要素を消したり、エディタ上でエンターする操作を実行したりできます。エディタでよくあるあの操作を実装したい、みたいな時はここのコマンドを見てみるといいかもしれないです。
useEditorで作られたeditorインスタンスからアクセスできます。

editor.commands.enter();

addCommandsはtiptapでデフォルトで用意されているコマンド群に自作したコマンドを追加するための関数です。もう一度、画像Extesionで実装されているaddCommandsを見てみます。

addCommands() {
  return {
    setImage:
      (options) =>
      ({ commands }) => {
        return commands.insertContent({
          type: this.name,
          attrs: options,
      });
    },
  };
},

setImageという名前でコマンドを追加しています。デフォルトで用意されているinsertContentコマンドを使ってoptionsで渡ってきた属性を自身の画像Extesionに渡してエディターに挿入しています。
先ほどaddAttributesで追加していた属性をoptionsから動的に渡すことができます。つまり、srcに渡すURLを変えることでエディタに挿入する画像を変えることができます。
以下のようにして画像をエディタに挿入できるようになります。

editor.commands.setImage({src: 'https:://~', ...});

これでコマンドを使って画像を挿入できるようになりました。ユーザーが画像を選んでエディタに入力する流れを見てみます。

まず画像を選んでもらいます。これはinputでできそうです。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Footer.tsx#L84-L103

次に画像が選択されたらURLなどImageExtensionでrenderHTMLをするのに必要な値を集めます。画像をアップロードしてsrcを作るのとwidht・heightを取得します。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Footer.tsx#L37-L47

そしてコマンドを使ってエディタに画像を入れます。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Footer.tsx#L49-L62

はい、ここまでで画像を選択してエディタに入れるところまでできました。

(自作したImageExtensionなどのExtensionは必ずuseEditorのextensionsに入れる必要があります。)

...

const extensions = [
  StarterKit.configure({ heading: { levels: [2, 3] } }),
  Image
];

...

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

  if (!editor) return false;

...

リロードすると消えてしまいますが、バックエンドを実装してDBにtiptapから吐き出されたHTMLを保存しておくことでコンテンツを保存しておくことができます。面倒だったので今回は実装していないです。tiptapのチュートリアルです。

画像のaltを変える

しずかなインターネットではエディタに入れた画像をクリックするとツールバーが出てきて画像のaltを設定できます。

なにかの要素が選択された時に何かを選択された要素の上に出すやつはtiptapが公式で提供しているBubbleMenuComponentで実装できます。

https://tiptap.dev/docs/editor/api/extensions/bubble-menu

基本的にはeditorインスタンスをpropsで渡してchildrenに出したいコンポーネントを置くだけです。

<BubbleMenu
  editor={editor}
>
  <div>hoge</div>
</BubbleMenu>

デフォルトではテキストを範囲選択したときに要素が出てきますが、今回は画像をクリックして選択した時にも要素を出したいです。これを実装するためにshouldShow propsを使います。

      <BubbleMenu
        editor={editor}
        shouldShow={({ from, to }) => {
          const isSelectImage = editor.isActive("image");
          const isSelectRange = from !== to;

          return isSelectImage || isSelectRange;
        }}
      >
        <ImageToolbar editor={editor} />
      </BubbleMenu>

editor.isActive('extensionの名前')を指定することで指定した要素が現在選択されているかどうかをとれます。今回はImageExtensionが指定されているかどうかを見ています。そして、from !== to の部分は範囲選択されているかどうかを見ています。fromは選択範囲の開始位置でtoは終了位置です。テキストが範囲選択された場合だとこの値は同じにはならないため、from !== toの条件式で範囲選択されてるかどうかを判定しています。
これで選択された要素が画像だった場合とテキストが範囲選択された場合に、選択している要素の上に何かしらの要素を出現させることができるようになりました。
今回はImageToolbarを出現させます。
この部分ですね。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/ImageToolbar.tsx

...

  return (
    <>
      {visibleAltTextInput ? (
        <form onSubmit={applyAltText} className="input-text">
          <div>
            <input value={altText} onChange={handleChangeAltText} />
            <button type="submit">適用</button>
          </div>
        </form>
      ) : (
        <div className="button-toolbar">
          <button
            type="button"
            style={{ color: isActiveAlt ? activeColor : defaultColor }}
            onClick={handleVisibleAltTextInput}
          >
            ALT
          </button>
...

ツールバーのaltをクリックするとツールバー一覧からaltのinputが出現するのでその状態をvisbleAltTextInputというフラグで管理しています。
isActiveAltでaltが設定されている時とされていない時で色を変えていますが、isActiveAltの実装は以下のようになっています。

  const { selection } = editor.state;

  if (!isNodeSelection(selection) || selection.node.type.name !== "image")
    return false;

  const imageAttrs = selection.node.attrs as ImageAttributes;

  const isActiveAlt = imageAttrs.alt !== "";

これは現在選択されている画像要素の属性を取得しています。(もっと簡潔に実装するならeditor.getAttributeでも同じことができます。)ここでは選択されている画像要素のalt属性の値を取得して、alt属性が設定されているかを判定してます。(isActiveAltという変数名はちょっと分かりにくいですね。)

inputで入力された文字をalt属性の値に設定するのはどうやっているか見てみます。

  const applyAltText = () => {
    editor.commands.updateAttributes("image", {
      alt: altText,
    });

    setVisibleAtlTextInput(false);
    setAltText("");
  };

commandsにupdateAttributesという便利なメソッドがあるのでそれを使って設定しています。あとはaltに値を適用後、inputの非表示と初期化をしています。

画像のスタイルと大きさを変える

画像のツールバーには画像のスタイルと大きさを変える項目があるためこれをどうやって実装しているのかみてみます。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/ImageToolbar.tsx#L74-L87

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/ImageToolbar.tsx#L26-L36

altの時と同じようにスタイルと大きさを変える場合もupdateAttributesを使っています。サイズを変える時はdata-size属性、スタイルを変える時はdata-styleの値を変えています。そして、CSSを見てみる次のようになっています。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/page.css#L93-L103

サイズ、スタイルを変えた時の動的な動きはエディタ上から属性を変えることでCSSプロパティの参照を変えて実装しています。こんな感じでエディタではCSSで挙動を操作する場面が結構あったりします。

画像を Drag and Drop で挿入できるようにする

しずかなインターネットでは画像を drag and drop で挿入できるのでこれを実装してみます。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/ImageExtension.tsx#L136-L156

ExtensionのaddProseMirrorPluginsを使うことでエディタで発生するイベントの幅を広げることができます。今回は画像がdropされた時のイベントをフックしたいのでhandleDropを使用しています。画像ファイルを受け取る動線が変わっただけなので画像をアップロードしてエディタに挿入する処理は、inputからファイルを受け取った時の関数を使い回しています。少しわかりにくい点としてbooleanを返しています。trueを返した場合はこのプラグインで実装した処理を実行して、falseを返した場合は他のプラグインのhandleDropの実装かブラウザのデフォルトの実装に処理が委ねられます。

リンク機能

リンク機能は公式のExtensionがあるのでそれを使って大体完成です。

https://tiptap.dev/docs/editor/api/marks/link

...

import Link from "@tiptap/extension-link";

const extensions = [
  StarterKit.configure({ heading: { levels: [2, 3] } }),
  Image,
  Link.configure({
    openOnClick: false,
  }),
];
...

LinkExtensionをインスコしてextensionsに追加すると機能が有効になります。
ちょと調整が必要なところとしてはBubbleMenuのところで、リンクにカーソルが当たっている時もBubbleMenuが出現するようにします。さらにchildrenを画像のツールバーとリンクの入力コンポーネントの表示を選択されたのが画像かどうかで切り替えています。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Tiptap.tsx#L50-L72

テキストを範囲選択してリンクを設定したり、解除したりするのは以下の関数でやっています。リンク機能はテキストに対して装飾を行うような機能のためMarkを使用しています。extendMarkRangeを使って選択範囲を指定後に追加・削除をしています。(Markの知見があまりなくて詳細は分かりませんが、Markは太文字と斜線を同時に装飾するなどHTMLの構造的に入れ子になることがあるためextendMarkRageみたいなメソッドが提供されていると思います)

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/BubbleLink.tsx#L9-L14

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/BubbleLink.tsx#L20-L22

文字数カウント機能

しずかなインターネットには現在の文字数を右下に表示しています。文字数カウントも公式が提供しているExtensionがあるのでそれで実装できます。インスコしてextensionsに追加するだけです。

https://tiptap.dev/docs/editor/api/extensions/character-count

文字数制限と現在の文字数取得は以下のようにできます。

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Tiptap.tsx#L17

https://github.com/kirikirisu/re-shizu-editor/blob/main/src/app/Footer.tsx#L78

まとめ

ということで一通りしずかなインターネットのエディタ機能を再現してみました。WYSIWYGエディタでよくみる機能は公式がExtensionを用意してくれているのでそれを追加するだけでできてしまいます。一方で、今回の画像機能のように自分で一からExtensionを作ってオリジナルの機能を作ることもできます。自分でExtensionを作る場合は、parseHTMLとrenderHTMLでHTMLをパースする部分が最初にちょっと躓くポイントかと思いますが、ここさえクリアすればSchemaやprosemirrorのAPIを使ってゴリゴリにオリジナルの機能を開発できる道が開けるため、この記事がその最初の部分のクリアに役立てば嬉しいです。

Discussion