mcpField
W
wysiwyg-editor

wysiwyg-editor

github.com/blink-new开发70 次安装
代码仓库

关于 wysiwyg-editor

面向使用 React 构建 Tiptap v3 富文本编辑器的开发者,提供组件示例及 HTML 存储、安全渲染和列表样式指南。

主要功能

  1. 01简易与完整编辑器示例
  2. 02工具栏格式与列表操作
  3. 03以 HTML 存储,无需转换 Markdown
  4. 04基于 DOMPurify 清理 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 构建可用于生产环境的 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 类)提供默认样式,但:

  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 一同打包)

评论

支持 Markdown