富文本编辑器
学习如何使用支持图片上传的 RichEditor 组件
富文本编辑器
ProductReady 包含一个强大的 RichEditor 组件(来自 sharelib),内置通过 S3 预签名 URL 上传图片的支持。非常适合博客文章、评论、文档以及任何需要富格式的内容。
位置: RichEditor 可从 sharelib/richeditor 获取,并与 attachments tRPC 路由配合使用。
功能特性
- ✅ 富文本格式 - 粗体、斜体、删除线、行内代码
- ✅ 结构化 - 标题(H1-H6)、列表(无序/有序)、引用块、代码块
- ✅ 图片上传 - 点击、粘贴(Ctrl/Cmd+V)或拖放
- ✅ S3 集成 - 通过预签名 URL 直接上传到 S3(无服务器带宽消耗)
- ✅ 安全性 - 10MB 限制、仅图片验证、用户范围存储
- ✅ 键盘快捷键 - Ctrl/Cmd+B(粗体)、Ctrl/Cmd+I(斜体)等
快速开始
基础用法(仅文本)
import { RichEditor } from "sharelib/richeditor";
import { useState } from "react";
export function MyForm() {
const [content, setContent] = useState("");
return (
<RichEditor
content={content}
onChange={setContent}
placeholder="在这里编写您的内容..."
/>
);
}支持图片上传
要支持图片上传,使用 useImageUpload 钩子:
import { RichEditor } from "sharelib/richeditor";
import { useImageUpload } from "~/hooks/useImageUpload";
import { useState } from "react";
export function MyFormWithImages() {
const [content, setContent] = useState("");
const { handleImageUpload } = useImageUpload({ context: "post" });
return (
<RichEditor
content={content}
onChange={setContent}
onImageUpload={handleImageUpload}
placeholder="编写您的内容... 您可以粘贴或拖放图片!"
/>
);
}图片上传设置
1. 环境变量
在 .env 文件中添加以下配置:
# S3 配置
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-bucket-name
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key2. 创建 useImageUpload 钩子
该钩子已在 src/hooks/useImageUpload.ts 中创建:
import { useState } from "react";
import { trpc } from "~/lib/trpc/client";
interface UseImageUploadOptions {
context?: "post" | "general";
onError?: (error: Error) => void;
}
export function useImageUpload(options: UseImageUploadOptions = {}) {
const [uploading, setUploading] = useState(false);
const requestUploadUrl = trpc.attachments.requestUploadUrl.useMutation();
const handleImageUpload = async (file: File): Promise<string> => {
setUploading(true);
try {
// 步骤 1: 从服务器请求预签名上传 URL
const { uploadUrl, publicUrl } = await requestUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
sizeBytes: file.size,
context: options.context,
});
// 步骤 2: 使用预签名 URL 直接上传文件到 S3
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error(`上传失败,状态码 ${uploadResponse.status}`);
}
// 步骤 3: 返回公共 URL 供编辑器插入
return publicUrl;
} catch (error) {
const errorObj = error instanceof Error ? error : new Error("图片上传失败");
if (options.onError) {
options.onError(errorObj);
}
throw errorObj;
} finally {
setUploading(false);
}
};
return {
handleImageUpload,
uploading,
};
}3. Attachments tRPC 路由
附件路由处理预签名 URL 生成(已在 src/server/routers/attachments.ts 中):
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { extractFileExtension, getS3Storage } from "sharelib/storage";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const attachmentsRouter = createTRPCRouter({
requestUploadUrl: protectedProcedure
.input(z.object({
fileName: z.string().min(1).max(255),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
context: z.enum(["post", "general"]).optional(),
}))
.mutation(async ({ input, ctx }) => {
// 验证文件大小
if (input.sizeBytes > MAX_FILE_SIZE) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "文件大小超过 10MB 限制",
});
}
// 验证 MIME 类型(仅图片)
const allowedMimeTypes = [
"image/jpeg", "image/jpg", "image/png",
"image/gif", "image/webp", "image/svg+xml"
];
if (!allowedMimeTypes.includes(input.mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "仅支持图片文件",
});
}
const storage = getS3Storage();
const userId = ctx.userId || "anonymous";
const ext = extractFileExtension(input.fileName);
const context = input.context || "general";
// 用户范围存储路径
const storageKey = `attachments/${userId}/${context}/${nanoid()}${ext ? `.${ext}` : ""}`;
// 生成预签名 URL
const uploadUrl = await storage.generateUploadPresignedUrl(
storageKey, input.mimeType, 3600 // 1 小时
);
const publicUrl = await storage.generatePresignedUrl(
storageKey, 86400 // 24 小时
);
return { uploadUrl, storageKey, publicUrl, expiresIn: 3600 };
}),
});上传流程
用户操作(粘贴/拖放/点击)
↓
RichEditor 调用 onImageUpload(file)
↓
useImageUpload 钩子 → tRPC requestUploadUrl
↓
服务器生成预签名 URL
↓
客户端直接上传到 S3(无服务器带宽消耗)
↓
返回公共 URL 给 RichEditor
↓
图片插入到内容中安全特性
文件验证
- 最大大小: 每个文件 10MB
- 允许类型: JPEG、PNG、GIF、WebP、SVG
- 身份验证: 需要登录用户(protectedProcedure)
存储组织
文件按用户和上下文组织:
s3://bucket/attachments/{userId}/{context}/{nanoid}.{ext}示例:
s3://my-bucket/attachments/user_abc123/post/x9K2mP1n.jpg预签名 URL 过期时间
- 上传 URL: 1 小时过期
- 访问 URL: 24 小时过期
预签名访问 URL 在 24 小时后过期。对于公共内容,考虑使用 CloudFront 或公共 S3 URL。
键盘快捷键
| 快捷键 | 操作 |
|---|---|
Ctrl/Cmd + B | 粗体 |
Ctrl/Cmd + I | 斜体 |
Ctrl/Cmd + Z | 撤销 |
Ctrl/Cmd + Shift + Z | 重做 |
Ctrl/Cmd + V (带图片) | 粘贴并上传图片 |
Tab | 缩进(在列表中) |
Shift + Tab | 减少缩进(在列表中) |
样式定制
编辑器使用 Tailwind CSS 类。默认样式包括:
.ProseMirror {
@apply prose prose-sm max-w-none;
@apply focus:outline-none;
@apply min-h-[200px] p-4;
}您可以通过包装编辑器来自定义:
<div className="border border-border rounded-lg bg-background">
<RichEditor
content={content}
onChange={setContent}
onImageUpload={handleImageUpload}
/>
</div>只读模式
用于显示已保存内容而不编辑:
<RichEditor
content={savedContent}
onChange={() => {}} // 空操作
editable={false}
/>错误处理
优雅地处理上传错误:
const { handleImageUpload } = useImageUpload({
context: "post",
onError: (error) => {
console.error("上传失败:", error);
toast.error(`图片上传失败: ${error.message}`);
},
});示例:博客文章编辑器
Posts 功能的完整示例:
import { Button } from "kui/button";
import { Card, CardContent, CardHeader, CardTitle } from "kui/card";
import { Input } from "kui/input";
import { Label } from "kui/label";
import { RichEditor } from "sharelib/richeditor";
import { useImageUpload } from "~/hooks/useImageUpload";
import { useState } from "react";
export function PostEditor() {
const [formData, setFormData] = useState({
title: "",
content: "",
});
const { handleImageUpload, uploading } = useImageUpload({ context: "post" });
const handleSave = () => {
// 保存 formData.title 和 formData.content
console.log("保存中:", formData);
};
return (
<Card>
<CardHeader>
<CardTitle>创建文章</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">标题</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="文章标题"
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">内容</Label>
<RichEditor
content={formData.content}
onChange={(content) => setFormData({ ...formData, content })}
onImageUpload={handleImageUpload}
placeholder="在这里编写您的文章内容..."
/>
{uploading && <p className="text-sm text-muted-foreground">正在上传图片...</p>}
</div>
<Button onClick={handleSave}>保存文章</Button>
</CardContent>
</Card>
);
}API 参考
RichEditor 属性
interface RichEditorProps {
content: string; // HTML 内容
onChange: (content: string) => void; // 内容变更处理器
placeholder?: string; // 占位符文本
editable?: boolean; // 默认: true
onImageUpload?: (file: File) => Promise<string>; // 图片上传处理器
}useImageUpload 返回值
interface UseImageUploadReturn {
handleImageUpload: (file: File) => Promise<string>; // 上传处理器
uploading: boolean; // 上传状态
}从 Textarea 迁移
从普通 textarea 升级很简单:
之前:
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="在这里编写..."
/>之后:
<RichEditor
content={content}
onChange={setContent}
onImageUpload={handleImageUpload}
placeholder="在这里编写..."
/>内容以 HTML 格式存储。现有纯文本将正确渲染,但您可能希望迁移旧内容。
故障排除
图片无法上传
- 检查 S3 环境变量是否设置
- 验证 AWS 凭证具有 S3 PutObject 权限
- 检查浏览器控制台错误
- 确保 S3 存储桶已配置 CORS
上传失败并显示 403
您的 AWS 凭证可能没有所需权限。确保您的 IAM 用户/角色具有:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::your-bucket/*"
}
]
}图片无法显示
如果图片上传但不显示,请检查:
- S3 存储桶权限允许公共读取(如果使用公共 URL)
- 预签名 URL 未过期(默认 24 小时)
- CORS 头已正确配置