tiptap-custom-editor
A customizable Tiptap rich text editor with customizable image upload
Tiptap Custom Editor
A customizable rich text editor built on Tiptap 2.x with an integrated, fully customizable image upload functionality. Designed to be easy to use while providing full control over image handling.
Features
- 🖼️ Drag-and-drop or click-to-upload image functionality
- ⚙️ Fully customizable image upload handler with progress tracking
- 🔄 Built-in base64 image conversion utility
- 📝 Rich text editing with extensive formatting options
- 📄 Document structure with headings and lists
- 🔗 Link support with customizable popover
- 📋 Task lists and checklists
- 🎨 Text alignment and highlight options
- 🔤 Typography, subscript, and superscript support
- 🎭 Light/Dark mode with theme toggle
- 📱 Responsive design for mobile and desktop
Installation
npm install tiptap-custom-editor
Or with yarn:
yarn add tiptap-custom-editor
Usage
Basic Usage
import React from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
// No need to import CSS separately! Styles are automatically included
// Custom image upload function
const handleImageUpload = async (file, onProgress, abortSignal) => {
// Example implementation using FormData and fetch
const formData = new FormData();
formData.append('image', file);
// Track upload progress
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://your-upload-api.com/images');
// Connect abort signal
if (abortSignal) {
abortSignal.addEventListener('abort', () => xhr.abort());
}
// Report progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
onProgress?.({ progress });
}
});
// Return a promise that resolves with the image URL
return new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const response = JSON.parse(xhr.responseText);
resolve(response.imageUrl); // Return the URL of the uploaded image
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.send(formData);
});
};
const MyEditor = () => (
<div style={{ height: '500px' }}>
<TiptapEditor
handleImageUpload={handleImageUpload}
maxImageFileSize={5 * 1024 * 1024} // 5MB
placeholder="Start writing..."
/>
</div>
);
export default MyEditor;
Using Base64 Images (Client-side only, no server required)
import React from 'react';
import {
TiptapEditor,
handleImageUpload,
convertFileToBase64,
} from 'tiptap-custom-editor';
// CSS is automatically included!
// Using the built-in handleImageUpload utility (for demo/testing)
const MyEditor = () => (
<div style={{ height: '500px' }}>
<TiptapEditor
handleImageUpload={handleImageUpload}
placeholder="Start writing..."
/>
</div>
);
// Or create your own base64 handler using the provided utility
const MyEditorWithBase64 = () => {
// Create a custom handler that uses the built-in base64 converter
const customBase64Handler = async (file, onProgress, abortSignal) => {
// Simulate progress for better UX
for (let i = 0; i <= 100; i += 25) {
if (abortSignal?.aborted) {
throw new Error('Upload cancelled');
}
await new Promise((resolve) => setTimeout(resolve, 100));
onProgress?.({ progress: i });
}
// Convert the file to a base64 string
return await convertFileToBase64(file, abortSignal);
};
return (
<div style={{ height: '500px' }}>
<TiptapEditor
handleImageUpload={customBase64Handler}
placeholder="Start writing with base64 images..."
maxImageFileSize={3 * 1024 * 1024} // 3MB
/>
</div>
);
};
export default MyEditor;
Full Configuration
import React, { useState, useCallback } from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
// Styles are automatically included with the component
const MyAdvancedEditor = () => {
const [content, setContent] = useState('<p>Initial content</p>');
const [isUploading, setIsUploading] = useState(false);
// Advanced custom upload implementation with progress tracking
const handleImageUpload = useCallback(
async (file, onProgress, abortSignal) => {
setIsUploading(true);
try {
// Validate file type
if (!file.type.startsWith('image/')) {
throw new Error('File must be an image');
}
const formData = new FormData();
formData.append('image', file);
formData.append('purpose', 'editor-upload');
// Create upload controller with abort capability
const controller = new AbortController();
const signal = abortSignal || controller.signal;
// Begin upload with progress reporting
const response = await fetch('https://api.example.com/images/upload', {
method: 'POST',
body: formData,
signal,
headers: {
Accept: 'application/json',
// Add any authentication headers needed
},
// Track upload progress using uploadWithProgress helper (not shown)
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress?.({ progress: percentCompleted });
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `Upload failed with status ${response.status}`
);
}
const data = await response.json();
return data.url; // Return the URL of the uploaded image
} catch (error) {
console.error('Image upload failed:', error);
throw error; // Re-throw to be handled by the editor
} finally {
setIsUploading(false);
}
},
[]
);
const handleChange = useCallback((html) => {
setContent(html);
console.log('Editor content changed:', html);
}, []);
return (
<div className="editor-container">
{isUploading && (
<div className="upload-indicator">Uploading image...</div>
)}
<TiptapEditor
content={content}
handleImageUpload={handleImageUpload}
maxImageFileSize={10 * 1024 * 1024} // 10MB
imageUploadLimit={5} // Max 5 images per upload
acceptedImageTypes="image/png,image/jpeg,image/gif,image/webp"
onImageUploadError={(error) => console.error('Upload error:', error)}
onImageUploadSuccess={(url) =>
console.log('Successfully uploaded to:', url)
}
onChange={handleChange}
editable={true}
autofocus={true}
className="my-custom-editor"
showToolbar={true}
toolbarConfig={{
showHeadings: true,
showLists: true,
showBlockquote: true,
showCodeBlock: true,
showFormattingOptions: true,
showAlignment: true,
showImageUpload: true,
showUndoRedo: true,
}}
/>
</div>
);
};
export default MyAdvancedEditor;
Props
Prop | Type | Default | Description |
---|---|---|---|
content |
string | '' |
Initial content for the editor (HTML string) |
handleImageUpload |
function | Required | Function to handle image uploads with signature: (file: File, onProgress?: (event: { progress: number }) => void, abortSignal?: AbortSignal) => Promise<string> |
maxImageFileSize |
number | 5242880 (5MB) |
Maximum file size for image uploads in bytes |
placeholder |
string | 'Start typing...' |
Placeholder text when editor is empty |
imageUploadLimit |
number | 3 |
Maximum number of image uploads allowed |
acceptedImageTypes |
string | 'image/*' |
MIME types for accepted image files |
onImageUploadError |
function | undefined |
Error handler for image uploads |
onImageUploadSuccess |
function | undefined |
Success handler for image uploads |
onChange |
function | undefined |
Callback when editor content changes |
editable |
boolean | true |
Editor is read-only when false |
autofocus |
boolean | false |
Autofocus the editor on mount |
className |
string | '' |
CSS class name to apply to the editor wrapper |
showToolbar |
boolean | true |
Show/hide toolbar |
toolbarConfig |
object | See below | Configuration for toolbar items |
Default toolbarConfig
Values
{
showHeadings: true, // Headings dropdown (H1-H4)
showLists: true, // Lists dropdown (bullet, ordered, task lists)
showBlockquote: true, // Blockquote button
showCodeBlock: true, // Code block button
showFormattingOptions: true, // Bold, italic, underline, strike, etc.
showAlignment: true, // Text alignment options
showImageUpload: true, // Image upload button
showUndoRedo: true // Undo/Redo buttons
}
Exported Functions and Components
The package exports several helpful utilities:
// Main component
import { TiptapEditor } from 'tiptap-custom-editor';
// Image handling utilities
import {
convertFileToBase64, // Convert File object to base64 string
handleImageUpload, // Default image upload handler (for testing/demo)
MAX_FILE_SIZE, // Default max file size constant (5MB)
} from 'tiptap-custom-editor';
// Image button utilities
import {
ImageUploadButton, // The button component used in the toolbar
useImageUploadButton, // Hook for custom image upload button implementation
isImageActive, // Check if image is active at current selection
insertImage, // Helper to programmatically insert an image
} from 'tiptap-custom-editor';
// Types for extension options
import {
ImageUploadNode,
type ImageUploadNodeOptions,
type UploadFunction,
} from 'tiptap-custom-editor';
Examples
These examples demonstrate common usage scenarios:
1. Simple Implementation with Base64 Images
import React from 'react';
import { TiptapEditor, convertFileToBase64 } from 'tiptap-custom-editor';
const SimpleEditor = () => {
// Basic base64 handler
const handleBase64Upload = async (file) => {
return convertFileToBase64(file);
};
return (
<div className="editor-container">
<TiptapEditor
handleImageUpload={handleBase64Upload}
placeholder="Write something and try adding images..."
/>
</div>
);
};
2. Server-side Upload Implementation
import React from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
const ServerUploadEditor = () => {
const handleServerUpload = async (file, onProgress, abortSignal) => {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
signal: abortSignal,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
return data.url;
} catch (error) {
console.error('Upload error:', error);
throw error;
}
};
return (
<div className="editor-container">
<TiptapEditor
handleImageUpload={handleServerUpload}
acceptedImageTypes="image/jpeg,image/png,image/gif"
maxImageFileSize={3 * 1024 * 1024} // 3MB
onImageUploadSuccess={(url) => console.log('Image uploaded:', url)}
/>
</div>
);
};
3. Advanced Implementation with Content Control
import React, { useState, useEffect } from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
const AdvancedEditor = ({ initialContent, onSave }) => {
const [content, setContent] = useState(initialContent || '');
const [wordCount, setWordCount] = useState(0);
// Count words whenever content changes
useEffect(() => {
const text = content.replace(/<[^>]*>/g, ' ');
const words = text.split(/\s+/).filter(word => word.length > 0);
setWordCount(words.length);
}, [content]);
return (
<div className="advanced-editor">
<div className="editor-stats">
<span>Words: {wordCount}</span>
</div>
<TiptapEditor
content={content}
onChange={setContent}
handleImageUpload={yourCustomUploadFunction}
className="custom-styled-editor"
toolbarConfig={{
// Customize visible toolbar items
showCodeBlock: false, // Hide code block button
showHeadings: true, // Show heading options
}}
/>
<div className="editor-actions">
<button onClick={() => onSave(content)}>
Save Content
</button>
</div>
</div>
);
};
## Styling
### No Import Needed!
Styles are automatically included with the component when you import it - no need for separate CSS imports! All styles are fully scoped to the editor wrapper element, ensuring they won't conflict with your application's existing styles.
```diff
import React from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
- import 'tiptap-custom-editor/dist/css/tiptap-editor.css'; // Not needed anymore!
const MyEditor = () => (
<div style={{ height: '500px' }}>
<TiptapEditor
handleImageUpload={myUploadFunction}
placeholder="Start writing..."
/>
</div>
);
Styling
Automatic CSS Inclusion
One of the key features of this package is that styles are automatically included when you import the component. This means:
- No need to import separate CSS files
- No build configuration changes required
- Just import and use!
Style Isolation
All styles in this package are carefully namespaced with the .tiptap-editor-wrapper
class to prevent them from affecting other elements in your application. This scoped approach means:
- No global style conflicts with your application
- No CSS reset affecting your other components
- Clean separation between editor styles and application styles
Customizing the Editor's Appearance
You can easily customize the editor's appearance by targeting the .tiptap-editor-wrapper
class in your application's CSS:
.tiptap-editor-wrapper {
/* Custom styles for the editor wrapper */
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.tiptap-editor-wrapper .tiptap.ProseMirror {
/* Custom styles for the editor content area */
font-family: 'Your Custom Font', sans-serif;
line-height: 1.6;
}
/* Custom styles for specific editor elements */
.tiptap-editor-wrapper h1 {
color: #3366cc;
font-weight: 700;
}
/* Custom toolbar styling */
.tiptap-editor-wrapper .tiptap-toolbar {
background-color: #f8f9fa;
border-bottom: 1px solid #e1e4e8;
}
/* Custom button styling */
.tiptap-editor-wrapper .tiptap-button {
color: #24292e;
}
.tiptap-editor-wrapper .tiptap-button:hover {
background-color: #f1f2f3;
}
.tiptap-editor-wrapper .tiptap-button.is-active {
color: #0366d6;
background-color: rgba(3, 102, 214, 0.1);
}
Dark Mode Support
The editor supports dark mode out of the box. The dark theme is applied automatically when the .dark-theme
class is added to the wrapper element. The ThemeToggle
component included with the editor handles this for you.
import React from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
const MyEditorWithThemeToggle = () => (
<div className="editor-container">
<TiptapEditor
handleImageUpload={myUploadFunc}
// The ThemeToggle is included by default in the toolbar
/>
</div>
);
You can also customize dark mode styles:
/* Dark theme customization */
.tiptap-editor-wrapper.dark-theme {
background-color: #1e1e1e;
color: #e0e0e0;
}
.tiptap-editor-wrapper.dark-theme .tiptap-toolbar {
background-color: #252526;
border-color: #333;
}
.tiptap-editor-wrapper.dark-theme .tiptap-button {
color: #cccccc;
}
.tiptap-editor-wrapper.dark-theme .tiptap-button:hover {
background-color: #2a2d2e;
}
.tiptap-editor-wrapper.dark-theme .tiptap-button.is-active {
color: #3794ff;
background-color: rgba(55, 148, 255, 0.1);
}
Advanced Usage
Custom Extensions
You can extend the editor with your own Tiptap extensions:
import React from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
import { Mention } from '@tiptap/extension-mention';
import { Placeholder } from '@tiptap/extension-placeholder';
const EditorWithExtensions = () => {
// Create your custom extensions
const extensions = [
Mention.configure({
suggestion: {
items: () => ['John', 'Jane', 'Joe'],
// additional configuration...
},
}),
Placeholder.configure({
placeholder: 'Type @ to mention someone...',
}),
];
return (
<TiptapEditor
handleImageUpload={myUploadFunc}
// Pass additional extensions to the editor
extensions={extensions}
/>
);
};
Content Validation
You can validate the editor content before saving:
import React, { useState } from 'react';
import { TiptapEditor } from 'tiptap-custom-editor';
const EditorWithValidation = () => {
const [content, setContent] = useState('');
const [error, setError] = useState('');
const handleChange = (html) => {
setContent(html);
// Simple validation example
if (html.length < 20) {
setError('Content must be at least 20 characters');
} else {
setError('');
}
};
return (
<div className="editor-with-validation">
<TiptapEditor
content={content}
onChange={handleChange}
handleImageUpload={myUploadFunc}
/>
{error && <div className="error-message">{error}</div>}
<button
disabled={!!error || !content}
onClick={() => console.log('Saving:', content)}
>
Save
</button>
</div>
);
};
Development
To build the package locally:
npm run build
To develop with live reload:
npm run dev
Project Structure
The package is organized with the following structure:
src/components/tiptap-editor/
- Main editor componentsrc/components/tiptap-extension/
- Custom Tiptap extensionssrc/components/tiptap-node/
- Node type implementationssrc/components/tiptap-ui/
- UI components for the editor toolbarsrc/components/tiptap-ui-primitive/
- Low-level UI primitivessrc/components/tiptap-icons/
- SVG icons as React componentssrc/lib/
- Utility functions and helperssrc/hooks/
- React hooks for editor functionality
Testing Your Changes
After making changes, you can test by linking the package locally:
# In the package directory
npm link
# In your test project directory
npm link tiptap-custom-editor
Browser Support
The editor is compatible with:
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
- Mobile browsers (iOS Safari, Android Chrome)
Dependencies
This package is built on:
- Tiptap v2.x - The core editor framework
- React 18+ - For component rendering
- TypeScript - For type safety
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
To contribute:
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add some amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request
License
MIT