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-key2. 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 contentSecurity 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.jpgPresigned 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
| Shortcut | Action |
|---|---|
Ctrl/Cmd + B | Bold |
Ctrl/Cmd + I | Italic |
Ctrl/Cmd + Z | Undo |
Ctrl/Cmd + Shift + Z | Redo |
Ctrl/Cmd + V (with image) | Paste and upload image |
Tab | Indent (in lists) |
Shift + Tab | Outdent (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
- Check S3 environment variables are set
- Verify AWS credentials have S3 PutObject permissions
- Check browser console for errors
- 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:
- S3 bucket permissions allow public read (if using public URLs)
- Presigned URLs haven't expired (24h default)
- CORS headers are configured correctly
Next Steps
- Learn about UI Components for more form controls
- Check Tutorial: Create Feature to build custom features
- Explore API Integration for more tRPC patterns