Skip to content

RichTextEditor Component

A powerful WYSIWYG rich text editor built on TipTap with comprehensive formatting tools, extensible architecture, and seamless React integration.

TipTap WYSIWYG Extensible

Rich Formatting

Bold, italic, underline, headings, lists, and custom text styling

Advanced Features

Links, code blocks, tables, images, and custom node types

Developer Experience

TypeScript support, React hooks integration, and extensible architecture

Content Security

Built-in sanitization, XSS protection, and content validation

Customizable UI

Flexible toolbar, themes, and custom button configurations

Performance

Optimized rendering, lazy loading, and efficient change detection

Simple implementation with default configuration:

Basic RichTextEditor Usage
import React, { useState } from 'react'
import { RichTextEditor } from '@/components/RichTextEditor'
export default function BasicEditor() {
const [content, setContent] = useState('<p>Start typing here...</p>')
const handleChange = (newContent: string) => {
setContent(newContent)
console.log('Content updated:', newContent)
}
return (
<div className="max-w-4xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Article Editor</h2>
<RichTextEditor
value={content}
onChange={handleChange}
placeholder="Write your article content..."
className="min-h-[400px]"
/>
{/* Preview the HTML output */}
<div className="mt-6">
<h3 className="text-lg font-semibold mb-2">Preview:</h3>
<div
className="prose max-w-none p-4 border rounded"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
)
}
Advanced Configuration
import React, { useState } from 'react'
import { RichTextEditor } from '@/components/RichTextEditor'
import type { RichTextEditorConfig } from '@/components/RichTextEditor/types'
export default function AdvancedEditor() {
const [content, setContent] = useState('')
const [characterCount, setCharacterCount] = useState(0)
const editorConfig: RichTextEditorConfig = {
// Toolbar configuration
toolbar: {
position: 'top', // 'top' | 'bottom' | 'floating'
sticky: true,
groups: [
{
name: 'history',
buttons: ['undo', 'redo']
},
{
name: 'formatting',
buttons: [
'bold', 'italic', 'underline', 'strike',
'code', 'highlight'
]
},
{
name: 'headings',
buttons: [
'heading1', 'heading2', 'heading3',
'paragraph'
]
},
{
name: 'lists',
buttons: ['bulletList', 'orderedList', 'taskList']
},
{
name: 'alignment',
buttons: ['alignLeft', 'alignCenter', 'alignRight', 'alignJustify']
},
{
name: 'insert',
buttons: ['link', 'image', 'table', 'horizontalRule', 'codeBlock']
},
{
name: 'advanced',
buttons: ['blockquote', 'subscript', 'superscript', 'clearFormatting']
}
]
},
// Content settings
content: {
characterLimit: 5000,
showCharacterCount: true,
autosave: {
enabled: true,
interval: 30000, // 30 seconds
key: 'article-draft'
}
},
// Extensions and features
extensions: {
placeholder: 'Start writing your amazing content...',
typography: true,
collaboration: false,
mentions: {
enabled: true,
trigger: '@',
suggestion: {
items: ({ query }) => {
return users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase())
)
},
render: () => ({
component: MentionsList,
props: {}
})
}
},
emoji: {
enabled: true,
trigger: ':'
}
},
// Security settings
security: {
allowedTags: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre'],
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'width', 'height'],
'*': ['class', 'style']
},
sanitize: true
},
// Styling
theme: {
borderRadius: 'md',
focusRing: true,
toolbar: {
background: 'bg-background',
border: 'border-border',
padding: 'p-2'
},
editor: {
padding: 'p-4',
minHeight: 'min-h-[300px]'
}
}
}
const handleContentChange = (newContent: string, editor: any) => {
setContent(newContent)
setCharacterCount(editor.storage.characterCount.characters())
// Auto-save to localStorage
localStorage.setItem('article-draft', newContent)
}
const handleImageUpload = async (file: File): Promise<string> => {
// Custom image upload logic
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData
})
const { url } = await response.json()
return url
}
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Advanced Article Editor</h2>
<div className="text-sm text-muted-foreground">
{characterCount} / 5000 characters
</div>
</div>
<RichTextEditor
value={content}
onChange={handleContentChange}
config={editorConfig}
onImageUpload={handleImageUpload}
onSave={(content) => {
// Handle save action
console.log('Saving content:', content)
}}
onError={(error) => {
console.error('Editor error:', error)
}}
className="border rounded-lg shadow-sm"
/>
{/* Editor statistics */}
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<div>
Words: {content.split(' ').filter(word => word.length > 0).length}
</div>
<div>
Last saved: {new Date().toLocaleTimeString()}
</div>
</div>
</div>
)
}
Custom Toolbar Implementation
import React from 'react'
import { RichTextEditor } from '@/components/RichTextEditor'
import { Button } from '@/components/ui/button'
import {
Bold, Italic, Underline, Code,
Palette, Image, Save
} from 'lucide-react'
// Custom toolbar button component
const CustomToolbarButton = ({
isActive,
onClick,
icon: Icon,
tooltip,
disabled = false
}) => (
<Button
variant={isActive ? 'default' : 'ghost'}
size="sm"
onClick={onClick}
disabled={disabled}
title={tooltip}
className="h-8 w-8 p-0"
>
<Icon className="h-4 w-4" />
</Button>
)
// Custom color picker for text highlighting
const ColorPicker = ({ editor }) => {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FECA57', '#FF9FF3'
]
return (
<div className="flex gap-1 p-2 border rounded">
{colors.map((color) => (
<button
key={color}
className="w-6 h-6 rounded border-2 border-white shadow-sm hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
onClick={() => editor.chain().focus().setColor(color).run()}
/>
))}
<button
className="w-6 h-6 rounded border-2 border-gray-300 bg-white hover:scale-110 transition-transform"
onClick={() => editor.chain().focus().unsetColor().run()}
title="Remove color"
>
×
</button>
</div>
)
}
// Custom extension for inserting current date
const insertCurrentDate = (editor) => {
const date = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
editor.chain().focus().insertContent(`<span class="date-stamp">${date}</span>`).run()
}
export default function CustomToolbarEditor() {
return (
<RichTextEditor
value=""
onChange={(content) => console.log(content)}
// Custom toolbar configuration
customToolbar={(editor) => (
<div className="flex items-center gap-1 p-2 border-b">
{/* Basic formatting */}
<div className="flex items-center gap-1 pr-3 border-r">
<CustomToolbarButton
isActive={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
icon={Bold}
tooltip="Bold (Ctrl+B)"
/>
<CustomToolbarButton
isActive={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
icon={Italic}
tooltip="Italic (Ctrl+I)"
/>
<CustomToolbarButton
isActive={editor.isActive('underline')}
onClick={() => editor.chain().focus().toggleUnderline().run()}
icon={Underline}
tooltip="Underline (Ctrl+U)"
/>
</div>
{/* Text color */}
<div className="flex items-center gap-1 pr-3 border-r">
<Popover>
<PopoverTrigger>
<CustomToolbarButton
isActive={false}
onClick={() => {}}
icon={Palette}
tooltip="Text Color"
/>
</PopoverTrigger>
<PopoverContent>
<ColorPicker editor={editor} />
</PopoverContent>
</Popover>
</div>
{/* Custom actions */}
<div className="flex items-center gap-1 pr-3 border-r">
<CustomToolbarButton
isActive={false}
onClick={() => insertCurrentDate(editor)}
icon={Calendar}
tooltip="Insert Current Date"
/>
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
id="image-upload"
onChange={async (e) => {
const file = e.target.files?.[0]
if (file) {
const url = await uploadImage(file)
editor.chain().focus().setImage({ src: url }).run()
}
}}
/>
<label htmlFor="image-upload">
<CustomToolbarButton
isActive={false}
onClick={() => {}}
icon={Image}
tooltip="Insert Image"
/>
</label>
</div>
{/* Save button */}
<div className="ml-auto">
<Button
variant="default"
size="sm"
onClick={() => {
const content = editor.getHTML()
// Handle save
console.log('Saving:', content)
}}
>
<Save className="h-4 w-4 mr-2" />
Save
</Button>
</div>
</div>
)}
/>
)
}
Form Integration
import React from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { RichTextEditor } from '@/components/RichTextEditor'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
// Validation schema
const articleSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters'),
excerpt: z.string().min(20, 'Excerpt must be at least 20 characters'),
content: z.string()
.min(100, 'Content must be at least 100 characters')
.refine(
(content) => {
// Remove HTML tags for word count
const textOnly = content.replace(/<[^>]*>/g, '')
const wordCount = textOnly.split(/\s+/).filter(word => word.length > 0).length
return wordCount >= 50
},
'Content must be at least 50 words'
),
tags: z.array(z.string()).min(1, 'At least one tag is required'),
publishDate: z.date().optional()
})
type ArticleForm = z.infer<typeof articleSchema>
export default function ArticleForm() {
const form = useForm<ArticleForm>({
resolver: zodResolver(articleSchema),
defaultValues: {
title: '',
excerpt: '',
content: '<p>Start writing your article...</p>',
tags: [],
publishDate: new Date()
}
})
const [contentStats, setContentStats] = useState({
characters: 0,
words: 0,
readingTime: 0
})
const calculateStats = (content: string) => {
const textOnly = content.replace(/<[^>]*>/g, '')
const characters = textOnly.length
const words = textOnly.split(/\s+/).filter(word => word.length > 0).length
const readingTime = Math.ceil(words / 200) // Assume 200 words per minute
setContentStats({ characters, words, readingTime })
}
const onSubmit = async (data: ArticleForm) => {
try {
// Validate content structure
const contentValidation = validateContentStructure(data.content)
if (!contentValidation.isValid) {
form.setError('content', { message: contentValidation.message })
return
}
// Submit to API
const response = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error('Failed to save article')
}
console.log('Article saved successfully')
} catch (error) {
console.error('Error saving article:', error)
}
}
const validateContentStructure = (content: string) => {
// Check for required elements
const hasHeading = /<h[1-6]/.test(content)
const hasMultipleParagraphs = (content.match(/<p>/g) || []).length >= 2
if (!hasHeading) {
return {
isValid: false,
message: 'Article should include at least one heading'
}
}
if (!hasMultipleParagraphs) {
return {
isValid: false,
message: 'Article should have multiple paragraphs'
}
}
return { isValid: true, message: '' }
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Create New Article</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Article Title</FormLabel>
<FormControl>
<Input
placeholder="Enter article title..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Excerpt */}
<FormField
control={form.control}
name="excerpt"
render={({ field }) => (
<FormItem>
<FormLabel>Excerpt</FormLabel>
<FormControl>
<textarea
className="w-full p-3 border rounded-md resize-none"
rows={3}
placeholder="Brief description of the article..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Content Editor */}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Article Content</FormLabel>
<div className="text-sm text-muted-foreground">
{contentStats.words} words • {contentStats.readingTime} min read
</div>
</div>
<FormControl>
<RichTextEditor
value={field.value}
onChange={(content, editor) => {
field.onChange(content)
calculateStats(content)
}}
config={{
toolbar: {
groups: [
{ name: 'history', buttons: ['undo', 'redo'] },
{ name: 'formatting', buttons: ['bold', 'italic', 'underline'] },
{ name: 'headings', buttons: ['heading1', 'heading2', 'heading3'] },
{ name: 'lists', buttons: ['bulletList', 'orderedList'] },
{ name: 'insert', buttons: ['link', 'blockquote', 'codeBlock'] }
]
},
content: {
characterLimit: 10000,
showCharacterCount: true
}
}}
className="min-h-[400px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Submit buttons */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{contentStats.characters > 0 && (
<>Characters: {contentStats.characters}</>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline">
Save as Draft
</Button>
<Button type="submit">
Publish Article
</Button>
</div>
</div>
</form>
</Form>
</div>
)
}
Image Handling
import React, { useState } from 'react'
import { RichTextEditor } from '@/components/RichTextEditor'
import { toast } from 'sonner'
// Image upload component
const ImageUploadHandler = {
// Upload single image
uploadImage: async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('image', file)
formData.append('folder', 'editor-images')
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${getAuthToken()}`
}
})
if (!response.ok) {
throw new Error('Failed to upload image')
}
const { url, id, metadata } = await response.json()
// Track uploaded images for cleanup if needed
uploadedImages.add(id)
return url
},
// Validate image before upload
validateImage: (file: File): { valid: boolean; error?: string } => {
const maxSize = 5 * 1024 * 1024 // 5MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (file.size > maxSize) {
return { valid: false, error: 'Image must be smaller than 5MB' }
}
if (!allowedTypes.includes(file.type)) {
return { valid: false, error: 'Only JPEG, PNG, WebP and GIF images are allowed' }
}
return { valid: true }
},
// Compress image before upload
compressImage: async (file: File, maxWidth = 1200): Promise<File> => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
const ratio = Math.min(maxWidth / img.width, maxWidth / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => {
if (blob) {
const compressedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
})
resolve(compressedFile)
}
}, file.type, 0.8)
}
img.src = URL.createObjectURL(file)
})
}
}
export default function ImageRichEditor() {
const [content, setContent] = useState('')
const [uploadedImages] = useState(new Set<string>())
const handleImageUpload = async (file: File): Promise<string> => {
try {
// Validate image
const validation = ImageUploadHandler.validateImage(file)
if (!validation.valid) {
toast.error(validation.error)
throw new Error(validation.error)
}
// Show upload progress
toast.loading('Uploading image...')
// Compress if needed
const compressedFile = await ImageUploadHandler.compressImage(file)
// Upload to server
const imageUrl = await ImageUploadHandler.uploadImage(compressedFile)
toast.success('Image uploaded successfully')
return imageUrl
} catch (error) {
toast.error('Failed to upload image')
throw error
}
}
const handlePasteImage = async (event: ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
try {
const url = await handleImageUpload(file)
// Insert image at cursor position
return url
} catch (error) {
console.error('Failed to upload pasted image:', error)
}
}
}
}
}
return (
<div className="max-w-4xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Rich Editor with Image Support</h2>
<RichTextEditor
value={content}
onChange={setContent}
// Image handling
onImageUpload={handleImageUpload}
onPasteImage={handlePasteImage}
config={{
toolbar: {
groups: [
{ name: 'formatting', buttons: ['bold', 'italic', 'underline'] },
{ name: 'headings', buttons: ['heading1', 'heading2', 'heading3'] },
{ name: 'media', buttons: ['image', 'video', 'file'] },
{ name: 'insert', buttons: ['link', 'table'] }
]
},
// Media settings
media: {
image: {
allowUpload: true,
allowUrl: true,
allowPaste: true,
allowDragDrop: true,
maxSize: '5MB',
allowedTypes: ['jpeg', 'png', 'webp', 'gif'],
resizable: true,
captionEnabled: true
},
video: {
allowEmbed: true,
allowedProviders: ['youtube', 'vimeo'],
allowUpload: false
}
},
// Drag and drop configuration
dragDrop: {
enabled: true,
allowedTypes: ['image/*', 'video/*'],
onDrop: async (files: File[]) => {
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await handleImageUpload(file)
} catch (error) {
console.error('Failed to upload dropped image:', error)
}
}
}
}
}
}}
// Custom image dialog
customImageDialog={(editor) => (
<ImageUploadDialog
onUpload={async (file) => {
const url = await handleImageUpload(file)
editor.chain().focus().setImage({ src: url }).run()
}}
onUrl={(url) => {
editor.chain().focus().setImage({ src: url }).run()
}}
/>
)}
className="min-h-[500px] border rounded-lg"
/>
{/* Image management panel */}
{uploadedImages.size > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Uploaded Images</h3>
<div className="grid grid-cols-4 gap-4">
{Array.from(uploadedImages).map((imageId) => (
<div key={imageId} className="relative group">
<img
src={`/api/images/${imageId}`}
alt="Uploaded"
className="w-full h-24 object-cover rounded border"
/>
<button
onClick={() => {
// Remove image from server and set
deleteImage(imageId)
uploadedImages.delete(imageId)
}}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
</div>
)}
</div>
)
}
PropTypeDescription
valuestringHTML content value
onChange(content: string, editor: Editor) => voidContent change handler
configRichTextEditorConfigEditor configuration
placeholderstringPlaceholder text
disabledbooleanDisable editor
classNamestringAdditional CSS classes
onImageUpload(file: File) => Promise<string>Image upload handler
onSave(content: string) => voidSave handler
onError(error: Error) => voidError handler
Configuration Interface
interface RichTextEditorConfig {
toolbar?: {
position?: 'top' | 'bottom' | 'floating'
sticky?: boolean
groups?: ToolbarGroup[]
}
content?: {
characterLimit?: number
showCharacterCount?: boolean
autosave?: {
enabled: boolean
interval: number
key: string
}
}
extensions?: {
placeholder?: string
typography?: boolean
collaboration?: boolean
mentions?: MentionConfig
emoji?: EmojiConfig
}
security?: {
allowedTags?: string[]
allowedAttributes?: Record<string, string[]>
sanitize?: boolean
}
theme?: {
borderRadius?: 'sm' | 'md' | 'lg'
focusRing?: boolean
toolbar?: ThemeConfig
editor?: ThemeConfig
}
media?: {
image?: ImageConfig
video?: VideoConfig
}
}
Available Toolbar Buttons
// Text formatting
'bold', 'italic', 'underline', 'strike', 'code', 'highlight'
// Headings
'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6', 'paragraph'
// Lists
'bulletList', 'orderedList', 'taskList'
// Alignment
'alignLeft', 'alignCenter', 'alignRight', 'alignJustify'
// Insert elements
'link', 'image', 'table', 'horizontalRule', 'codeBlock', 'blockquote'
// Advanced formatting
'subscript', 'superscript', 'clearFormatting'
// History
'undo', 'redo'
// Custom buttons
'customButton1', 'customButton2' // Defined in config.customButtons
Custom Extensions
import { Extension } from '@tiptap/core'
import { Node } from '@tiptap/core'
// Custom mention extension
const CustomMention = Node.create({
name: 'mention',
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
id: { default: null },
label: { default: null }
}
},
parseHTML() {
return [
{
tag: 'span[data-type="mention"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'span',
{
'data-type': 'mention',
'data-id': HTMLAttributes.id,
class: 'mention'
},
`@${HTMLAttributes.label}`
]
}
})
// Custom callout extension
const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
addAttributes() {
return {
type: {
default: 'info'
}
}
},
parseHTML() {
return [
{
tag: 'div[data-type="callout"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
{
'data-type': 'callout',
'data-callout-type': HTMLAttributes.type,
class: `callout callout-${HTMLAttributes.type}`
},
0
]
},
addCommands() {
return {
setCallout: (attributes) => ({ commands }) => {
return commands.wrapIn(this.name, attributes)
}
}
}
})
// Register custom extensions
<RichTextEditor
config={{
customExtensions: [
CustomMention,
Callout
]
}}
/>

RichTextEditor provides a complete WYSIWYG editing solution with powerful customization and extensibility! ✏️📝