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, mentions, and horizontal rules
  • Undo/Redo: Configurable history stack with debounced input snapshots (500ms) and full document state
  • Bidirectional DOM Sync: Automatic synchronization between contentEditable DOM and document AST via adapter pattern
  • Multiple Serializers: HTML, JSON, and PlainText with bidirectional conversion and nested mark support
  • Mentions: Built-in mention support with autocomplete provider integration
  • Extensible Middleware: Hooks for paste handling, save validation, image uploads, and more
  • Type-Safe: Full TypeScript support with comprehensive type definitions
  • React Integration: RichContentProvider for composable components + useRichContent hook for standalone usage

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/rich-content';

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

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

// Execute commands via convenience methods
service.insertText('Hello');
service.toggleMark('bold');
service.insertHeading(1);
service.undo();
service.redo();

// Or via the generic execute method
service.execute('toggleMark', { mark: 'italic' });

React Hook (Standalone)

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

export function MyEditor() {
const editorRef = useRef<HTMLDivElement>(null);
const {
content,
toggleMark,
insertHeading,
insertList,
undo,
redo,
canUndo,
canRedo,
isFocused,
setFocus,
selectedFormats,
} = useRichContent({
editorRef: editorRef as any,
});

return (
<div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<button onClick={() => toggleMark('bold')}>Bold</button>
<button onClick={() => toggleMark('italic')}>Italic</button>
<button onClick={() => toggleMark('underline')}>Underline</button>
<button onClick={() => insertHeading(2)}>H2</button>
<button onClick={() => insertList('unordered')}>Bullet List</button>
<button onClick={() => undo()} disabled={!canUndo}>Undo</button>
<button onClick={() => redo()} disabled={!canRedo}>Redo</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
border: isFocused ? '2px solid #3b82f6' : '1px solid #ccc',
padding: '12px',
minHeight: '300px',
borderRadius: '4px',
outline: 'none',
}}
/>
</div>
);
}

For composable editor/toolbar components, use RichContentProvider to share state via React Context:

import {
RichContentProvider,
useRichContentContext
} from '@codella-software/react';
import { useEffect, useState, useRef } from 'react';
import { DefaultContentEditableAdapter } from '@codella-software/utils/rich-content';

// Parent component wraps with provider
export function RichContentEditor() {
return (
<RichContentProvider>
<div>
<Toolbar />
<Editor />
</div>
</RichContentProvider>
);
}

// Toolbar subscribes directly to state$
function Toolbar() {
const { service } = useRichContentContext();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [selectedFormats, setSelectedFormats] = useState(new Set());

useEffect(() => {
const sub = service.getState$().subscribe((state) => {
setCanUndo(state.canUndo);
setCanRedo(state.canRedo);
setSelectedFormats(state.selectedFormats || new Set());
});
return () => sub.unsubscribe();
}, [service]);

return (
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<button onClick={() => service.toggleMark('bold')}>
{selectedFormats.has('bold') ? '✓' : ''} Bold
</button>
<button onClick={() => service.undo()} disabled={!canUndo}>Undo</button>
<button onClick={() => service.redo()} disabled={!canRedo}>Redo</button>
</div>
);
}

// Editor connects DOM adapter
function Editor() {
const { service } = useRichContentContext();
const editorRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);

useEffect(() => {
const sub = service.getIsFocused$().subscribe(setIsFocused);
return () => sub.unsubscribe();
}, [service]);

useEffect(() => {
if (!editorRef.current) return;
const adapter = new DefaultContentEditableAdapter();
adapter.mount(editorRef.current);
service.attachAdapter(adapter);
return () => {
adapter.unmount();
service.detachAdapter();
};
}, [service]);

return (
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onFocus={() => service.setFocus(true)}
onBlur={() => service.setFocus(false)}
style={{
border: isFocused ? '2px solid #3b82f6' : '1px solid #ccc',
padding: '12px',
minHeight: '300px',
borderRadius: '4px',
}}
/>
);
}

Service API

Configuration

interface RichContentConfig {
// Initial document content
initialContent?: DocumentNode;

// Allowed text marks (default: bold, italic, underline, strikethrough, code)
allowedMarks?: MarkType[];

// Allowed block types (default: document, paragraph, heading, list, blockquote, code-block, horizontal-rule)
allowedBlocks?: string[];

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

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

// Mention suggestion provider
mentionProvider?: (query: string) => Promise<Array<{ id: string; label: string }>>;

// Middleware hooks
middleware?: RichContentMiddleware;

// Enable undo/redo history (default: true)
enableHistory?: boolean;

// Maximum undo/redo steps (default: 100)
maxHistorySteps?: number;

// Placeholder text when empty
placeholder?: string;

// Read-only mode (default: false)
readOnly?: boolean;
}

