mcpField
W
wysiwyg-editor

wysiwyg-editor

github.com/blink-new開発70 回インストール
リポジトリ

wysiwyg-editor について

React と Tiptap v3 でリッチテキストエディターを開発する人向けのスキルです。HTML の保存、安全な表示、リストのスタイル設定に関する例と手順を提供します。

主な機能

  1. 01シンプル版と多機能版の例
  2. 02書式やリスト用のツールバー
  3. 03Markdown 変換なしの HTML 保存
  4. 04DOMPurify による HTML サニタイズ
  5. 05リスト表示用 CSS のガイド

活用シーン

  1. 01メールやコメント用エディターの構築
  2. 02見出し、リンク、コードブロックの追加
  3. 03ユーザー生成 HTML の安全な表示
  4. 04箇条書きや番号が出ない問題の修正

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/blink-new/claude --skill wysiwyg-editor

インストール後、Claude Code で/wysiwyg-editorを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

assets/components/
html-content.tsx
rich-text-editor.tsx
simple-editor.tsx
assets/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 クラス)はデフォルトのスタイルを提供しますが、次の点に注意してください。

  1. list-disc/list-decimal を明示しないと、リストに箇条書き記号や番号が表示されない場合があります
  2. リストマーカーを表示するには、左側のパディング(pl-5 または pl-6)が必要です
  3. 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.xReact との統合
@tiptap/starter-kit^3.xコア拡張機能のバンドル
@tiptap/extension-link^3.xハイパーリンクのサポート
@tiptap/extension-placeholder^3.xプレースホルダーテキスト
@tiptap/pm^3.xProseMirror の依存関係
dompurify^3.xHTML のサニタイズ
@tailwindcss/typography*prose クラス(通常は Tailwind v4 に含まれます)

コメント