RichTextState¶
RichTextState is the core component that manages the state of the Rich Text Editor. It handles: - Text content and styling - Selection and cursor position - Configuration settings - Text operations and modifications - Import/export functionality
Basic Usage¶
Creating the State¶
To create a RichTextState, use the rememberRichTextState function:
val state = rememberRichTextState()
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth()
)
Starting with content¶
To create the state with content already in it, pass an initializer. It runs once on the new state, before the first composition uses it:
Use whichever setter matches your content:
rememberRichTextState { setText("Plain text") }
rememberRichTextState { setHtml("<p>Hello <b>world</b></p>") }
rememberRichTextState { setMarkdown("Hello **world**") }
rememberRichTextState { setRichTextDocument(document) }
rememberRichTextState { setAnnotatedString(annotatedString) }
The block is an initializer, not a binding:
- It does not run again when the values it reads change. To replace the content later, call the setter on the state.
- It does not run when the state is restored from saved instance state. The saved content wins, so the user's edits survive a configuration change.
- What it does is the starting point of the undo history, not a step in it. Undo after typing returns to the initial content.
Outside composition, the setters return the state, so RichTextState().setHtml(html) does the same.
Loading an AnnotatedString¶
If your app already holds styled text as a Compose AnnotatedString (from its own parser, from resources or from another library), load it with setAnnotatedString. The content can then be edited and exported with toHtml(), toMarkdown() or toRichTextDocument():
@OptIn(ExperimentalRichTextApi::class)
richTextState.setAnnotatedString(
buildAnnotatedString {
append("Read the ")
withLink(LinkAnnotation.Url("https://example.com")) { append("guide") }
append(" before the ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("first run") }
append(".\nA second paragraph.")
}
)
Like the other setters, it replaces the content, clears the undo history, moves the selection to the end and returns the state.
How the string is read:
In the AnnotatedString | In the editor |
|---|---|
Line break (\n, \r\n or \r) | Starts a new paragraph, as in setText |
SpanStyle range | Span style. Where ranges overlap, the later (inner) one wins property by property, and text decorations combine |
ParagraphStyle range | Paragraph style of the paragraphs it covers. A range that starts or ends inside a line splits the line there, as Compose does when it lays the string out |
Bullet (withBulletList, withBulletListItem, addBullet) | Unordered list item, for the paragraph that starts where the bullet's range starts, which is the paragraph Compose draws the bullet on. A bullet that does not start a paragraph is dropped |
LinkAnnotation.Url, deprecated UrlAnnotation | Link to its url. A link's TextLinkStyles are dropped; links are drawn with config.linkColor and config.linkTextDecoration |
LinkAnnotation.Clickable | Dropped, the text is kept. It carries a tag and a listener but no URL for a link to point to |
String annotations (inline content from appendInlineContent included), TtsAnnotation, any other annotation | Dropped. The text is kept |
Bullet lists keep their nesting. Compose nests a bullet list by indenting it further, so consecutive bulleted paragraphs nest by their text indent: an item indented further than the one before it is one level deeper, and an item indented less returns to the level that had its indentation. A bulleted paragraph without a text indent is at the first level, and text between two lists starts the nesting again.
@OptIn(ExperimentalRichTextApi::class)
richTextState.setAnnotatedString(
buildAnnotatedString {
withBulletList {
withBulletListItem { append("Fruits") }
withBulletList {
withBulletListItem { append("Apples") } // second level
}
withBulletListItem { append("Vegetables") }
}
}
)
The indent that makes room for the bullet and the bullet's shape, size and brush are not kept. The item is indented and its marker drawn from the config like every list item, see Ordered and Unordered Lists. A bullet range that spans several lines makes its first line the list item; the lines after it load as indented paragraphs without a marker, as Compose shows them.
Formatting outside config.features is removed and its text kept, see Editor features. Without RichTextFeature.UnorderedList, a bulleted paragraph loads as a plain paragraph.
setAnnotatedString is not the inverse of richTextState.annotatedString. That property is the rendered text: paragraphs are joined by a space, list markers are characters, and links, code spans and headings are plain styling. Loading it back keeps the text and its look, but a list comes back as paragraphs that start with the marker characters, a link as colored text, and each paragraph but the last keeps a trailing space. To copy content between states use toRichTextDocument() and setRichTextDocument(), or toHtml() and setHtml().
Configuration¶
Appearance Settings¶
// Link appearance
richTextState.config.linkColor = Color.Blue
richTextState.config.linkTextDecoration = TextDecoration.Underline
// Code span appearance
richTextState.config.codeSpanColor = Color.Yellow
richTextState.config.codeSpanBackgroundColor = Color.Transparent
richTextState.config.codeSpanStrokeColor = Color.LightGray
richTextState.config.codeSpanCornerRadius = 8.sp
richTextState.config.codeSpanStrokeWidth = 1.sp
richTextState.config.codeSpanPadding = TextPaddingValues(horizontal = 2.sp, vertical = 2.sp)
// Heading typography, per level (see Headings)
richTextState.config.headingTextStyles = mapOf(
HeadingStyle.H1 to TextStyle(fontSize = 28.sp, fontWeight = FontWeight.SemiBold),
)
These settings style the editor and are not part of the document, so they are not written to HTML or Markdown. See Headings for headingTextStyles.
List Configuration¶
// Global list indentation
richTextState.config.listIndent = 20
// Specific list type indentation
richTextState.config.orderedListIndent = 20
richTextState.config.unorderedListIndent = 20
// List behavior
richTextState.config.exitListOnEmptyItem = true // Exit list when pressing Enter on empty item
richTextState.config.listTypingShortcutsEnabled = true // "- " or "1. " at a paragraph start creates a list
richTextState.config.listMarkerStyle = SpanStyle(color = Color.Red) // Style bullets and numbers on their own
Automatic Links¶
A word that starts with http://, https:// or www. becomes a link when a space or Enter is typed after it, and a pasted URL is inserted as a link. See Links.
Typing Shortcuts¶
// "**bold**", "*italic*", "`code`" and "~~strike~~" format as you type (off by default)
richTextState.config.inlineTypingShortcutsEnabled = true
// "# " to "###### " at a paragraph start makes a heading (off by default)
richTextState.config.headingTypingShortcutsEnabled = true
See Span Style and Headings. The list shortcuts are controlled by listTypingShortcutsEnabled above.
Clipboard¶
By default, copy and paste carry rich text: copying writes HTML alongside plain text, and pasting from a browser or another rich text app imports its formatting (bold, underline, links, lists, and so on). Set richClipboardEnabled to false to restrict the clipboard to plain text:
When disabled, paste inserts plain text styled by the editor's normal insertion logic (it inherits the style at the caret, exactly like typed text), and copy writes plain text only. The web clipboard event handlers (Ctrl+C/V/X on JS and Wasm) follow the same rule.
To keep rich copy but restrict what a paste (or any load) may bring in, declare the editor's supported formatting with config.features. See Editor features.
Text Operations¶
Selection Management¶
The editor's selection can be controlled programmatically:
// Set selection range
richTextState.selection = TextRange(0, 5)
// Select all text
richTextState.selection = TextRange(0, richTextState.annotatedString.text.length)
// Move cursor to end
richTextState.selection = TextRange(richTextState.annotatedString.text.length)
Selection and focus¶
The Compose text field collapses its selection when it loses focus. A toolbar button that takes focus on click therefore finds no selection to style. There are three ways to handle this, pick the one that fits your UI.
Keep the toolbar out of the focus order. Make each formatting control non-focusable, so the editor never loses focus. This is what the sample toolbars do:
IconButton(
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontWeight = FontWeight.Bold)) },
modifier = Modifier.focusProperties { canFocus = false },
) {
Icon(Icons.Outlined.FormatBold, contentDescription = "Bold")
}
Save and restore the selection. For a real focus move in the same window, such as a URL field next to the editor, read the selection before focus moves and assign it back afterwards:
val savedSelection = richTextState.selection
// ... focus moves to the other field, the user confirms ...
richTextState.selection = savedSelection
A Dialog or Popup is a separate layer and does not take focus from the editor underneath, so the selection survives it without any of this.
Let the editor keep its selection. Set preserveSelectionOnFocusLoss and the editor keeps the selected range, and its highlight, while it is unfocused:
Focusable controls can then style the selection directly. Clicking back into the editor places the caret as usual. With two editors on one screen, each keeps its own highlight, so both can show a selection at once. The flag defaults to false and is marked @ExperimentalRichTextApi: the behavior may change.
Replacing a selection¶
Typing, an IME commit, or a plain-text paste over a non-collapsed selection styles the inserted text from the replaced range's start (the platform typing-attributes convention), not from the character before the caret. The same goes for an IME autocorrect or suggestion pick that rewrites a word while the caret is collapsed: the new text takes the style of the first character it replaces. The restyle is part of the same edit, so undo treats the replacement as a single entry. Rich span styles are inherited only when they accept edge text and are not atomic, so replacing a whole link or image never linkifies or atomizes the typed text.
Text Modification¶
The RichTextState provides methods to modify text while preserving styles:
// Insert text at specific position
richTextState.addTextAtIndex(5, "Hello")
// Insert text after current selection
richTextState.addTextAfterSelection("Hello")
// Remove text
richTextState.removeTextRange(TextRange(0, 5))
richTextState.removeSelectedText()
// Replace text
richTextState.replaceTextRange(TextRange(0, 5), "Hello")
richTextState.replaceSelectedText("Hello")
Text Change Monitoring¶
You can monitor text changes using the annotatedString property:
LaunchedEffect(richTextState.annotatedString) {
// Handle text changes
println("Text changed: ${richTextState.annotatedString.text}")
}
State Persistence¶
To save and restore the editor's state:
// Save state
val html = richTextState.toHtml()
// or
val markdown = richTextState.toMarkdown()
// Restore state
richTextState.setHtml(savedHtml)
// or
richTextState.setMarkdown(savedMarkdown)
Exporting a Range¶
toText, toHtml, toMarkdown and toRichTextDocument also take a TextRange and export only that part of the content with its formatting. Pass the selection to get what the user selected:
val selectedHtml = richTextState.toHtml(richTextState.selection)
val selectedMarkdown = richTextState.toMarkdown(richTextState.selection)
Undo / Redo¶
RichTextState ships its own undo/redo stack that snapshots the full rich-text tree (paragraphs, spans, list prefixes, link/image/token spans, selection, and pending styles). It overrides BasicTextField's built-in undo so rich content never gets out of sync with plain text.
val state = rememberRichTextState(
historyLimit = 100,
coalesceWindowMs = 500L,
)
IconButton(onClick = { state.history.undo() }, enabled = state.history.canUndo) {
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Undo")
}
IconButton(onClick = { state.history.redo() }, enabled = state.history.canRedo) {
Icon(Icons.AutoMirrored.Filled.Redo, contentDescription = "Redo")
}
Keyboard shortcuts (hardware keyboard)¶
- Undo:
Ctrl+Z(Windows/Linux) /Cmd+Z(macOS) - Redo:
Ctrl+Shift+Z(Windows/Linux) /Cmd+Shift+Z(macOS)
Coalescing¶
Consecutive typing / deletion within coalesceWindowMs (default 500ms) collapses into a single undo step. The following always start a new group:
- Line breaks (Enter)
- Formatting toggles (bold, color, link, list, paragraph style, rich span style)
- Structural changes (image/token insert, list level changes)
- Paste operations
- Programmatic replacements (
setHtml,setMarkdown,setConfig- these also clear the stacks entirely since they typically mean "load a new document") - Caret moves (do not push a snapshot but seal the pending group, so the next typed character starts a fresh undo step)
Grouping edits into one undo step¶
A multi-step programmatic edit (for example applying a style to several disjoint ranges) normally records one undo entry per call. Wrap the calls in state.history.group { } to record everything committed inside the block as a single entry:
state.history.group {
state.addSpanStyle(SpanStyle(color = Color.Red), TextRange(0, 3))
state.addSpanStyle(SpanStyle(color = Color.Red), TextRange(8, 13))
}
One undo restores the state from before the block; one redo reapplies the whole block. The function is marked @ExperimentalRichTextApi.
- Works for every commit kind inside the block: formatting, rich spans, text replacement, list and paragraph toggles.
- Entering the group seals any pending typing group, and the group itself is sealed on exit, so surrounding edits never merge into it.
- Nested
groupcalls join the outermost group. - A block that commits nothing adds no entry.
- If the block throws, whatever it already committed is kept as one undo entry and the exception is rethrown.
Opt out¶
Pass undoBehavior = UndoBehavior.Disabled to any editor composable to fall back to BasicTextField's native shortcuts. You can still call state.history.undo() directly from your own UI.
Limits¶
state.history.limit caps the undo stack (default 100 entries; oldest are evicted FIFO). state.history.clear() empties both stacks.
Scrolling¶
RichTextState exposes the editor's internal scroll position as scrollState, a standard Compose ScrollState. It's hoisted on the state so app code can read, observe, or drive the editor's scrolling from outside the composable. Custom span overlays (rich span backgrounds, underlines, and similar custom drawing) track it automatically, no extra wiring required.
val state = rememberRichTextState()
val coroutineScope = rememberCoroutineScope()
// Read the current scroll offset
val offset = state.scrollState.value
// Observe scroll changes
LaunchedEffect(state.scrollState) {
snapshotFlow { state.scrollState.value }.collect { value ->
println("Scrolled to: $value")
}
}
// Scroll to a position programmatically
Button(
onClick = {
coroutineScope.launch {
state.scrollState.animateScrollTo(state.scrollState.maxValue)
}
}
) {
Text("Scroll to bottom")
}
Related Documentation¶
- For styling text spans, see Span Style
- For styling paragraphs, see Paragraph Style
- For headings (H1..H6), see Headings
- For display-only styles such as find-in-text matches, see Highlights
- For working with lists, see Ordered and Unordered Lists
- For inline images, see Images
- For mentions, hashtags, and slash commands, see Mentions & Triggers
- For HTML conversion, see HTML Import and Export
- For Markdown conversion, see Markdown Import and Export