Rich Text Editor - Module Specification
1. Overview
1.1 Purpose
Rich Text Editor provides a shared Lexical-based editor component and type definitions for rich text content across all modules. Handles content creation, storage format, and rendering of formatted text with support for headings, lists, links, and media embeds.
1.2 Scope
In Scope:
- Lexical editor React component
- LexicalEditorState type definition (JSON schema)
- Rich text renderer component for public display
- Editor configuration and plugins
Out of Scope:
- Media upload (→ Media module)
- Content storage (handled by consuming modules)
- Page/blog management (→ CMS module)
2. Dependencies
2.1 Upstream (What This Module Needs)
| Module/Service |
What We Need |
Interface |
| Media |
Media picker for embedding images/videos |
React Component |
2.2 Downstream (What Needs This Module)
| Module/Service |
What They Need |
Interface |
| Inventory |
Editor for puppy/breed descriptions |
React Component |
| Admin Settings |
Editor for disclaimers, notification banner |
React Component |
| CMS |
Editor for blog posts, page content |
React Component |
| Customer Website |
Renderer for displaying rich text |
React Component |
3. Data Ownership
3.1 Entities This Module Owns
| Entity |
Description |
Key Fields |
| — |
This module owns no database entities |
— |
Note: Rich Text Editor provides type definitions and UI components only. Content is stored by consuming modules in their own entities.
3.2 Entities This Module Uses (Read-Only)
| Entity |
Owner |
How We Use It |
| Media |
Media |
Embed images/videos in rich text content |
3.3 Type Definitions
import type { Media } from '@/modules/media';
/**
* Lexical editor serialized state
* Stored as JSON in database text/jsonb columns
*/
interface LexicalEditorState {
root: {
children: LexicalNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
type: 'root';
version: number;
};
}
type LexicalNode =
| ParagraphNode
| HeadingNode
| ListNode
| ListItemNode
| LinkNode
| TextNode
| ImageNode
| VideoNode;
interface ParagraphNode {
type: 'paragraph';
children: LexicalNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
version: number;
}
interface HeadingNode {
type: 'heading';
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
children: LexicalNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
version: number;
}
interface ListNode {
type: 'list';
listType: 'bullet' | 'number';
children: ListItemNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
start: number;
version: number;
}
interface ListItemNode {
type: 'listitem';
children: LexicalNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
value: number;
version: number;
}
interface LinkNode {
type: 'link';
url: string;
target?: '_blank' | '_self';
rel?: string;
children: TextNode[];
direction: 'ltr' | 'rtl' | null;
format: string;
indent: number;
version: number;
}
interface TextNode {
type: 'text';
text: string;
format: number; // Bitmask: bold=1, italic=2, underline=4, etc.
style: string;
detail: number;
mode: 'normal' | 'token' | 'segmented';
version: number;
}
interface ImageNode {
type: 'image';
mediaId: Media['id'];
altText?: string;
width?: number;
height?: number;
version: number;
}
interface VideoNode {
type: 'video';
mediaId: Media['id'];
version: number;
}
4. Service Level Objectives
| Objective |
Target |
Measurement |
| Editor Load Time |
< 200ms |
Application monitoring |
| Typing Latency |
< 16ms (60fps) |
Performance profiling |
| Render Time |
< 50ms |
Application monitoring |
| Bundle Size |
< 100KB gzipped |
Build metrics |
Note: Editor performance is critical for admin UX. Renderer must be lightweight for customer-facing pages.
5. Interface Contract
5.1 Exported Types
// Primary type exported for use in other modules
export type { LexicalEditorState } from './types';
// Node types for advanced usage
export type {
LexicalNode,
ParagraphNode,
HeadingNode,
ListNode,
ListItemNode,
LinkNode,
TextNode,
ImageNode,
VideoNode,
} from './types';
5.2 React Components
import type { Media } from '@/modules/media';
/**
* Rich text editor component for admin interfaces
*/
interface RichTextEditorProps {
value?: LexicalEditorState;
onChange: (state: LexicalEditorState) => void;
placeholder?: string;
disabled?: boolean;
// Feature toggles
features?: {
headings?: boolean; // h1-h6 support (default: true)
lists?: boolean; // bullet/numbered lists (default: true)
links?: boolean; // hyperlinks (default: true)
media?: boolean; // image/video embeds (default: false)
};
// Media picker integration
onMediaSelect?: () => Promise<Media | null>;
// Styling
className?: string;
minHeight?: number;
}
declare function RichTextEditor(props: RichTextEditorProps): JSX.Element;
/**
* Read-only renderer for displaying rich text content
*/
interface RichTextRendererProps {
content: LexicalEditorState;
className?: string;
// Media URL transformer for responsive images
transformMediaUrl?: (mediaId: string) => string;
}
declare function RichTextRenderer(props: RichTextRendererProps): JSX.Element;
5.3 Utility Functions
/**
* Create empty editor state
*/
function createEmptyEditorState(): LexicalEditorState;
/**
* Convert plain text to editor state
*/
function textToEditorState(text: string): LexicalEditorState;
/**
* Extract plain text from editor state (for search indexing)
*/
function editorStateToText(state: LexicalEditorState): string;
/**
* Validate editor state structure
*/
function isValidEditorState(value: unknown): value is LexicalEditorState;
6. Error Handling Strategy
| Error Code |
Condition |
User Message |
| RTE_INVALID_STATE |
Malformed editor state JSON |
"Content could not be loaded" |
| RTE_RENDER_FAILED |
Error during content render |
"Content could not be displayed" |
| RTE_MEDIA_LOAD_FAILED |
Embedded media failed to load |
"Media could not be loaded" |
| RTE_PASTE_FAILED |
Paste operation failed |
"Could not paste content" |
Error Handling Approach
// Editor gracefully handles invalid state by resetting to empty
<RichTextEditor
value={maybeCorruptedState}
onError={(error) => console.error('Editor error:', error)}
/>
// Renderer shows fallback for invalid content
<RichTextRenderer
content={content}
fallback={<p>Content unavailable</p>}
/>
7. Observability
7.1 Logging
| Event |
Level |
Fields to Include |
| Editor initialized |
DEBUG |
editor_id, features_enabled |
| Content saved |
DEBUG |
editor_id, content_size_bytes |
| Invalid state recovered |
WARN |
editor_id, error_type |
| Render error |
ERROR |
content_hash, error_message |
7.2 Metrics
| Metric |
Type |
Description |
| rte_editor_load_ms |
Histogram |
Editor initialization time |
| rte_render_duration_ms |
Histogram |
Content render time |
| rte_content_size_bytes |
Histogram |
Saved content size |
| rte_errors_total |
Counter |
Editor/render errors by type |
8. Testing Strategy
| Test Type |
Coverage Target |
Tools |
Focus Areas |
| Unit |
80%+ |
Vitest |
Utility functions, state validation, text extraction |
| Component |
Critical paths |
Vitest + Testing Library |
Editor interactions, renderer output |
| E2E |
Happy paths |
Playwright |
Full editing workflows, media embeds |
| Visual |
Key states |
Playwright |
Editor UI, rendered content appearance |
9. Feature Inventory
| Feature ID |
Name |
Status |
Priority |
| FEATURE-052 |
Rich Text Editor |
Draft |
Must Have |
| FEATURE-053 |
Rich Text Renderer |
Draft |
Must Have |
FEATURE-052 Note: Includes editor component, LexicalEditorState type definition, utility functions, and media embed support.
FEATURE-053 Note: Read-only renderer for customer-facing display with media URL transformation.
10. Access Control
Operation Permissions
| Operation |
Public |
Sales |
Kennel Staff |
Photographer |
Admin |
| View rendered content |
Yes |
Yes |
Yes |
Yes |
Yes |
| Use editor component |
No |
No |
No |
No |
Yes |
Note: Editor access is controlled by the consuming module's permissions. The editor component itself is only available in admin interfaces.
11. Decisions
D1: Lexical as Editor Framework (2026-01-20)
Status: Accepted
Context: Need a free, open-source rich text editor that supports React components as custom blocks
Decision: Use Lexical (MIT, React-first, best support for custom React nodes/components)
Rationale:
- Do not use TipTap paid extensions; use Lexical core + @lexical/react plugins
- There is no official pre-built UI kit for Lexical. Use the Lexical Playground as a reference and port toolbar, floating link editor, and block type dropdown as needed
- Use Payload CMS's implementation only as a behavioral reference, not as a UI kit
Implementation Notes:
- Prefer Lexical plugins (RichTextPlugin, HistoryPlugin, ListPlugin, LinkPlugin, etc.)
- Build custom blocks via Lexical nodes and React components
- Keep the solution fully open source and React-native
Consequences: Requires wiring our own toolbar/plugins, but offers example UI code in the Playground; modern architecture, good React integration, active development
Status: Accepted
Context: Need to store rich text content in database
Decision: Store Lexical's native JSON state directly in database jsonb columns
Consequences: No conversion needed, preserves full fidelity, easy to query/index text content
D3: Separate Editor and Renderer (2026-01-20)
Status: Accepted
Context: Customer website needs lightweight rendering without editor overhead
Decision: Provide separate RichTextRenderer component that doesn't load Lexical editor code
Consequences: Smaller bundle for customer pages, faster load times, editor only loaded in admin
12. References
- Architecture: ARCH-001
- Inventory Module: MODULE-002
- Media Module: MODULE-003
- Admin Settings Module: MODULE-004
- Lexical Documentation: https://lexical.dev/
- Lexical Playground: https://playground.lexical.dev/
- Payload CMS Lexical Implementation: https://github.com/payloadcms/payload/tree/main/packages/richtext-lexical
Change Log
| Version |
Date |
Author |
Changes |
| 1.0 |
2026-01-20 |
Claude |
Initial module spec - Draft |