DeveloperRich Text Editor

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

PropTypeRequiredDefaultDescription
valuestringYes-HTML content to display in the editor
onChange(value: string) => voidYes-Callback fired on every change with new HTML content
placeholderstringNo'Start writing...'Text shown when editor is empty
readOnlybooleanNofalseIf true, shows editor without toolbar or editing capability
onSave(value: string) => voidNo-Manual save callback (shows toast notification)
onImportMarkdown(markdown: string) => voidNo-Enables markdown import functionality
autoSavebooleanNotrueEnable automatic saving on content changes
autoSaveDelaynumberNo1000Delay 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

ExtensionDescriptionToolbar IconKeyboard Shortcut
StarterKitCore formatting with lists, block types, and basic text styles--
PlaceholderPlaceholder text when editor is empty--
LinkHyperlinks with dialog input🔗Ctrl+K
ImageInline images with src attribute🖼️-
UnderlineText underline formattingCtrl+U
TextStyleText color and font family--
ColorText 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 strike
  • Inline 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+Y or Ctrl+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 onSave is 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-4

Customizing 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:

  • .md files
  • .markdown files
  • .txt files (basic markdown)

The import process converts Markdown to HTML using these mappings:

MarkdownHTML
# 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>
![alt](url)<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:

  1. User makes changes to content
  2. Timer starts (default 1000ms delay)
  3. If no changes for delay period, onSave is called
  4. New change cancels previous timer and restarts
  5. 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) => void

Fired on every content change with the new HTML content. Typical usage:

const [content, setContent] = useState('');
 
<RichTextEditor
  value={content}
  onChange={setContent}
/>

onSave

onSave: (value: string) => void

Called 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) => void

Called when a user selects a Markdown file to import. You should:

  1. Parse the Markdown to HTML
  2. 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:

  1. 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
  2. Verify the value prop is a string (HTML content)
  3. Check browser console for TipTap initialization errors

Auto-Save Not Working

Problem: Content doesn’t auto-save despite enabling the feature

Solutions:

  1. Ensure onSave callback is provided when autoSave: true
  2. Check that autoSaveDelay is a valid number (in milliseconds)
  3. Verify no unhandled errors in the onSave function
  4. Check for multiple editors with conflicting timeouts

Markdown Import Fails

Problem: Import button doesn’t work or content doesn’t appear

Solutions:

  1. Verify onImportMarkdown callback is provided
  2. Check file type is accepted (.md, .markdown, .txt)
  3. Ensure the callback properly converts Markdown to HTML
  4. Check file reader is working in browser context

Images Not Displaying

Problem: Images show broken icon or don’t appear

Solutions:

  1. Verify image file is valid (not corrupted)
  2. Check image size (large images may fail with base64)
  3. For production, use server-upload instead of base64
  4. Verify HTMLAttributes configuration allows images

Toolbar Buttons Not Working

Problem: Clicking toolbar buttons has no effect

Solutions:

  1. Check editor is not in readOnly mode
  2. Verify the command exists for the extension
  3. Check if isActive state matches actual formatting
  4. Ensure extensions are properly configured

Word Count Incorrect

Problem: Word count doesn’t match actual content

Solutions:

  1. Word count splits by whitespace and filters empty strings
  2. HTML tags are not counted (correct behavior)
  3. 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:

  1. Ensure you’re saving full HTML, not plain text
  2. Verify the database stores HTML content correctly
  3. Check that onChange receives HTML string
  4. Verify value prop is set correctly on re-render

Next Steps