【Reactアプリ100本ノック】09 Memo

## はじめに
ゆかっしゅです。
2021年に事務職からデザイナー/コーダーにジョブチェンジをし、現在はフロントエンドエンジニアにスキルアップするべく、モダンフロントエンドを学習しています。

ReactやTypeScriptの基礎学習は終了したのに、なかなか0から自分だけでアプリを作れないので手を動かすべく「Reactアプリ100本ノック」 に挑戦してみようと思います。

https://qiita.com/Sicut_study/items/a27e5789a1b23438d77c

## Reactアプリ100本ノックルール
- 主要なライブラリやフレームワークはReactである必要がありますが、その他のツールやライブラリ（例: Redux, Next.js, Styled Componentsなど）を組み合わせて使用することは自由
- TypeScriptを利用する
- 要件をみたせばデザインなどは自由

## 09. Memo
![スクリーンショット 2025-05-08 152708.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4068307/cba4a3da-31e6-4a52-8461-924efdc8d228.png)

## 問題
ユーザーが思いついたことを迅速かつ効率的に記録し、整理するためのツールを提供します。リッチテキストエディタは、メモをフォーマットし、より視覚的かつ整理された形で情報を保存することを可能にします。

## 達成条件
- メモを作成できる
- メモを削除できる
- メモを選択すると選択されたメモの内容がエディタに表示される

## 実際に解いてみた
### 利用技術
React(19.0.0)
TypeScript(5.0)
Next.js(15.3.1)
Tailwindcss(4.0)
jotai(2.12.3)
tiptap(2.12.0)
Vercel


解答時間　4時間半

もうちょっとブロックパーツ部分のスタイル作りこみたかったんですけど、時間がかかりすぎちゃったのでやめました。

まず使うリッチテキストエディタライブラリの選定から始めました。最初は学習コスト少なそうな「Quill」を使おうと色々調べてたのですが、なんと __React v19に対応していない__ ことがわかり、選定しなおしへ...

結局「tiptap」を使用しました。キャッチアップに時間がかかりました...
tiptapのドキュメントは↓こちら↓（サンプルコードもあります）

https://next.tiptap.dev/

今後ブログとかに応用できそうだなぁと思ったので、備忘録的に「tiptap」の使い方をまとめたいと思います。

※tailwindcssと併用するときの注意点もあります。

### リンク
https://react-app-100.vercel.app/



## 【備忘録】Tiptap
### 準備
まずはインストールします。
Tiptapにはいろいろなキット（？）があり、使いたいものをインストールするような形です。
今回のようなBlog風のエディタを作成するには以下をインストールしました。

- __@tiptap/react__：React用のTiptapの公式パッケージ
- __@tiptap/starter-kit__：基本機能のセット（太字、打消し、リスト等）
- __@tiptap/extension-heading__：H1-H6などの見出しを使えるようにする拡張機能
- __@tiptap/extension-link__：アンカーリンクを使える由生にする拡張機能



```
pnpm add @tiptap/extension-heading　@tiptap/extension-link　@tiptap/react　@tiptap/starter-kit
```

### 初期表示
```typescript:editor.tsx
// components/Editor.tsx
"use client";

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

export const Editor = () => {
  const editor = useEditor({
    extensions: [StarterKit],
    content: "<p>ここに初期表示されます。</p>",
  });

  return (
    <div className="p-4 border rounded">
      <EditorContent editor={editor} />
    </div>
  );
};
```

__useEditor__：先ほどインストールした`tiptap/react`にはいっているフックです。ここでエディターの初期化を行います。

__extensions: [StarterKit]__：さきほどインストールした`tiptap/starter-kit`を`extensions`に設定することで基本的なリッチテキスト機能（太字、打消し、リスト等）が使用できます。

__extensions: [StarterKit]__：さきほどインストールした`tiptap/starter-kit`を`extensions`に設定することで基本的なリッチテキスト機能（太字、打消し、リスト等）が使用できます。

__content: ""__：初期表示内容です。空にもできます。


このコードで下記のような実装ができます。
最初は`<textarea>`と変わりませんがここから太字にするボタンなどを追加していきます。


![スクリーンショット 2025-05-08 160401.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4068307/f569622f-5a3a-44c4-b738-7f4af38053ae.png)


