
Declarative, type-safe rich text editor engine with UI components handling dynamic edits: automatic span shifting, atomic mutations, semantic runs, paragraph styles, lists, and constraint-driven formatting.
Arranger is a declarative, type-safe rich text editor engine and UI ecosystem for Compose Multiplatform (Android, Desktop, iOS, Web/Wasm).
Think of Arranger as the foundational framework (analogous to ProseMirror or Lexical) for building modern, full-featured text editing experiences on Compose—providing Notion-like WYSIWYG auto-formatting, interactive @mentions with zero-math cursor popups, dynamic enter-key strategies, semantic Runs querying, atomic undo/redo, and lossless Markdown/HTML interoperability.
[!WARNING] Work In Progress: This library is currently under active development. APIs are unstable and subject to change without notice. We highly welcome your feedback, feature requests, and bug reports via GitHub Issues!
Comprehensive guides, deep-dive architecture tutorials, and complete API references are available at our official documentation site:
👉 https://mkeeda.github.io/arranger/
RichTextEditor vs WysiwygEditor, state management, and undo/redo.| Platform | Support Status | Target |
|---|---|---|
| Android | ✅ Supported | API Level 26+ |
| Desktop (JVM) | ✅ Supported | macOS, Windows, Linux |
| iOS | ✅ Supported | iOS 14+ |
| Web | ✅ Supported | WasmJs |
Requires Kotlin 2.4.10+ and Compose Multiplatform.
Arranger artifacts are published on Maven Central:
// build.gradle.kts (commonMain)
kotlin {
sourceSets {
commonMain.dependencies {
// Core editor UI (includes arranger-richtext)
implementation("dev.mkeeda.arranger:arranger-richtext-editor:0.4.0-alpha04")
// Optional: Markdown / HTML conversions
implementation("dev.mkeeda.arranger:arranger-richtext-markdown:0.4.0-alpha04")
implementation("dev.mkeeda.arranger:arranger-richtext-html:0.4.0-alpha04")
// Optional: Material 3 style resolver
implementation("dev.mkeeda.arranger:arranger-richtext-editor-material3:0.4.0-alpha04")
}
}
}Create a rich text editor in just a few lines of Compose code:
@Composable
fun SimpleEditor() {
val state = rememberRichTextState(
initialText = RichString("Hello Compose!").edit {
editAttributes(range = 6..12) { bold() }
},
)
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}For live Markdown shortcuts as you type (Notion-style auto-formatting), simply swap in WysiwygEditor:
@Composable
fun WysiwygSample() {
val state = rememberRichTextState()
WysiwygEditor(state = state, modifier = Modifier.fillMaxSize())
}While Compose provides basic text editing primitives, building a production-grade rich text editor (such as Notion, Slack, or Bear) requires orchestrating complex editing interactions. Arranger delivers that complete framework out of the box.
Arranger provides two complementary editor components to fit any writing experience:
RichTextEditor: Clean canvas designed for toolbar-driven editing without unexpected text transformations.WysiwygEditor: Notion-style instant Markdown auto-formatting as you type (# , **bold**, - , > ) with immediate Backspace reversal (pressing Backspace restores raw markdown text) and seamless undo/redo integration.Building suggestion menus (@mentions, #channels, :emojis:) usually requires complex cursor coordinate calculations and scroll adjustments. Arranger solves this declaratively:
autocompleteTriggers = listOf(AutocompleteTrigger(prefix = "@")).match.createPopupPositionProvider() automatically subtracts scroll offsets, aligns the popup to the cursor bottom, flips upward when screen space is constrained, and clamps within screen bounds.state.applyCompletion(match, replacement, attributes) replaces the query, styles the mention, positions the caret, and commits an atomic undo step.var autocompleteMatch by remember { mutableStateOf<AutocompleteMatch?>(null) }
Box {
RichTextEditor(
state = state,
autocompleteTriggers = listOf(AutocompleteTrigger(prefix = "@")),
onAutocompleteChange = { match -> autocompleteMatch = match },
)
autocompleteMatch?.let { match ->
Popup(
popupPositionProvider = match.createPopupPositionProvider(),
onDismissRequest = { autocompleteMatch = null },
) {
UserSuggestionMenu(
query = match.query,
onSelectUser = { user ->
state.applyCompletion(
match = match,
replacement = "@${user.name} ",
attributes = attributeContainerOf(BoldKey to Unit),
)
autocompleteMatch = null
},
)
}
}
}Make mentions, hashtags, and hyperlinks actionable with onSpanClick. By explicitly calling event.consume(), you handle the tap without triggering default editor caret movement or text selection:
val uriHandler = LocalUriHandler.current
RichTextEditor(
state = state,
onSpanClick = { event ->
val span = event.span
when {
span.attributes.containsKey(MentionKey) -> {
showUserProfile(span.attributes[MentionKey])
event.consume() // Suppresses editor caret placement
}
span.attributes.containsKey(LinkKey) -> {
uriHandler.openUri(span.attributes[LinkKey]!!)
event.consume()
}
}
},
)Never calculate substring indices or slice strings manually. Arranger's immutable RichString and mutable RichTextState provide a type-safe DSL for synchronized text and formatting updates:
// Declarative mutation DSL
state.edit {
insert(index = textLength, text = "Important Note") {
bold()
textColor(Color.Red)
headingLevel(HeadingLevel.H2)
}
}Inspired by SwiftUI's AttributedString.Runs, Arranger lets you query and iterate over contiguous chunks of text sharing identical attributes without complex regex:
// Inspect and batch-edit all Bold text runs
state.edit {
val boldRuns = state.richString.runs(BoldKey)
editAll(boldRuns) {
textColor(Color.Magenta)
}
}Orchestrate what happens when users press Enter via composable EnterKeyStrategy implementations:
InheritParagraphStrategy: Inherits alignment and blockquote formatting to subsequent lines.ListEnterStrategy: Automatically increments ordered list numbers (1., 2., 3.), continues bullet points, and outdents or removes list markers when Enter is pressed on an empty line.HeadingEnterStrategy: Automatically reverts to normal body text on newlines following a heading.Convert rich text to and from CommonMark Markdown and HTML without data loss:
// Markdown export / import (:arranger-richtext-markdown)
val markdown: String = state.richString.toMarkdown()
val importedString = RichString.fromMarkdown("# Hello **World**\n- Item 1\n- Item 2")
// HTML export / import (:arranger-richtext-html)
val html: String = state.richString.toHtml()
val htmlString = RichString.fromHtml("<p>Hello <span style=\"color: #ff0000;\"><strong>Red Bold</strong></span></p>")Arranger includes fully functioning sample applications demonstrating real-world integration across Android, Desktop, iOS, and Web:
| Sample | Screenshot | Description |
|---|---|---|
| Document Editor UI | ![]() |
Full-screen document editor with a rich formatting toolbar, heading dropdowns, list indentation, hyperlinks dialog, and undo/redo history controls. |
| Mention Autocomplete Chat | ![]() |
Modern chat composer featuring real-time @mention and #channel suggestion popups positioned dynamically with createPopupPositionProvider(). |
| WYSIWYG Markdown Shortcuts | ![]() |
Keyboard-first writing experience with instant Markdown shorthand expansion and one-tap backspace reversal. |
Run the sample application across any supported platform:
./gradlew :sample:web:wasmJsBrowserDevelopmentRun
./gradlew :sample:desktop:run
:sample:android run configuration in Android Studio.sample/ios/ArrangerSample.xcodeproj in Xcode and press Cmd + R.Arranger is licensed under the Apache License 2.0.
Arranger is a declarative, type-safe rich text editor engine and UI ecosystem for Compose Multiplatform (Android, Desktop, iOS, Web/Wasm).
Think of Arranger as the foundational framework (analogous to ProseMirror or Lexical) for building modern, full-featured text editing experiences on Compose—providing Notion-like WYSIWYG auto-formatting, interactive @mentions with zero-math cursor popups, dynamic enter-key strategies, semantic Runs querying, atomic undo/redo, and lossless Markdown/HTML interoperability.
[!WARNING] Work In Progress: This library is currently under active development. APIs are unstable and subject to change without notice. We highly welcome your feedback, feature requests, and bug reports via GitHub Issues!
Comprehensive guides, deep-dive architecture tutorials, and complete API references are available at our official documentation site:
👉 https://mkeeda.github.io/arranger/
RichTextEditor vs WysiwygEditor, state management, and undo/redo.| Platform | Support Status | Target |
|---|---|---|
| Android | ✅ Supported | API Level 26+ |
| Desktop (JVM) | ✅ Supported | macOS, Windows, Linux |
| iOS | ✅ Supported | iOS 14+ |
| Web | ✅ Supported | WasmJs |
Requires Kotlin 2.4.10+ and Compose Multiplatform.
Arranger artifacts are published on Maven Central:
// build.gradle.kts (commonMain)
kotlin {
sourceSets {
commonMain.dependencies {
// Core editor UI (includes arranger-richtext)
implementation("dev.mkeeda.arranger:arranger-richtext-editor:0.4.0-alpha04")
// Optional: Markdown / HTML conversions
implementation("dev.mkeeda.arranger:arranger-richtext-markdown:0.4.0-alpha04")
implementation("dev.mkeeda.arranger:arranger-richtext-html:0.4.0-alpha04")
// Optional: Material 3 style resolver
implementation("dev.mkeeda.arranger:arranger-richtext-editor-material3:0.4.0-alpha04")
}
}
}Create a rich text editor in just a few lines of Compose code:
@Composable
fun SimpleEditor() {
val state = rememberRichTextState(
initialText = RichString("Hello Compose!").edit {
editAttributes(range = 6..12) { bold() }
},
)
RichTextEditor(
state = state,
modifier = Modifier.fillMaxWidth(),
)
}For live Markdown shortcuts as you type (Notion-style auto-formatting), simply swap in WysiwygEditor:
@Composable
fun WysiwygSample() {
val state = rememberRichTextState()
WysiwygEditor(state = state, modifier = Modifier.fillMaxSize())
}While Compose provides basic text editing primitives, building a production-grade rich text editor (such as Notion, Slack, or Bear) requires orchestrating complex editing interactions. Arranger delivers that complete framework out of the box.
Arranger provides two complementary editor components to fit any writing experience:
RichTextEditor: Clean canvas designed for toolbar-driven editing without unexpected text transformations.WysiwygEditor: Notion-style instant Markdown auto-formatting as you type (# , **bold**, - , > ) with immediate Backspace reversal (pressing Backspace restores raw markdown text) and seamless undo/redo integration.Building suggestion menus (@mentions, #channels, :emojis:) usually requires complex cursor coordinate calculations and scroll adjustments. Arranger solves this declaratively:
autocompleteTriggers = listOf(AutocompleteTrigger(prefix = "@")).match.createPopupPositionProvider() automatically subtracts scroll offsets, aligns the popup to the cursor bottom, flips upward when screen space is constrained, and clamps within screen bounds.state.applyCompletion(match, replacement, attributes) replaces the query, styles the mention, positions the caret, and commits an atomic undo step.var autocompleteMatch by remember { mutableStateOf<AutocompleteMatch?>(null) }
Box {
RichTextEditor(
state = state,
autocompleteTriggers = listOf(AutocompleteTrigger(prefix = "@")),
onAutocompleteChange = { match -> autocompleteMatch = match },
)
autocompleteMatch?.let { match ->
Popup(
popupPositionProvider = match.createPopupPositionProvider(),
onDismissRequest = { autocompleteMatch = null },
) {
UserSuggestionMenu(
query = match.query,
onSelectUser = { user ->
state.applyCompletion(
match = match,
replacement = "@${user.name} ",
attributes = attributeContainerOf(BoldKey to Unit),
)
autocompleteMatch = null
},
)
}
}
}Make mentions, hashtags, and hyperlinks actionable with onSpanClick. By explicitly calling event.consume(), you handle the tap without triggering default editor caret movement or text selection:
val uriHandler = LocalUriHandler.current
RichTextEditor(
state = state,
onSpanClick = { event ->
val span = event.span
when {
span.attributes.containsKey(MentionKey) -> {
showUserProfile(span.attributes[MentionKey])
event.consume() // Suppresses editor caret placement
}
span.attributes.containsKey(LinkKey) -> {
uriHandler.openUri(span.attributes[LinkKey]!!)
event.consume()
}
}
},
)Never calculate substring indices or slice strings manually. Arranger's immutable RichString and mutable RichTextState provide a type-safe DSL for synchronized text and formatting updates:
// Declarative mutation DSL
state.edit {
insert(index = textLength, text = "Important Note") {
bold()
textColor(Color.Red)
headingLevel(HeadingLevel.H2)
}
}Inspired by SwiftUI's AttributedString.Runs, Arranger lets you query and iterate over contiguous chunks of text sharing identical attributes without complex regex:
// Inspect and batch-edit all Bold text runs
state.edit {
val boldRuns = state.richString.runs(BoldKey)
editAll(boldRuns) {
textColor(Color.Magenta)
}
}Orchestrate what happens when users press Enter via composable EnterKeyStrategy implementations:
InheritParagraphStrategy: Inherits alignment and blockquote formatting to subsequent lines.ListEnterStrategy: Automatically increments ordered list numbers (1., 2., 3.), continues bullet points, and outdents or removes list markers when Enter is pressed on an empty line.HeadingEnterStrategy: Automatically reverts to normal body text on newlines following a heading.Convert rich text to and from CommonMark Markdown and HTML without data loss:
// Markdown export / import (:arranger-richtext-markdown)
val markdown: String = state.richString.toMarkdown()
val importedString = RichString.fromMarkdown("# Hello **World**\n- Item 1\n- Item 2")
// HTML export / import (:arranger-richtext-html)
val html: String = state.richString.toHtml()
val htmlString = RichString.fromHtml("<p>Hello <span style=\"color: #ff0000;\"><strong>Red Bold</strong></span></p>")Arranger includes fully functioning sample applications demonstrating real-world integration across Android, Desktop, iOS, and Web:
| Sample | Screenshot | Description |
|---|---|---|
| Document Editor UI | ![]() |
Full-screen document editor with a rich formatting toolbar, heading dropdowns, list indentation, hyperlinks dialog, and undo/redo history controls. |
| Mention Autocomplete Chat | ![]() |
Modern chat composer featuring real-time @mention and #channel suggestion popups positioned dynamically with createPopupPositionProvider(). |
| WYSIWYG Markdown Shortcuts | ![]() |
Keyboard-first writing experience with instant Markdown shorthand expansion and one-tap backspace reversal. |
Run the sample application across any supported platform:
./gradlew :sample:web:wasmJsBrowserDevelopmentRun
./gradlew :sample:desktop:run
:sample:android run configuration in Android Studio.sample/ios/ArrangerSample.xcodeproj in Xcode and press Cmd + R.Arranger is licensed under the Apache License 2.0.