ProductReadyProductReady

富文本编辑器

学习如何使用支持图片上传的 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-key

2. 创建 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 格式存储。现有纯文本将正确渲染,但您可能希望迁移旧内容。


故障排除

图片无法上传

  1. 检查 S3 环境变量是否设置
  2. 验证 AWS 凭证具有 S3 PutObject 权限
  3. 检查浏览器控制台错误
  4. 确保 S3 存储桶已配置 CORS

上传失败并显示 403

您的 AWS 凭证可能没有所需权限。确保您的 IAM 用户/角色具有:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket/*"
    }
  ]
}

图片无法显示

如果图片上传但不显示,请检查:

  1. S3 存储桶权限允许公共读取(如果使用公共 URL)
  2. 预签名 URL 未过期(默认 24 小时)
  3. CORS 头已正确配置

下一步

On this page