🦔
React RouterでRich Text Editorを使う
React Routerの設定
まずはReact Routerのコマンドで、アプリケーションの雛形を作成します。
npx create-react-router@latest my-react-router-app
Routing
前回までの記事を参考にルーティングを設定しましょう。
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>
);
}
Rich Text Editorを設定する
今回はEditorにTipTapを使用します。
まず、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