tiptap-custom-editor

tiptap-custom-editor JS library on npm Download tiptap-custom-editor JS library

A customizable Tiptap rich text editor with customizable image upload

Version 1.2.6 License MIT
tiptap-custom-editor has no homepage
tiptap-custom-editor JS library on GitHub
tiptap-custom-editor JS library on npm
Download tiptap-custom-editor JS library
Keywords
tiptapeditorwysiwygrich-textcustomizableimage-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 component
  • src/components/tiptap-extension/ - Custom Tiptap extensions
  • src/components/tiptap-node/ - Node type implementations
  • src/components/tiptap-ui/ - UI components for the editor toolbar
  • src/components/tiptap-ui-primitive/ - Low-level UI primitives
  • src/components/tiptap-icons/ - SVG icons as React components
  • src/lib/ - Utility functions and helpers
  • src/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:

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

MIT