Skip to main content

Rich Content Editor

A powerful, framework-agnostic rich text editor service with optional React integration. Build sophisticated content editors with support for formatted text, structured content, undo/redo, and extensible middleware.

Key Features

  • Framework-agnostic Core: Service-first architecture with zero framework dependencies
  • Reactive State Management: Built on RxJS Observables for predictable state updates
  • Rich Formatting: Text marks (bold, italic, underline, strikethrough, code, subscript, superscript)
  • Structured Content: Support for headings, lists, images, blockquotes, code blocks, and custom blocks
  • Undo/Redo: Configurable history stack with full document snapshots
  • DOM Sync: Bidirectional synchronization with contentEditable elements
  • Multiple Serializers: HTML, JSON, and PlainText with bidirectional conversion
  • Extensible Middleware: Hooks for paste handling, save validation, image uploads, mentions, and more
  • Type-Safe: Full TypeScript support with comprehensive type definitions
  • React Integration: Lightweight useRichContent hook with automatic subscription management

Installation

Core Service Only

npm install @codella-software/utils

With React Hook

npm install @codella-software/utils @codella-software/react

Quick Start

Core Service

import { RichContentService } from '@codella-software/utils';

// Create service
const service = new RichContentService({
maxHistorySteps: 50,
imageUploadHandler: async (file) => {
// Handle upload...
return { url: 'https://...' };
},
});

// Subscribe to content changes
service.getContent$().subscribe((doc) => {
console.log('Document changed:', doc);
});

// Execute commands
service.execute('insertText', { text: 'Hello' });
service.execute('toggleMark', { mark: 'bold' });
service.execute('insertHeading', { level: 1 });
service.execute('undo');
service.execute('redo');

React Hook

import { useRichContent } from '@codella-software/react';
import { useRef } from 'react';

export function MyEditor() {
const editorRef = useRef<HTMLDivElement>(null);
const { content, execute, canUndo, canRedo } = useRichContent({
editorRef,
});

return (
<div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<button onClick={() => execute('toggleMark', { mark: 'bold' })}>
Bold
</button>
<button onClick={() => execute('toggleMark', { mark: 'italic' })}>
Italic
</button>
<button onClick={() => execute('undo')} disabled={!canUndo}>
Undo
</button>
<button onClick={() => execute('redo')} disabled={!canRedo}>
Redo
</button>
</div>
<div
ref={editorRef}
contentEditable
style={{
border: '1px solid #ccc',
padding: '10px',
minHeight: '300px',
}}
/>
</div>
);
}

Service API

Configuration

interface RichContentConfig {
// Maximum undo/redo steps (default: 50)
maxHistorySteps?: number;

// Placeholder text when empty
placeholder?: string;

// Allowed text marks
allowedMarks?: Array<
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'code'
| 'subscript'
| 'superscript'
>;

// Allowed block types
allowedBlocks?: Array<
| 'paragraph'
| 'heading'
| 'list'
| 'image'
| 'blockquote'
| 'codeBlock'
| 'horizontalRule'
>;

// Maximum list nesting depth (default: 3)
maxListDepth?: number;

// Image upload handler
imageUploadHandler?: (file: File) => Promise<{ url: string }>;
}

Observable State

// Get current document
service.getContent$().subscribe((doc) => {
console.log('Document:', doc);
});

// Get current state
service.getState$().subscribe((state) => {
console.log('State:', state);
console.log('Can undo:', state.canUndo);
console.log('Can redo:', state.canRedo);
console.log('Is focused:', state.isFocused);
});

// Get undo/redo capabilities
service.getCanUndo$().subscribe((canUndo) => {
console.log('Can undo:', canUndo);
});

service.getCanRedo$().subscribe((canRedo) => {
console.log('Can redo:', canRedo);
});

// Get selected text formats
service.getSelectedFormats$().subscribe((formats) => {
console.log('Bold:', formats.bold);
console.log('Italic:', formats.italic);
});

