TipTap Customization Guide
Extending and customizing the TipTap-based Rich Text Editor
Overview
This guide covers advanced customization of the TipTap Rich Text Editor. You’ll learn how to add custom extensions, integrate entity field references, and customize the theme appearance.
Adding Custom Extensions
Step 1: Install Additional TipPack Extensions
Install the extension package:
# Table extension
npm install @tiptap/extension-table
# Task list extension
npm install @tiptap/extension-task-list
# Collaboration extension (for real-time editing)
npm install @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
# Highlight extension
npm install @tiptap/extension-highlight
# Subscript/Superscript
npm install @tiptap/extension-subscript @tiptap/extension-superscriptStep 2: Create Custom Extension
Create a new extension file:
// src/extensions/EntityReference.ts
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
entityReference: {
insertEntityReference: (entityId: string, entityType: string) => ReturnType;
};
}
}
export const EntityReference = Extension.create({
name: 'entityReference',
addOptions() {
return {
HTMLAttributes: {
class: 'entity-reference',
},
};
},
addProseMirrorPlugins() {
return [
Plugin({
key: new PluginKey('entityReference'),
props: {
attributes: {
class: this.options.HTMLAttributes.class,
},
},
}),
];
},
addCommands() {
return {
insertEntityReference:
(entityId: string, entityType: string) =>
({ chain }) => {
return chain()
.insertContent({
type: 'entityReference',
attrs: { entityId, entityType },
})
.run();
},
};
},
});Step 3: Register Extensions in RichTextEditor
Update RichTextEditor.tsx to include your custom extensions:
// src/components/RichTextEditor.tsx
import { EntityReference } from '@/extensions/EntityReference';
import { Table } from '@tiptap/extension-table';
import { TaskItem, TaskList } from '@tiptap/extension-task-list';
export function RichTextEditor({ value, onChange, ...props }: RichTextEditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
// Customize starter kit
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
codeBlock: {
keepMarkdown: true,
},
}),
// Your custom extension
EntityReference,
// Table extension
Table.configure({
resizable: true,
lastColumnResizable: true,
}),
TableRow,
TableHeader,
TableCell,
// Task list extension
TaskList,
TaskItem.configure({
nested: true,
}),
// Existing extensions
Placeholder.configure({ placeholder: props.placeholder || 'Start writing...' }),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-surface-primary-color underline hover:text-surface-primary-hover',
},
}),
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg my-4',
},
}),
Underline,
TextStyle,
Color.configure({ types: ['textStyle'] }),
],
content: value,
editable: !props.readOnly,
// ... rest of configuration
});
}Step 4: Add Toolbar Buttons
Add toolbar buttons for your custom extensions:
// In RichTextEditor.tsx toolbar section
{/* Entity Reference */}
<ToolbarButton
onClick={() => {
const entityId = prompt('Enter entity ID:');
const entityType = prompt('Enter entity type (character, location, etc.):');
if (entityId && entityType) {
editor.chain().focus().insertEntityReference(entityId, entityType).run();
}
}}
title="Insert Entity Reference"
>
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
{/* Table */}
<ToolbarButton
onClick={() => {
const rows = parseInt(prompt('Number of rows:', '3') || '3');
const cols = parseInt(prompt('Number of columns:', '3') || '3');
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: false }).run();
}}
title="Insert Table"
>
<Table className="w-4 h-4" />
</ToolbarButton>
{/* Task List */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleTaskList().run()}
isActive={editor.isActive('taskList')}
title="Toggle Task List"
>
<CheckSquare className="w-4 h-4" />
</ToolbarButton>Entity Field Integration
Field Reference Syntax
Insert entity references using the custom extension:
// Insert a character reference
editor.chain().focus().insertEntityReference('rex_marshall', 'character').run();
// Insert a location reference
editor.chain().focus().insertEntityReference('neo_downtown', 'location').run();Auto-Completion
Add auto-completion for entity references:
import { StarterKit } from '@tiptap/starter-kit';
import { suggester } from '@tiptap/extension-mention';
// Add mention extension
import Mention from '@tiptap/extension-mention';
const entitySuggester = suggester({
match: /@(\w*)$/,
items: ({ query }) => {
// Search entities matching query
return searchEntities(query).slice(0, 5);
},
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertEntityReference(props.id, props.type)
.run();
},
});
// Register in editor
const editor = useEditor({
extensions: [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: 'entity-reference',
},
}),
entitySuggester,
],
});Validation Rules
Validate entity references:
// src/extensions/EntityReferenceValidation.ts
import { Extension } from '@tiptap/core';
export const EntityReferenceValidation = Extension.create({
name: 'entityReferenceValidation',
addProseMirrorPlugins() {
return [
Plugin({
key: new PluginKey('entityReferenceValidation'),
props: {
validateTransaction: (transaction) => {
transaction.steps.forEach((step) => {
if (step.addToSet && step.addToSet.key === 'entityReference') {
const node = step.node;
const { entityId, entityType } = node.attrs;
// Validate against API
if (!entityId || !entityType) {
return false;
}
// Check if entity exists
return validateEntityReference(entityId, entityType);
}
});
return true;
},
},
}),
];
},
});Custom Node Styles
Style entity references with CSS:
/* src/app/globals.css */
.entity-reference {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--surface-primary-bg);
color: var(--surface-primary-text);
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.entity-reference:hover {
background-color: var(--surface-primary-hover);
}
.entity-reference .entity-type {
font-size: 0.75rem;
opacity: 0.8;
margin-left: 0.25rem;
}
.entity-reference .remove-btn {
margin-left: 0.375rem;
opacity: 0;
transition: opacity 0.2s;
}
.entity-reference:hover .remove-btn {
opacity: 1;
}Theme Customization
CSS Variables
The RichTextEditor uses semantic surface classes for theming. Customize via CSS variables:
/* src/app/globals.css */
/* Customize editor content area */
.ProseMirror {
color: var(--surface-elevated-text);
min-height: 200px;
max-height: 600px;
overflow-y: auto;
padding: 1rem;
}
/* Customize placeholder */
.ProseMirror > p.is-editor-empty:first-child::before {
color: var(--surface-elevated-text-secondary);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Customize headings */
.ProseMirror h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--surface-elevated-text);
}
.ProseMirror h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--surface-elevated-text);
}
.ProseMirror h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--surface-elevated-text);
}
/* Customize links */
.ProseMirror a {
color: var(--surface-primary-color);
text-decoration: underline;
transition: color 0.2s;
}
.ProseMirror a:hover {
color: var(--surface-primary-hover);
}
/* Customize images */
.ProseMirror img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1rem 0;
}
/* Customize code blocks */
.ProseMirror pre {
background-color: var(--surface-elevated-bg);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
.ProseMirror code {
color: var(--surface-accent-color);
font-family: 'Fira Code', monospace;
font-size: 0.875rem;
}
.ProseMirror pre code {
color: var(--surface-elevated-text);
background-color: transparent;
}
/* Customize lists */
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin: 1rem 0;
}
.ProseMirror li {
margin-bottom: 0.5rem;
}
.ProseMirror li p {
margin-bottom: 0.5rem;
}
/* Customize blockquotes */
.ProseMirror blockquote {
border-left: 4px solid var(--surface-primary-border);
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: var(--surface-elevated-text-secondary);
}
/* Customize horizontal rule */
.ProseMirror hr {
border: none;
border-top: 2px solid var(--surface-elevated-border);
margin: 2rem 0;
}
/* Customize selected text */
.ProseMirror ::selection {
background-color: var(--surface-primary-color);
}Dark Mode Support
Dark mode is automatic with the surface system:
/* The same classes work in both modes */
.ProseMirror {
/* These variables change based on .dark class */
color: var(--surface-elevated-text);
}
/* Light mode (default) */
:root {
--surface-elevated-text: #1a1a2e;
--surface-elevated-bg: #f9fafb;
}
/* Dark mode */
.dark {
--surface-elevated-text: #e5e5e5;
--surface-elevated-bg: #1a1a2e;
}Custom Color Scheme
Create a custom color scheme by overriding CSS variables:
/* Custom accent colors */
:root {
/* Override primary color */
--surface-primary-bg: #8b5cf6;
--surface-primary-hover: #7c3aed;
--surface-primary-border: #a78bfa;
/* Override accent color */
--surface-accent-bg: #10b981;
--surface-accent-hover: #059669;
--surface-accent-border: #34d399;
}
/* Apply custom scheme globally */
[data-theme="custom"] {
/* All components will use these colors */
}Dynamic Theme from Settings
Integrate with the Settings theme editor:
// src/components/RichTextEditor.tsx
import { useSettings } from '@/contexts/SettingsContext';
export function RichTextEditor({ value, onChange, ...props }: RichTextEditorProps) {
const { theme } = useSettings();
return (
<div
style={{
'--editor-bg': theme.backgroundColor,
'--editor-text': theme.textColor,
} as React.CSSProperties}
>
<RichTextEditor value={value} onChange={onChange} {...props} />
</div>
);
}Advanced Configuration
Editor Configuration Options
const editor = useEditor({
extensions: [...],
content: value,
editable: !readOnly,
// Update behavior
onUpdate: ({ editor, transaction }) => {
const html = editor.getHTML();
onChange(html);
// Log transaction for audit trail
console.log('Transaction:', transaction);
},
// Selection change
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
console.log('Selection:', from, to);
},
// Command history
onFocus: ({ editor }) => {
console.log('Editor focused');
},
onBlur: ({ editor }) => {
console.log('Editor blurred');
},
// Content drop
onDrop: ({ event, editor }) => {
console.log('Dropped content:', event.dataTransfer);
},
// Drag handle
onDraggable: ({ event, editor }) => {
return editor.state.selection.$from.node();
},
// Editor props (for fine-grained control)
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none',
style: 'color: var(--text-surface-elevated-text)',
spellcheck: 'false',
autocapitalize: 'off',
autocomplete: 'off',
autocorrect: 'off',
},
handleDOMEvents: {
keydown: (_view, event) => {
// Prevent default browser behaviors
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
editor.chain().focus().insertContent(' ').run();
return true;
}
return false;
},
},
},
});Content Handling
Convert between formats:
// HTML to Markdown
const handleExportMarkdown = () => {
if (!editor) return;
const html = editor.getHTML();
const markdown = htmlToMarkdown(html);
// Download
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.md';
a.click();
URL.revokeObjectURL(url);
};
// Markdown to HTML
const handleImportMarkdown = (markdown: string) => {
if (!editor || !onImportMarkdown) return;
const html = marked.parse(markdown) as string;
editor.commands.setContent(html);
};
// Plain text extraction
const getPlainText = () => {
if (!editor) return '';
return editor.getText();
};
// Get character count
const getCharCount = () => {
if (!editor) return 0;
return editor.state.doc.textContent.length;
};
// Get word count
const getWordCount = () => {
if (!editor) return 0;
return editor.state.doc.textContent.split(/\s+/).filter(Boolean).length;
};Command API Reference
Use the editor command API:
// Text formatting
editor.chain().focus().toggleBold().run();
editor.chain().focus().toggleItalic().run();
editor.chain().focus().toggleUnderline().run();
editor.chain().focus().toggleStrike().run();
// Headings
editor.chain().focus().toggleHeading({ level: 1 }).run();
editor.chain().focus().toggleHeading({ level: 2 }).run();
editor.chain().focus().toggleHeading({ level: 3 }).run();
// Lists
editor.chain().focus().toggleBulletList().run();
editor.chain().focus().toggleOrderedList().run();
editor.chain().focus().toggleTaskList().run();
// Block elements
editor.chain().focus().toggleCode().run();
editor.chain().focus().toggleCodeBlock().run();
editor.chain().focus().toggleBlockquote().run();
editor.chain().focus().setHorizontalRule().run();
// Links and images
editor.chain().focus().setLink({ href: 'https://example.com' }).run();
editor.chain().focus().unsetLink().run();
editor.chain().focus().setImage({ src: 'image.jpg' }).run();
// Indentation
editor.chain().focus().sinkListItem('taskItem').run();
editor.chain().focus().liftListItem('taskItem').run();
// Clear formatting
editor.chain().focus().unsetAllMarks().run();
editor.chain().focus().clearNodes().run();
// History
editor.chain().focus().undo().run();
editor.chain().focus().redo().run();
// Set content
editor.chain().focus().setContent('<p>New content</p>').run();
editor.chain().focus().insertContent('<p>Inserted</p>').run();
editor.chain().focus().deleteSelection().run();
// Selection
editor.chain().focus().selectNodeBackward().run();
editor.chain().focus().selectAll().run();Common Customization Patterns
Custom Toolbar Layout
Group toolbar buttons logically:
<div className="flex flex-wrap items-center gap-1 p-2 border-b border-surface-elevated-border bg-surface-base">
{/* History group */}
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} title="Undo">
<Undo className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} title="Redo">
<Redo className="w-4 h-4" />
</ToolbarButton>
</div>
<ToolbarDivider />
{/* Text formatting group */}
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">
<Bold className="w-4 h-4" />
</ToolbarButton>
{/* ... other buttons */}
</div>
<ToolbarDivider />
{/* Insert group */}
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => insertTable()} title="Insert Table">
<Table className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => insertEntity()} title="Insert Entity">
<Users className="w-4 h-4" />
</ToolbarButton>
</div>
<div className="flex-1" />
{/* Actions group */}
<div className="flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().unsetAllMarks().run()} title="Clear formatting">
<Eraser className="w-4 h-4" />
</ToolbarButton>
</div>
</div>Keyboard Shortcuts
Custom keyboard shortcuts:
editor = useEditor({
// ... other config
// Add custom keyboard shortcuts
addKeyboardShortcuts() {
return {
'Mod-b': () => editor.chain().focus().toggleBold().run(),
'Mod-i': () => editor.chain().focus().toggleItalic().run(),
'Mod-u': () => editor.chain().focus().toggleUnderline().run(),
// Custom shortcut for entity reference
'Mod-e': () => {
const entityId = prompt('Enter entity ID:');
if (entityId) {
editor.chain().focus().insertEntityReference(entityId, 'character').run();
}
return true;
},
// Custom shortcut for table
'Mod-t': () => {
const rows = parseInt(prompt('Rows:', '3') || '3');
const cols = parseInt(prompt('Columns:', '3') || '3');
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: false }).run();
return true;
},
};
},
});Content Validation
Add validation before saving:
const validateContent = (html: string): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
// Check minimum length
const text = html.replace(/<[^>]*>/g, '');
if (text.trim().length < 50) {
errors.push('Content must be at least 50 characters');
}
// Check for required heading
if (!html.includes('<h')) {
errors.push('Content must contain at least one heading');
}
// Check for broken links
const links = html.match(/<a[^>]*href="([^"]*)"[^>]*>/g) || [];
links.forEach((link) => {
const href = link.match(/href="([^"]*)"/)?.[1];
if (href && !isValidUrl(href)) {
errors.push(`Invalid URL: ${href}`);
}
});
return { valid: errors.length === 0, errors };
};
const handleSave = async (value: string) => {
const { valid, errors } = validateContent(value);
if (!valid) {
errors.forEach((error) => toast.error(error));
return;
}
await api.saveEntity(entityId, { markdown_body: value });
toast.success('Saved successfully!');
};Troubleshooting
Custom Extension Not Appearing
Problem: Custom extension doesn’t show in toolbar
Solutions:
- Verify extension is imported and included in
extensionsarray - Check extension name matches command name
- Ensure extension is registered before use
- Check browser console for extension errors
Theme Not Applying
Problem: Custom CSS styles aren’t taking effect
Solutions:
- Verify CSS is loaded after Tailwind
- Check selector specificity (use more specific selectors)
- Ensure CSS variables are defined
- Use
!importantif needed (temporary solution)
Keyboard Shortcuts Not Working
Problem: Custom keyboard shortcuts don’t trigger
Solutions:
- Verify shortcut doesn’t conflict with browser defaults
- Check that editor has focus when shortcut is pressed
- Ensure
addKeyboardShortcuts()returns object correctly - Check for other shortcuts using same key combination
Entity References Not Rendering
Problem: Entity references appear as plain text
Solutions:
- Verify extension is registered in
extensionsarray - Check HTML output includes proper node structure
- Ensure CSS styling is applied
- Verify node is being rendered correctly in ProseMirror
Performance Degradation
Problem: Editor becomes slow with many extensions
Solutions:
- Lazy load heavy extensions
- Remove unused extensions
- Optimize content validation
- Use virtualization for long documents
- Debounce save operations
Related Documentation
- Rich Text Editor API - Core component documentation
- Document Outline - Section navigation
- Theme Customization - Color system
- Component Blocks - Layout components