Skip to Content
Editor

Editor

Rosetta uses a custom Lexical 0.39 implementation with token based documents, template anchors, and AI suggestion integration.

Architecture

Core Components

ComponentPurpose
EditorCoreContenteditable wrapper with character-level positioning
NoteEditorV2Lexical integration with plugin system
TemplateManagerAnchor and field management
SuggestionOverlayAI suggestion rendering and interaction

Document Model

Documents are serialized as JSON tokens, not raw HTML:

interface SerializedEditorState { root: { type: 'root'; children: SerializedNode[]; direction: 'ltr' | 'rtl' | null; }; } interface SerializedNode { type: string; version: number; children?: SerializedNode[]; text?: string; format?: number; }

Benefits:

  • Portable across platforms
  • Precise diffing for sync
  • Structured AI manipulation
  • No sanitization needed

Node Types

Built-in Nodes

// Text with formatting class RosettaTextNode extends TextNode { __format: number; // Bold, italic, underline, etc. } // Paragraph container class RosettaParagraphNode extends ParagraphNode { __indent: number; } // Template anchor (locked region) class AnchorNode extends ElementNode { __label: string; __isLocked: boolean; } // Template field (user input) class FieldNode extends DecoratorNode { __fieldType: 'SELECT' | 'MULTISELECT' | 'DYNAMIC_SELECT'; __options: string[]; }

Custom Node Example

export class SmartPhraseNode extends TextNode { static getType(): string { return 'smart-phrase'; } static clone(node: SmartPhraseNode): SmartPhraseNode { return new SmartPhraseNode(node.__text, node.__key); } createDOM(): HTMLElement { const span = document.createElement('span'); span.className = 'smart-phrase'; return span; } }

Template Anchors

Structure

Anchors mark regions with specific behaviors:

interface TemplateAnchor { label: string; isLocked: boolean; // AI cannot edit if true currentText: string; nodeKey: string; }

Anchor Navigation

ShortcutAction
Ctrl + ]Jump to next anchor
Ctrl + [Jump to previous anchor
Ctrl + EnterFill anchor and advance

Anchor API

const { anchors, currentAnchor, navigateToAnchor } = useTemplateManager(); // Get all anchors anchors.forEach(anchor => { console.log(anchor.label, anchor.isLocked); }); // Navigate programmatically navigateToAnchor('assessment');

Template Fields

Field Types

SELECT - Single choice dropdown:

{{SELECT: medication | aspirin, clopidogrel, warfarin}}

MULTISELECT - Multiple choice:

{{MULTISELECT: symptoms | chest pain, dyspnea, diaphoresis}}

DYNAMIC_SELECT - Options from RAG query:

{{DYNAMIC_SELECT: drug | query: medications for hypertension}}

Field Rendering

class FieldNode extends DecoratorNode<JSX.Element> { decorate(): JSX.Element { return ( <FieldComponent type={this.__fieldType} options={this.__options} onSelect={this.handleSelect} /> ); } }

SmartPhrases

Configuration

SmartPhrases are text expansion shortcuts:

const smartPhrases: SmartPhrase[] = [ { trigger: '.cc', expansion: 'Chief Complaint:\n' }, { trigger: '.hpi', expansion: 'History of Present Illness:\n' }, { trigger: '.pe', expansion: 'Physical Examination:\n' }, { trigger: '.ap', expansion: 'Assessment & Plan:\n' }, { trigger: '.acs', expansion: getACSTemplate() } ];

Expansion Plugin

function SmartPhrasePlugin(): null { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerTextContentListener((text) => { smartPhrases.forEach(({ trigger, expansion }) => { if (text.endsWith(trigger)) { editor.update(() => { // Replace trigger with expansion const selection = $getSelection(); if ($isRangeSelection(selection)) { selection.insertText(expansion); } }); } }); }); }, [editor]); return null; }

AI Suggestions

Suggestion Model

interface AISuggestion { type: 'replace' | 'insert' | 'delete'; start: number; // Character offset end: number; // Character offset replacementText: string; reasoning: string; confidence: number; }

Visual Indicators

TypeColorBehavior
InsertGreenAdds new text
ReplaceYellowModifies existing text
DeleteRedRemoves text

Suggestion Overlay

function SuggestionOverlay({ suggestions }: Props) { return ( <div className="suggestion-layer"> {suggestions.map(suggestion => ( <SuggestionMark key={suggestion.id} type={suggestion.type} position={calculatePosition(suggestion)} onAccept={() => applySuggestion(suggestion)} onReject={() => dismissSuggestion(suggestion)} /> ))} </div> ); }

Applying Suggestions

function applySuggestion(suggestion: AISuggestion) { editor.update(() => { const root = $getRoot(); const textContent = root.getTextContent(); // Apply the change const before = textContent.slice(0, suggestion.start); const after = textContent.slice(suggestion.end); const newContent = before + suggestion.replacementText + after; // Update editor state updateEditorContent(newContent); }); }

Selection API

Character-Level Positioning

// Get precise cursor position const selection = $getSelection(); if ($isRangeSelection(selection)) { const anchor = selection.anchor; // Start const focus = selection.focus; // End console.log({ anchorNode: anchor.key, anchorOffset: anchor.offset, focusNode: focus.key, focusOffset: focus.offset }); }

Programmatic Selection

// Select a specific range editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isTextNode(node)) { node.select(startOffset, endOffset); } }); // Select all content editor.update(() => { const root = $getRoot(); root.selectStart(); const selection = $getSelection(); selection?.modify('extend', 'end', 'lineboundary'); });

Plugin System

Built-in Plugins

<LexicalComposer initialConfig={config}> <RichTextPlugin /> <HistoryPlugin /> <OnChangePlugin onChange={handleChange} /> <SmartPhrasePlugin /> <TemplatePlugin /> <SuggestionPlugin /> <AutocompletePlugin /> </LexicalComposer>

Custom Plugin Pattern

function MyPlugin(): null { const [editor] = useLexicalComposerContext(); useEffect(() => { // Register commands const unregister = editor.registerCommand( MY_COMMAND, (payload) => { // Handle command return true; }, COMMAND_PRIORITY_NORMAL ); return unregister; }, [editor]); return null; }

Performance

Optimization Tips

  • Use editor.update() batching for multiple changes
  • Avoid $getRoot().getTextContent() in hot paths
  • Memoize decorator components
  • Use useMemo for computed selections

Update Batching

// Bad: Multiple discrete updates editor.update(() => { /* change 1 */ }); editor.update(() => { /* change 2 */ }); // Good: Single batched update editor.update(() => { // change 1 // change 2 });

Related:

Last updated on