Commands

Text Operations

// Insert text at cursor
service.execute('insertText', { text: 'Hello' });

// Delete selected content or previous character
service.execute('delete');

// Insert new paragraph
service.execute('insertParagraph');

Formatting

// Toggle text mark (bold, italic, underline, etc.)
service.execute('toggleMark', { mark: 'bold' });

// Apply mark to selection
service.execute('applyMark', { mark: 'italic' });

// Remove mark from selection
service.execute('removeMark', { mark: 'underline' });

Block Operations

// Insert heading
service.execute('insertHeading', { level: 1 }); // h1-h6

// Insert list
service.execute('insertList', {
type: 'ordered', // or 'unordered'
items: [[{ type: 'text', text: 'Item 1' }]],
});

// Insert image
service.execute('insertImage', {
url: 'https://...',
alt: 'Description',
});

// Insert blockquote
service.execute('insertBlockquote', {
children: [[{ type: 'text', text: 'Quote' }]],
});

// Insert code block
service.execute('insertCodeBlock', {
language: 'typescript',
code: 'const x = 1;',
});

// Insert horizontal rule
service.execute('insertHorizontalRule');

History

service.execute('undo');
service.execute('redo');

DOM Adapter

Connect editor to contentEditable element:

import { ContentEditableAdapter } from '@codella-software/utils/rich-content';

const editor = document.querySelector('[contenteditable]') as HTMLDivElement;
const adapter = new ContentEditableAdapter(editor, service);

// Sync DOM to document
adapter.syncFromDOM();

// Sync document to DOM
adapter.syncToDOM();

// Auto-sync on input
editor.addEventListener('input', () => adapter.syncFromDOM());

Serializers

Convert between document formats:

import {
HTMLSerializer,
JSONSerializer,
PlainTextSerializer,
} from '@codella-software/utils/rich-content';

const doc = service.getContent$().getValue();

// To HTML
const html = new HTMLSerializer().serialize(doc);

// From HTML
const doc2 = new HTMLSerializer().deserialize(html);

// To JSON
const json = new JSONSerializer().serialize(doc);

// From JSON
const doc3 = new JSONSerializer().deserialize(json);

// To plain text
const text = new PlainTextSerializer().serialize(doc);

Middleware & Extensibility

BeforePaste Hook

Sanitize or transform pasted HTML:

service.use('beforePaste', (html) => {
// Remove scripts and event handlers
html = html.replace(/<script[^>]*>.*?<\/script>/gi, '');
html = html.replace(/on\w+\s*=\s*['"][^'"]*['"]/gi, '');
return html;
});

BeforeSave Hook

Validate document before saving:

service.use('beforeSave', (doc) => {
if (!doc.children || doc.children.length === 0) {
throw new Error('Cannot save empty document');
}
return doc;
});

OnImageUpload Hook

Handle image uploads:

service.use('onImageUpload', async (file) => {
if (file.size > 5 * 1024 * 1024) {
throw new Error('File too large');
}
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
return { url: data.url };
});

OnMentionQuery Hook

Implement mention suggestions:

service.use('onMentionQuery', async (query) => {
if (!query) return [];
const res = await fetch(`/api/users?q=${query}`);
const users = await res.json();
return users.map((user) => ({ id: user.id, name: user.name }));
});

Document Structure

Documents use an Abstract Syntax Tree (AST) format:

interface DocumentNode {
type: 'document';
children: (BlockNode | TextNode)[];
}

interface BlockNode {
type:
| 'paragraph'
| 'heading'
| 'list'
| 'image'
| 'blockquote'
| 'codeBlock'
| 'horizontalRule';
children?: (InlineNode | TextNode)[];
level?: number; // For heading: 1-6
language?: string; // For code block
url?: string; // For image
alt?: string; // For image
}

interface InlineNode {
type: 'text';
text: string;
marks?: Array<
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'code'
| 'subscript'
| 'superscript'
>;
}

