【Next.js+Tiptap】Notion風エディタを実装してみた

## 初めに
今回は、`tiptap`ライブラリを使用して、`Notion`風のエディタを実装しました。その過程を解説します。

## tiptapとは
Tiptapは、リッチテキストエディターのツールキットであるProseMirrorのヘッドレスラッパーです。これにより、簡単にWYSIWYGエディターを構築できるオープンソースのライブラリです。

https://tiptap.dev/docs

## 完成デモ動画


<iframe width="560" height="315" src="https://www.youtube.com/embed/5sxsI6EYtoc?si=tBw5wyzPKym2o04Y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


## 目次
1. 初期設定
2. エディタ表示
3. h1~h3タグの実装


## 前提条件
 -- Next.jsのプロジェクトを作成されていること。


## 1.初期設定
まずは、必要なtiptapのライブラリをインストールします。

```
npm install @tiptap/react @tiptap/starter-kit
```

## 2.エディタ表示
次に、テキストの書き込みができる状態にします。

```typescript:src/components/editor.tsx
"use client";

import { useEffect } from "react";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";

export const Editor = () => {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Placeholder.configure({
        placeholder: "Type / to browse options",
      }),
    ],
    content: "<p></p>",
  });

  return (
    <div className="mt-4 relative border border-gray-300 rounded-lg mx-2 min-h-[400px]">
      <EditorContent editor={editor} className="w-full h-full pl-4 pt-2" />
    </div>
  );
};
```
上記のコードで、テキスト入力ができる基本的なエディタが完成します。ただし、この状態では `placeholder`が正しく表示されません。
これは、`global.css`でも適用させる必要があるためです。`::before`疑似要素は、`content`プロパティが明示的に設定されていないと、何も表示されません。

なので`global.css`に以下のように書いてください。
`is-editor-empty`クラスは`web`ブラウザ上の開発者ツールで確認すると、確認できると思います。
```css/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

.is-editor-empty::before {
  content: attr(data-placeholder);
  @apply text-gray-500 absolute top-0 left-0;
}
```

次に、エディタを選択すると、`border`が出ると思いますが、個人的にはない方がいいので、以下のコードを`content`の下に追加します。

```typescript
editorProps: {
      attributes: {
        class: "focus:outline-none",
      },
    },
```

ここまでの全体のコードを以下に出します。

```typescript
"use client";

import { useEffect } from "react";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";

export const Editor = () => {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Placeholder.configure({
        placeholder: "Type / to browse options",
      }),
    ],
    content: "<p></p>",
    editorProps: {
      attributes: {
        class: "focus:outline-none", //入力時のborderを非表示にする
      },
    },
  });

  return (
    <div className="mt-4 relative border border-gray-300 rounded-lg mx-2 min-h-[400px]">
      <EditorContent editor={editor} className="w-full h-full pl-4 pt-2" />
    </div>
  );
};
```
ここまでの実装の画像です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2749225/ed3abece-4707-695c-ded2-8ff2f96aeec2.png)


## 3.h1~h3タグの実装

次に、`/` を入力するとメニューを表示し、選択したタグに応じて文字サイズを変更する機能を実装します。
`h1～h3`タグのデザインを適用するために、以下を`global.css`に追加してください。
```css/global.css
h1 {
  @apply text-2xl font-bold;
}

h2 {
  @apply text-xl font-semibold;
}

h3 {
  @apply text-lg font-medium;
}
```


次に`EditorMenu.tsx`コンポーネントを作成します。このコンポーネントは、`/`がクリックされたときに、表示されるメニューになります。
`addHeading`関数によって入力された文字がそれぞれの`h1~h3`タグのどれかに変わります。

```typescript:src/components/EditorMenu.tsx
type Props = {
  addHeading: (level: 1 | 2 | 3) => void;
};

export const EditorMenu = (props: Props) => {
  const { addHeading } = props;

  return (
    <div className="bg-white border border-gray-300 shadow-lg p-2 rounded-lg mt-2">
      <button onClick={() => addHeading(1)} className="block px-4 py-2 text-left hover:bg-gray-100 w-full">
        見出し 1
      </button>
      <button onClick={() => addHeading(2)} className="block px-4 py-2 text-left hover:bg-gray-100 w-full">
        見出し 2
      </button>
      <button onClick={() => addHeading(3)} className="block px-4 py-2 text-left hover:bg-gray-100 w-full">
        見出し 3
      </button>
    </div>
  );
};
```

次に`Editor.tsx`を以下のように修正します。

```typescript:src/components/Editor.tsx
"use client";

import { useCallback, useState } from "react";

import { EditorMenu } from "./EditMenu";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Heading from "@tiptap/extension-heading";
import Placeholder from "@tiptap/extension-placeholder";

export const Editor = () => {
  const [showMenu, setShowMenu] = useState(false);

  const editor = useEditor({
    extensions: [
      StarterKit,
      Heading.configure({ levels: [1, 2, 3] }),
      Placeholder.configure({
        placeholder: "Type / to browse options",
      }),
    ],
    content: "<p></p>",
    editorProps: {
      attributes: {
        class: "focus:outline-none",
      },
    },
    onUpdate: ({ editor }) => {
      const currentLine = editor.state.doc.textBetween(editor.state.selection.$anchor.start(), editor.state.selection.$anchor.end(), "\n");

      if (currentLine.trim() === "/") {
        setShowMenu(true);
      } else {
        setShowMenu(false);
      }

      setTimeout(() => {
        if (editor.isEmpty) {
          editor.commands.clearNodes();
        }
      }, 0);
    },
  });

  const addHeading = useCallback(
    (level: 1 | 2 | 3) => {
      if (!editor) return;
      const { from } = editor.state.selection;

      editor
        .chain()
        .focus()
        .deleteRange({ from: from - 1, to: from })
        .insertContent(" ")
        .setNode("paragraph")
        .toggleHeading({ level })
        .run();

      setShowMenu(false);
    },
    [editor]
  );

  return (
    <div className="mt-4 relative border border-gray-300 rounded-lg mx-2 min-h-[400px]">
      <EditorContent editor={editor} className="w-full h-full pl-4 pt-2" />
      {showMenu && <EditorMenu addHeading={addHeading} />}
    </div>
  );
};
 ```

 
上記のコードで、`.insertContent(" ")`を追加する理由として、`/`が消えた状態だと、文字が何もなくなってしまい、`toggleHeading`メソッドが効かなくなってしまいます。なのでこれの対策法として`/`を削除後に空白を入れるか`<br>`タグを入れる解決策があります。
`<br>`の場合、色々バグが起きたので今回は、空白を入れるやり方で実装しています






これで動作確認してみると以下のように実装することができました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2749225/31347751-d2ac-6b65-a12f-4a75d578849a.png)



## 終わりに
他にも機能はたくさんあるので色々実装してみようと思います。
ここまで読んでくれた方ありがとうございました。