wysiwyg-editor
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWYSIWYG 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/dompurifybash
bun add @tiptap/react @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-placeholder @tiptap/pm dompurify
bun add -D @types/dompurify2. Copy Components
2. 复制组件
Copy the component files from to your project:
assets/components/- → Full-featured editor with headings, code blocks
rich-text-editor.tsx - → Simplified editor for emails/comments
simple-editor.tsx - → Safe HTML rendering component
html-content.tsx
将下的组件文件复制到你的项目中:
assets/components/- → 包含标题、代码块的全功能编辑器
rich-text-editor.tsx - → 适用于邮件/评论的简化版编辑器
simple-editor.tsx - → 安全HTML渲染组件
html-content.tsx
3. Add Required CSS
3. 添加必要的CSS
Add these styles to your or the editor's class. This is critical for proper list rendering:
globals.csscss
/* 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.csscss
/* 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-0Architecture
架构设计
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-0Why This Matters
为什么这很重要
Tailwind's (prose classes) provides default styling, but:
@tailwindcss/typography- Lists may not show bullets/numbers without explicit /
list-disclist-decimal - Left padding (or
pl-5) is required for list markers to be visiblepl-6 - Without , list items have excessive vertical spacing
prose-li:my-0
Tailwind的(prose类)提供了默认样式,但存在以下问题:
@tailwindcss/typography- 若不显式设置/
list-disc,列表可能不会显示项目符号/编号list-decimal - 需要左内边距(或
pl-5)才能让列表标记可见pl-6 - 若没有,列表项会有过多的垂直间距
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-6Editor Flashing on Initial Render (SSR)
初始渲染时编辑器闪烁(SSR场景)
Set in options.
immediatelyRender: falseuseEditor在选项中设置。
useEditorimmediatelyRender: falseExternal Value Not Syncing
外部值无法同步
Implement the sync pattern shown above. Compare with to avoid infinite loops.
useEffecteditor.getHTML()实现上述的同步逻辑。通过与比较避免无限循环。
useEffecteditor.getHTML()Empty Paragraph on Clear
清空编辑器后残留空段落
Check for in the handler and return empty string instead.
<p></p>onUpdate在处理函数中检查,并返回空字符串替代。
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 availablesrc/
├── 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 availableDependencies
依赖项
| Package | Version | Purpose |
|---|---|---|
| @tiptap/react | ^3.x | React integration |
| @tiptap/starter-kit | ^3.x | Core extensions bundle |
| @tiptap/extension-link | ^3.x | Hyperlink support |
| @tiptap/extension-placeholder | ^3.x | Placeholder text |
| @tiptap/pm | ^3.x | ProseMirror dependencies |
| dompurify | ^3.x | HTML sanitization |
| @tailwindcss/typography | * | Prose classes (usually bundled with Tailwind v4) |
| 包名 | 版本 | 用途 |
|---|---|---|
| @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捆绑) |