wysiwyg-editor について
React と Tiptap v3 でリッチテキストエディターを開発する人向けのスキルです。HTML の保存、安全な表示、リストのスタイル設定に関する例と手順を提供します。
主な機能
- 01シンプル版と多機能版の例
- 02書式やリスト用のツールバー
- 03Markdown 変換なしの HTML 保存
- 04DOMPurify による HTML サニタイズ
- 05リスト表示用 CSS のガイド
活用シーン
- 01メールやコメント用エディターの構築
- 02見出し、リンク、コードブロックの追加
- 03ユーザー生成 HTML の安全な表示
- 04箇条書きや番号が出ない問題の修正
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ blink-new/ claude --skill wysiwyg-editorインストール後、Claude Code で/wysiwyg-editorを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
assets/components/html-content.tsxrich-text-editor.tsxsimple-editor.tsxassets/config/editor-styles.css詳細
WYSIWYG リッチテキストエディターのスキル
Tiptap v3 を使用して、適切な Markdown 形式、即時レンダリング、箇条書き/番号付きリストに対応した、本番環境品質の WYSIWYG エディターを構築します。
使用する場面
次のような場合にこのスキルを使用してください。
- メール、コメント、コンテンツ用のリッチテキストエディターを構築する
- ツールバー操作に対応した WYSIWYG 編集を実装する
- ユーザー生成の HTML コンテンツを安全にレンダリングする
- 適切な箇条書きと番号付きリストのスタイルが必要(よく見落とされます!)
クイックスタート
1. 依存関係をインストールする
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder @tiptap/pm dompurify
bun add -D @types/dompurify
2. コンポーネントをコピーする
assets/components/ のコンポーネントファイルをプロジェクトにコピーします。
rich-text-editor.tsx→ 見出しやコードブロックに対応した高機能エディターsimple-editor.tsx→ メールやコメント向けのシンプルなエディターhtml-content.tsx→ HTML を安全にレンダリングするコンポーネント
3. 必須の CSS を追加する
globals.css またはエディターのクラスに、以下のスタイルを追加します。リストを適切に表示するために不可欠です。
/* CRITICAL: List styling - often missed, causes bullets/numbers to not appear */
[&_ul]:list-disc [&_ul]:pl-6
[&_ol]:list-decimal [&_ol]:pl-6
/* Tight spacing for prose content */
prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0
アーキテクチャ
データフロー
User Input → Tiptap Editor → getHTML() → Store as HTML in DB
↓
Display ← dangerouslySetInnerHTML ← DOMPurify.sanitize() ← HTML from DB
重要な原則:Markdown ではなく HTML で保存する
- コンテンツは HTML として保存および送信されます
- Markdown への変換は不要です
- HTML は表示前に DOMPurify でサニタイズされます
- 変換による遅延なしで即時にレンダリングできます
実装の詳細
エディターの設定
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
const editor = useEditor({
immediatelyRender: false, // Required for SSR/Next.js
extensions: [
StarterKit.configure({
// For simplified editors, disable unused features:
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
// For full editors, configure heading levels:
// heading: { levels: [1, 2, 3] },
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-primary underline underline-offset-2",
},
}),
Placeholder.configure({
placeholder: "Write your message...",
emptyEditorClass: "before:content-[attr(data-placeholder)] before:text-muted-foreground before:absolute before:opacity-50 before:pointer-events-none",
}),
],
content: value,
editable: true,
editorProps: {
attributes: {
// CRITICAL: These classes enable proper list rendering
class: cn(
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[120px] px-3 py-2",
"prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0",
"[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6"
),
},
},
onUpdate: ({ editor }) => {
const html = editor.getHTML();
// Handle empty content
if (html === "<p></p>") {
onChange("");
} else {
onChange(html);
}
},
});
ツールバーのコマンド
// Bold
editor.chain().focus().toggleBold().run()
editor.isActive("bold")
// Italic
editor.chain().focus().toggleItalic().run()
editor.isActive("italic")
// Bullet List
editor.chain().focus().toggleBulletList().run()
editor.isActive("bulletList")
// Numbered List
editor.chain().focus().toggleOrderedList().run()
editor.isActive("orderedList")
// Headings
editor.chain().focus().toggleHeading({ level: 1 }).run()
editor.isActive("heading", { level: 1 })
// Links
editor.chain().focus().setLink({ href: url }).run()
editor.chain().focus().unsetLink().run()
editor.isActive("link")
// Undo/Redo
editor.chain().focus().undo().run()
editor.chain().focus().redo().run()
editor.can().undo()
editor.can().redo()
外部からの値の変更を同期する
useEffect(() => {
if (editor && value !== editor.getHTML()) {
const currentHtml = editor.getHTML();
const normalizedValue = value || "<p></p>";
if (normalizedValue !== currentHtml && value !== "") {
editor.commands.setContent(value);
} else if (value === "" && currentHtml !== "<p></p>") {
editor.commands.setContent("");
}
}
}, [editor, value]);
HTML を安全にレンダリングする
DOMPurify の設定
import DOMPurify from "dompurify";
import { useMemo } from "react";
const sanitizedHtml = useMemo(() => {
if (!htmlContent) return null;
return DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: [
"p", "br", "strong", "b", "em", "i", "u", "s", "a",
"ul", "ol", "li", "blockquote", "pre", "code", "span", "div",
"h1", "h2", "h3"
],
ALLOWED_ATTR: ["href", "target", "rel", "class"],
ADD_ATTR: ["target"],
});
}, [htmlContent]);
HTML コンテンツコンポーネント
function HtmlContent({ html, className }: { html: string; className?: string }) {
const sanitizedHtml = useMemo(() => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "br", "strong", "b", "em", "i", "u", "s", "a", "ul", "ol", "li", "blockquote", "pre", "code", "span", "div"],
ALLOWED_ATTR: ["href", "target", "rel", "class"],
});
}, [html]);
// Check for actual content
const hasContent = sanitizedHtml.replace(/<[^>]*>/g, "").trim() !== "";
if (!hasContent) {
return <span className="italic opacity-70">No content</span>;
}
return (
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0",
"[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5",
"prose-a:underline prose-a:underline-offset-2",
className
)}
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
}
リストに必須の CSS
最もよく見落とされるポイントです! これらのスタイルがないと、箇条書きや番号付きリストが正しく表示されません。
/* In the editor's editorProps.attributes.class */
[&_ul]:list-disc [&_ul]:pl-6 /* Bullet points with left padding */
[&_ol]:list-decimal [&_ol]:pl-6 /* Numbers with left padding */
/* For rendered content */
[&_ul]:list-disc [&_ul]:pl-5
[&_ol]:list-decimal [&_ol]:pl-5
/* Tight vertical spacing */
prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0
重要な理由
Tailwind の @tailwindcss/typography(prose クラス)はデフォルトのスタイルを提供しますが、次の点に注意してください。
list-disc/list-decimalを明示しないと、リストに箇条書き記号や番号が表示されない場合があります- リストマーカーを表示するには、左側のパディング(
pl-5またはpl-6)が必要です prose-li:my-0がないと、リスト項目の上下の間隔が広くなりすぎます
コンポーネントの完全な例
シンプルなメールエディター
assets/components/simple-editor.tsx を参照してください。
- 太字、斜体
- 箇条書き、番号付きリスト
- リンク
- プレースホルダーテキスト
- シンプルですっきりしたツールバー
高機能なリッチテキストエディター
assets/components/rich-text-editor.tsx を参照してください。
- シンプルなエディターのすべての機能
- H1、H2、H3 の見出し
- コードブロック
- 引用
- 元に戻す/やり直す
HTML コンテンツの表示
assets/components/html-content.tsx を参照してください。
- DOMPurify による安全な HTML レンダリング
- 適切なリストのスタイル
- 空のコンテンツへの対応
- ダークモード対応
使用例
"use client";
import { useState } from "react";
import { SimpleEditor } from "@/components/ui/simple-editor";
import { HtmlContent } from "@/components/ui/html-content";
export function EmailComposer() {
const [content, setContent] = useState("");
return (
<div>
<SimpleEditor
value={content}
onChange={setContent}
placeholder="Write your email..."
/>
{/* Preview */}
<div className="mt-4 p-4 border rounded-md">
<h3 className="text-sm font-medium mb-2">Preview:</h3>
<HtmlContent html={content} />
</div>
</div>
);
}
トラブルシューティング
リストの箇条書き記号/番号が表示されない
エディターのコンテンツ領域に次のクラスを追加します。
[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6
初期レンダリング時にエディターがちらつく(SSR)
useEditor のオプションで immediatelyRender: false を設定します。
外部の値が同期されない
上記の useEffect 同期パターンを実装します。無限ループを防ぐため、editor.getHTML() と比較してください。
クリア時に空の段落が残る
onUpdate ハンドラーで <p></p> を確認し、代わりに空文字列を返します。
ファイル構成
src/
├── components/
│ └── ui/
│ ├── simple-editor.tsx # Email-style editor
│ ├── rich-text-editor.tsx # Full-featured editor
│ └── html-content.tsx # Safe HTML display
└── app/
└── globals.css # Ensure prose classes available
依存関係
| パッケージ | バージョン | 用途 |
|---|---|---|
| @tiptap/react | ^3.x | React との統合 |
| @tiptap/starter-kit | ^3.x | コア拡張機能のバンドル |
| @tiptap/extension-link | ^3.x | ハイパーリンクのサポート |
| @tiptap/extension-placeholder | ^3.x | プレースホルダーテキスト |
| @tiptap/pm | ^3.x | ProseMirror の依存関係 |
| dompurify | ^3.x | HTML のサニタイズ |
| @tailwindcss/typography | * | prose クラス(通常は Tailwind v4 に含まれます) |