リッチテキストエディタ実装でcontenteditableに泣いた僕がTipTapに逃げた話
リッチテキストエディタ実装の地雷を回避する。contenteditableの罠、TipTap/Lexical/ProseMirrorの違い、保存形式、XSSサニタイズ、ペースト処理を実コード付きで。
「太字と見出しが付けられる入力欄、サッと作っといて」
軽い気持ちでcontenteditableをtrueにして、divの中で文字を打てるようにした僕は、その日のうちに後悔しました。Wordから文章を貼り付けたら、<span style="mso-...">みたいな謎タグが大量に流れ込んできたんです。Enterを押すたびに<div>が増えたり<p>になったり、ブラウザによってバラバラ。極めつけは、テスト用に貼った<img src=x onerror=alert(1)>が、何の抵抗もなく実行されたこと。
入力欄ひとつのはずが、HTMLパーサーとセキュリティ対策を自前で書く羽目になっていました。リッチテキストエディタは、見た目の地味さと実装の重さがいちばん噛み合わないUIです。今日は、僕がcontenteditable地獄から抜け出してTipTapに落ち着くまでの話と、コピペで動く最小エディタを置いていきます。
この記事の要点
contenteditableを直接いじるのは罠だらけ。ペースト処理・改行・ブラウザ差・XSSを全部自分で背負うことになる- 編集エンジンはTipTap / Lexical / ProseMirrorから選ぶ。まず壊れにくいWYSIWYGが欲しいならTipTapが最短
- 保存形式はHTMLだけにしない。再編集用のJSONと表示用のサニタイズ済みHTMLを両方持つと後で楽
- XSSは保存前と表示前の二段構え。DOMPurifyを通し、許可タグとURLスキームを絞る
- 貼り付けHTMLは無加工で受けない。
transformPastedHTMLでサニタイズしてから取り込む
なぜ contenteditable を直接いじると地獄なのか
contenteditableは、HTML要素を「編集可能」にするだけの属性です。divに付ければ文字は打てる。でも、打った結果がどんなHTMLになるかは、ブラウザが勝手に決めます。
たとえばEnterキー。Chromeでは<div>で改行が入ることがあり、別の状況では<p>、Firefoxではまた違う。リストの途中でBackspaceを押したときの挙動も統一されていません。つまり「同じ操作をしても、出てくるHTMLがバラバラ」なんです。これを正規化するコードを自分で書き始めると、それだけで沼です。
そしてペースト。MDNのcontenteditableの説明にも、trueでは貼り付け時に書式が保持され、plaintext-onlyでは書式が削除されると書かれています。trueのままだと、コピー元のstyleやクラスがまるごと流れ込む。plaintext-onlyにすると、今度は太字すら貼れない。ちょうどいい中間が標準では用意されていないわけです。
極めつけがXSSです。ユーザーが貼った内容をそのままinnerHTMLで表示すると、onerrorやjavascript:が走ります。入力欄を作っているつもりが、攻撃の入り口を作っている。だから結論はシンプルで、contenteditableの上に「編集エンジン」を一枚かぶせる。これが事故を減らす一番の近道でした。
TipTap / Lexical / ProseMirror、結局どれを選ぶか
候補はだいたいこの3つ(とSlate、Quillあたり)に絞られます。関係を一言で整理すると、ProseMirrorがエンジンの土台、TipTapがそれを使いやすく包んだもの、Lexicalは別系統でMeta製、という地図になります。
- ProseMirror: 編集モデルの基盤。スキーマで「許される文書構造」を厳密に定義できる。自由度は最高だが、ツールバーもReact連携も自前で組む覚悟がいる。
- TipTap: ProseMirrorの上に乗ったフレームワーク。Reactバインディングがあり、
StarterKitを入れるだけで段落・見出し・太字・斜体・リストが揃う。JSONとHTMLの出力が素直。 - Lexical: Meta製の編集フレームワーク。コアは22kbほどと軽量で、Facebook・Messenger・WhatsApp・Instagramの入力欄を支えています。ただし機能はプラグイン単位で、ツールバーもリストもリンクも別パッケージで足す設計です。
僕の基準はこうです。ブログのCMS、社内ナレッジ、商品説明みたいに「まず壊れないWYSIWYGが欲しい」ならTipTap。Slack風の凝った入力欄や、独自ノードをガリガリ作りたいならLexical。ProseMirrorを直接触るのは、編集仕様が特殊で土台から制御したいときだけ。
| 観点 | ProseMirror | TipTap | Lexical |
|---|---|---|---|
| 立ち位置 | 土台のエンジン | ProseMirrorの上の枠組み | Meta製の別系統 |
| 初期実装 | 重い(全部自前) | StarterKitで最短 | プラグイン設計の理解が前提 |
| 保存形式 | スキーマで設計 | JSON/HTML両方が素直 | EditorStateの扱いを決める |
| カスタム性 | 最高 | Extensionで拡張 | 独自ノード・コマンドに強い |
| まず動かす速さ | 遅い | 速い | 中くらい |
この記事ではTipTapで進めます。理由は、コピペで動く完成形を最短で見せられるからです。
インストールと、Claude Codeへの頼み方
まずReact/TypeScriptのプロジェクトに依存を入れます。ViteでもNext.jsでも考え方は同じです(Next.js App Routerならエディタはクライアントコンポーネントにする)。TipTap公式のReactインストールガイドが案内する基本の3点セットに、リンク・画像・文字数・サニタイズ用のパッケージを足します。
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
@tiptap/reactがReactバインディング、@tiptap/pmがProseMirror本体、@tiptap/starter-kitが段落や見出しなどの基本拡張です。この3つはセットで入れます。
ここで僕がよくやる失敗が「いい感じのエディタ作って」とだけ頼むこと。これをやると、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 + TypeScript)
下のコンポーネントは、ツールバー、リンク・画像URLの検証、文字数上限、JSONとHTMLの出力をまとめた最小構成です。Tailwind CSSのクラスを使っていますが、使っていないプロジェクトではclassNameを置き換えるだけで動きます。ポイントは1か所、transformPastedHTMLで貼り付けHTMLをサニタイズしてから取り込む門番です。ここがあるだけで、冒頭のWordペースト地獄が消えます。
"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",
];
// 許可する属性。style や on* 系は意図的に入れない
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
// http/https 以外のURLは弾く門番(javascript: や data: を拒否)
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 {
// 次の候補を試す
}
}
return null;
}
// HTMLをサニタイズ。許可タグ・許可属性だけ通す
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
// 保存用payload。JSON(再編集用)・HTML(表示用)・plainText(抜粋用)を同時に作る
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, // Next.jsのSSRで二重描画を防ぐ
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": "本文エディタ",
},
// 貼り付けHTMLは取り込む前に必ずサニタイズ
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} 文字
</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"
>
下書き保存
</button>
{lastSaved && (
<span className="text-slate-500">保存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("リンク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("http または https のURLを入れてください。");
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
};
const addImage = () => {
const input = window.prompt("画像URL", "https://");
if (input === null) return;
const src = normalizeHttpUrl(input);
if (!src) {
window.alert("http または https の画像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="書式ツールバー">
<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とHTMLの両建て
ここが、後から効いてくる分かれ道です。HTMLだけ保存すると、将来エディタのスキーマを変えたとき移行が地獄になります。逆にJSONだけ保存すると、一覧ページ・RSS・検索インデックス用の抜粋を作るたびに毎回変換が必要になります。
僕の答えは両建てです。再編集の正本はJSON、表示はサニタイズ済みHTML、抜粋やOGP説明文はplainText。下はローカル保存の例ですが、APIに送る場合もpayloadの形は同じにできます。
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
// 起動時にlocalStorageから下書きを復元
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>新しい記事の本文</p>"}
onChange={setDraft}
/>
{/* プレビューに渡すHTMLは必ずサニタイズ済みのものだけ */}
<article className="prose max-w-none" dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }} />
</main>
);
}
データの流れを図にすると、どこで検証すべきかが一目で分かります。
flowchart LR
A["エディタUI"] --> B["TipTap JSON"]
A --> C["サニタイズ済みHTML"]
B --> D["DBの下書き"]
C --> D
D --> E["プレビュー / 公開ページ"]
E --> F["検索インデックス / RSS / CTA"]
公開ページでJSONからHTMLを再生成したい場合は、TipTapのStatic Rendererやサーバー側変換を使います。ただし、再生成したHTMLにもサニタイズと許可タグの確認を入れる。編集画面で問題なく見えることと、公開ページで安全に表示できることは、別の検証です。
XSSサニタイズは保存前と表示前の二段構え
エディタを作るうえで、いちばん手を抜けないのがここです。TipTapのJSON/HTML出力ガイドにも、JSONでもHTMLでも攻撃者は悪意ある内容を送れるので、ユーザー入力は常に検証すべきだと明記されています。DOMPurifyは公式リポジトリでHTML・MathML・SVG向けのXSSサニタイザーとして説明されていますが、クライアントで通しただけで安心してはいけません。
理由は単純で、攻撃者はブラウザのUIを通さず、APIへ直接POSTできるからです。だからサーバー側でも同じ方針で検証します。
type EditorPayload = {
json: unknown;
html: string;
plainText: string;
};
// サーバーで受け取ったpayloadの形・サイズを検証する門番
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 && // 巨大HTMLでの攻撃を防ぐ
record.plainText.length <= 20_000 &&
typeof record.json === "object" &&
record.json !== null
);
}
export async function saveEditorPayload(value: unknown) {
if (!isEditorPayload(value)) {
throw new Error("不正なエディタpayloadです");
}
// ここを実際のDB insert/update に置き換える
return {
json: value.json,
html: value.html,
plainText: value.plainText.trim(),
savedAt: new Date().toISOString(),
};
}
レビューで見るべきは、見た目よりデータ境界です。リンク挿入時にjavascript:を拒否しているか、画像URLにdata:やfile:を許していないか、貼り付けHTMLにイベント属性(onerrorなど)が残っていないか、保存前と表示前の両方で検証しているか。DOMPurifyを通していても、許可タグを広げすぎれば危険になります。特にstyle・iframe・script・任意のdata-*は、要件が固まるまで開けないほうが安全です。このあたりはセキュリティのベストプラクティスもあわせて確認しておくと安心です。
こんな場面で使ってきた(実用ユースケース)
- ブログCMS。本文をTipTap JSONで保存し、一覧やOGP説明文には
plainText、公開ページにはサニタイズ済みHTMLを使う。Markdownインポートが要るなら、既存のMarkdown処理と役割分担を先に決める。 - 社内ナレッジベース。見出し・箇条書き・コードブロック・リンクが使えれば足りることが多い。メンションやコメント、承認フローまで最初から入れると複雑化するので、まず「編集・保存・検索・公開範囲」を固める。
- ECの商品説明。太字・リスト・リンク・画像で訴求力は上がるが、商品詳細はSEOと表示速度が命。保存時にHTMLサイズ・画像URL・禁止タグを検査し、一覧ではプレーンテキストを使うと軽くなる。
- AI生成文の編集画面。Claude Codeなどで下書きを作り、人間がリッチテキストで直す流れ。ここで大事なのは「AIが出したHTMLをそのまま信じない」こと。AI出力もユーザー入力と同じ扱いで検証し、公開前にリンク・表記・事実を確認する。
僕がハマった落とし穴
正直に書きます。最初のエディタは穴だらけでした。
ひとつ目は、保存形式をHTMLだけにしたこと。小さなフォームなら平気でしたが、後から目次生成・共同編集・Markdown出力・別テーマ表示をやりたくなって、全部詰まりました。JSONとHTMLを両方持っておけば避けられた手戻りです。
ふたつ目は、クライアントのサニタイズだけで満足したこと。「DOMPurify通してるから大丈夫」と思っていたら、APIに直接叩かれたら無防備でした。サーバー側でサイズ・必須フィールド・許可タグ・URLスキーム・投稿者権限を検証して、ようやく安心できました。
みっつ目は、ツールバーの状態と選択範囲がずれたこと。太字ボタンが押されて見えるのに実際は解除されている、リンク範囲が広がりすぎる。これはエディタUIの定番事故で、「選択範囲を変えながら手動確認するチェックリスト」をClaude Codeに作らせてから、レビュー漏れが減りました。
よっつ目は、モバイル確認を後回しにしたこと。iOSやAndroidは、テキスト選択・キーボード表示・ツールバー位置がデスクトップと別物です。最低でもスマホ幅、長いURL、長い日本語見出し、画像挿入後の改行は確認します。
よくある質問
Q. TipTapとLexical、初心者はどっちから始めるべき?
まず壊れにくいWYSIWYGが欲しいならTipTapです。StarterKitを入れた瞬間に基本の書式が揃い、JSON/HTML出力も素直なので、完成形まで最短で到達できます。独自ノードや特殊な編集体験を作り込む段階になったらLexicalを検討する、で十分です。
Q. ProseMirrorは使わなくていいの?
TipTapを使う時点で、内部ではProseMirrorが動いています(@tiptap/pmがそれ)。ProseMirrorを直接触るのは、文書構造をスキーマから厳密に制御したい特殊なケースだけ。普段はTipTap経由で十分です。
Q. 保存はHTMLとJSONどっちが正解? 両方持つのが正解です。再編集の正本はJSON、表示はサニタイズ済みHTML、抜粋・OGPはplainText。HTMLだけだと将来の移行で、JSONだけだと一覧や検索の抜粋生成で、それぞれ苦しくなります。
Q. DOMPurifyを通せばXSSは完全に防げる?
クライアントだけでは不十分です。攻撃者はUIを通さずAPIへ直接POSTできるので、サーバー側でも同じサニタイズと検証を入れます。さらに許可タグを絞る(script・iframe・styleを開けない)ことが効きます。
Q. Next.jsで「Hydration mismatch」みたいな警告が出る
useEditorの設定にimmediatelyRender: falseを入れてください。SSR時の即時描画を止め、クライアントで描画させることで、サーバーとクライアントの差分による警告を防げます。
実際に試した結果
この構成で記事編集画面を組んだとき、いちばん効いたのは「エディタの見た目」を後回しにしたことでした。先に決めたのは、保存payloadの形・サニタイズ・復元・公開ページの4つ。順番をこうしただけで、Claude Codeに出させる差分が小さくなり、手戻りもぐっと減りました。
公開前の最終チェックは毎回同じです。npm run typecheck、ブラウザでの貼り付け確認、javascript:リンクの拒否、長文入力、モバイル幅、保存後の復元。contenteditableを素手で触っていた頃に比べて、夜中に「貼り付けで壊れた」と叩き起こされる回数がゼロになりました。賢い実装を探すより、転んでもケガしない門番(サニタイズと許可リスト)を先に置く。遠回りに見えて、これが一番速かったです。
エディタは単なる入力欄ではなく、コンテンツ事業の収益導線に直結します。本文を速く・安全に直せるほど、SEO記事も商品ページも改善サイクルが回ります。導入相談やプロンプト設計、CLAUDE.md整備までまとめて整えたいなら、研修・導入相談やClaude Code関連プロダクトから確認できます。
公式情報はTipTap Reactガイド、TipTapのJSON/HTML出力ガイド、Lexical公式ドキュメント、DOMPurify、MDN contenteditableをあわせて確認してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。