🦔

React RouterでRich Text Editorを使う

に公開

React Routerの設定

まずはReact Routerのコマンドで、アプリケーションの雛形を作成します。

npx create-react-router@latest my-react-router-app

https://reactrouter.com/start/framework/installation

Routing

https://reactrouter.com/start/framework/routing
ルーティングはroutes.tsに書きます。
前回までの記事を参考にルーティングを設定しましょう。
https://zenn.dev/keita_f/articles/f791869b941244#ルーティング
layout、...prefix、index、routesなどを使用してルーティングを設定します。
appディレクトリの中にディレクトリを作成し、そのディレクトリの中に作成したファイルを読み込むことでroutes.tsに設定したルーティングに応じて表示されます。

今回は、Rich Text Editorを設定するので、サンプルとして以下のように設定します。

app/routes.ts
import {
  type RouteConfig,
  index,
  layout,
  prefix,
  route,
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  layout("./editor/layout.tsx", [
    ...prefix("editor", [
      index("editor/editor.tsx"),
    ]),
  ]),
] satisfies RouteConfig;
app/editor/layout.ts
import { Outlet } from "react-router";

export default function EditorLayout() {
  return (
    <div className="flex items-center flex-col min-h-screen justify-center mx-auto">
      <Outlet />
    </div>
  );
}

https://tailwindcss.com/docs/installation/framework-guides/react-router

Rich Text Editorを設定する

今回はEditorにTipTapを使用します。
https://tiptap.dev/docs/editor/getting-started/overview

まず、TiptapのコアパッケージとReact用のパッケージ、さらに必要な拡張機能をインストールします。

npm install @tiptap/react @tiptap/core @tiptap/starter-kit
# または
yarn add @tiptap/react @tiptap/core @tiptap/starter-kit

まずは、editor内で使用するToolbarを作成します。

