Editor
Rosetta uses a custom Lexical 0.39 implementation with token based documents, template anchors, and AI suggestion integration.
Architecture
Core Components
| Component | Purpose |
|---|---|
EditorCore | Contenteditable wrapper with character-level positioning |
NoteEditorV2 | Lexical integration with plugin system |
TemplateManager | Anchor and field management |
SuggestionOverlay | AI 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
| Shortcut | Action |
|---|---|
Ctrl + ] | Jump to next anchor |
Ctrl + [ | Jump to previous anchor |
Ctrl + Enter | Fill 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
| Type | Color | Behavior |
|---|---|---|
| Insert | Green | Adds new text |
| Replace | Yellow | Modifies existing text |
| Delete | Red | Removes 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
useMemofor 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