Observable State

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

// Get full state as observable
service.getState$().subscribe((state) => {
console.log('Can undo:', state.canUndo);
console.log('Can redo:', state.canRedo);
console.log('Is focused:', state.isFocused);
console.log('Is dirty:', state.isDirty);
console.log('Selected formats:', state.selectedFormats);
});

// Individual state observables (with distinctUntilChanged)
service.getCanUndo$().subscribe((canUndo) => { /* ... */ });
service.getCanRedo$().subscribe((canRedo) => { /* ... */ });
service.getIsFocused$().subscribe((focused) => { /* ... */ });
service.getSelectedFormats$().subscribe((formats) => { /* ... */ });

// Mention autocomplete observable
service.getMentions$().subscribe(({ query, position }) => {
// Show suggestion dropdown at position
});

// Synchronous state getters
const doc = service.getContent();
const state = service.getState();
const text = service.getPlainText();

Commands

Text Operations

// Insert text at cursor
service.insertText('Hello');

// Insert text with marks
service.insertText('bold text', [{ type: 'bold' }]);

// Insert new paragraph
service.insertParagraph();

// Delete content
service.deleteContent();

Formatting

// Toggle text mark (bold, italic, underline, strikethrough, code)
service.toggleMark('bold');
service.toggleMark('italic');
service.toggleMark('underline');
service.toggleMark('strikethrough');
service.toggleMark('code');

Block Operations

// Insert heading (levels 1-6)
service.insertHeading(1);
service.insertHeading(2);

// Insert list
service.insertList('ordered');
service.insertList('unordered');

// Insert image (inline or block)
service.insertImage('https://...', { alt: 'Description', display: 'block' });

// Upload image (uses configured imageUploadHandler)
await service.uploadImage(file);

// Insert mention
service.insertMention('user-123', 'Jane Doe', { role: 'admin' });

History

service.undo();
service.redo();
service.clearHistory();

// Check history status
service.canUndo(); // boolean
service.canRedo(); // boolean

History Behavior

The history system captures two types of changes:

  • Command history: Every toolbar action (toggle mark, insert heading, etc.) creates an immediate history entry.
  • Input history: User typing in the contentEditable is captured via debounced snapshots — after 500ms of no input, a history entry is created. This groups natural typing into single undo steps.

When a command executes or undo/redo is triggered, any pending input is flushed to history first, ensuring typed content is never lost.

DOM Adapter

The DefaultContentEditableAdapter bridges contentEditable DOM elements with the document AST:

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

const adapter = new DefaultContentEditableAdapter();
const element = document.querySelector('[contenteditable]') as HTMLElement;

// Mount to element (auto-adds event listeners)
adapter.mount(element);

// Sync DOM to AST
const doc = adapter.syncFromDOM();

// Sync AST to DOM (preserves focus and selection)
adapter.syncToDOM(doc);

// Listen for user input changes
adapter.onInput = (content) => {
console.log('User typed, new content:', content);
};

// Selection management
const sel = adapter.getSelection(); // { from, to, backward }
adapter.setSelection({ from: 0, to: 5 });
adapter.setCursorPos(10);

// Cleanup
adapter.unmount();

When used with RichContentService.attachAdapter(), the onInput callback is automatically wired up to keep the service in sync with DOM changes:

service.attachAdapter(adapter);  // Wires up bidirectional sync
service.detachAdapter(); // Cleans up

Serializers

Convert between document formats:

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

const doc = service.getContent();

// HTML serialization (supports nested marks like <strong><em>text</em></strong>)
const htmlSerializer = new HTMLSerializer();
const html = htmlSerializer.serialize(doc);
const doc2 = htmlSerializer.deserialize(html);

// JSON serialization
const jsonSerializer = new JSONSerializer();
const json = jsonSerializer.serialize(doc);
const doc3 = jsonSerializer.deserialize(json);

// Plain text serialization (headings get #, lists get bullets)
const textSerializer = new PlainTextSerializer();
const text = textSerializer.serialize(doc);
const doc4 = textSerializer.deserialize(text);

