Skip to Content
EditorEditor Guide

Editor

The editor is built on Lexical 0.39. Documents are stored as a node tree. Agent edits show up as pending suggestions first; you accept or reject each one before it changes the document. Template sections can be locked so agents can’t write to them.

Components

AreaWhat it does
EditorCoreMounts Lexical, tracks selection, dispatches commands.
NoteEditorV2Sets up the composer, loads plugins, wraps every change in a transaction.
TemplateManagerHolds template anchors and fields. Blocks writes to locked regions.
SuggestionOverlayShows pending agent diffs and the accept/reject controls.
SyncBridgeWrites to the local store and pushes to the cloud.

Runtime Topology

Core Rules

  1. All document mutations go through editor.update(). There is no other way to write to the document, so every change is transactional and observable.
  2. Agents and automation cannot modify locked template anchors. Only a user edit can.
  3. Each suggestion is tagged with the revision it was generated against. If the user edits in between, the suggestion’s range is remapped onto the new revision, or marked stale if remapping can’t resolve it.
  4. Undo/redo only covers changes that actually landed. Rejected and stale suggestions never wrote to the document, so they don’t appear in history.
  5. Every applied change, whether from a user or an agent, emits the same change event. Sync, logging, and other subscribers read from one stream.

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"; }

Node Types

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[]; }

Change Tracking

Rosetta keeps two separate records of changes:

  • Lexical history stack: what undo/redo walks through. Only contains changes that were applied.
  • Operational log: what sync and agent bookkeeping read from. Contains every transaction with its source, revision, and ranges.

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; }; }

Keystroke-To-Commit Flow

Diff Strategy

The diff runs in three passes. Each pass only runs if the previous one didn’t produce a usable result:

  1. Node-level: compare subtrees by identity. Unchanged subtrees are skipped.
  2. Token-level: diff by sentence or field chunk. Most edits stop here.
  3. Character-level: used when token precision drops below 98%. Needed so overlays can highlight the exact changed characters.
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

A suggestion points at specific character offsets. If the user types before the suggestion is applied, those offsets now point at the wrong place. The fix is to shift each offset by the net length change of every user edit that happened at or before it:

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

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; }

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

TypeColorBehavior
InsertGreenPreview as additive text
ReplaceYellowOriginal highlighted with replacement preview
DeleteRedOriginal shown with remove indicator

Template Anchors

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

ShortcutAction
Ctrl + ]Next anchor
Ctrl + [Previous anchor
Ctrl + EnterFill current anchor and advance

Template Fields

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

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" } ];

Expansion is treated as a user transaction. Pending agent suggestions touching the trigger span are remapped; if remap fails, they are marked stale.

Plugin Order

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

TemplatePlugin before SuggestionPlugin so locks are enforced at preview time. OnChangePlugin before sync side effects. SmartPhrasePlugin before agent preview to avoid stale ranges.

Keyboard Shortcuts

ShortcutAction
Ctrl/Cmd + BBold
Ctrl/Cmd + IItalic
Ctrl/Cmd + UUnderline
Ctrl/Cmd + ZUndo
Ctrl/Cmd + Shift + ZRedo
TabAccept focused suggestion
EscReject focused suggestion
Ctrl/Cmd + Shift + AAccept all pending
Ctrl/Cmd + Shift + XReject all pending
Ctrl + ]Next anchor
Ctrl + [Previous anchor
Ctrl + EnterFill anchor and advance

Performance

  • Batch related changes in one editor.update(). Each call triggers reconciliation, so N separate updates cause N re-renders.
  • Memoize decorator output. DecoratorNode.decorate() runs on every re-render. Cache on the input data, not the render count.
  • Update overlays incrementally. When the suggestion overlay repositions, diff against the previous revision. Don’t walk the whole document.
  • Don’t call $getRoot().getTextContent() on keystroke. It concatenates the entire tree. Read from the specific node you need.
  • Debounce sync writes. Group rapid keystrokes into one transaction before sending. One PATCH per burst, not one per keypress.
Last updated on