GameCraftGameCraft

Rich Text Editor

Learn how to use the RichEditor component with image upload support

Rich Text Editor

ProductReady includes a powerful RichEditor component from sharelib with built-in image upload support via S3 presigned URLs. Perfect for blog posts, comments, documentation, and any content that needs rich formatting.

Location: The RichEditor is available from sharelib/richeditor and works with the attachments tRPC router.


Features

  • Rich Text Formatting - Bold, italic, strikethrough, inline code
  • Structure - Headings (H1-H6), lists (bullet/ordered), blockquotes, code blocks
  • Image Upload - Click, paste (Ctrl/Cmd+V), or drag-and-drop
  • S3 Integration - Direct upload to S3 via presigned URLs (no server bandwidth)
  • Security - 10MB limit, images-only validation, user-scoped storage
  • Keyboard Shortcuts - Ctrl/Cmd+B (bold), Ctrl/Cmd+I (italic), etc.

Quick Start

Basic Usage (Text Only)

import { RichEditor } from "sharelib/richeditor";
import { useState } from "react";

export function MyForm() {
  const [content, setContent] = useState("");

  return (
    <RichEditor
      content={content}
      onChange={setContent}
      placeholder="Write your content here..."
    />
  );
}

With Image Upload

For image upload support, use the useImageUpload hook:

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="Write your content... You can paste or drag images!"
    />
  );
}

Image Upload Setup

1. Environment Variables

Add these to your .env file:

# S3 Configuration
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. Create useImageUpload Hook

The hook is already created in 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 {
      // Step 1: Request presigned upload URL from server
      const { uploadUrl, publicUrl } = await requestUploadUrl.mutateAsync({
        fileName: file.name,
        mimeType: file.type,
        sizeBytes: file.size,
        context: options.context,
      });

      // Step 2: Upload file directly to S3 using presigned URL
      const uploadResponse = await fetch(uploadUrl, {
        method: "PUT",
        body: file,
        headers: {
          "Content-Type": file.type,
        },
      });

      if (!uploadResponse.ok) {
        throw new Error(`Upload failed with status ${uploadResponse.status}`);
      }

      // Step 3: Return public URL for editor to insert
      return publicUrl;
    } catch (error) {
      const errorObj = error instanceof Error ? error : new Error("Failed to upload image");
      if (options.onError) {
        options.onError(errorObj);
      }
      throw errorObj;
    } finally {
      setUploading(false);
    }
  };

  return {
    handleImageUpload,
    uploading,
  };
}

3. Attachments tRPC Router

The attachments router handles presigned URL generation (already in 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 }) => {
      // Validate file size
      if (input.sizeBytes > MAX_FILE_SIZE) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "File size exceeds 10MB limit",
        });
      }

      // Validate MIME type (images only)
      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: "Only images are supported",
        });
      }

      const storage = getS3Storage();
      const userId = ctx.userId || "anonymous";
      const ext = extractFileExtension(input.fileName);
      const context = input.context || "general";
      
      // User-scoped storage path
      const storageKey = `attachments/${userId}/${context}/${nanoid()}${ext ? `.${ext}` : ""}`;

      // Generate presigned URLs
      const uploadUrl = await storage.generateUploadPresignedUrl(
        storageKey, input.mimeType, 3600 // 1 hour
      );
      const publicUrl = await storage.generatePresignedUrl(
        storageKey, 86400 // 24 hours
      );

      return { uploadUrl, storageKey, publicUrl, expiresIn: 3600 };
    }),
});

Upload Flow

User Action (paste/drag/click)

RichEditor calls onImageUpload(file)

useImageUpload hook → tRPC requestUploadUrl

Server generates presigned URL

Client uploads directly to S3 (no server bandwidth)

Public URL returned to RichEditor

Image inserted into content

Security Features

File Validation

  • Max Size: 10MB per file
  • Allowed Types: JPEG, PNG, GIF, WebP, SVG
  • Authentication: Requires logged-in user (protectedProcedure)

Storage Organization

Files are organized by user and context:

s3://bucket/attachments/{userId}/{context}/{nanoid}.{ext}

Example:

s3://my-bucket/attachments/user_abc123/post/x9K2mP1n.jpg

Presigned URL Expiration

  • Upload URL: 1 hour expiration
  • Access URL: 24 hours expiration

Presigned access URLs expire after 24 hours. For public content, consider using CloudFront or public S3 URLs.


Keyboard Shortcuts

ShortcutAction
Ctrl/Cmd + BBold
Ctrl/Cmd + IItalic
Ctrl/Cmd + ZUndo
Ctrl/Cmd + Shift + ZRedo
Ctrl/Cmd + V (with image)Paste and upload image
TabIndent (in lists)
Shift + TabOutdent (in lists)

Styling

The editor uses Tailwind CSS classes. Default styling includes:

.ProseMirror {
  @apply prose prose-sm max-w-none;
  @apply focus:outline-none;
  @apply min-h-[200px] p-4;
}

You can customize by wrapping the editor:

<div className="border border-border rounded-lg bg-background">
  <RichEditor
    content={content}
    onChange={setContent}
    onImageUpload={handleImageUpload}
  />
</div>

Read-Only Mode

For displaying saved content without editing:

<RichEditor
  content={savedContent}
  onChange={() => {}} // No-op
  editable={false}
/>

Error Handling

Handle upload errors gracefully:

const { handleImageUpload } = useImageUpload({
  context: "post",
  onError: (error) => {
    console.error("Upload failed:", error);
    toast.error(`Failed to upload image: ${error.message}`);
  },
});

Example: Blog Post Editor

Complete example from the Posts feature:

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 = () => {
    // Save formData.title and formData.content
    console.log("Saving:", formData);
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Create Post</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="title">Title</Label>
          <Input
            id="title"
            value={formData.title}
            onChange={(e) => setFormData({ ...formData, title: e.target.value })}
            placeholder="Post title"
          />
        </div>

        <div className="space-y-2">
          <Label htmlFor="content">Content</Label>
          <RichEditor
            content={formData.content}
            onChange={(content) => setFormData({ ...formData, content })}
            onImageUpload={handleImageUpload}
            placeholder="Write your post content here..."
          />
          {uploading && <p className="text-sm text-muted-foreground">Uploading image...</p>}
        </div>

        <Button onClick={handleSave}>Save Post</Button>
      </CardContent>
    </Card>
  );
}

API Reference

RichEditor Props

interface RichEditorProps {
  content: string;              // HTML content
  onChange: (content: string) => void;  // Content change handler
  placeholder?: string;         // Placeholder text
  editable?: boolean;           // Default: true
  onImageUpload?: (file: File) => Promise<string>;  // Image upload handler
}

useImageUpload Return

interface UseImageUploadReturn {
  handleImageUpload: (file: File) => Promise<string>;  // Upload handler
  uploading: boolean;           // Upload state
}

Migration from Textarea

Upgrading from plain textarea is simple:

Before:

<Textarea
  value={content}
  onChange={(e) => setContent(e.target.value)}
  placeholder="Write here..."
/>

After:

<RichEditor
  content={content}
  onChange={setContent}
  onImageUpload={handleImageUpload}
  placeholder="Write here..."
/>

Content is stored as HTML. Existing plain text will render correctly, but you may want to migrate old content.


Troubleshooting

Images Not Uploading

  1. Check S3 environment variables are set
  2. Verify AWS credentials have S3 PutObject permissions
  3. Check browser console for errors
  4. Ensure CORS is configured on your S3 bucket

Upload Fails with 403

Your AWS credentials may not have the required permissions. Ensure your IAM user/role has:

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

Images Not Displaying

If images upload but don't display, check:

  1. S3 bucket permissions allow public read (if using public URLs)
  2. Presigned URLs haven't expired (24h default)
  3. CORS headers are configured correctly

Next Steps

On this page