Mention Serialization

Mentions are serialized consistently across HTML serializer and DOM adapter:

<!-- HTML Serializer output -->
<span data-mention="user-123" data-meta='{"role":"admin"}'>@Jane Doe</span>

<!-- DOM Adapter output (also sets data-mention-id for compat) -->
<span data-mention="user-123" data-mention-id="user-123" data-meta='{"role":"admin"}'>@Jane Doe</span>

Both the serializer and adapter read from either data-mention or data-mention-id when deserializing, ensuring compatibility regardless of source.

React Hook API

RichContentProvider

Component that creates and shares a RichContentService instance via React Context.

interface RichContentProviderProps {
// Service configuration
config?: RichContentConfig;
// Child components
children: ReactNode;
}

// Usage
<RichContentProvider config={{ maxHistorySteps: 50 }}>
<Toolbar />
<Editor />
</RichContentProvider>

useRichContentContext

Hook to access the service from context. Must be used within RichContentProvider.

function MyComponent() {
const { service } = useRichContentContext();

useEffect(() => {
const sub = service.getState$().subscribe((state) => {
console.log('State updated:', state);
});
return () => sub.unsubscribe();
}, [service]);
}

useRichContent (Standalone)

Hook that creates its own service instance. Use this for single-component editors.

Options

interface UseRichContentOptions extends RichContentConfig {
// Reference to contentEditable element
editorRef?: React.RefObject<HTMLElement>;

// Custom adapter instance (optional — a default is created if editorRef is provided)
adapter?: ContentEditableAdapter;
}

Return Value

interface UseRichContentReturn {
// Service instance (for advanced use)
service: RichContentService;

// Current document content
content: DocumentNode;

// Full state object
state: RichContentState;

// Reactive state
isFocused: boolean;
canUndo: boolean;
canRedo: boolean;
selectedFormats: Set<MarkType>;
selection: Selection | null;
isDirty: boolean;

// Command methods
insertText: (text: string) => void;
insertParagraph: () => void;
insertHeading: (level: 1 | 2 | 3 | 4 | 5 | 6) => void;
insertImage: (url: string) => void;
uploadImage: (file: File) => Promise<void>;
insertMention: (id: string, label: string) => void;
insertList: (type: 'ordered' | 'unordered') => void;
toggleMark: (mark: MarkType) => void;
deleteContent: () => void;

// History
undo: () => void;
redo: () => void;
clearHistory: () => void;

// Focus management
focus: () => void;
setFocus: (focused: boolean) => void;

// Utilities
getPlainText: () => string;
setSelection: (selection: Selection | null) => void;
}

Composing Editor Components

Use RichContentProvider to share a single service instance across components via React Context:

import { RichContentProvider, useRichContentContext } from '@codella-software/react';

function RichContent() {
return (
<RichContentProvider>
<Toolbar />
<Editor />
</RichContentProvider>
);
}

function Toolbar() {
const { service } = useRichContentContext();
const [state, setState] = useState(service.getState());

useEffect(() => {
const sub = service.getState$().subscribe(setState);
return () => sub.unsubscribe();
}, [service]);

return (
<button onClick={() => service.toggleMark('bold')} disabled={!state.canUndo}>
Bold
</button>
);
}

function Editor() {
const { service } = useRichContentContext();
const editorRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!editorRef.current) return;
const adapter = new DefaultContentEditableAdapter();
adapter.mount(editorRef.current);
service.attachAdapter(adapter);
return () => {
adapter.unmount();
service.detachAdapter();
};
}, [service]);

return <div ref={editorRef} contentEditable />;
}

Benefits:

  • ✅ Components subscribe directly to state$ observables for real-time updates
  • ✅ No prop drilling - toolbar and editor access service independently
  • ✅ More composable - components can be used/reordered flexibly
  • ✅ Follows React Context patterns

Alternative: useRichContent with Props

You can also use a single useRichContent hook in the parent and pass props:

function RichContent() {
const editorRef = useRef<HTMLDivElement>(null);
const {
toggleMark, undo, redo,
canUndo, canRedo, isFocused, setFocus, selectedFormats,
} = useRichContent({ editorRef: editorRef as any });

return (
<div>
<Toolbar
onBold={() => toggleMark('bold')}
onUndo={undo}
canUndo={canUndo}
selectedFormats={selectedFormats}
/>
<Editor
editorRef={editorRef}
isFocused={isFocused}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
</div>
);
}