### 機能ボタンの追加
```typescript:toolmenu.tsx
import { Editor } from "@tiptap/react";
import {
  MdFormatBold,
  MdFormatStrikethrough,
  MdRedo,
  MdUndo,
  MdOutlineTextFields,
  MdCode,
  MdFormatQuote,
  MdOutlineAddLink,
  MdLinkOff,
} from "react-icons/md";
import { FaListUl, FaListOl } from "react-icons/fa";

export const ToolMenu = ({ editor }: { editor: Editor }) => {
  if (!editor) {
    return null;
  }
  const headings = [
    { label: "H1", level: 1 },
    { label: "H2", level: 2 },
    { label: "H3", level: 3 },
  ] as const;
  return (
    <div className="flex flex-wrap gap-2 border-b border-gray-600 p-4 text-2xl">
      <div className="relative group">
        <button className={!editor.isActive("heading") ? "opacity-20" : ""}>
          <MdOutlineTextFields />
        </button>
        <div className="absolute left-0 top-full z-10 hidden flex-col gap-1 bg-white text-black p-2 rounded shadow-md group-hover:flex">
          {headings.map((h) => (
            <button
              key={h.level}
              onClick={() =>
                editor.chain().focus().toggleHeading({ level: h.level }).run()
              }
              className={`text-left px-2 py-1 hover:bg-gray-200  ${
                editor.isActive("heading", { level: h.level })
                  ? "font-bold"
                  : ""
              }`}
            >
              {h.label}
            </button>
          ))}
          <button
            onClick={() => editor.chain().focus().setParagraph().run()}
            className={`text-left px-2 py-1 hover:bg-gray-200 ${
              editor.isActive("paragraph") ? "font-bold" : ""
            }`}
          >
            P
          </button>
        </div>
      </div>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={!editor.isActive("bold") ? "opacity-20" : ""}
      >
        <MdFormatBold />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleStrike().run()}
        className={!editor.isActive("strike") ? "opacity-20" : ""}
      >
        <MdFormatStrikethrough />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        className={!editor.isActive("bulletList") ? "opacity-20" : ""}
      >
        <FaListOl />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleOrderedList().run()}
        className={!editor.isActive("orderedList") ? "opacity-20" : ""}
      >
        <FaListUl />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleBlockquote().run()}
        className={!editor.isActive("blockquote") ? "opacity-20" : ""}
      >
        <MdFormatQuote />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().toggleCodeBlock().run()}
        className={!editor.isActive("codeBlock") ? "opacity-20" : ""}
      >
        <MdCode />
      </button>
      <button
        type="button"
        onClick={() => {
          const url = prompt("リンク先のURLを入力してください");
          if (url) {
            editor
              .chain()
              .focus()
              .extendMarkRange("link")
              .setLink({ href: url })
              .run();
          }
        }}
        className={!editor.isActive("orderedlist") ? "opacity-20" : ""}
      >
        <MdOutlineAddLink />
      </button>
      <button
        type="button"
        onClick={() => editor.chain().focus().unsetLink().run()}
        className={!editor.isActive("orderedlist") ? "opacity-20" : ""}
      >
        <MdLinkOff />
      </button>
      <button onClick={() => editor.chain().focus().undo().run()} type="button">
        <MdUndo />
      </button>
      <button onClick={() => editor.chain().focus().redo().run()} type="button">
        <MdRedo />
      </button>
    </div>
  );
};

```

トグルメニューのコンポーネントを作成しました。このコンポーネントに機能ボタンのコードが記載されています。
太字にするボタンを抜粋して説明します。

```typescript:toolmenu.tsx
<button
        type="button"
        onClick={() => editor.chain().focus().toggleBold().run()}
        className={!editor.isActive("bold") ? "opacity-20" : ""}
      >
        <MdFormatBold />
      </button>
```

__onClick={() => editor.chain().focus().toggleBold().run()}__

正直、機能ボタンのonClick部分は一部を除いてほぼ一緒です。
` editor.chain()`でTiptapのチェーン操作を実行します。（この後に続く処理を続けて実行するみたいな）
そして`.focus()`でエディターにフォーカスし、最後の`run()`で実行します。
変わる部分は`.toggleBold()`で今回は太字なので`.toggleBold()`ですが、引用だったら`.toggleBlockquote()`、打消し戦だったら`.toggleStrike()`と変わります。

__className={!editor.isActive("bold") ? "opacity-20" : ""}__
ここは必須でないですが、太字機能がActiveになっている時にボタンの色を濃くしてます。
`("bold")`のところは必要に応じて`("blockquote")`とか`("strike")`にします。

__<MdFormatBold />__
React　iconを使用しました。

https://react-icons.github.io/react-icons/


## 【備忘録】TiptapとTailwindcss
上記のようにエディターやボタンの設定をしてもTailwindcssを使用していると、初期スタイルがないので見た目には何も変化がありません。
h1はこのスタイル、h2はこのスタイルと別でスタイルを充てる必要があります。

```css:globals.css
@import "tailwindcss";

.prose h1 {
  @apply text-3xl font-bold;
}

.prose h2 {
  @apply text-2xl font-bold;
}

.prose h3 {
  @apply text-lg font-bold;
}

.prose ol{
  @apply list-disc w-full text-wrap px-10;
}

.prose ul{
  @apply list-decimal w-full text-wrap px-10;
}

.prose blockquote{
  @apply bg-neutral-200 italic inline-block px-2 rounded-sm;
}

.prose a{
  @apply cursor-pointer underline;
}


.prose code{
  @apply p-2 rounded-sm bg-gray-900 text-white;
}
```

正しい方法かはわかりませんが、私は今回global.cssにブロックパーツ用のスタイルを当てました。


## おわりに
だんだんウェイトが重くなってきたので、毎日更新ではなく2日に一回、更新をしようと思います。