Rich Formatting
Bold, italic, underline, headings, lists, and custom text styling
A powerful WYSIWYG rich text editor built on TipTap with comprehensive formatting tools, extensible architecture, and seamless React integration.
TipTap WYSIWYG ExtensibleRich 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:
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>)}
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>)}
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 componentconst 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 highlightingconst 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 dateconst 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> )} />)}
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 schemaconst 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>)}
import React, { useState } from 'react'import { RichTextEditor } from '@/components/RichTextEditor'import { toast } from 'sonner'
// Image upload componentconst ImageUploadHandler = {// Upload single imageuploadImage: 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 uploadvalidateImage: (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 uploadcompressImage: 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>)}
Prop | Type | Description |
---|---|---|
value | string | HTML content value |
onChange | (content: string, editor: Editor) => void | Content change handler |
config | RichTextEditorConfig | Editor configuration |
placeholder | string | Placeholder text |
disabled | boolean | Disable editor |
className | string | Additional CSS classes |
onImageUpload | (file: File) => Promise<string> | Image upload handler |
onSave | (content: string) => void | Save handler |
onError | (error: Error) => void | Error handler |
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}}
// 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
import { Extension } from '@tiptap/core'import { Node } from '@tiptap/core'
// Custom mention extensionconst 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 extensionconst 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<RichTextEditorconfig={{ customExtensions: [ CustomMention, Callout ]}}/>
RichTextEditor provides a complete WYSIWYG editing solution with powerful customization and extensibility! ✏️📝