用 Claude Code 实现富文本编辑器:Tiptap + React 实战
用 Claude Code、Tiptap 和 React 构建可保存 JSON/HTML、带工具栏和安全过滤的富文本编辑器。
富文本编辑器不是一个“把 textarea 变漂亮”的小功能。真正上线后,你会遇到保存格式、粘贴内容、XSS、链接协议、图片 URL、移动端选择、公开页渲染、搜索摘要和 SEO 摘要等问题。如果只让 Claude Code 生成一个 contenteditable 示例,短期看能输入文字,长期很容易在安全和迁移上付出代价。
这篇文章采用 Tiptap。它基于 ProseMirror,官方 React 安装文档建议安装 @tiptap/react、@tiptap/pm 和 @tiptap/starter-kit。StarterKit 已经包含段落、标题、粗体、斜体、列表等常用功能,适合先做一个稳定的 CMS 编辑栏。Lexical 也很好,官方 Lexical 说明它是轻量、模块化的文本编辑框架;如果你要做复杂自定义节点或聊天输入框,可以考虑 Lexical。本文的目标是让初学者用 Claude Code 做出可维护的第一版。
先定边界,再让 Claude Code 写代码
Claude Code 官方文档把它描述为可以读取代码库、编辑文件、运行命令并连接开发工具的 agentic coding tool。它很适合处理跨文件任务,但前提是你把边界写清楚。不要只说“加一个编辑器”,而要说明库、文件范围、保存格式、安全要求和验证步骤。
可以这样写提示词:
请用 Tiptap 实现 React/TypeScript 富文本编辑器。
只修改 src/components/RichTextEditor.tsx。
支持粗体、斜体、H2/H3、无序列表、有序列表、链接、图片 URL。
输出 Tiptap JSON、消毒后的 HTML 和纯文本。
链接和图片只允许 http/https。
使用 DOMPurify 处理 HTML,并加入手动测试清单。
如果项目还没有 Claude Code 的使用规则,先看 Claude Code 入门指南。如果编辑器会提交到表单或 API,配合 表单验证设计一起看;如果需要 Markdown 导入导出,再参考 Markdown 处理指南。
安装依赖
下面是最小依赖。这里暂时不做协同编辑、评论、slash command 或复杂媒体库,因为这些功能会引入权限、schema 和迁移成本。先把“编辑、保存、预览、恢复、安全过滤”跑通。
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
可复制的 React/TypeScript 组件
这个组件包含工具栏、链接验证、图片 URL 验证、字符数限制、JSON/HTML 输出和本地草稿保存。示例使用 Tailwind class;如果项目不用 Tailwind,只替换样式即可,编辑器逻辑不用改。
"use client";
import type { Editor, JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import CharacterCount from "@tiptap/extension-character-count";
import DOMPurify from "dompurify";
import type { ReactNode } from "react";
import { useState } from "react";
export type SavedEditorContent = {
json: JSONContent;
html: string;
plainText: string;
};
type Props = {
initialContent?: JSONContent | string;
maxCharacters?: number;
onChange?: (content: SavedEditorContent) => void;
};
const allowedTags = ["p", "br", "strong", "em", "s", "h2", "h3", "ul", "ol", "li", "blockquote", "code", "pre", "a", "img"];
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
function normalizeHttpUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
for (const candidate of [trimmed, `https://${trimmed}`]) {
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") return url.toString();
} catch {
// Try the next candidate.
}
}
return null;
}
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
function buildPayload(editor: Editor): SavedEditorContent {
return {
json: editor.getJSON(),
html: sanitizeEditorHtml(editor.getHTML()),
plainText: editor.getText(),
};
}
export function RichTextEditor({
initialContent = "<p>Start writing...</p>",
maxCharacters = 8000,
onChange,
}: Props) {
const [lastSaved, setLastSaved] = useState<SavedEditorContent | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
}),
Image.configure({ allowBase64: false }),
CharacterCount.configure({ limit: maxCharacters }),
],
content: initialContent,
immediatelyRender: false,
editorProps: {
attributes: {
class: "min-h-[260px] rounded-b-md border border-t-0 border-slate-300 bg-white p-4 leading-7 outline-none focus:ring-2 focus:ring-sky-500",
"aria-label": "Rich text body",
},
transformPastedHTML(html) {
return sanitizeEditorHtml(html);
},
},
onUpdate({ editor }) {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
},
});
if (!editor) return null;
const characters = editor.storage.characterCount.characters();
const saveDraft = () => {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
window.localStorage.setItem("article-draft", JSON.stringify(payload));
};
return (
<section className="rounded-md border border-slate-300 bg-slate-50">
<div className="flex flex-wrap gap-1 border-b bg-white p-2" role="toolbar" aria-label="Formatting toolbar">
<ToolButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>B</ToolButton>
<ToolButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>I</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolButton>
<ToolButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>List</ToolButton>
<ToolButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>1.</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Link URL", "https://");
const href = input ? normalizeHttpUrl(input) : null;
if (!href) return window.alert("Use an http or https URL.");
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}}>Link</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Image URL", "https://");
const src = input ? normalizeHttpUrl(input) : null;
if (!src) return window.alert("Use an http or https image URL.");
editor.chain().focus().setImage({ src, alt: "" }).run();
}}>Image</ToolButton>
</div>
<EditorContent editor={editor} />
<footer className="flex items-center justify-between gap-3 border-t p-3 text-sm">
<span>{characters}/{maxCharacters} characters</span>
<button type="button" onClick={saveDraft} className="rounded bg-slate-900 px-3 py-2 text-white">Save draft</button>
{lastSaved && <span>HTML: {lastSaved.html.length} bytes</span>}
</footer>
</section>
);
}
function ToolButton({ active, onClick, children }: { active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-pressed={active} onClick={onClick} className={active ? "rounded border bg-sky-100 px-2 py-1" : "rounded border bg-white px-2 py-1"}>
{children}
</button>
);
}
保存、恢复和预览
最稳妥的初始设计是同时保存三份信息:Tiptap JSON 用于再次编辑,消毒后的 HTML 用于公开页渲染,纯文本用于摘要、搜索索引和字数检查。Tiptap 的 JSON/HTML 输出指南也强调,JSON 或 HTML 都不能天然保证安全,用户输入必须验证。
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
useEffect(() => {
const raw = window.localStorage.getItem("article-draft");
if (raw) setDraft(JSON.parse(raw) as SavedEditorContent);
}, []);
return (
<main>
<RichTextEditor initialContent={draft?.json ?? "<p>New article</p>"} onChange={setDraft} />
<article dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }} />
</main>
);
}
服务器端还要再次验证。不要因为客户端已经用了 DOMPurify 就信任请求体。攻击者可以绕过 UI 直接调用 API。至少要检查登录状态、文章归属、HTML 长度、纯文本长度、允许标签、URL 协议和图片域名。DOMPurify 官方仓库把它定位为 HTML、MathML、SVG 的 XSS sanitizer,但它应该是防线之一,而不是放开所有标签的理由。
概念图和审查清单
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Public page"]
E --> F["Search / RSS / CTA"]
审查时重点看数据边界,而不是只看按钮是否好看。链接是否拒绝 javascript:,图片是否拒绝 data: 和 file:,粘贴内容是否移除了事件属性,保存前和展示前是否都过滤,移动端键盘是否遮挡工具栏,这些都要检查。Claude Code 完成实现后,可以要求它再做一次安全 review,只列风险和验证步骤。
适合的使用场景
第一个场景是博客 CMS。编辑时使用 JSON,公开页使用 HTML,列表页和 meta description 使用纯文本。这样 SEO、RSS 和站内搜索都更容易处理。
第二个场景是内部知识库。多数团队只需要标题、列表、代码块和链接。评论、提及、审批流可以等编辑和搜索稳定后再加。
第三个场景是电商商品说明。富文本能让卖点更清晰,但商品页需要快且安全,所以要限制 HTML 大小、图片 URL 和允许标签。
第四个场景是 AI 文章草稿编辑。AI 生成的 HTML 也必须当作不可信输入处理。发布前仍然要人工检查事实、链接和品牌语气。
在这些场景里,最容易被低估的是“以后怎么迁移”。如果最初只把最终 HTML 当正文保存,后面想做全文搜索、高亮目录、协同编辑、AI 摘要、Markdown 导出时会突然变难。我的建议是把 Tiptap JSON 当作编辑数据、经过过滤的 HTML 当作公开展示数据、纯文本当作搜索和摘要数据来分开保存。向 Claude Code 提需求时,也要明确写上“同时更新这三种数据格式,并补上类型和测试”,这样保存流程会稳定得多。
常见坑
只保存 HTML 是第一大坑。短期最省事,但以后做目录、协同编辑、主题切换或 Markdown 导出时会很麻烦。只在客户端过滤也是坑,因为 API 可能被直接调用。还有一个常见问题是移动端测试太晚做,结果虚拟键盘、长 URL、图片后换行和选区状态都出问题。把这些检查写进 Claude Code 的 review prompt,后续每次改动都会更稳定。
变现 CTA 和测试结果
富文本编辑器会影响内容业务的收入路径。它让团队更快更新 SEO 文章、商品说明、培训材料和 CTA 文案。如果你需要把编辑器、公开页、搜索、分析和转化路径一起设计,可以查看 Claude Code 培训与咨询或 Claude Code 产品与模板。
实际测试时,先确定 payload,再设计工具栏,Claude Code 生成的代码更容易审查。JSON、HTML、纯文本、URL 验证和手动测试清单都写进提示词后,返工明显减少。发布前我会至少检查格式化粘贴、javascript: 链接、长文、移动端宽度、保存恢复和公开页消毒 HTML。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。