コードを確認する
app/components/Toolbar.tsx
import React, { useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Heading, { type Level } from "@tiptap/extension-heading";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Highlight from "@tiptap/extension-highlight";
import Underline from "@tiptap/extension-underline";
import Strike from "@tiptap/extension-strike";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import Color from "@tiptap/extension-color";
import TextStyle from "@tiptap/extension-text-style";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import TextAlign from "@tiptap/extension-text-align"; // テキストアラインメント用
import Blockquote from "@tiptap/extension-blockquote";

// CSS for basic styling
import "app/EditorStyles.css"; // このファイルは後述します
import { uploadImageToBackend } from "~/utils/imageUpload";

interface RichTextEditorWithToolbarProps {
  initialContent?: string;
  onChange?: (htmlContent: string) => void;
}

const RichTextEditorWithToolbar: React.FC<RichTextEditorWithToolbarProps> = ({
  initialContent = "<p>ここにテキストを入力...</p>",
  onChange,
}) => {
  // 画像アップロード中の状態を管理するための state (オプション)
  const [isUploadingImage, setIsUploadingImage] = React.useState(false);

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        // StarterKitに含まれる一部の拡張機能を無効にして、個別に設定する
        heading: false, // headingを個別に設定するため無効化
        // strike: false, // strikeを個別に設定するため無効化
        // underline: false, // underlineを個別に設定するため無効化
        // subscript: false, // subscriptを個別に設定するため無効化
        // superscript: false, // superscriptを個別に設定するため無効化
      }),
      Heading.configure({
        levels: [1, 2, 3, 4, 5], // 見出しレベル
      }),
      Link.configure({
        openOnClick: false, // リンクをクリックしてもエディタを離れないように
        autolink: true, // 自動リンク変換
      }),
      Image.configure({
        inline: true, // インライン要素として画像を挿入
        // allowBase64: true, // 小規模な画像をbase64で埋め込むことを許可 (非推奨な場合も多い)
      }),
      Highlight.configure({
        multicolor: true, // 複数のハイライト色を許可
      }),
      Underline,
      Strike,
      Subscript,
      Superscript,
      TextStyle, // Colorを使うためにはTextStyleが必要
      Color,
      Table.configure({
        resizable: true, // テーブルのリサイズを許可
      }),
      TableRow,
      TableHeader,
      TableCell,
      TextAlign.configure({
        types: ["heading", "paragraph"], // テキストアラインメントを適用するノード
      }),
      Blockquote,
    ],
    content: initialContent,
    onUpdate: ({ editor }) => {
      // コンテンツが変更されるたびにHTMLを親に返す
      if (onChange) {
        onChange(editor.getHTML());
      }
    },
    editorProps: {
      attributes: {
        class:
          "prose prose-sm sm:prose lg:prose-lg xl:prose-xl focus:outline-none max-w-full min-h-[300px] p-4 border border-gray-300 rounded-b-md dark:border-gray-700",
      },
      handleDrop: (view, event, slice, moved) => {
        if (
          event.dataTransfer &&
          event.dataTransfer.files &&
          event.dataTransfer.files.length > 0
        ) {
          const file = event.dataTransfer.files[0];

          // ファイルが画像であれば、カスタムのアップロード関数を呼び出す
          if (file.type.startsWith("image/")) {
            hadleFileUpload(file);
            return true; // TipTapのデフォルトのドロップ処理を停止
          }
        }
        return false; // それ以外のドロップはTipTapのデフォルトの挙動に任せる
      },
      handlePaste: (view, event, slice) => {
        if (
          event.clipboardData &&
          event.clipboardData.files &&
          event.clipboardData.files.length > 0
        ) {
          const file = event.clipboardData.files[0];
          // ファイルが画像であれば、カスタムのアップロード関数を呼び出す
          if (file.type.startsWith("image/")) {
            hadleFileUpload(file);
            return true; // TipTapのデフォルトのドロップ処理を停止
          }
        }
        return false; // それ以外のドロップはTipTapのデフォルトの挙動に任せる
      },
    },
  });

  // リンクの追加/編集ハンドラー
  const setLink = useCallback(() => {
    if (!editor) return;

    const previousUrl = editor.getAttributes("link").href;
    const url = window.prompt("URLを入力してください:", previousUrl);

    // 空のURLはリンク解除
    if (url === null) {
      return;
    }
    if (url === "") {
      editor.chain().focus().extendMarkRange("link").unsetLink().run();
      return;
    }
    // URLを設定
    editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
  }, [editor]);

  // 画像アップロードの共通処理関数
  const hadleFileUpload = useCallback(
    async (file: File) => {
      if (!file || !editor) return;

      // ファイルタイプが画像であることを確認
      if (!file.type.startsWith("image/")) {
        alert("画像ファイルのみをアップロードできます。");
        return;
      }

      setIsUploadingImage(true);

      try {
        const uploadResult = await uploadImageToBackend(file);
        if (uploadResult && uploadResult.url) {
          // trueの場合
          editor
            .chain()
            .focus()
            .setImage({
              src: `${process.env.API_ROOT_URL}/api/v1/upload/upload_image}`,
            })
            .run();
        } else {
          throw new Error("画像のURLがバックエンドから送信されませんでした。");
        }
      } catch (error: unknown) {
        console.error("画像アップロードエラー:", error);
        alert(
          "画像のアップロードに失敗しまいた。\n" + (error as Error).message
        );
      } finally {
        setIsUploadingImage(false);
      }
    },
    [editor]
  );

  // ★★★ addImage 関数(ボタンクリック用)を修正 ★★★
  const addImage = useCallback(() => {
    if (!editor) return;

    // ファイル選択用のinput要素を動的に作成
    const input = document.createElement("input");
    input.setAttribute("type", "file");
    input.setAttribute("accept", "image/*");
    input.click();

    input.onchange = async () => {
      const file = input.files?.[0];
      if (file) {
        await hadleFileUpload(file); // 共通のファイルアップロード処理を呼び出す
      }
    };
  }, [editor, hadleFileUpload]); // handleFileUpload も依存関係に追加

  if (!editor) {
    return null; // エディタの初期化が完了するまで何も表示しない
  }

  return (
    <div className="border rounded-md shadow-sm dark:bg-gray-800 dark:border-gray-700">
      <div className="print:hidden toolbar-wrapper p-2 border-b border-gray-300 rounded-t-md bg-gray-50 flex flex-wrap gap-1 dark:bg-gray-900 dark:border-gray-700">
        {/* Undo/Redo */}
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
          className="toolbar-button"
        >
          <i className="fas fa-undo"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
          className="toolbar-button"
        >
          <i className="fas fa-redo"></i>
        </button>

        {/* Basic Text Formatting */}
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={`toolbar-button ${
            editor.isActive("bold") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-bold"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={`toolbar-button ${
            editor.isActive("italic") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-italic"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleUnderline().run()}
          className={`toolbar-button ${
            editor.isActive("underline") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-underline"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={`toolbar-button ${
            editor.isActive("strike") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-strikethrough"></i>
        </button>

        {/* Subscript/Superscript */}
        <button
          onClick={() => editor.chain().focus().toggleSubscript().run()}
          className={`toolbar-button ${
            editor.isActive("subscript") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-subscript"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleSuperscript().run()}
          className={`toolbar-button ${
            editor.isActive("superscript") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-superscript"></i>
        </button>

        {/* Text Align */}
        <button
          onClick={() => editor.chain().focus().setTextAlign("left").run()}
          className={`toolbar-button ${
            editor.isActive({ textAlign: "left" }) ? "is-active" : ""
          }`}
        >
          <i className="fas fa-align-left"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().setTextAlign("center").run()}
          className={`toolbar-button ${
            editor.isActive({ textAlign: "center" }) ? "is-active" : ""
          }`}
        >
          <i className="fas fa-align-center"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().setTextAlign("right").run()}
          className={`toolbar-button ${
            editor.isActive({ textAlign: "right" }) ? "is-active" : ""
          }`}
        >
          <i className="fas fa-align-right"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().setTextAlign("justify").run()}
          className={`toolbar-button ${
            editor.isActive({ textAlign: "justify" }) ? "is-active" : ""
          }`}
        >
          <i className="fas fa-align-justify"></i>
        </button>

        {/* Headings */}
        {[1, 2, 3, 4, 5].map((levelNum) => (
          <button
            key={levelNum}
            onClick={() =>
              editor
                .chain()
                .focus()
                .toggleHeading({ level: levelNum as Level })
                .run()
            }
            className={`toolbar-button ${
              editor.isActive("heading", { level: levelNum as Level })
                ? "is-active"
                : ""
            }`}
          >
            H{levelNum}
          </button>
        ))}

        {/* Lists */}
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={`toolbar-button ${
            editor.isActive("bulletList") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-list-ul"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={`toolbar-button ${
            editor.isActive("orderedList") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-list-ol"></i>
        </button>

        {/* Blockquote & Code Block */}
        <button
          onClick={() => editor.chain().focus().toggleBlockquote().run()}
          className={`toolbar-button ${
            editor.isActive("blockquote") ? "is-active" : ""
          }`}
        >
          <i className="fa-solid fa-indent"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleCodeBlock().run()}
          className={`toolbar-button ${
            editor.isActive("codeBlock") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-code"></i>
        </button>

        {/* Links & Images */}
        <button
          onClick={setLink}
          className={`toolbar-button ${
            editor.isActive("link") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-link"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().unsetLink().run()}
          disabled={!editor.isActive("link")}
          className="toolbar-button"
        >
          <i className="fas fa-unlink"></i>
        </button>
        <button onClick={addImage} className="toolbar-button">
          <i className="fas fa-image"></i>
        </button>

        {/* Colors & Highlight */}
        <input
          type="color"
          onInput={(event) =>
            editor
              .chain()
              .focus()
              .setColor((event.target as HTMLInputElement).value)
              .run()
          }
          value={editor.getAttributes("textStyle").color || "#000000"} // 現在の色を取得
          className="toolbar-color-input w-[35px]"
        />
        <button
          onClick={() => editor.chain().focus().unsetColor().run()}
          className="toolbar-button"
        >
          <i className="fas fa-eraser"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().toggleHighlight().run()}
          className={`toolbar-button ${
            editor.isActive("highlight") ? "is-active" : ""
          }`}
        >
          <i className="fas fa-highlighter"></i>
        </button>

        {/* Tables */}
        <button
          onClick={() =>
            editor
              .chain()
              .focus()
              .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
              .run()
          }
          className="toolbar-button"
        >
          <i className="fas fa-table"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().addRowAfter().run()}
          className="toolbar-button"
        >
          <i className="fas fa-plus-square"></i> 行追加
        </button>
        <button
          onClick={() => editor.chain().focus().addColumnAfter().run()}
          className="toolbar-button"
        >
          <i className="fas fa-plus-square"></i> 列追加
        </button>
        <button
          onClick={() => editor.chain().focus().deleteRow().run()}
          className="toolbar-button"
        >
          <i className="fas fa-minus-square"></i> 行削除
        </button>
        <button
          onClick={() => editor.chain().focus().deleteColumn().run()}
          className="toolbar-button"
        >
          <i className="fas fa-minus-square"></i> 列削除
        </button>
        <button
          onClick={() => editor.chain().focus().deleteTable().run()}
          className="toolbar-button"
        >
          <i className="fas fa-trash-alt"></i> テーブル削除
        </button>
        <button
          onClick={() => editor.chain().focus().mergeCells().run()}
          disabled={!editor.can().mergeCells()}
          className="toolbar-button"
        >
          <i className="fas fa-compress-alt"></i> セル結合
        </button>
        <button
          onClick={() => editor.chain().focus().splitCell().run()}
          disabled={!editor.can().splitCell()}
          className="toolbar-button"
        >
          <i className="fas fa-expand-alt"></i> セル分割
        </button>

        {/* Other actions */}
        <button
          onClick={() => editor.chain().focus().setHorizontalRule().run()}
          className="toolbar-button"
        >
          <i className="fas fa-minus"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().setHardBreak().run()}
          className="toolbar-button"
        >
          <i className="fas fa-level-down-alt"></i>
        </button>
        <button
          onClick={() => editor.chain().focus().clearNodes().run()}
          className="toolbar-button"
        >
          <i className="fas fa-text-width"></i>書式クリア
        </button>
        <button
          onClick={() => editor.chain().focus().setParagraph().run()}
          className="toolbar-button"
        >
          <i className="fas fa-paragraph"></i> P
        </button>
      </div>

      <EditorContent editor={editor} />
    </div>
  );
};

export default RichTextEditorWithToolbar;

次に、editorコンポーネントを作成していきます。
基本的な機能を備えたeditorは以下のコードで設定できます。
Toolbarコンポーネントを読み込み、エディターを設定します。

コードを確認する
app/components/editor.tsx
// import RichTextEditorWithToolbar from "./Toolbar";
import { useEffect, useState, type SetStateAction } from "react";

interface EditorProps {
  initialContent?: string;
  onChange: (html: string) => void;
}

const Editor = ({ initialContent, onChange }: EditorProps) => {
  const [editorContent, setEditorContent] = useState<string>("");
  // const [saveStatus, setSaveStatus] = useState<string>("");

  // RichTextEditorWithToolbar コンポーネントを動的にインポートするための状態
  const [RichTextEditorWithToolbar, setRichTextEditorWithToolbar] =
    useState<any>(null);

  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    if (typeof window !== "undefined") {
      setIsClient(true);
      // クライアントサイドでのみ動的にインポート(SSR時にはこのブロックは実行されない)
      import("./Toolbar")
        .then((mod) => setRichTextEditorWithToolbar(() => mod.default))
        .catch((err) =>
          console.error("Failed to load RicheTextEditorWithToolbar.", err)
        );
    }
  }, []);

  // RichTextEditorWithToolbar からコンテンツが変更されるたびに呼び出されるハンドラ
  const handleEditorChange = (newHtml: SetStateAction<string>) => {
    setEditorContent(newHtml);
    console.log("Editor content (HTML): ", newHtml);
  };
  return (
    <div className="w-full">
      <label>Content:</label>
      {isClient && RichTextEditorWithToolbar ? (
        // EditorProvider もRichTextEditorWithToolbar内部でラップするか、ここでレンダリングする
        // EditorProvider もDOMに依存するため、RichTextEditorWithToolbarがロードされた後でレンダリングされるようにする
        <RichTextEditorWithToolbar
          onChange={onChange}
          initialContent={initialContent}
        />
      ) : (
        // サーバーサイドでレンダリングされるプレースホルダー
        <div
          className="prose dark:prose-invert min-h-[150px] max-h-[300px] overflow-y-auto w-full p-4 border border-gray-300 rounded bg-gray-50"
          dangerouslySetInnerHTML={{ __html: initialContent || "" }}
        >
          {/* 初期コンテンツが表示されるか、ローディングメッセージが表示される */}
          {/* initialContentがない場合は、"Loading rich text editor..." などのメッセージを表示することも可能 */}
        </div>
      )}
      {/* debug */}
      {/* <div
        style={{
          margin: "20px auto",
          maxWidth: "800px",
          height: "auto",
          border: "1px dashed #ccc",
          padding: "15px",
          flexWrap: "wrap",
        }}
      >
        <div className="flex justify-evenly flex-wrap">
          <div className="w-full">
            <h2>Preview HTML:</h2>
            <pre className="">{editorContent}</pre>
          </div>
          <div className="w-full">
            <h2>Preview:</h2>
            <div
              className="prose"
              dangerouslySetInnerHTML={{ __html: editorContent }}
            />
          </div>
        </div>
      </div> */}
    </div>
  );
};

