Lexical Editor Implementation Guide¶
Overview¶
This document describes the Lexical editor implementation in the Story Desk application, including custom nodes, plugins, and their internal workings.
Architecture¶
The Lexical editor is implemented as a React component (_lexicalEditor.tsx) that wraps the core Lexical functionality with custom nodes and plugins. The editor supports both editable and read-only modes with different UI configurations.
Core Components¶
- LexicalComposer: The main wrapper that provides the editor context
- RichTextPlugin: Handles the core rich text editing functionality
- ContentEditable: The actual editable DOM element
- Custom Nodes: ImageNode and ReviewNode for specialized content
- Custom Plugins: Formatting, drag-and-drop, state management
Custom Nodes¶
ImageNode¶
Location: app/_components/lexicalEditor/nodes/imageNode.tsx
The ImageNode is a custom DecoratorNode that renders images within the editor.
Key Features:¶
- Inline rendering: Images are displayed inline with text content
- Drag and drop support: Images can be dragged to reorder within the document
- Delete functionality: Hover to reveal delete button
- Loading states: Shows skeleton loader while image loads
- Responsive design: Fixed width (250px) with proper aspect ratio
Implementation Details:¶
export class ImageNode extends DecoratorNode<React.ReactNode> {
__src: string;
__alt: string;
// Core methods
static getType() {
return "image";
}
static clone(node: ImageNode) {
/* ... */
}
decorate(): React.ReactNode {
/* ... */
}
createDOM(): HTMLElement {
/* ... */
}
updateDOM(): boolean {
return false;
} // React handles updates
isInline(): boolean {
return true;
} // Inline with text
}
Serialization:¶
- Exports to JSON with
srcandaltproperties - Supports DOM export for external use
- Maintains state across editor sessions
ReviewNode¶
Location: app/_components/lexicalEditor/nodes/reviewNode.tsx
The ReviewNode provides a diff-like interface for accepting or rejecting text changes.
Key Features:¶
- Side-by-side comparison: Shows old vs new content
- Accept/Reject actions: Buttons to apply or discard changes
- Block-level rendering: Takes full width (not inline)
- Content replacement: Replaces itself with chosen content on action
Implementation Details:¶
export class ReviewNode extends DecoratorNode<React.ReactNode> {
__oldText: string;
__newText: string;
// Core methods
static getType() {
return "review";
}
decorate(): React.ReactNode {
/* ... */
}
isInline(): boolean {
return false;
} // Block-level
}
Usage Pattern:¶
- Created when text changes need review
- Displays old and new content with action buttons
- On accept/reject, replaces itself with appropriate content
- Automatically removed from document after action
Plugins¶
FormattingToolBarPlugin¶
Location: app/_components/lexicalEditor/plugins/formattingToolBar.tsx
Provides a floating toolbar with text formatting options.
Features:¶
- Text formatting: Bold, italic, underline
- Block types: Paragraph, headings (H1, H2, H3)
- Lists: Bullet list toggle
- Font size: Custom font size selection
- History: Undo/redo functionality
- Content insertion: Add images and review nodes
Implementation:¶
- Uses
ToolMenucomponent for UI - Registers command listeners for state updates
- Tracks selection state for button enabling/disabling
- Updates editor state through Lexical commands
ImageDragDropPlugin¶
Location: app/_components/lexicalEditor/plugins/imageDragDropPlugin.tsx
Handles drag-and-drop functionality for images and other content.
Features:¶
- Visual indicators: Shows drop zones with blue lines
- Multi-target support: Works with blocks and images
- Position detection: Determines drop position (before/after)
- External URLs: Accepts image URLs from external sources
- Internal reordering: Move existing images within document
Implementation Details:¶
// Attaches listeners to DOM elements
const attachListeners = (
key: string,
htmlElement: HTMLElement,
type: "block" | "image"
) => {
// Handles drag over, drag enter, drag leave events
// Updates visual indicators based on mouse position
// Determines drop position (before/after target)
};
// Main drop handler
const removeDropListener = editor.registerCommand(DROP_COMMAND, (event) => {
// Processes dropped content
// Creates new nodes or reorders existing ones
// Handles both internal and external drops
});
Visual Feedback:¶
- Horizontal lines: For block-level drops
- Vertical lines: For inline image drops
- Border highlighting: Shows which image is being dragged over
- Position indicators: Clear visual cues for drop location
OnChangeJsonPlugin¶
Location: app/_components/lexicalEditor/plugins/onChangeJsonPlugin.tsx
Optimized change detection that prevents redundant callbacks.
Features:¶
- Debounced updates: Only fires when content actually changes
- JSON comparison: Uses stringified state for deep equality check
- Performance optimization: Avoids unnecessary re-renders
Implementation:¶
const lastStateJSONRef = useRef<string | undefined>(undefined);
// Only calls onChange if state actually changed
if (serialized !== lastStateJSONRef.current) {
lastStateJSONRef.current = serialized;
onChange(editorState, editor);
}
LoadJsonStatePlugin¶
Location: app/_components/lexicalEditor/plugins/loadJsonStatePlugin.tsx
Handles loading serialized editor state.
Features:¶
- One-time loading: Only loads state once per component lifecycle
- Error handling: Gracefully handles invalid state
- State parsing: Converts serialized state back to editor state
Implementation:¶
useEffect(() => {
if (loadedRef.current || state == null) return;
editor.update(() => {
const parsed = editor.parseEditorState(state as SerializedEditorState);
editor.setEditorState(parsed);
loadedRef.current = true;
});
}, [state, editor]);
Internal Workings¶
Node Registration¶
Custom nodes are registered in the LexicalComposer configuration:
const initialConfig = {
namespace: "LexicalEditor",
theme: getTheme(readOnly),
onError,
nodes: [ListNode, ListItemNode, HeadingNode, ImageNode, ReviewNode],
editable: !readOnly,
};
Theme System¶
The editor uses a custom theme that adapts based on read-only mode:
const getTheme = (readOnly: boolean) => {
return {
text: { bold: "font-bold", italic: "font-italic" /* ... */ },
list: {
/* list styling */
},
paragraph: "mb-4",
heading: {
h1: readOnly ? "text-xl font-bold mb-4" : "text-4xl font-bold mb-4",
// Different sizes for read-only vs editable
},
};
};
State Management¶
The editor maintains state through:
- EditorState: Core Lexical state object
- Plugin state: Individual plugins maintain their own state
- React state: UI components use React state for rendering
- Serialization: State is serialized to JSON for persistence
Event Flow¶
- User interaction → DOM events
- Plugin listeners → Process events and update state
- Editor commands → Modify editor state
- Node updates → React components re-render
- State serialization → JSON output for persistence
Best Practices¶
Node Development¶
- Extend
DecoratorNodefor custom content types - Implement
decorate()for React rendering - Override
isInline()to control layout behavior - Provide proper serialization methods
- Handle cleanup in component unmount
Plugin Development¶
- Use
useLexicalComposerContext()to access editor - Register commands with appropriate priorities
- Clean up listeners in useEffect cleanup
- Handle errors gracefully
- Optimize for performance with debouncing
State Management¶
- Use
editor.update()for state modifications - Avoid direct DOM manipulation
- Leverage Lexical's built-in commands
- Serialize state for persistence
- Handle loading states properly
Integration Points¶
With Story Canvas¶
- Editor state is serialized and passed to story canvas
- Custom nodes (images, reviews) are processed for rendering
- State synchronization between editor and canvas
With LLM Chat¶
- Review nodes are created from LLM suggestions
- Content changes trigger LLM processing
- Editor state influences chat context
With Backend¶
- Serialized state is sent to backend for processing
- Flattened JSON structure is generated from editor state while passing to LLM
- State is persisted and restored across sessions