Rich Text Editor (TipTap)
TipTap-based rich text editor for entity content, supporting markdown-like syntax and collaborative editing
Overview
The RichTextEditor component is a powerful, production-ready rich text editor built on TipTap, a headless framework for rich text editor building. It provides a full-featured WYSIWYG editing experience with markdown compatibility, auto-save functionality, and import/export capabilities.
Key Features
- Rich text editing with full HTML output
- Markdown import/export for portability and version control
- Auto-save with configurable delay (default: 1 second)
- Word and character counting in real-time
- Undo/Redo history management
- Link dialog for easy URL insertion
- Image upload support (base64 encoding)
- Read-only mode for viewing content
- Keyboard shortcuts for common actions
- Responsive toolbar with collapsible sections
- Status bar with editing statistics
Component API
Props Interface
export interface RichTextEditorProps {
/** HTML content to display */
value: string;
/** Callback when content changes */
onChange: (value: string) => void;
/** Placeholder text when editor is empty */
placeholder?: string;
/** Disable editing (view-only mode) */
readOnly?: boolean;
/** Manual save callback */
onSave?: (value: string) => void;
/** Markdown import callback */
onImportMarkdown?: (markdown: string) => void;
/** Enable auto-save */
autoSave?: boolean;
/** Auto-save delay in milliseconds */
autoSaveDelay?: number;
}Props Reference
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
value | string | Yes | - | HTML content to display in the editor |
onChange | (value: string) => void | Yes | - | Callback fired on every change with new HTML content |
placeholder | string | No | 'Start writing...' | Text shown when editor is empty |
readOnly | boolean | No | false | If true, shows editor without toolbar or editing capability |
onSave | (value: string) => void | No | - | Manual save callback (shows toast notification) |
onImportMarkdown | (markdown: string) => void | No | - | Enables markdown import functionality |
autoSave | boolean | No | true | Enable automatic saving on content changes |
autoSaveDelay | number | No | 1000 | Delay before auto-save triggers (ms) |
Usage Examples
Basic Usage
import { RichTextEditor } from '@/components/RichTextEditor';
function CharacterBioEditor({ characterId }: { characterId: string }) {
const [bio, setBio] = useState('');
return (
<RichTextEditor
value={bio}
onChange={setBio}
placeholder="Enter character biography..."
/>
);
}With Auto-Save
<RichTextEditor
value={content}
onChange={setContent}
autoSave={true}
autoSaveDelay={2000}
placeholder="Write your content here..."
/>With Manual Save
function EntityEditor({ entityId }: { entityId: string }) {
const [markdownBody, setMarkdownBody] = useState('');
const handleSave = async (value: string) => {
await fetch(`/api/entities/${entityId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markdown_body: value }),
});
toast.success('Entity saved!');
};
return (
<RichTextEditor
value={markdownBody}
onChange={setMarkdownBody}
onSave={handleSave}
placeholder="Start writing the entity description..."
/>
);
}Read-Only Mode
<RichTextEditor
value={publishedContent}
onChange={() => {}}
readOnly={true}
placeholder="This content is published and cannot be edited."
/>With Markdown Import/Export
function DocumentEditor() {
const [content, setContent] = useState('');
const handleImport = (markdown: string) => {
// Convert markdown to HTML before setting
const html = marked.parse(markdown) as string;
setContent(html);
};
return (
<RichTextEditor
value={content}
onChange={setContent}
onImportMarkdown={handleImport}
placeholder="Write or import your document..."
/>
);
}Available Extensions
The RichTextEditor includes the following TipTap extensions based on actual implementation:
Core Extensions
| Extension | Description | Toolbar Icon | Keyboard Shortcut |
|---|---|---|---|
| StarterKit | Core formatting with lists, block types, and basic text styles | - | - |
| Placeholder | Placeholder text when editor is empty | - | - |
| Link | Hyperlinks with dialog input | 🔗 | Ctrl+K |
| Image | Inline images with src attribute | 🖼️ | - |
| Underline | Text underline formatting | ⬌ | Ctrl+U |
| TextStyle | Text color and font family | - | - |
| Color | Text color (via TextStyle) | - | - |
Formatting Features
Text Styles
- Bold -
Ctrl+B| Button: B - Italic -
Ctrl+I| Button: I - Underline -
Ctrl+U| Button: U Strikethrough- Button: S with strikeInline code- Button:< >
Headings
- Heading 1 - Button: H¹
- Heading 2 - Button: H²
- Heading 3 - Button: H³
Lists
- Bullet list - Button: • List
- Ordered list - Button: 1. List
Block Elements
- Blockquote - Button: ” | Button:
" - Code block - Button:
< > - Horizontal rule - Button: —
Extensions Not Included
The following common TipTap extensions are not currently used in the implementation:
- Tables (
@tiptap/extension-table) - Task lists (
@tiptap/extension-task-list) - Dropcursor (
@tiptap/extension-dropcursor) - Gapcursor (
@tiptap/extension-gapcursor) - Focus (
@tiptap/extension-focus) - Typography (
@tiptap/extension-typography) - Subscript/Superscript (
@tiptap/extension-subscribe,@tiptap/extension-superscript) - Highlight (
@tiptap/extension-highlight)
You can add these by extending the extensions array in the component configuration.
Toolbar Reference
The toolbar is organized into logical groups separated by dividers:
History Group
- Undo (
Ctrl+Z) - Redo (
Ctrl+YorCtrl+Shift+Z)
Text Formatting Group
- Bold, Italic, Underline, Strikethrough
Headings Group
- Heading 1, Heading 2, Heading 3
Lists Group
- Bullet List, Ordered List
Block Elements Group
- Code, Blockquote, Horizontal Rule
Insert Group
- Add Link (opens dialog)
- Insert Image (file picker)
Import/Export Group
- Import Markdown (file picker)
- Export Markdown (download)
Actions Group
- Save (manual save, when
onSaveis provided)
Styling and Appearance
CSS Classes
The editor uses semantic surface classes for theming:
/* Editor container */
bg-surface-elevated border border-surface-elevated-border
/* Toolbar */
bg-surface-base border-b border-surface-elevated-border
/* Editor content area */
prose prose-sm max-w-none
text-surface-elevated-text
/* Button states */
isActive: bg-surface-primary/20 text-surface-primary-color
hover: bg-surface-base-hover text-surface-elevated-text
disabled: opacity-50 cursor-not-allowed
/* Links */
text-surface-primary-color underline hover:text-surface-primary-hover
/* Images */
max-w-full h-auto rounded-lg my-4Customizing Editor Appearance
You can customize the editor’s appearance by overriding CSS variables:
/* Customize placeholder color */
.ProseMirror-placeholder {
color: var(--surface-base-text-secondary);
}
/* Customize selected text */
.ProseMirror::selection {
background: var(--surface-primary-color);
}
/* Customize link styling */
.ProseMirror a {
color: var(--surface-primary-color);
text-decoration: underline;
}
/* Customize focus state */
.ProseMirror:focus {
outline: none;
}Import/Export Features
Markdown Export
Click the Download icon to export your content as Markdown. The export includes:
- Headings (h1, h2, h3)
- Lists (bullet and ordered)
- Text formatting (bold, italic, underline, strikethrough)
- Links and images
- Blockquotes
- Code blocks
- Horizontal rules
Export filename format: document-YYYY-MM-DD.md
Markdown Import
Click the Upload icon to import Markdown files. Supported formats:
.mdfiles.markdownfiles.txtfiles (basic markdown)
The import process converts Markdown to HTML using these mappings:
| Markdown | HTML |
|---|---|
# Title | <h1>Title</h1> |
## Subtitle | <h2>Subtitle</h2> |
**bold** | <strong>bold</strong> |
*italic* | <em>italic</em> |
_underline_ | <u>underline</u> |
~~strikethrough~~ | <s>strikethrough</s> |
- item | <li>item</li> |
1. item | <li>item</li> |
[text](url) | <a href="url">text</a> |
 | <img src="url" alt="alt"> |
> quote | <blockquote>quote</blockquote> |
`code` | <code>code</code> |
code``` | <pre><code>code</code></pre> |
--- | <hr> |
State Management
Word and Character Counting
The editor automatically tracks:
- Word count: Split by whitespace, filters empty strings
- Character count: Total character length including spaces
Both counts update in real-time and display in the status bar.
Auto-Save Behavior
When autoSave is enabled:
- User makes changes to content
- Timer starts (default 1000ms delay)
- If no changes for delay period,
onSaveis called - New change cancels previous timer and restarts
- Cleanup on unmount prevents memory leaks
Content Synchronization
The editor syncs with external value prop changes:
useEffect(() => {
if (editor && value !== editor.getHTML()) {
// Only update if significantly different
const currentHtml = editor.getHTML();
if (value !== currentHtml) {
editor.commands.setContent(value, { emitUpdate: false });
}
}
}, [value, editor]);This prevents unnecessary re-renders while keeping content in sync.
Event Handlers
onChange
onChange: (value: string) => voidFired on every content change with the new HTML content. Typical usage:
const [content, setContent] = useState('');
<RichTextEditor
value={content}
onChange={setContent}
/>onSave
onSave: (value: string) => voidCalled when:
- User clicks the Save button
- Auto-save triggers (if enabled)
const handleSave = useCallback(async (value: string) => {
await api.saveEntity(entityId, { markdown_body: value });
toast.success('Saved successfully');
}, [entityId]);onImportMarkdown
onImportMarkdown: (markdown: string) => voidCalled when a user selects a Markdown file to import. You should:
- Parse the Markdown to HTML
- Update your state with the HTML content
const handleImport = (markdown: string) => {
const html = marked.parse(markdown) as string;
setContent(html);
};Best Practices
1. Control the Editor
Always use the editor in controlled mode for predictable behavior:
// ✅ Good - Controlled
<RichTextEditor
value={content}
onChange={setContent}
/>
// ❌ Bad - Uncontrolled (won't sync with state)
<RichTextEditor
onChange={setContent}
/>2. Handle Empty Content
Provide sensible defaults and handle empty states:
const [content, setContent] = useState('<p>Start writing...</p>');
// Or use placeholder
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Your content here..."
/>3. Validate Before Saving
const handleSave = async (value: string) => {
// Check for required elements
const text = value.replace(/<[^>]*>/g, '');
if (text.trim().length < 10) {
toast.error('Content must be at least 10 characters');
return;
}
await saveToDatabase(value);
};4. Optimize Large Documents
For very long documents, consider:
- Limiting max height (
maxHeight: '800px') - Enabling virtualization if supported
- Debouncing save operations (
autoSaveDelay: 5000)
5. Handle Image Uploads
The editor uses base64 encoding for images. For production:
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Convert to base64 (current implementation)
const reader = new FileReader();
reader.onload = (event) => {
const src = event.target?.result as string;
editor.chain().focus().setImage({ src }).run();
};
reader.readAsDataURL(file);
};
// For production, upload to server first:
const handleImageUpload = async (file: File) => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const { url } = await response.json();
editor.chain().focus().setImage({ src: url }).run();
};Troubleshooting
Editor Not Rendering
Problem: Editor shows “Loading editor…” indefinitely
Solutions:
- Check that all TipTap dependencies are installed:
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-link npm install @tiptap/extension-image @tiptap/extension-underline npm install @tiptap/extension-text-style @tiptap/extension-color npm install @tiptap/extension-placeholder - Verify the
valueprop is a string (HTML content) - Check browser console for TipTap initialization errors
Auto-Save Not Working
Problem: Content doesn’t auto-save despite enabling the feature
Solutions:
- Ensure
onSavecallback is provided whenautoSave: true - Check that
autoSaveDelayis a valid number (in milliseconds) - Verify no unhandled errors in the
onSavefunction - Check for multiple editors with conflicting timeouts
Markdown Import Fails
Problem: Import button doesn’t work or content doesn’t appear
Solutions:
- Verify
onImportMarkdowncallback is provided - Check file type is accepted (
.md,.markdown,.txt) - Ensure the callback properly converts Markdown to HTML
- Check file reader is working in browser context
Images Not Displaying
Problem: Images show broken icon or don’t appear
Solutions:
- Verify image file is valid (not corrupted)
- Check image size (large images may fail with base64)
- For production, use server-upload instead of base64
- Verify
HTMLAttributesconfiguration allows images
Toolbar Buttons Not Working
Problem: Clicking toolbar buttons has no effect
Solutions:
- Check editor is not in
readOnlymode - Verify the command exists for the extension
- Check if
isActivestate matches actual formatting - Ensure extensions are properly configured
Word Count Incorrect
Problem: Word count doesn’t match actual content
Solutions:
- Word count splits by whitespace and filters empty strings
- HTML tags are not counted (correct behavior)
- For accurate count, strip HTML first:
const text = editor.getText(); // Gets plain text
Editor Content Loses Formatting on Save
Problem: After saving and reloading, formatting is lost
Solutions:
- Ensure you’re saving full HTML, not plain text
- Verify the database stores HTML content correctly
- Check that
onChangereceives HTML string - Verify
valueprop is set correctly on re-render
Related Documentation
- Document Outline - Section navigation component
- Component Blocks Reference - Layout components
- Template Authoring - Entity templates
- Theme Customization - Styling guide
Next Steps
- Add Tables Extension - Extend editor capabilities
- Entity Field References - Link to other entities
- Asset Embedding - Media management