wysiwyg-editor

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

WYSIWYG Rich Text Editor Skill

WYSIWYG富文本编辑器技能指南

Build production-grade WYSIWYG editors using Tiptap v3 with proper markdown-style formatting, instant rendering, and bullet/numbered list support.
使用Tiptap v3构建生产级所见即所得(WYSIWYG)编辑器,支持标准的Markdown风格格式、即时渲染以及项目符号/编号列表功能。

When to Use

适用场景

Use this skill when:
  • Building rich text editors for emails, comments, or content
  • Implementing WYSIWYG editing with toolbar controls
  • Rendering user-generated HTML content safely
  • Need proper bullet and numbered list styling (commonly missed!)
在以下场景中使用本技能:
  • 为邮件、评论或内容系统构建富文本编辑器
  • 实现带工具栏控件的WYSIWYG编辑功能
  • 安全渲染用户生成的HTML内容
  • 需要规范的项目符号和编号列表样式(这是常被忽略的点!)

Quick Start

快速开始

1. Install Dependencies

1. 安装依赖

bash
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder @tiptap/pm dompurify
bun add -D @types/dompurify
bash
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder @tiptap/pm dompurify
bun add -D @types/dompurify

2. Copy Components

2. 复制组件

Copy the component files from
assets/components/
to your project:
  • rich-text-editor.tsx
    → Full-featured editor with headings, code blocks
  • simple-editor.tsx
    → Simplified editor for emails/comments
  • html-content.tsx
    → Safe HTML rendering component
assets/components/
下的组件文件复制到你的项目中:
  • rich-text-editor.tsx
    → 包含标题、代码块的全功能编辑器
  • simple-editor.tsx
    → 适用于邮件/评论的简化版编辑器
  • html-content.tsx
    → 安全HTML渲染组件

3. Add Required CSS

3. 添加必要的CSS

Add these styles to your
globals.css
or the editor's class. This is critical for proper list rendering:
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
将以下样式添加到
globals.css
或编辑器的类中。这对正确渲染列表至关重要
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

Architecture

架构设计

Data Flow

数据流

User Input → Tiptap Editor → getHTML() → Store as HTML in DB
Display ← dangerouslySetInnerHTML ← DOMPurify.sanitize() ← HTML from DB
用户输入 → Tiptap编辑器 → getHTML() → 以HTML格式存储到数据库
展示 ← dangerouslySetInnerHTML ← DOMPurify.sanitize() ← 数据库中的HTML内容

Key Principle: HTML Storage, Not Markdown

核心原则:存储HTML,而非Markdown

  • Content is stored and transmitted as HTML
  • No markdown conversion needed
  • HTML is sanitized with DOMPurify before display
  • This provides instant rendering without conversion lag
  • 内容以HTML格式存储和传输
  • 无需进行Markdown转换
  • HTML内容在展示前会通过DOMPurify进行净化
  • 这种方式实现了无转换延迟的即时渲染

Implementation Details

实现细节

Editor Configuration

编辑器配置

tsx
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);
    }
  },
});
tsx
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);
    }
  },
});

Toolbar Commands

工具栏命令

tsx
// 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()
tsx
// 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()

Syncing External Value Changes

同步外部值变更

tsx
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]);
tsx
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]);

Safe HTML Rendering

安全HTML渲染

DOMPurify Configuration

DOMPurify配置

tsx
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]);
tsx
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 Content Component

HTML内容组件

tsx
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 }}
    />
  );
}
tsx
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 }}
    />
  );
}

Critical CSS for Lists

列表渲染关键CSS

This is the most commonly missed part! Without these styles, bullet points and numbered lists won't display properly:
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
这是最容易被忽略的部分! 没有这些样式,项目符号和编号列表将无法正常显示:
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

Why This Matters

为什么这很重要

Tailwind's
@tailwindcss/typography
(prose classes) provides default styling, but:
  1. Lists may not show bullets/numbers without explicit
    list-disc
    /
    list-decimal
  2. Left padding (
    pl-5
    or
    pl-6
    ) is required for list markers to be visible
  3. Without
    prose-li:my-0
    , list items have excessive vertical spacing
Tailwind的
@tailwindcss/typography
(prose类)提供了默认样式,但存在以下问题:
  1. 若不显式设置
    list-disc
    /
    list-decimal
    ,列表可能不会显示项目符号/编号
  2. 需要左内边距(
    pl-5
    pl-6
    )才能让列表标记可见
  3. 若没有
    prose-li:my-0
    ,列表项会有过多的垂直间距

Complete Component Examples

完整组件示例

Simple Email Editor

简易邮件编辑器

See
assets/components/simple-editor.tsx
:
  • Bold, Italic
  • Bullet and Numbered lists
  • Links
  • Placeholder text
  • Clean minimal toolbar
查看
assets/components/simple-editor.tsx
  • 粗体、斜体
  • 项目符号和编号列表
  • 链接
  • 占位符文本
  • 简洁的极简工具栏

Full Rich Text Editor

全功能富文本编辑器

See
assets/components/rich-text-editor.tsx
:
  • All simple editor features
  • H1, H2, H3 headings
  • Code blocks
  • Blockquotes
  • Undo/Redo
查看
assets/components/rich-text-editor.tsx
  • 包含简易编辑器的所有功能
  • H1、H2、H3标题
  • 代码块
  • 块引用
  • 撤销/重做

HTML Content Display

HTML内容展示组件

See
assets/components/html-content.tsx
:
  • Safe HTML rendering with DOMPurify
  • Proper list styling
  • Empty content handling
  • Dark mode support
查看
assets/components/html-content.tsx
  • 基于DOMPurify的安全HTML渲染
  • 标准列表样式
  • 空内容处理
  • 深色模式支持

Usage Example

使用示例

tsx
"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>
  );
}
tsx
"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>
  );
}

Troubleshooting

故障排除

Lists Not Showing Bullets/Numbers

列表不显示项目符号/编号

Add these classes to the editor content area:
css
[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6
为编辑器内容区域添加以下类:
css
[&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6

Editor Flashing on Initial Render (SSR)

初始渲染时编辑器闪烁(SSR场景)

Set
immediatelyRender: false
in
useEditor
options.
useEditor
选项中设置
immediatelyRender: false

External Value Not Syncing

外部值无法同步

Implement the
useEffect
sync pattern shown above. Compare with
editor.getHTML()
to avoid infinite loops.
实现上述的
useEffect
同步逻辑。通过与
editor.getHTML()
比较避免无限循环。

Empty Paragraph on Clear

清空编辑器后残留空段落

Check for
<p></p>
in the
onUpdate
handler and return empty string instead.
onUpdate
处理函数中检查
<p></p>
,并返回空字符串替代。

File Structure

文件结构

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
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

Dependencies

依赖项

PackageVersionPurpose
@tiptap/react^3.xReact integration
@tiptap/starter-kit^3.xCore extensions bundle
@tiptap/extension-link^3.xHyperlink support
@tiptap/extension-placeholder^3.xPlaceholder text
@tiptap/pm^3.xProseMirror dependencies
dompurify^3.xHTML sanitization
@tailwindcss/typography*Prose classes (usually bundled with Tailwind v4)
包名版本用途
@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捆绑)