export default Editor;

次に、componentsディレクトリに、Form.tsxを作成します。
このコンポーネント内でEditor.tsxを読み込み、Rich Text Editorが表示されます。

コードを確認する
app/components/Form.tsx
import { useCallback, useEffect, useState, type SetStateAction } from "react";
import { useNavigate, useParams } from "react-router";
import Editor from "./editor";


interface FormProps {
  initialData?: initialData; // 編集時に既存データを渡す
  // author_uuid はログインユーザーから取得するか、propsで渡すなど
  currentAuthorUuid: string; // 現在ログインしているユーザーのUUID
}

const Form: React.FC<FormProps> = ({
  initialData,
  currentAuthorUuid,
}) => {
  const navigate = useNavigate();

  // state
  const [title, setTitle] = useState(initialData?.title || "");
  const [content, setContent] = useState(
    initialData?.content || "<p>ここにテキストを入力...</p>"
  );
  const [isPublished, setIsPublished] = useState(
    initialData?.is_published || false
  );
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [saveStatus, setSaveStatus] = useState<string>("");

  const [authToken, setAuthToken] = useState<string | null>(null);
  useEffect(() => {
    if (typeof window !== "undefined") {
      setAuthToken(localStorage.getItem("authToken")); // 例: 認証トークン
    }
  }, []);

  // TipTap エディタのコンテンツ変更ハンドラ
  const handleEditorChange = useCallback((newHtml: SetStateAction<string>) => {
    if (typeof newHtml === "function") {
      setContent((prev) => newHtml(prev));
    } else {
      setContent(newHtml);
    }
  }, []);

  return (
    <div className="m-5">
      <div className="flex justify-center">
        <div className="flex text-4xl">
          <span className="text-emerald-400">P</span>ress{" "}
          <span className="text-emerald-400">R</span>elease{" "}
          {initialData ? (
            <p>
              <span className="text-emerald-400">E</span>dit
            </p>
          ) : (
            <p>
              <span className="text-emerald-400">C</span>reate
            </p>
          )}
        </div>
      </div>
      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4">
          {error}
        </div>
      )}
      {saveStatus && saveStatus !== "保存中..." && (
        <div
          className={`mb-4 px-4 py-2 rounded ${
            saveStatus === "保存完了!"
              ? "bg-green-100 text-green-700"
              : "bg-yellow-100 text-yellow-700"
          }`}
        >
          {saveStatus}
        </div>
      )}
      <div className="flex flex-col my-3">
        <label htmlFor="title">Title:</label>
        <textarea
          id="title"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
          className="p-1.5 rounded-md border border-gray-300 hover:border-emerald-400 w-full"
        />
      </div>
      <div className="max-w-2xl w-full">
        <Editor initialContent={content} onChange={handleEditorChange} />
      </div>

      {/* submit button */}
      <button
        type="submit"
        className="max-w-2xl w-full cursor-pointer my-2 px-4 py-2 bg-cyan-600 text-white font-semibold rounded-md shadow-sm hover:bg-emerald-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emelard-500 disabled:opacity-50"
        disabled={loading}
      >
        {loading
          ? "保存中..."
          : initialData
          ? "プレスリリースを更新"
          : "プレスリリースを作成"}
      </button>
    </div>
  );
};

