@kalabamssalu/rich-text-editor
v0.2.3
Published
Lexical rich text editor for React with configurable mentions, templates, and tools
Downloads
344
Readme
@kalabamssalu/rich-text-editor
Lexical-based rich text editor for React (Next.js compatible) with a formatting toolbar, status bar, @ mentions, autocomplete, note templates, import/export, and electronic signatures.
Built for clinical and documentation workflows, but ships no demo or clinical data — you provide mentions, templates, and domain terms from your app.
Table of contents
- Features
- Requirements
- Installation
- Styling
- Quick start
RichTextEditorBoxprops- Saving and loading documents
- Configuration
- Full configuration example
- Next.js
- Other frameworks
- Public API exports
- Troubleshooting
- Breaking changes (0.1 → 0.2)
- License
Features
| Area | Capabilities |
|------|----------------|
| Formatting toolbar | Undo/redo, block type, alignment, fonts, colors, bold/italic/underline, sub/superscript, links, inserts (images, tables, embeds, horizontal rule, columns, date/time, etc.) |
| Status bar | Character count, copy all, import/export (.lexical JSON, .docx), markdown toggle, edit mode, clear |
| Optional status tools | Autocomplete toggle, templates, signature, speech-to-text, AI assistant placeholder, voice translator placeholder, audit log placeholder |
| Mentions | Type @ for a searchable category tree with insertable values |
| Autocomplete | Inline word suggestions from your terms, mention labels, patients, and optional English dictionary |
| Templates | Insert markdown note templates; host-owned custom template storage |
| Export | lexicalJson (round-trip) + html (display/archive) on every change |
Requirements
- React 18 or 19
- Lexical
^0.44.0and matching@lexical/*packages (see Installation) - Tailwind CSS in the host app (utility classes such as
bg-background,text-muted-foregroundare used throughout the UI) - Client-only rendering — the editor uses browser APIs and must not run on the server
Installation
npm install @kalabamssalu/rich-text-editorInstall peer dependencies at the same Lexical version (required for correct bundling; avoids duplicate Lexical copies):
npm install lexical@^0.44.0 \
@lexical/react@^0.44.0 \
@lexical/code@^0.44.0 \
@lexical/extension@^0.44.0 \
@lexical/file@^0.44.0 \
@lexical/hashtag@^0.44.0 \
@lexical/html@^0.44.0 \
@lexical/link@^0.44.0 \
@lexical/list@^0.44.0 \
@lexical/markdown@^0.44.0 \
@lexical/overflow@^0.44.0 \
@lexical/rich-text@^0.44.0 \
@lexical/selection@^0.44.0 \
@lexical/table@^0.44.0 \
@lexical/text@^0.44.0 \
@lexical/utils@^0.44.0 \
react react-domWith pnpm, add to package.json:
{
"dependencies": {
"@kalabamssalu/rich-text-editor": "^0.2.0",
"lexical": "^0.44.0",
"@lexical/react": "^0.44.0"
}
}Then install the remaining @lexical/* peers listed above at ^0.44.0.
Styling
1. Import package CSS
Always import the bundled editor theme and mention styles:
import "@kalabamssalu/rich-text-editor/styles.css";This file is published at @kalabamssalu/rich-text-editor/styles.css and includes Lexical editor theme rules and mention popover styles.
2. Tailwind CSS (required for layout and colors)
The UI uses Tailwind utility classes. Your app must generate those utilities from the built package output.
Tailwind CSS v4 — in your global CSS (adjust the path to node_modules):
@import "tailwindcss";
@source "../node_modules/@kalabamssalu/rich-text-editor/dist/**/*.{js,mjs}";Tailwind CSS v3 — add to content in tailwind.config.js:
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"./node_modules/@kalabamssalu/rich-text-editor/dist/**/*.{js,mjs}",
],
};3. Design tokens (shadcn-style)
The editor expects CSS variables used by shadcn/ui, for example:
--background,--foreground--muted,--muted-foreground--border,--input,--ring--primary,--accent,--destructive
If your app uses shadcn/ui or a similar theme, these are usually already defined. Without them, the editor still works but colors may look flat.
4. Toast notifications (import/export)
Import and export actions use Sonner. Add a toaster once in your app root:
import { Toaster } from "sonner";
export function RootLayout({ children }) {
return (
<>
{children}
<Toaster />
</>
);
}Quick start
"use client";
import { useState } from "react";
import { RichTextEditorBox } from "@kalabamssalu/rich-text-editor";
import type { RichTextEditorDocumentExport } from "@kalabamssalu/rich-text-editor";
import "@kalabamssalu/rich-text-editor/styles.css";
export function NotesEditor() {
const [doc, setDoc] = useState<RichTextEditorDocumentExport | null>(null);
return (
<RichTextEditorBox
label="Clinical note"
placeholder="Start typing…"
onChange={(document) => setDoc(document)}
/>
);
}RichTextEditorBox props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| config | RichTextEditorConfig | — | Nested configuration object (see below) |
| mentions | RichTextEditorMentionsConfig | — | Shorthand; overrides config.mentions |
| autocomplete | RichTextEditorAutocompleteConfig | — | Shorthand; overrides config.autocomplete |
| templates | RichTextEditorTemplatesConfig | — | Shorthand; overrides config.templates |
| tools | RichTextEditorToolsConfig | — | Shorthand; overrides config.tools |
| signer | RichTextEditorSignerConfig | — | Shorthand; overrides config.signer |
| namespace | string | "rich-text-editor" | Lexical editor namespace (use unique names if multiple editors on one page) |
| documentKey | string | — | When this identity changes, the composer remounts and reloads snapshot (switching SOAP notes). See Controlled |
| id | string | — | HTML id on the wrapper |
| label | string | — | Accessible label above the editor |
| placeholder | string | "Start typing…" | Placeholder when empty |
| className | string | — | Classes on the outer wrapper |
| minHeightClassName | string | "min-h-[260px]" (surface) | Tailwind min-height for the typing area |
| disabled | boolean | false | Disables editing and onChange |
| defaultValue | string \| null | — | Initial Lexical JSON string (uncontrolled) |
| value | string \| null | — | Lexical JSON string to load (see Saving and loading) |
| onChange | (doc: RichTextEditorDocumentExport) => void | — | Called when the document changes |
| onChangeDebounceMs | number | 0 | Debounce onChange (milliseconds). 0 = every update |
| exportFormat | 'both' \| 'lexical' \| 'html' | 'both' | Fields to compute in onChange (omitted fields are "") |
| syncValue | boolean | false | Apply external value while mounted (see External sync) |
| slots | RichTextEditorSlotsConfig | — | Shorthand; overrides config.slots |
Config merge rule: Top-level props (mentions, autocomplete, etc.) override the same field inside config.
Saving and loading documents
onChange payload
interface RichTextEditorDocumentExport {
/** Stringified Lexical EditorState JSON — use for save/load round-trip */
lexicalJson: string;
/** HTML snapshot for display, PDF, or search (includes wrapper div) */
html: string;
}- Use
lexicalJsonwhen you need to reload the editor or preserve custom nodes (mentions, signatures, embeds). - Use
htmlfor read-only views, printing, or backends that only store HTML.
Uncontrolled (initial content only)
<RichTextEditorBox
defaultValue={savedLexicalJson}
onChange={({ lexicalJson }) => {
// persist lexicalJson to your API
}}
/>Controlled (load existing note)
Pass previously saved lexicalJson:
const [noteJson, setNoteJson] = useState<string | null>(savedFromApi);
<RichTextEditorBox
value={noteJson ?? undefined}
documentKey={encounterNoteId}
onChange={({ lexicalJson }) => setNoteJson(lexicalJson)}
/>When switching to another encounter note, documentKey must change (or wrap the editor in conditional rendering keyed by note id). That remounts Lexical once with the newly saved snapshot.
<RichTextEditorBox
documentKey={`${soapId}-${revision}`}
value={storedJson ?? undefined}
onChange={(doc) => setStoredJson(doc.lexicalJson)}
/>Focus stays while typing (controlled value)
v0.2.3+: The editor no longer reconnects $initialEditorState on every keystroke. You can safely keep value={...} and setState(onChange) without losing caret focus each character.
Older versions recreated the Lexical composer whenever lexicalJson changed; use v0.2.3+ or, on older builds, defaultValue only plus your own persistence (no controlled value loop).
Still true:
valuemust be Lexical EditorState JSON, not HTML.
External value sync
When another source updates the same note while the editor is open (e.g. WebSocket), use one of:
documentKey— change the key when switching notes (full remount; recommended for note switches).syncValue={true}— applyvalueeven when the editor is focused.- Default — if
valuechanges and the editor is not focused, the document syncs automatically (skips when JSON already matches).
<RichTextEditorBox
value={noteJson ?? undefined}
syncValue={forceApplyRemoteUpdate}
onChange={({ lexicalJson }) => setNoteJson(lexicalJson)}
/>Performance: debounce and export format
<RichTextEditorBox
onChangeDebounceMs={300}
exportFormat="lexical"
onChange={({ lexicalJson }) => saveDraft(lexicalJson)}
/>Use exportFormat="lexical" when you do not need HTML on every keystroke (smaller onChange work).
Invalid value errors
Avoid passing "" or stray strings when there is no saved note — use undefined or omit the prop. Never persist a Lexical snapshot where root.children is empty; that triggers setEditorState: the editor state is empty on load.
v0.2.2+: RichTextEditorBox normalizes value / defaultValue before init (blank strings, invalid JSON, missing root, or empty root children). In those cases the editor opens a normal blank document and logs a [RichTextEditor] warning instead of crashing.
Configuration
Pass everything through config or via flat props.
Mentions
Enable by providing mentions with a categoryTree (required). Type @ in the editor to open the picker.
import { RichTextEditorBox, buildMentionSearchIndex } from "@kalabamssalu/rich-text-editor";
import type { MentionMenuNode } from "@kalabamssalu/rich-text-editor";
const categoryTree: MentionMenuNode[] = [
{
id: "vitals",
label: "Vitals",
icon: "Activity",
children: [
{
id: "bp",
label: "Blood pressure",
icon: "HeartPulse",
insertValue: "BP 120/80 mmHg",
},
],
},
{
id: "meds",
label: "Medications",
icon: "Pill",
insertValue: "No new medications",
},
];
<RichTextEditorBox
config={{
mentions: {
categoryTree,
// Optional: pre-built index (otherwise built automatically)
searchIndex: buildMentionSearchIndex(categoryTree, { patients: [] }),
patients: [{ id: "p1", name: "Jane Doe", mrn: "MRN-001" }],
activePatient: { id: "p1", name: "Jane Doe", mrn: "MRN-001" },
},
tools: { mentions: true }, // default when mentions is set; set false to hide @ picker
}}
/>MentionMenuNode fields
| Field | Type | Description |
|-------|------|-------------|
| id | string | Stable id |
| label | string | Display label |
| icon | MentionIconName | Lucide-based icon name (e.g. "Stethoscope", "Pill") |
| children | MentionMenuNode[] | Nested categories |
| insertValue | string | Text inserted when the row is chosen (leaf nodes) |
| sampleData | string | Extra text for search indexing |
Icons: UserRound, Stethoscope, Pill, ClipboardList, Activity, Microscope, HeartPulse, Hospital, CalendarDays, FileText, IdCard, History, PillBottle, Syringe, ClipboardSignature, Building2, Scan, FlaskConical, Package.
Disable mentions while keeping other config:
tools: { mentions: false }Autocomplete
Enable by passing autocomplete (even {}). The status bar shows an autocomplete toggle when autocomplete is configured.
<RichTextEditorBox
config={{
autocomplete: {
additionalTerms: ["hypertension", "dyspnea", "SOB"],
enableEnglishDictionary: true, // default: true
},
}}
/>| Field | Type | Default | Description |
|-------|------|---------|-------------|
| additionalTerms | string[] | [] | Domain-specific words |
| enableEnglishDictionary | boolean | true | Built-in English word list (lazy-loaded when autocomplete is enabled) |
| localStorageKey | string | emr-rich-text-autocomplete-enabled | Key for the status-bar autocomplete toggle |
Autocomplete also indexes mention labels, insertValues, patient names/MRNs, and active patient fields when mentions are configured.
Users can toggle autocomplete from the status bar; preference is stored in localStorage (see localStorageKey above).
Note templates
Template body is markdown (headings # / ## / ###, paragraphs). Inserting replaces an empty editor or appends to the end.
const [customTemplates, setCustomTemplates] = useState<NoteTemplate[]>([]);
<RichTextEditorBox
config={{
templates: {
items: [
{
id: "soap",
title: "SOAP note",
description: "Subjective, objective, assessment, plan",
body: "## Subjective\n\n## Objective\n\n## Assessment\n\n## Plan\n",
},
],
customItems: customTemplates,
onCustomItemsChange: setCustomTemplates, // host persists to API/localStorage
},
}}
/>| Field | Type | Description |
|-------|------|-------------|
| items | NoteTemplate[] | Built-in templates you define |
| customItems | NoteTemplate[] | User-created templates (your state) |
| onCustomItemsChange | (templates: NoteTemplate[]) => void | Called when user adds a custom template |
The templates button appears in the status bar only when templates is set and there is at least one item or custom template.
Signer
Used by the electronic signature block in the status bar (when signature tool is enabled).
config={{
signer: { name: "Dr. Sam Rivera", title: "Attending Physician" },
}}Default if omitted: { name: "Signer", title: "" }.
Tools (toolbar and status bar)
config={{
tools: {
toolbar: ["history", "blockFormat", "link", "insert"],
statusBar: ["characterCount", "copyAll", "clear"],
mentions: true,
},
}}| toolbar / statusBar value | Behavior |
|-------------------------------|----------|
| omitted | Toolbar: all tools. Status bar: default set (see below) |
| true | All toolbar tools; status bar default + autocomplete/templates when configured (not placeholder AI/audit/voice tools) |
| false | Hidden |
| ToolbarToolId[] / StatusBarToolId[] | Only listed tools |
Default status bar (when statusBar is omitted):characterCount, copyAll, importExport, markdown, editMode, clear
Plus autocompleteToggle if autocomplete is configured, and templates if templates are configured.
Toolbar tool IDs
| ID | Feature |
|----|---------|
| history | Undo / redo |
| blockFormat | Paragraph, headings, lists, quote, code block |
| elementFormat | Alignment, indent, line height |
| fontFamily | Font family |
| fontSize | Font size |
| fontColor | Text color |
| fontBackground | Highlight color |
| fontFormat | Bold, italic, underline, strikethrough |
| subSuper | Subscript / superscript |
| clearFormatting | Clear inline formatting |
| link | Insert / edit links |
| insert | Images, tables, embeds, HR, columns, date/time, etc. |
Status bar tool IDs
| ID | Feature |
|----|---------|
| characterCount | UTF-8 character count |
| copyAll | Copy plain text |
| autocompleteToggle | Enable/disable autocomplete |
| templates | Note template picker |
| signature | Insert signature block |
| speechToText | Browser speech recognition (where supported) |
| aiAssistant | Placeholder dialog, or config.slots.aiAssistant |
| voiceTranslator | Placeholder dialog, or config.slots.voiceTranslator |
| importExport | Import .lexical / .docx, export Lexical file |
| markdown | Toggle markdown source view |
| editMode | Toggle read-only |
| clear | Clear document |
| auditLog | Placeholder audit UI, or config.slots.auditLog |
Status bar slots (host UI)
config={{
tools: { statusBar: ["aiAssistant", "auditLog"] },
slots: {
aiAssistant: <MyAiButton />,
auditLog: <MyAuditPanelTrigger />,
},
}}Speech-to-text callback
config={{
onSpeechTranscript: (transcript, isFinal) => {
if (isFinal) console.log("Final:", transcript);
},
}}Minimal toolbar example:
<RichTextEditorBox
config={{
tools: {
toolbar: false,
statusBar: ["characterCount", "clear"],
},
}}
/>Full configuration example
"use client";
import { useState } from "react";
import {
RichTextEditorBox,
buildMentionSearchIndex,
} from "@kalabamssalu/rich-text-editor";
import type {
NoteTemplate,
RichTextEditorDocumentExport,
} from "@kalabamssalu/rich-text-editor";
import "@kalabamssalu/rich-text-editor/styles.css";
const categoryTree = [
{
id: "dx",
label: "Diagnosis",
icon: "Stethoscope" as const,
insertValue: "Primary diagnosis: ",
},
];
export function EncounterNoteEditor({
initialLexicalJson,
}: {
initialLexicalJson?: string | null;
}) {
const [customTemplates, setCustomTemplates] = useState<NoteTemplate[]>([]);
const handleChange = (doc: RichTextEditorDocumentExport) => {
// await saveToApi({ lexicalJson: doc.lexicalJson, html: doc.html });
};
return (
<RichTextEditorBox
label="Encounter note"
value={initialLexicalJson ?? undefined}
onChange={handleChange}
config={{
mentions: {
categoryTree,
searchIndex: buildMentionSearchIndex(categoryTree),
},
autocomplete: {
additionalTerms: ["hypertension", "NPO", "PRN"],
enableEnglishDictionary: false,
},
templates: {
items: [
{
id: "hpi",
title: "HPI",
description: "History of present illness",
body: "## History of present illness\n\n",
},
],
customItems: customTemplates,
onCustomItemsChange: setCustomTemplates,
},
signer: { name: "Dr. Smith", title: "MD" },
tools: {
toolbar: true,
statusBar: [
"characterCount",
"copyAll",
"templates",
"signature",
"importExport",
"markdown",
"clear",
],
},
}}
/>
);
}Next.js
- Client component — the editor must run on the client:
"use client";- Transpile the package (App Router or Pages):
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@kalabamssalu/rich-text-editor"],
};
export default nextConfig;Import styles in a client layout or page (see Styling).
Add
<Toaster />fromsonnerif you use import/export (sonneris an optional peer dependency).
The editor renders a lightweight placeholder until the client mounts (avoids SSR hydration issues).
Bundle size
The published dist/index.js is a single bundle (toolbar, Lexical UI, optional features). The English autocomplete dictionary is loaded at runtime only when autocomplete is configured and enableEnglishDictionary is not false. CI reports dist/ file sizes on each build.
Other frameworks
- Vite / CRA / Remix: Import styles globally; ensure Tailwind scans
node_modules/@kalabamssalu/rich-text-editor/dist. - Multiple editors: Use a unique
namespaceper instance. - Read-only:
disabled={true}disables editing and suppressesonChange.
Public API exports
// Component
export { RichTextEditorBox } from "@kalabamssalu/rich-text-editor";
// Helpers
export { mergeEditorConfig } from "@kalabamssalu/rich-text-editor";
export { buildMentionSearchIndex } from "@kalabamssalu/rich-text-editor";
export { normalizeInitialLexicalJson } from "@kalabamssalu/rich-text-editor";
export { DEFAULT_AUTOCOMPLETE_STORAGE_KEY } from "@kalabamssalu/rich-text-editor";
/** @deprecated Prefer RichTextEditorBox — registers minimal nodes only */
export { createRichTextEditorInitialConfig } from "@kalabamssalu/rich-text-editor";
// Types
export type {
RichTextEditorBoxProps,
RichTextEditorConfig,
RichTextEditorDocumentExport,
RichTextEditorExportFormat,
RichTextEditorSlotsConfig,
RichTextEditorMentionsConfig,
RichTextEditorAutocompleteConfig,
RichTextEditorTemplatesConfig,
RichTextEditorToolsConfig,
RichTextEditorSignerConfig,
ToolbarToolId,
StatusBarToolId,
NoteTemplate,
MentionMenuNode,
MentionEntry,
MentionIconName,
MentionSearchPatient,
} from "@kalabamssalu/rich-text-editor";createRichTextEditorInitialConfig registers only basic nodes. RichTextEditorBox is the supported integration path — it registers mentions, images, signatures, embeds, and all extensions.
Troubleshooting
| Symptom | Likely cause | Fix |
|---------|----------------|-----|
| "An error was thrown." in the editor area | React error inside the content surface (often caught by Lexical’s error boundary) | Open the browser console for the real error. Ensure @kalabamssalu/rich-text-editor is up to date (v0.2.0+ includes required TooltipProvider). |
| Unstyled / broken layout | Tailwind not scanning the package | Add @source or content path to dist (see Styling). |
| Gray boxes, wrong colors | Missing CSS variables | Add shadcn-style theme variables or match your design tokens. |
| Editor empty / plugins broken | Missing Lexical peers or duplicate Lexical | Install all @lexical/* peers at ^0.44.0; dedupe with npm ls lexical. |
| setEditorState: … editor state is empty | Saved JSON has root.children: [], invalid JSON, or value="" | Use undefined when no note (not ""). Don’t persist empty-root snapshots from “clear”; upgrade to ≥0.2.2 which normalizes bad snapshots to a blank doc. |
| Caret jumps / blur after each keystroke | Package older than v0.2.3 with value + setState(onChange) (composer remounted every update) | Upgrade to ≥0.2.3 or use defaultValue only (no controlled loop). |
| value does not load | Passing HTML instead of Lexical JSON | Use onChange’s lexicalJson for value / defaultValue. |
| Import/export toasts missing | No Sonner toaster | Add <Toaster /> from sonner to your app root. |
| Build error: react-day-picker | Old install without dependencies | Run pnpm install / npm install after upgrading the package. |
| Hydration warning in Next.js | Editor rendered on server | Use "use client"; do not import RichTextEditorBox in Server Components. |
Enable verbose logging: errors are also logged as [RichTextEditor] in the console from the editor’s onError handler.
Breaking changes (0.1 → 0.2)
- Removed
MEDICAL_AUTOCOMPLETE_TERMSexport and bundled demo data undersrc/defaults/. autocomplete.terms→autocomplete.additionalTermsautocomplete.enableDictionary→autocomplete.enableEnglishDictionarytemplates.templates→templates.items- Custom template persistence: use
customItems+onCustomItemsChange(host-owned) instead of packagestorageKey.
License
MIT
