Skip to content

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 src and alt properties
  • 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:

  1. Created when text changes need review
  2. Displays old and new content with action buttons
  3. On accept/reject, replaces itself with appropriate content
  4. 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 ToolMenu component 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:

  1. EditorState: Core Lexical state object
  2. Plugin state: Individual plugins maintain their own state
  3. React state: UI components use React state for rendering
  4. Serialization: State is serialized to JSON for persistence

Event Flow

  1. User interaction → DOM events
  2. Plugin listeners → Process events and update state
  3. Editor commands → Modify editor state
  4. Node updates → React components re-render
  5. State serialization → JSON output for persistence

Best Practices

Node Development

  • Extend DecoratorNode for 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