Important: Do not call useRichContent in both parent and child components — this creates two competing service instances and adapter bindings on the same DOM element, causing state conflicts. Use the provider pattern if multiple components need service access.

Form Integration

Use the built-in RichContentField with FormBuilder for structured forms:

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

const builder = new FormBuilder()
.addField({
name: 'title',
type: 'text',
label: 'Title',
validation: { required: true },
})
.addField({
name: 'content',
type: 'rich-content',
label: 'Content',
validation: { required: true },
});

The RichContentField component (available in both shadcn and base variants) handles:

  • HTML serialization/deserialization for form values
  • Memoized serializer instance for performance
  • Graceful error handling on deserialization failures
  • Proper readOnly and disabled state forwarding

Document Structure

Documents use an Abstract Syntax Tree (AST) format:

interface DocumentNode {
type: 'document';
version: string;
children: BlockNode[];
}

// Block types
type BlockNode =
| ParagraphNode
| HeadingNode
| ListNode
| BlockquoteNode
| CodeBlockNode
| HorizontalRuleNode;

interface ParagraphNode {
type: 'paragraph';
children: ContentChild[];
}

interface HeadingNode {
type: 'heading';
level: 1 | 2 | 3 | 4 | 5 | 6;
children: ContentChild[];
}

interface ListNode {
type: 'list';
listType: 'ordered' | 'unordered';
children: ListItemNode[];
}

// Inline content types
type ContentChild = TextNode | ImageNode | MentionNode;

interface TextNode {
type: 'text';
text: string;
marks?: TextMark[]; // e.g. [{ type: 'bold' }, { type: 'italic' }]
}

interface MentionNode {
type: 'mention';
id: string;
label: string;
meta?: Record<string, any>;
}

interface ImageNode {
type: 'image';
url: string;
attrs?: { alt?: string; title?: string; width?: number; height?: number; display?: 'inline' | 'block' };
}

Best Practices

  1. Use the Convenience Methods Instead of execute()

    // Preferred
    service.toggleMark('bold');
    service.insertHeading(2);

    // Also works but less type-safe
    service.execute('toggleMark', { mark: 'bold' });
  2. Debounce Auto-Save Using Content Observable

    service.getContent$().pipe(
    debounceTime(2000)
    ).subscribe((doc) => {
    const html = new HTMLSerializer().serialize(doc);
    saveToServer(html);
    });
  3. Memoize Serializers in React Components

    // Always memoize — new HTMLSerializer() on every render causes issues
    const serializer = useMemo(() => new HTMLSerializer(), []);
  4. Handle Upload Errors

    const service = RichContentService.create({
    imageUploadHandler: async (file) => {
    if (file.size > 5 * 1024 * 1024) {
    throw new Error('File too large (max 5MB)');
    }
    const formData = new FormData();
    formData.append('file', file);
    const res = await fetch('/api/upload', { method: 'POST', body: formData });
    return await res.json();
    },
    });
  5. Serialize Before Storage

    // Store as HTML, not the raw AST
    const html = new HTMLSerializer().serialize(content);
    localStorage.setItem('draft', html);

    // Restore from HTML
    const doc = new HTMLSerializer().deserialize(savedHtml);

CLI Component Templates

Use the Codella CLI to scaffold pre-built rich content components:

codella add rich-content          # Full editor with toolbar
codella add form-builder # Includes RichContentField

Available in shadcn and base framework variants. The CLI templates use the provider pattern and include:

  • rich-content.tsx — Parent component with RichContentProvider
  • rich-content-editor.tsx — Styled contentEditable div that subscribes to service via context
  • rich-content-toolbar.tsx — Formatting toolbar that subscribes to state$ for real-time updates

Architecture:

  • Toolbar and editor both use useRichContentContext() to access the shared service
  • Each subscribes directly to observables (state$, isFocused$) for reactive updates
  • No prop drilling - components are independently composable

Performance Considerations

  • Undo history is capped by maxHistorySteps (default 100) — input history uses debounced 500ms snapshots to avoid per-keystroke entries
  • Large documents (1000+ blocks) may have slower DOM sync — syncToDOM rebuilds the entire DOM
  • Image uploads run asynchronously and don't block the editor
  • Serialization is O(n) in document size — debounce HTML conversions in form fields
  • distinctUntilChanged() on observables prevents unnecessary re-renders when state hasn't changed

Browser Support

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