export default Form;

表示ページの設定

設定したエディターをアクセスするページに表示するように設定します。

コードを確認する
app/page/create.tsx
import { useEffect, useState } from "react";
import { Form, useNavigate } from "react-router";

import Form from "~/components/Form";
import type { Route } from "./+types/create";

// (オプション) 既存のプレスリリースの編集を考慮する場合の型
interface initialData {
  uuid?: string; // 編集時は存在する
  title: string;
  content: string; // HTMLコンテンツ
  author_uuid: string; // CustomUser の UUID
}

interface FormProps {
  initialData?: initialData; // 編集時に既存データを渡す
  // author_uuid はログインユーザーから取得するか、propsで渡すなど
  currentAuthorUuid: string; // 現在ログインしているユーザーのUUID
}

// meta data
export function meta({}: Route.MetaArgs) {
  return [
    { title: "Create" },
    {
      property: "og:title",
      content: "content",
    },
    { name: "description", content: "Create page." },
  ];
}

export default function Create({
  loaderData,
}: {
  loaderData: { error: string };
}) {
  const navigate = useNavigate();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const [authToken, setAuthToken] = useState<string | null>(null);
  useEffect(() => {
    if (typeof window !== "undefined") {
      setAuthToken(localStorage.getItem("authToken")); // 例: 認証トークン
    }
  }, []);

  return (
    <div className="m-5">
      <Form method="post" encType="multipart/form-data">
        <div className="w-full">
          <Form
            // currentAuthorUuid={currentAuthorUuid}
            currentAuthorUuid={"95928bb7-4e68-4d4e-8f56-7a01c2ba7657"}
            tags={tags}
            tagError={loaderData.error}
          />
        </div>
      </Form>
    </div>
  );
}

以上でエディターが表示されます。

Discussion