Editor
Rosetta uses a custom Lexical 0.39 stack with tokenized document state, template anchors, and an agent review queue. This page covers the editor internals, including change tracking and edge case behavior.
At A Glance
| Area | What It Handles |
|---|---|
EditorCore | Host, selection, command routing |
NoteEditorV2 | Composer, plugins, transactions |
TemplateManager | Anchors, fields, lock enforcement |
SuggestionOverlay | Pending diffs and review actions |
SyncBridge | Local store and cloud sync |
Figure 1: Runtime Topology
Core Invariants
- All mutations happen inside
editor.update(). - Locked anchors are immutable for agent edits and template automation.
- Character ranges used by suggestions are revision-scoped and remapped before apply.
- Undo/redo affects only applied document changes, never discarded suggestions.
- Every accepted agent edit emits a structured change event for auditability.
Document Model
Documents are serialized as node trees, not HTML strings:
interface SerializedEditorState {
root: {
type: "root";
version: number;
children: SerializedNode[];
direction: "ltr" | "rtl" | null;
};
}
interface SerializedNode {
type: string;
version: number;
children?: SerializedNode[];
text?: string;
format?: number;
detail?: number;
mode?: "normal" | "segmented" | "token";
}Why This Model
- Portable between browser/runtime boundaries
- Safer than HTML transport for clinical text
- Deterministic diffing against prior snapshots
- Enables anchored range mapping for suggestions
Node Types
Built-In + Rosetta Nodes
class RosettaTextNode extends TextNode {
__format: number;
}
class RosettaParagraphNode extends ParagraphNode {
__indent: number;
}
class AnchorNode extends ElementNode {
__label: string;
__isLocked: boolean;
}
class FieldNode extends DecoratorNode<JSX.Element> {
__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;
}
}Change Tracking
Rosetta tracks changes in two layers:
- Lexical history stack for user-facing undo/redo
- Operational log for sync, agent traceability, and conflict logic
Transaction Shape
type ChangeSource = "user" | "ai" | "template" | "sync";
type ChangeKind = "insert" | "replace" | "delete" | "format";
interface ChangeTransaction {
id: string;
revision: number;
parentRevision: number;
source: ChangeSource;
kind: ChangeKind;
createdAt: number;
ranges: Array<{
start: number;
end: number;
beforeText: string;
afterText: string;
anchorId?: string;
}>;
metadata?: {
suggestionId?: string;
agent?: "chat" | "shorthander" | "reasoner" | "reformatter" | "citations" | "ingester";
batched?: boolean;
};
}Figure 2: Keystroke-To-Commit Flow
Diff Strategy
For reliable clinical text diffing, Rosetta uses staged comparison:
- Node-level quick check (skip unchanged subtrees)
- Token-level diff (sentence or field chunks)
- Character-level fallback for precise selection overlays
function computeRanges(prev: string, next: string): RangeDelta[] {
const tokenPass = diffByTokens(prev, next);
if (tokenPass.precision >= 0.98) return tokenPass.ranges;
return diffByCharacters(prev, next).ranges;
}Range Remapping
Pending agent edits are revision-bound. If user edits arrive first, ranges are remapped before apply:
function remapRange(range: { start: number; end: number }, deltas: RangeDelta[]) {
let { start, end } = range;
for (const delta of deltas) {
if (delta.pos <= start) start += delta.netLength;
if (delta.pos < end) end += delta.netLength;
}
return { start, end };
}Overlap and Conflict Rules
When two pending edits overlap, Rosetta resolves deterministically:
- Locked anchor guard always wins.
- User edits override pending agent edits.
- Newer agent suggestion supersedes older suggestion from the same agent scope.
- Cross-agent overlap enters manual review state.
Agent Suggestion Lifecycle
Suggestion Object
type SuggestionStatus =
| "pending"
| "focused"
| "accepted"
| "rejected"
| "stale"
| "superseded";
interface AISuggestion {
id: string;
revision: number;
type: "replace" | "insert" | "delete";
start: number;
end: number;
replacementText: string;
reasoning: string;
confidence: number;
status: SuggestionStatus;
}Figure 3: Suggestion State Machine
Safe Apply Logic
function applySuggestion(suggestion: AISuggestion) {
editor.update(() => {
if (isLockedRange(suggestion.start, suggestion.end)) return;
const remapped = remapSuggestionAgainstLatestRevision(suggestion);
if (!remapped) {
markSuggestion(suggestion.id, "stale");
return;
}
replaceText(remapped.start, remapped.end, remapped.replacementText);
appendChangeTransaction({
source: "ai",
kind: suggestion.type === "insert" ? "insert" : "replace",
metadata: { suggestionId: suggestion.id }
});
markSuggestion(suggestion.id, "accepted");
});
}Visual Indicators
| Type | Color | Behavior |
|---|---|---|
| Insert | Green | Preview as additive text |
| Replace | Yellow | Original text highlighted with replacement preview |
| Delete | Red | Original text shown with remove indicator |
Template Anchors
Anchors define protected structure:
interface TemplateAnchor {
id: string;
label: string;
isLocked: boolean;
nodeKey: string;
start: number;
end: number;
}Guard Rails
- Agents cannot modify locked anchors.
- Bulk accept skips suggestions touching locked anchors.
- Template updates can move anchors but cannot silently unlock them.
- Anchor deletion requires explicit user confirmation.
Anchor Navigation
| Shortcut | Action |
|---|---|
Ctrl + ] | Jump to next anchor |
Ctrl + [ | Jump to previous anchor |
Ctrl + Enter | Fill current anchor and advance |
Template Fields
Field Types
SELECT single choice:
{{SELECT: medication | aspirin, clopidogrel, warfarin}}MULTISELECT multiple values:
{{MULTISELECT: symptoms | chest pain, dyspnea, diaphoresis}}DYNAMIC_SELECT options resolved from query:
{{DYNAMIC_SELECT: drug | query: medications for hypertension}}Field Node Rendering
class FieldNode extends DecoratorNode<JSX.Element> {
decorate(): JSX.Element {
return (
<FieldComponent
type={this.__fieldType}
options={this.__options}
onSelect={this.handleSelect}
/>
);
}
}SmartPhrases
SmartPhrases are expansion rules for high-frequency sections:
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" }
];Nuance: Expansion + Suggestions
- Expansion is treated as a user transaction (
source: "user"). - Pending agent suggestions touching the trigger span are remapped.
- If remap fails, those suggestions are marked
stale.
Selection And Positioning
Character-Level Selection
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
console.log({
anchorNode: anchor.key,
anchorOffset: anchor.offset,
focusNode: focus.key,
focusOffset: focus.offset
});
}Programmatic Range Selection
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isTextNode(node)) {
node.select(startOffset, endOffset);
}
});Plugin And Command Order
Recommended plugin sequence:
<LexicalComposer initialConfig={config}>
<RichTextPlugin />
<HistoryPlugin />
<OnChangePlugin onChange={handleChange} />
<TemplatePlugin />
<SmartPhrasePlugin />
<SuggestionPlugin />
<AutocompletePlugin />
</LexicalComposer>Why Ordering Matters
TemplatePluginshould run beforeSuggestionPluginso locks are enforceable at preview time.OnChangePluginshould run before network sync side effects.SmartPhrasePluginshould execute before agent preview generation to avoid stale ranges.
Command Priority Pattern
function MyPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
MY_COMMAND,
(payload) => {
if (!payload) return false;
editor.update(() => {
// Apply a single atomic transaction.
});
return true;
},
COMMAND_PRIORITY_NORMAL
);
}, [editor]);
return null;
}Keyboard Shortcuts Reference
| Shortcut | Action |
|---|---|
Ctrl/Cmd + B | Bold |
Ctrl/Cmd + I | Italic |
Ctrl/Cmd + U | Underline |
Ctrl/Cmd + Z | Undo |
Ctrl/Cmd + Shift + Z | Redo |
Tab | Accept focused agent suggestion |
Esc | Reject focused agent suggestion |
Ctrl/Cmd + Shift + A | Accept all pending suggestions |
Ctrl/Cmd + Shift + X | Reject all pending suggestions |
Ctrl + ] | Next anchor |
Ctrl + [ | Previous anchor |
Ctrl + Enter | Fill anchor and advance |
Performance Notes
Do
- Batch related edits in one
editor.update(). - Memoize expensive decorators (field components, inline preview components).
- Keep suggestion overlay calculations incremental by revision.
Avoid
- Calling
$getRoot().getTextContent()repeatedly in hot paths. - Rebuilding full range maps on every cursor move.
- Triggering sync writes per keystroke without buffering.
Batching Example
// Preferred: one atomic transaction.
editor.update(() => {
applyTemplateDefaults();
insertSmartPhraseExpansion();
queueAutosaveFlag();
});Related: