关于 wysiwyg-editor
面向使用 React 构建 Tiptap v3 富文本编辑器的开发者,提供组件示例及 HTML 存储、安全渲染和列表样式指南。
主要功能
- 01简易与完整编辑器示例
- 02工具栏格式与列表操作
- 03以 HTML 存储,无需转换 Markdown
- 04基于 DOMPurify 清理 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 构建可用于生产环境的 WYSIWYG 编辑器,支持规范的 Markdown 风格格式、即时渲染以及项目符号列表和编号列表。
适用场景
在以下情况下使用此技能:
- 为邮件、评论或内容构建富文本编辑器
- 实现带工具栏控件的 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
核心原则:存储 HTML,而不是 Markdown
- 内容以 HTML 格式存储和传输
- 无需进行 Markdown 转换
- 显示前使用 DOMPurify 清理 HTML
- 无需转换延迟,即可即时渲染
实现细节
编辑器配置
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 一同打包) |
评论
支持 Markdown