Claude Codeでリッチテキストエディタを実装する方法
Claude CodeとTiptapで、保存・ツールバー・安全なHTML処理まで備えたReact製エディタを作る実践ガイド。
リッチテキストエディタは、見た目よりも実装の地雷が多いUIです。太字、見出し、リンク、画像、リストだけなら簡単に見えますが、実際には「保存形式を何にするか」「貼り付けHTMLをどう扱うか」「XSSを防げるか」「スマホで選択範囲が崩れないか」「将来MarkdownやCMS表示に変換できるか」まで決める必要があります。
この記事では、Claude Codeに丸投げするのではなく、要件を狭く定義して、TiptapとReact/TypeScriptでコピーして動かしやすい最小構成を作ります。TiptapはProseMirrorを土台にしたエディタフレームワークで、StarterKitだけでも段落、見出し、太字、斜体、リストなどを扱えます。公式のReactインストールガイドでも、@tiptap/react、@tiptap/pm、@tiptap/starter-kitを基本依存として案内しています。
Claude Codeは公式ドキュメントで「コードベースを読み、ファイル編集、コマンド実行、開発ツール連携を行うエージェント型コーディングツール」と説明されています。だからこそ、最初のプロンプトに保存形式、変更してよいファイル、検証コマンド、セキュリティ条件を入れると、単なるデモではなく運用しやすい差分になります。
Tiptapを選ぶ理由
リッチテキストの候補としては、Tiptap、Lexical、Slate、Quillなどがあります。Claude Codeで初心者が実装するなら、最初はTiptapを選ぶのが現実的です。Reactのサンプルが整っていて、JSONとHTMLの出力が分かりやすく、拡張も「Extension」という単位で追加できます。
Lexicalも強力です。公式サイトでは軽量でモジュール化されたテキストエディタフレームワークとして説明され、リスト、リンク、テーブルなどをパッケージで追加できます。一方で、ツールバーや保存形式まで自分で設計する場面が増えます。Slack風の入力欄や高度な独自ノードを作るならLexicalは良い選択ですが、ブログCMS、ナレッジベース、商品説明の編集欄のように「まず壊れにくいWYSIWYGを作る」目的ならTiptapのほうが説明しやすいです。
| 観点 | Tiptap | Lexical |
|---|---|---|
| 初期実装 | StarterKitで早い | プラグイン設計を理解する必要がある |
| 保存 | JSONとHTMLの両方を扱いやすい | EditorStateの扱いを決める必要がある |
| カスタム | Extensionで拡張 | 独自ノードやコマンドに強い |
| Claude Codeとの相性 | 既存例を読ませやすい | 要件定義を細かく書くほど安定する |
重要なのは「contenteditableを直接いじらない」ことです。MDNのcontenteditable説明にも、trueでは貼り付け時に書式が保持され、plaintext-onlyでは書式が削除されるとあります。ブラウザ差分や貼り付けHTMLを自前で処理し続けるのは大変なので、TiptapやLexicalのような編集エンジンに任せ、アプリ側は保存、検証、サニタイズに集中します。
インストールとClaude Codeへの依頼文
まずReact/TypeScriptのプロジェクトに依存を入れます。ViteでもNext.jsでも考え方は同じです。Next.js App Routerで使う場合は、エディタコンポーネントをクライアントコンポーネントにします。
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
Claude Codeには、次のように「採用するライブラリ」と「やらないこと」を一緒に伝えます。ここが曖昧だと、contenteditableを直接操作する実装や、サニタイズなしのdangerouslySetInnerHTMLが混ざりやすくなります。
TiptapでReact/TypeScriptのリッチテキストエディタを作ってください。
変更してよいのは src/components/RichTextEditor.tsx だけです。
機能は太字、斜体、H2/H3、箇条書き、番号付きリスト、リンク、画像URL、JSON/HTML保存です。
HTMLはDOMPurifyでサニタイズし、http/https以外のリンクと画像URLは拒否してください。
Next.jsでも使えるように immediatelyRender: false を入れてください。
最後に使い方、手動テスト、落とし穴を短く説明してください。
Claude Codeで作業する前に、Claude Code入門ガイドのように「調査、編集、検証」を分けると安全です。フォーム連携まで必要ならフォームバリデーション設計、Markdown変換も扱うならMarkdown処理の記事とセットで確認してください。
コピーして使えるReactコンポーネント
以下は、ツールバー、リンク/画像URL検証、文字数上限、JSONとHTMLの出力をまとめた最小コンポーネントです。Tailwind CSSのクラスを使っていますが、使っていないプロジェクトではclassNameだけ置き換えれば動きます。
"use client";
import type { Editor, JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import CharacterCount from "@tiptap/extension-character-count";
import DOMPurify from "dompurify";
import type { ReactNode } from "react";
import { useState } from "react";
export type SavedEditorContent = {
json: JSONContent;
html: string;
plainText: string;
};
type RichTextEditorProps = {
initialContent?: JSONContent | string;
maxCharacters?: number;
onChange?: (content: SavedEditorContent) => void;
};
const allowedTags = [
"p",
"br",
"strong",
"em",
"s",
"h2",
"h3",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"a",
"img",
];
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
function normalizeHttpUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
const candidates = [trimmed, `https://${trimmed}`];
for (const candidate of candidates) {
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
// Try the next candidate.
}
}
return null;
}
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
function buildPayload(editor: Editor): SavedEditorContent {
return {
json: editor.getJSON(),
html: sanitizeEditorHtml(editor.getHTML()),
plainText: editor.getText(),
};
}
export function RichTextEditor({
initialContent = "<p>ここに本文を書き始めます。</p>",
maxCharacters = 8000,
onChange,
}: RichTextEditorProps) {
const [lastSaved, setLastSaved] = useState<SavedEditorContent | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: {
rel: "noopener noreferrer nofollow",
target: "_blank",
},
}),
Image.configure({
allowBase64: false,
}),
CharacterCount.configure({
limit: maxCharacters,
}),
],
content: initialContent,
immediatelyRender: false,
editorProps: {
attributes: {
class:
"min-h-[260px] rounded-b-md border border-t-0 border-slate-300 bg-white p-4 text-base leading-7 outline-none focus:ring-2 focus:ring-sky-500",
"aria-label": "Rich text body",
},
transformPastedHTML(html) {
return sanitizeEditorHtml(html);
},
},
onUpdate({ editor }) {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
},
});
if (!editor) return null;
const characters = editor.storage.characterCount.characters();
const isOverLimit = characters > maxCharacters;
const saveDraft = () => {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
window.localStorage.setItem("article-draft", JSON.stringify(payload));
};
return (
<section className="rounded-md border border-slate-300 bg-slate-50">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 p-3 text-sm">
<span className={isOverLimit ? "text-red-600" : "text-slate-600"}>
{characters}/{maxCharacters} characters
</span>
<button
type="button"
onClick={saveDraft}
disabled={isOverLimit}
className="rounded bg-slate-900 px-3 py-2 font-medium text-white disabled:cursor-not-allowed disabled:bg-slate-400"
>
Save draft
</button>
{lastSaved && (
<span className="text-slate-500">
Saved HTML: {lastSaved.html.length} bytes
</span>
)}
</footer>
</section>
);
}
function Toolbar({ editor }: { editor: Editor }) {
const setLink = () => {
const current = editor.getAttributes("link").href as string | undefined;
const input = window.prompt("Link URL", current ?? "https://");
if (input === null) return;
if (input.trim() === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
const href = normalizeHttpUrl(input);
if (!href) {
window.alert("Use an http or https URL.");
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
};
const addImage = () => {
const input = window.prompt("Image URL", "https://");
if (input === null) return;
const src = normalizeHttpUrl(input);
if (!src) {
window.alert("Use an http or https image URL.");
return;
}
editor.chain().focus().setImage({ src, alt: "" }).run();
};
return (
<div className="flex flex-wrap gap-1 rounded-t-md border-b border-slate-300 bg-white p-2" role="toolbar" aria-label="Formatting toolbar">
<ToolButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>
B
</ToolButton>
<ToolButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>
I
</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
H3
</ToolButton>
<ToolButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>
List
</ToolButton>
<ToolButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
1.
</ToolButton>
<ToolButton active={editor.isActive("link")} onClick={setLink}>
Link
</ToolButton>
<ToolButton onClick={addImage}>Image</ToolButton>
</div>
);
}
function ToolButton({
active,
onClick,
children,
}: {
active?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`rounded border px-2.5 py-1.5 text-sm font-medium ${
active
? "border-sky-600 bg-sky-100 text-sky-800"
: "border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
}`}
>
{children}
</button>
);
}
このコンポーネントは、入力のたびにjson、html、plainTextを親へ渡します。実サービスでは、プレビュー表示にはサニタイズ済みHTML、再編集にはJSONを使う構成が扱いやすいです。HTMLだけを保存すると、将来エディタのスキーマを変えるときに移行が難しくなります。逆にJSONだけを保存すると、一覧ページやRSS、検索インデックス用の抜粋生成で毎回変換が必要になります。
JSONとHTMLを保存・復元する
保存の最小形は、JSONとHTMLを同時に保存することです。下の例はローカル保存ですが、APIに送る場合もpayloadの形は同じにできます。
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
useEffect(() => {
const raw = window.localStorage.getItem("article-draft");
if (raw) setDraft(JSON.parse(raw) as SavedEditorContent);
}, []);
return (
<main className="mx-auto max-w-3xl space-y-6 p-6">
<RichTextEditor
initialContent={draft?.json ?? "<p>New article body</p>"}
onChange={setDraft}
/>
<article
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }}
/>
</main>
);
}
ここで大事なのは、プレビューに入れるHTMLを必ずサニタイズ済みにすることです。Tiptapの公式出力ガイドにも、JSONでもHTMLでも攻撃者は悪意ある内容を送れるので、ユーザー入力は常に検証すべきだと書かれています。DOMPurifyは公式リポジトリでHTML、MathML、SVG向けのXSSサニタイザーとして説明されていますが、クライアントだけで安心せず、サーバーでも同じ方針で検証します。
type EditorPayload = {
json: unknown;
html: string;
plainText: string;
};
function isEditorPayload(value: unknown): value is EditorPayload {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return (
typeof record.html === "string" &&
typeof record.plainText === "string" &&
record.html.length <= 200_000 &&
record.plainText.length <= 20_000 &&
typeof record.json === "object" &&
record.json !== null
);
}
export async function saveEditorPayload(value: unknown) {
if (!isEditorPayload(value)) {
throw new Error("Invalid editor payload");
}
// Replace this with your database insert/update.
return {
json: value.json,
html: value.html,
plainText: value.plainText.trim(),
savedAt: new Date().toISOString(),
};
}
公開ページでJSONからHTMLを再生成したい場合は、TiptapのStatic Rendererやサーバー側変換を検討します。ただし、変換後のHTMLにもサニタイズと許可タグの確認を入れます。編集画面で問題なく見えることと、公開ページで安全に表示できることは別の検証です。
構成図とレビュー観点
コード例が増えるほど、Claude Codeには全体の流れを渡したほうが安定します。次のような概念図をissueやCLAUDE.mdに貼ってから依頼すると、どこで検証するべきかが伝わります。
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Preview / public page"]
E --> F["Search index / RSS / CTA"]
レビューで見るべき点は、見た目よりもデータ境界です。リンク挿入時にjavascript:を拒否しているか、画像URLにdata:やfile:を許していないか、貼り付けHTMLにイベント属性が残らないか、保存前と表示前の両方で検証しているかを確認します。DOMPurifyを通していても、許可タグを広げすぎれば危険になります。特にstyle、iframe、script、任意のdata-*は、要件が明確になるまで許可しないほうが安全です。
3つ以上の実用ユースケース
1つ目は、ブログCMSです。記事本文をTiptap JSONで保存し、一覧やOGP説明文にはplainTextを使い、公開ページにはサニタイズ済みHTMLを使います。Markdownインポートが必要なら、既存のMarkdown処理と役割分担を決めます。
2つ目は、社内ナレッジベースです。見出し、箇条書き、コードブロック、リンクが使えれば十分なことが多いです。メンション、コメント、承認フローまで最初から入れると複雑になるので、まず「編集、保存、検索、公開範囲」を固めます。
3つ目は、ECの商品説明です。太字、リスト、リンク、画像が使えると訴求力は上がりますが、商品詳細ページはSEOと表示速度が重要です。保存時にHTMLサイズ、画像URL、禁止タグを検査し、商品一覧ではプレーンテキストを使うと軽くなります。
4つ目は、AI生成文の編集画面です。Claude Codeや別のAIで下書きを作り、人間がリッチテキストで修正する流れです。この場合は「AIが生成したHTMLをそのまま信じない」ことが重要です。AI出力もユーザー入力と同じ扱いで検証し、公開前にリンク、表記、事実確認を入れます。
よくある落とし穴
最初の落とし穴は、保存形式をHTMLだけにすることです。小さなフォームなら問題ありませんが、後から目次生成、共同編集、Markdown出力、別テーマでの表示をしたくなると苦しくなります。JSONとHTMLを両方保存し、どちらを信頼するかを決めておくと移行しやすくなります。
次の落とし穴は、クライアントサイドのサニタイズだけで満足することです。攻撃者はブラウザのUIを通さずAPIへ直接POSTできます。API側ではサイズ、必須フィールド、許可タグ、URLスキーム、投稿者権限を検証します。
3つ目は、ツールバーの状態が選択範囲とずれることです。太字ボタンが押されているように見えるのに実際は解除されている、リンク範囲が広がりすぎる、といった問題はエディタUIでよく起きます。Claude Codeに「選択範囲を変えながら手動確認するチェックリスト」まで作らせると、レビュー漏れが減ります。
4つ目は、モバイル確認を後回しにすることです。iOSやAndroidではテキスト選択、キーボード表示、ツールバー位置がデスクトップと違います。最低でもスマホ幅、長いURL、長い日本語見出し、画像挿入後の改行を確認します。
収益導線とCTA
リッチテキストエディタは、単なる入力欄ではなくコンテンツ事業の収益導線に直結します。ブログCMSなら関連記事やCTAの編集、ECなら商品説明、研修サイトなら教材更新、SaaSならヘルプセンター改善に使えます。本文を速く直せるほど、SEO記事、比較記事、導入事例、商品ページの改善サイクルが短くなります。
ClaudeCodeLabでは、Claude Codeの導入相談、プロンプト設計、CLAUDE.md整備、記事改善フローの設計を扱っています。編集画面だけでなく、公開ページ、検索、分析、購入導線までまとめて整えたい場合は、研修・導入相談やClaude Code関連プロダクトから確認できます。
実際に試した結果
この記事の構成で小さな記事編集画面を作ると、最初にTiptapを選び、JSONとHTMLを両方保存し、リンクと画像URLをhttp/httpsに限定した段階で、レビュー観点がかなり明確になりました。特に「エディタの見た目」より先に「保存payload」「サニタイズ」「復元」「公開ページ」を決めたほうが、Claude Codeの差分は小さく、手戻りも少なくなります。
公開前の最終チェックでは、npm run typecheck、ブラウザでの貼り付け確認、javascript:リンクの拒否、長文入力、モバイル幅、保存後の復元を見ます。公式情報はTiptap Reactガイド、TiptapのJSON/HTML出力ガイド、DOMPurify、MDN contenteditable、Claude Code公式ドキュメントを確認してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。