React Hook API

Options

interface UseRichContentOptions {
// Reference to contentEditable element (required)
editorRef: React.RefObject<HTMLDivElement>;

// Initial document content
initialContent?: DocumentNode;

// Service configuration
config?: RichContentConfig;

// Auto-focus on mount
autoFocus?: boolean;

// Save callback
onSave?: (content: DocumentNode) => void;

// Change callback
onChange?: (content: DocumentNode) => void;
}

Return Value

interface UseRichContentReturn {
// Current document
content: DocumentNode;

// Current state
state: RichContentState;

// Execute command
execute: (command: string, payload?: any) => void;

// Get plain text version
getPlainText: () => string;

// Undo/Redo helpers
undo: () => void;
redo: () => void;

// History status
canUndo: boolean;
canRedo: boolean;

// Service instance for advanced use
service: RichContentService;
}

Form Integration

Integrate with FormBuilder for structured forms:

import { useFormBuilder } from '@codella-software/utils';
import { useRichContent } from '@codella-software/react';
import { useRef } from 'react';

export function BlogPostForm() {
const form = useFormBuilder({
fields: [
{
name: 'title',
type: 'text',
label: 'Title',
validation: { required: true },
},
{
name: 'content',
type: 'richContent',
label: 'Content',
validation: { required: true, minLength: 100 },
},
],
});

const editorRef = useRef<HTMLDivElement>(null);
const { content } = useRichContent({ editorRef });

const handleSubmit = async () => {
// Validate and serialize
const html = new HTMLSerializer().serialize(content);
await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({
title: form.values.title,
content: html,
}),
});
};

return (
<form onSubmit={handleSubmit}>
<input {...form.register('title')} />
<div ref={editorRef} contentEditable />
<button type="submit">Save</button>
</form>
);
}

Best Practices

  1. Use Debouncing for Auto-Save

    const debouncedSave = useMemo(
    () => debounce(saveToServer, 2000),
    []
    );
  2. Validate Before Save

    const validateContent = (doc: DocumentNode): string[] => {
    const errors: string[] = [];
    if (!doc.children?.length) errors.push('Content is required');
    // Add more validation...
    return errors;
    };
  3. Handle Upload Errors

    service.use('onImageUpload', async (file) => {
    try {
    return await uploadFile(file);
    } catch (err) {
    console.error('Upload failed:', err);
    throw new Error('Could not upload image');
    }
    });
  4. Serialize Before Storage

    // Always store serialized HTML, not the document object
    const html = new HTMLSerializer().serialize(content);
    localStorage.setItem('post', html);
  5. Implement Keyboard Shortcuts

    editor.addEventListener('keydown', (e) => {
    if (e.ctrlKey || e.metaKey) {
    switch (e.key) {
    case 'b':
    execute('toggleMark', { mark: 'bold' });
    break;
    // ...
    }
    }
    });

Examples

Blog Post Editor

See React package documentation for detailed React hook examples.

Collaborative Editing

// Server sends document updates
socket.on('documentUpdate', (doc) => {
service.setDocument(doc);
adapter.syncToDOM();
});

// Client publishes changes
service.getContent$().subscribe((doc) => {
socket.emit('documentUpdate', doc);
});

Draft Auto-Save

let saveTimeout: NodeJS.Timeout;

service.getContent$().subscribe((doc) => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const html = new HTMLSerializer().serialize(doc);
localStorage.setItem(`draft-${postId}`, html);
}, 2000);
});

Browser Support

  • Chrome/Edge: Latest 2 versions
  • Firefox: Latest 2 versions
  • Safari: Latest 2 versions
  • Requires contentEditable support

Performance Considerations

  • Undo history grows with each edit—configure maxHistorySteps appropriately
  • Large documents (1000+ blocks) may have slower selection updates
  • Image uploads can block UI—consider showing progress
  • Serialization is O(n) in document size—debounce conversions