
WYSIWYG Markdown editor with live inline formatting, Markdown-preserving clipboard, rich keyboard shortcuts, granular undo/redo, customizable styles, and toolbar-friendly components for seamless editing.
A WYSIWYG Markdown editor for Compose Multiplatform.
Type in Markdown, see formatting live. Copy as Markdown. Works on Android, Desktop, and Web.
Type Markdown syntax directly and watch it convert as you write — no mode switching, no preview pane required.
| Syntax | Style |
|---|---|
**text** |
Bold |
*text* |
Italic |
__text__ |
Underline |
[text](https://github.com/DenserMeerkat/hyphen/blob/main/url) |
Link |
`text` |
Inline code |
~~text~~ |
|
==text== |
Highlight |
# at line start |
Heading 1 |
## at line start |
Heading 2 |
### at line start |
Heading 3 |
#### at line start |
Heading 4 |
##### at line start |
Heading 5 |
###### at line start |
Heading 6 |
- at line start |
Bullet list |
1. at line start |
Ordered list |
> at line start |
Blockquote |
- [ ] at line start |
Checkbox (unchecked) |
- [x] at line start |
Checkbox (checked) |
[text](https://github.com/DenserMeerkat/hyphen/blob/main/scheme:id) |
Mention |
Cut, copy, and paste all work across Android, Desktop, and Web. Copying a selection serializes it to Markdown automatically, paste into any Markdown-aware editor and all formatting travels with it.
When you need to write something else to the clipboard from inside the editor (e.g. a raw URL from a link context menu), use LocalHyphenRawClipboard to reach the underlying system clipboard directly.
Full hardware keyboard support on Desktop and Web:
| Shortcut | Action |
|---|---|
Ctrl / Cmd + B |
Toggle bold |
Ctrl / Cmd + I |
Toggle italic |
Ctrl / Cmd + U |
Toggle underline |
Ctrl / Cmd + Shift + S |
Toggle strikethrough |
Ctrl / Cmd + Shift + X |
Toggle strikethrough |
Ctrl / Cmd + Alt + X |
Toggle strikethrough |
Ctrl / Cmd + Shift + H |
Toggle highlight |
Ctrl / Cmd + Space |
Clear all styles on selection |
Ctrl / Cmd + 1 |
Toggle Heading 1 |
Ctrl / Cmd + 2 |
Toggle Heading 2 |
Ctrl / Cmd + 3 |
Toggle Heading 3 |
Ctrl / Cmd + 4 |
Toggle Heading 4 |
Ctrl / Cmd + 5 |
Toggle Heading 5 |
Ctrl / Cmd + 6 |
Toggle Heading 6 |
Ctrl / Cmd + Enter |
Toggle checkbox on current line |
Ctrl / Cmd + K |
Toggle link on selection |
Ctrl / Cmd + Z |
Undo |
Ctrl / Cmd + Y |
Redo |
Ctrl / Cmd + Shift + Z |
Redo |
Granular history with snapshots saved at word boundaries, pastes, and Markdown conversions. The redo stack is maintained correctly across all operations, including toolbar toggles and programmatic edits.
Single shared implementation targeting Android, Desktop (JVM), and Web (WasmJS / JS).
Powerful trigger-based autocomplete and interaction system. Define triggers like @ or #, show custom suggestion menus, and handle mention-specific hover cards or context menus.
Add the version and library entry to your version catalog:
gradle/libs.versions.toml
[versions]
hyphen = "0.5.0-alpha04"
[libraries]
hyphen = { group = "io.github.densermeerkat", name = "hyphen", version.ref = "hyphen" }Then reference it in your shared module:
shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.hyphen)
}
}
}
commonMainis the source set that compiles for every target at once — Android, Desktop, and Web. Declaring Hyphen there means you write the dependency once and all platforms pick it up automatically.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.densermeerkat:hyphen:0.5.0-alpha04")
}
}
}val state = rememberHyphenTextState(
initialText = "**Hello**, *Hyphen*!"
)
HyphenTextField(
state = state,
label = { Text("Notes") },
)
// Read the result at any time
val markdown = state.toMarkdown()Hyphen ships two editor composables. Use whichever fits your design:
A thin wrapper around BasicTextField with no decoration. Use this when you control the layout yourself or want full design freedom.
HyphenBasicTextEditor(
state = state,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onMarkdownChange = { markdown -> /* sync to ViewModel */ },
)Wraps HyphenBasicTextEditor inside a standard Material3 filled text field decorator — labels, placeholder, leading/trailing icons, supporting text, and error state all work out of the box.
HyphenTextField(
state = state,
label = { Text("Notes") },
placeholder = { Text("Start typing…") },
trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
supportingText = { Text("Markdown supported") },
modifier = Modifier.fillMaxWidth(),
)Both composables accept the same styleConfig, mentionConfig, triggerPopup, onTextChange, onMarkdownChange, and clipboardLabel parameters. The Material3 variant additionally accepts colors, shape, labelPosition, contentPadding, and all standard decoration slots.
On Desktop and Web, clicking a button moves keyboard focus away from the editor. This causes the text selection to be lost before the style toggle runs. Fix this by adding focusProperties { canFocus = false } to every toolbar button so focus never leaves the editor when a button is tapped:
IconToggleButton(
checked = state.hasStyle(MarkupStyle.Bold),
onCheckedChange = { state.toggleStyle(MarkupStyle.Bold) },
modifier = Modifier.focusProperties { canFocus = false }, // ← required on Desktop & Web
) {
Icon(Icons.Default.FormatBold, contentDescription = "Bold")
}This applies to any clickable element in your toolbar — IconButton, Button, IconToggleButton, etc.
HyphenBasicTextEditor(
state = state,
styleConfig = HyphenStyleConfig(
boldStyle = SpanStyle(
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF1A73E8),
),
highlightStyle = SpanStyle(
background = Color(0xFFFFF176),
),
inlineCodeStyle = SpanStyle(
background = Color(0xFFF1F3F4),
fontFamily = FontFamily.Monospace,
color = Color(0xFFD93025),
),
),
)// Load new Markdown content (resets undo history)
state.setMarkdown("# New content\n\nHello!")
// Toggle formatting from a custom button
Button(onClick = { state.toggleStyle(MarkupStyle.Bold) }) { Text("B") }
// Remove all inline formatting from the current selection
Button(onClick = { state.clearAllStyles() }) { Text("Clear") }
// Undo / redo
state.undo()
state.redo()Hyphen provides a powerful trigger-based autocomplete and interaction framework for mentions (such as @users), hashtags (such as #features), or custom template variables.
Configure the triggers and the Markdown schemes they map to. Triggers are completely optional and can be defined at initialization:
val triggers = listOf(
TriggerConfig(trigger = "@", scheme = "user"),
TriggerConfig(trigger = "#", scheme = "tag")
)
// Triggers can be optionally passed directly to rememberHyphenTextState
val state = rememberHyphenTextState(triggerConfigs = triggers)Use mentionConfig (HyphenMentionConfig) to manage interactions with completed mentions, such as handling taps, presenting hover cards, or showing custom context Dropdown menus:
HyphenTextField(
state = state,
mentionConfig = HyphenMentionConfig(
onMentionClick = { mention ->
println("Clicked mention: ${mention.display} with ID: ${mention.id}")
},
hoverCardContent = { mention ->
Surface(tonalElevation = 8.dp, shape = MaterialTheme.shapes.medium) {
Text("Viewing details for ${mention.display}", Modifier.padding(8.dp))
}
},
dropdownContent = { span, offset, onDismiss ->
// Display a custom context menu on right-click / long-press
val mention = span.style as? MarkupStyle.Mention
if (mention != null) {
DropdownMenu(expanded = true, onDismissRequest = onDismiss, offset = offset) {
DropdownMenuItem(
text = { Text("Profile") },
onClick = {
println("Opening profile for ${mention.id}")
onDismiss()
}
)
}
}
}
),
triggerPopup = { triggerState ->
// Render your custom autocomplete suggestions menu
MySuggestionMenu(
query = triggerState.query,
onSelected = { item ->
state.completeMention(id = item.id, display = item.display, trigger = triggerState)
}
)
}
)When an autocomplete trigger is active, the editor automatically intercepts and routes arrow keys (Up/Down) and the Enter key to coordinate suggestion selection.
state.suggestionSelectedIndex, state.suggestionCount, and state.suggestionSelectionRequested to control your suggestions popup.state.completeMention(id, display) to insert a completed mention, which replaces the active query and registers a MarkupStyle.Mention style range.state.insertMention(display, scheme, id) to programmatically insert a formatted mention at the current cursor index.[!TIP] Hyphen includes a built-in
TriggerSuggestionscomposable that handles standard Material3 list styling and keyboard selection synchronization for you out-of-the-box. Check the sample project for a full demonstration.
// Callback — fires on every text or formatting change
HyphenBasicTextEditor(
state = state,
onMarkdownChange = { markdown -> viewModel.onContentChanged(markdown) },
)
// Flow — collect anywhere, debounce freely
viewModelScope.launch {
state.markdownFlow
.debounce(500)
.collect { markdown -> repository.save(markdown) }
}Hyphen intercepts every Clipboard.setClipEntry() call made from within the editor. When there is an active selection it replaces whatever you wrote with the Markdown-serialized form of that selection. This is the right behaviour for a user pressing Ctrl+C, but not for a programmatic action like "Copy link URL" that is triggered from a context menu while the link text happens to be selected.
Use LocalHyphenRawClipboard to access the original, unintercepted system clipboard:
import com.denser.hyphen.ui.LocalHyphenRawClipboard
@Composable
fun LinkContextMenu(url: String, onDismiss: () -> Unit) {
// Always falls through to the real system clipboard, ignoring any editor selection
val rawClipboard = LocalHyphenRawClipboard.current
val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text("Copy URL") },
onClick = {
scope.launch {
rawClipboard?.setClipEntry(
ClipEntry(ClipData.newPlainText("URL", url))
)
}
onDismiss()
}
)
}LocalHyphenRawClipboard.current is null when the composable is rendered outside a HyphenBasicTextEditor. Fall back to LocalClipboard.current if you need to handle both cases:
val clipboard = LocalHyphenRawClipboard.current ?: LocalClipboard.current[!IMPORTANT] On Android,
LocalHyphenRawClipboardis essential for any custom clipboard write originating from inside the editor. Android has no alternative clipboard path — allsetClipEntry()calls go throughLocalClipboard, which Hyphen intercepts. If the editor has an active selection at the time (e.g. because a long-press on a link both selects it and opens a context menu), your custom value will be silently replaced with the Markdown-serialized selection unless you useLocalHyphenRawClipboard.On Desktop and Web the interception follows the same pattern but there are additional native clipboard APIs available as fallbacks.
| Parameter | Type | Default | Description |
|---|---|---|---|
state |
HyphenTextState |
Required | Holds text content, spans, selection, and undo/redo history. |
modifier |
Modifier |
Modifier |
Applied to the underlying BasicTextField. |
enabled |
Boolean |
true |
When false, the field is neither editable nor focusable. |
readOnly |
Boolean |
false |
When true, the field cannot be modified but can be focused and copied. |
textStyle |
TextStyle |
TextStyle(fontSize = 16.sp) |
Typographic style applied to the visible text. |
styleConfig |
HyphenStyleConfig |
HyphenStyleConfig() |
Visual appearance of each MarkupStyle. |
linkConfig |
HyphenLinkConfig |
HyphenLinkConfig() |
Interaction configuration for link spans (menus, dialogs, opening URLs). |
mentionConfig |
HyphenMentionConfig |
HyphenMentionConfig() |
Interaction configuration for mention spans (hover cards, dropdowns, click handlers). |
triggerPopup |
@Composable (TriggerState) -> Unit |
{} |
Composable content shown in a popup when a trigger is active. |
keyboardOptions |
KeyboardOptions |
Sentences, no autocorrect | Software keyboard options. |
lineLimits |
TextFieldLineLimits |
TextFieldLineLimits.Default |
Single-line or multi-line behaviour. |
scrollState |
ScrollState |
rememberScrollState() |
Controls vertical or horizontal scroll of the field content. |
interactionSource |
MutableInteractionSource? |
null |
Hoist to observe focus, hover, and press interactions externally. |
cursorBrush |
Brush |
SolidColor(Color.Black) |
Brush used to paint the cursor. |
decorator |
TextFieldDecorator? |
null |
Optional decorator for external visual styling. |
onTextLayout |
(Density.(...) -> Unit)? |
null |
Callback invoked on every text layout recalculation. |
clipboardLabel |
String |
"Markdown Text" |
Label attached to clipboard entries on copy/cut. |
onTextChange |
((String) -> Unit)? |
null |
Callback invoked whenever the plain text changes. |
onMarkdownChange |
((String) -> Unit)? |
null |
Callback invoked whenever text or formatting changes, providing the serialized Markdown string. |
| Property / Method | Type / Return | Description |
|---|---|---|
textFieldState |
TextFieldState |
The underlying Compose foundation state driving the editor. |
text |
String |
Plain text with all Markdown syntax stripped. |
selection |
TextRange |
Current cursor position or selected range. |
spans |
List<MarkupStyleRange> |
Snapshot-observable list of active formatting spans. |
pendingOverrides |
Map<MarkupStyle, Boolean> |
Transient style intent applied to the next typed characters. |
canUndo / canRedo
|
Boolean |
true if undo/redo actions are available in the history stack. |
activeLinkForEditing |
MarkupStyleRange? |
The link span currently being edited via the built-in dialog. |
isFocused |
Boolean |
Whether the text field currently has input focus. |
triggerConfigs |
List<TriggerConfig> |
Active autocomplete trigger configurations (e.g. @ or #). |
activeTrigger |
TriggerState? |
The current active trigger being typed by the user, if any. |
suggestionSelectedIndex |
Int |
The index of the currently highlighted suggestion in the autocomplete menu. |
suggestionCount |
Int |
Total number of suggestions currently available (must be set by the custom menu). |
suggestionSelectionRequested |
Boolean |
Set to true when Enter is pressed on an active trigger to request selection completion. |
toggleStyle(style) |
Unit |
Toggles an inline or block style on the current selection. |
toggleCheckbox(index?) |
Unit |
Toggles the checked/unchecked state of checkboxes in the selection or at a specific index. |
clearAllStyles() |
Unit |
Removes all inline formatting from the selection; suppresses at cursor. |
toggleLink() |
Unit |
Wraps selection in a link, or opens an existing link at the cursor for editing. |
updateLink(span, text, url) |
Unit |
Updates an existing link's display text and URL. |
completeMention(id, display, triggerEnd?, trigger?) |
Unit |
Completes the active trigger, replacing the query with a formatted mention span. |
dismissActiveTrigger() |
Unit |
Clears the currently active trigger state, dismissing suggestions popups. |
activateTrigger(config, query) |
Unit |
Programmatically activates an autocomplete trigger at the current cursor position. |
insertText(value) |
Unit |
Programmatically inserts text at the current cursor, running full Markdown/trigger checks. |
insertMention(display, scheme, id) |
Unit |
Programmatically inserts a formatted mention at the current cursor position. |
hasStyle(style) |
Boolean |
true if the style is active at the current selection or cursor. |
isStyleAt(index, style) |
Boolean |
Point query against the span list (ignores selection / overrides). |
clearPendingOverrides() |
Unit |
Resets transient typing intent. |
undo() / redo()
|
Unit |
Navigates the undo / redo history stack. |
toMarkdown(start?, end?) |
String |
Serializes content (or a substring range) to a Markdown formatted string. |
setMarkdown(markdown) |
Unit |
Replaces all content programmatically, parses it, and resets history. |
markdownFlow |
Flow<String> |
Emits the serialized Markdown string on every text or formatting change. |
| Property | Default Value |
|---|---|
boldStyle |
SpanStyle(fontWeight = FontWeight.Bold) |
italicStyle |
SpanStyle(fontStyle = FontStyle.Italic) |
underlineStyle |
SpanStyle(textDecoration = TextDecoration.Underline) |
strikethroughStyle |
SpanStyle(textDecoration = TextDecoration.LineThrough) |
highlightStyle |
SpanStyle(background = Color(0xFFFFEB3B).copy(alpha = 0.4f)) |
inlineCodeStyle |
SpanStyle(background = Color.Gray.copy(alpha = 0.15f), fontFamily = FontFamily.Monospace) |
blockquoteSpanStyle |
SpanStyle(fontStyle = FontStyle.Italic, color = Color.Gray, background = Color.Gray.copy(0.05f)) |
bulletListStyle |
ListItemStyle() |
orderedListStyle |
ListItemStyle() |
checkboxCheckedStyle |
SpanStyle(textDecoration = TextDecoration.LineThrough) |
checkboxUncheckedStyle |
null |
h1Style |
SpanStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold) |
h2Style |
SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold) |
h3Style |
SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold) |
h4Style |
SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold) |
h5Style |
SpanStyle(fontSize = 17.sp, fontWeight = FontWeight.Bold) |
h6Style |
SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) |
linkStyle |
SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline) |
mentionStyle |
SpanStyle(color = Color(0xFF1976D2), fontWeight = FontWeight.Medium) |
mentionStyles |
emptyMap<String, SpanStyle>() |
Interaction configuration for mention spans, controlling what happens when they are clicked, hovered, or right-clicked/long-pressed.
| Property | Type | Default | Description |
|---|---|---|---|
onMentionClick |
(MarkupStyle.Mention) -> Unit |
{} |
Callback invoked when a mention span is clicked or tapped. |
hoverCardContent |
@Composable (MarkupStyle.Mention) -> Unit |
{} |
Composable content displayed in a popup hover card when hovered. |
dropdownContent |
@Composable (span: MarkupStyleRange, menuOffset: Offset, onDismiss: () -> Unit) -> Unit |
null |
Optional context DropdownMenu composable displayed on right-click or long-press. |
Built-in Material 3 helper popup for displaying autocomplete items and managing selection index synchronization.
TriggerSuggestions(
state = state,
trigger = triggerState,
items = listOf(
SuggestionItem(id = "alice", display = "Alice"),
SuggestionItem(id = "bob", display = "Bob")
),
onSelect = { item ->
state.completeMention(id = item.id, display = item.display, trigger = triggerState)
}
)| Property | Type | Default | Description |
|---|---|---|---|
id |
String |
Required | The unique identifier of the target entity. |
display |
String |
Required | The text displayed in the suggestion list and inserted into the editor. |
subtitle |
String? |
null |
Optional descriptive label displayed below the main text. |
icon |
@Composable (() -> Unit)? |
null |
Optional leading composable slot (e.g. user avatar or icon). |
// Inline styles
MarkupStyle.Bold
MarkupStyle.Italic
MarkupStyle.Underline
MarkupStyle.Strikethrough
MarkupStyle.InlineCode
MarkupStyle.Highlight
// Heading styles
MarkupStyle.H1
MarkupStyle.H2
MarkupStyle.H3
MarkupStyle.H4
MarkupStyle.H5
MarkupStyle.H6
// Block styles
MarkupStyle.BulletList
MarkupStyle.OrderedList
MarkupStyle.Blockquote
MarkupStyle.CheckboxUnchecked
MarkupStyle.CheckboxChecked
// Mention style
MarkupStyle.Mention(display, scheme, id)Controls the prefix marker and content text of a list item independently. Used by bulletListStyle
and orderedListStyle on HyphenStyleConfig.
| Property | Type | Default | Description |
|---|---|---|---|
prefixStyle |
SpanStyle? |
null |
Applied to the marker (-, 1., - [ ], - [x]). |
contentStyle |
SpanStyle? |
null |
Applied to the text after the marker. |
Checkboxes in Hyphen are rendered as native Material3 widgets overlaid on the editor. This ensures
they always match your theme and remain perfectly aligned regardless of font size. Use
checkboxCheckedStyle and checkboxUncheckedStyle to style the label text of the checklist
items:
HyphenBasicTextEditor(
state = state,
styleConfig = HyphenStyleConfig(
checkboxCheckedStyle = SpanStyle(
textDecoration = TextDecoration.LineThrough,
color = Color.Gray,
),
),
)| Platform | Status |
|---|---|
| Android | ✅ |
| Desktop (JVM) | ✅ |
| Web (WasmJS) | ✅ |
| Web (JS / IR) | ✅ |
| iOS | 🚧 Planned |
A WYSIWYG Markdown editor for Compose Multiplatform.
Type in Markdown, see formatting live. Copy as Markdown. Works on Android, Desktop, and Web.
Type Markdown syntax directly and watch it convert as you write — no mode switching, no preview pane required.
| Syntax | Style |
|---|---|
**text** |
Bold |
*text* |
Italic |
__text__ |
Underline |
[text](https://github.com/DenserMeerkat/hyphen/blob/main/url) |
Link |
`text` |
Inline code |
~~text~~ |
|
==text== |
Highlight |
# at line start |
Heading 1 |
## at line start |
Heading 2 |
### at line start |
Heading 3 |
#### at line start |
Heading 4 |
##### at line start |
Heading 5 |
###### at line start |
Heading 6 |
- at line start |
Bullet list |
1. at line start |
Ordered list |
> at line start |
Blockquote |
- [ ] at line start |
Checkbox (unchecked) |
- [x] at line start |
Checkbox (checked) |
[text](https://github.com/DenserMeerkat/hyphen/blob/main/scheme:id) |
Mention |
Cut, copy, and paste all work across Android, Desktop, and Web. Copying a selection serializes it to Markdown automatically, paste into any Markdown-aware editor and all formatting travels with it.
When you need to write something else to the clipboard from inside the editor (e.g. a raw URL from a link context menu), use LocalHyphenRawClipboard to reach the underlying system clipboard directly.
Full hardware keyboard support on Desktop and Web:
| Shortcut | Action |
|---|---|
Ctrl / Cmd + B |
Toggle bold |
Ctrl / Cmd + I |
Toggle italic |
Ctrl / Cmd + U |
Toggle underline |
Ctrl / Cmd + Shift + S |
Toggle strikethrough |
Ctrl / Cmd + Shift + X |
Toggle strikethrough |
Ctrl / Cmd + Alt + X |
Toggle strikethrough |
Ctrl / Cmd + Shift + H |
Toggle highlight |
Ctrl / Cmd + Space |
Clear all styles on selection |
Ctrl / Cmd + 1 |
Toggle Heading 1 |
Ctrl / Cmd + 2 |
Toggle Heading 2 |
Ctrl / Cmd + 3 |
Toggle Heading 3 |
Ctrl / Cmd + 4 |
Toggle Heading 4 |
Ctrl / Cmd + 5 |
Toggle Heading 5 |
Ctrl / Cmd + 6 |
Toggle Heading 6 |
Ctrl / Cmd + Enter |
Toggle checkbox on current line |
Ctrl / Cmd + K |
Toggle link on selection |
Ctrl / Cmd + Z |
Undo |
Ctrl / Cmd + Y |
Redo |
Ctrl / Cmd + Shift + Z |
Redo |
Granular history with snapshots saved at word boundaries, pastes, and Markdown conversions. The redo stack is maintained correctly across all operations, including toolbar toggles and programmatic edits.
Single shared implementation targeting Android, Desktop (JVM), and Web (WasmJS / JS).
Powerful trigger-based autocomplete and interaction system. Define triggers like @ or #, show custom suggestion menus, and handle mention-specific hover cards or context menus.
Add the version and library entry to your version catalog:
gradle/libs.versions.toml
[versions]
hyphen = "0.5.0-alpha04"
[libraries]
hyphen = { group = "io.github.densermeerkat", name = "hyphen", version.ref = "hyphen" }Then reference it in your shared module:
shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.hyphen)
}
}
}
commonMainis the source set that compiles for every target at once — Android, Desktop, and Web. Declaring Hyphen there means you write the dependency once and all platforms pick it up automatically.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.densermeerkat:hyphen:0.5.0-alpha04")
}
}
}val state = rememberHyphenTextState(
initialText = "**Hello**, *Hyphen*!"
)
HyphenTextField(
state = state,
label = { Text("Notes") },
)
// Read the result at any time
val markdown = state.toMarkdown()Hyphen ships two editor composables. Use whichever fits your design:
A thin wrapper around BasicTextField with no decoration. Use this when you control the layout yourself or want full design freedom.
HyphenBasicTextEditor(
state = state,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onMarkdownChange = { markdown -> /* sync to ViewModel */ },
)Wraps HyphenBasicTextEditor inside a standard Material3 filled text field decorator — labels, placeholder, leading/trailing icons, supporting text, and error state all work out of the box.
HyphenTextField(
state = state,
label = { Text("Notes") },
placeholder = { Text("Start typing…") },
trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
supportingText = { Text("Markdown supported") },
modifier = Modifier.fillMaxWidth(),
)Both composables accept the same styleConfig, mentionConfig, triggerPopup, onTextChange, onMarkdownChange, and clipboardLabel parameters. The Material3 variant additionally accepts colors, shape, labelPosition, contentPadding, and all standard decoration slots.
On Desktop and Web, clicking a button moves keyboard focus away from the editor. This causes the text selection to be lost before the style toggle runs. Fix this by adding focusProperties { canFocus = false } to every toolbar button so focus never leaves the editor when a button is tapped:
IconToggleButton(
checked = state.hasStyle(MarkupStyle.Bold),
onCheckedChange = { state.toggleStyle(MarkupStyle.Bold) },
modifier = Modifier.focusProperties { canFocus = false }, // ← required on Desktop & Web
) {
Icon(Icons.Default.FormatBold, contentDescription = "Bold")
}This applies to any clickable element in your toolbar — IconButton, Button, IconToggleButton, etc.
HyphenBasicTextEditor(
state = state,
styleConfig = HyphenStyleConfig(
boldStyle = SpanStyle(
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF1A73E8),
),
highlightStyle = SpanStyle(
background = Color(0xFFFFF176),
),
inlineCodeStyle = SpanStyle(
background = Color(0xFFF1F3F4),
fontFamily = FontFamily.Monospace,
color = Color(0xFFD93025),
),
),
)// Load new Markdown content (resets undo history)
state.setMarkdown("# New content\n\nHello!")
// Toggle formatting from a custom button
Button(onClick = { state.toggleStyle(MarkupStyle.Bold) }) { Text("B") }
// Remove all inline formatting from the current selection
Button(onClick = { state.clearAllStyles() }) { Text("Clear") }
// Undo / redo
state.undo()
state.redo()Hyphen provides a powerful trigger-based autocomplete and interaction framework for mentions (such as @users), hashtags (such as #features), or custom template variables.
Configure the triggers and the Markdown schemes they map to. Triggers are completely optional and can be defined at initialization:
val triggers = listOf(
TriggerConfig(trigger = "@", scheme = "user"),
TriggerConfig(trigger = "#", scheme = "tag")
)
// Triggers can be optionally passed directly to rememberHyphenTextState
val state = rememberHyphenTextState(triggerConfigs = triggers)Use mentionConfig (HyphenMentionConfig) to manage interactions with completed mentions, such as handling taps, presenting hover cards, or showing custom context Dropdown menus:
HyphenTextField(
state = state,
mentionConfig = HyphenMentionConfig(
onMentionClick = { mention ->
println("Clicked mention: ${mention.display} with ID: ${mention.id}")
},
hoverCardContent = { mention ->
Surface(tonalElevation = 8.dp, shape = MaterialTheme.shapes.medium) {
Text("Viewing details for ${mention.display}", Modifier.padding(8.dp))
}
},
dropdownContent = { span, offset, onDismiss ->
// Display a custom context menu on right-click / long-press
val mention = span.style as? MarkupStyle.Mention
if (mention != null) {
DropdownMenu(expanded = true, onDismissRequest = onDismiss, offset = offset) {
DropdownMenuItem(
text = { Text("Profile") },
onClick = {
println("Opening profile for ${mention.id}")
onDismiss()
}
)
}
}
}
),
triggerPopup = { triggerState ->
// Render your custom autocomplete suggestions menu
MySuggestionMenu(
query = triggerState.query,
onSelected = { item ->
state.completeMention(id = item.id, display = item.display, trigger = triggerState)
}
)
}
)When an autocomplete trigger is active, the editor automatically intercepts and routes arrow keys (Up/Down) and the Enter key to coordinate suggestion selection.
state.suggestionSelectedIndex, state.suggestionCount, and state.suggestionSelectionRequested to control your suggestions popup.state.completeMention(id, display) to insert a completed mention, which replaces the active query and registers a MarkupStyle.Mention style range.state.insertMention(display, scheme, id) to programmatically insert a formatted mention at the current cursor index.[!TIP] Hyphen includes a built-in
TriggerSuggestionscomposable that handles standard Material3 list styling and keyboard selection synchronization for you out-of-the-box. Check the sample project for a full demonstration.
// Callback — fires on every text or formatting change
HyphenBasicTextEditor(
state = state,
onMarkdownChange = { markdown -> viewModel.onContentChanged(markdown) },
)
// Flow — collect anywhere, debounce freely
viewModelScope.launch {
state.markdownFlow
.debounce(500)
.collect { markdown -> repository.save(markdown) }
}Hyphen intercepts every Clipboard.setClipEntry() call made from within the editor. When there is an active selection it replaces whatever you wrote with the Markdown-serialized form of that selection. This is the right behaviour for a user pressing Ctrl+C, but not for a programmatic action like "Copy link URL" that is triggered from a context menu while the link text happens to be selected.
Use LocalHyphenRawClipboard to access the original, unintercepted system clipboard:
import com.denser.hyphen.ui.LocalHyphenRawClipboard
@Composable
fun LinkContextMenu(url: String, onDismiss: () -> Unit) {
// Always falls through to the real system clipboard, ignoring any editor selection
val rawClipboard = LocalHyphenRawClipboard.current
val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text("Copy URL") },
onClick = {
scope.launch {
rawClipboard?.setClipEntry(
ClipEntry(ClipData.newPlainText("URL", url))
)
}
onDismiss()
}
)
}LocalHyphenRawClipboard.current is null when the composable is rendered outside a HyphenBasicTextEditor. Fall back to LocalClipboard.current if you need to handle both cases:
val clipboard = LocalHyphenRawClipboard.current ?: LocalClipboard.current[!IMPORTANT] On Android,
LocalHyphenRawClipboardis essential for any custom clipboard write originating from inside the editor. Android has no alternative clipboard path — allsetClipEntry()calls go throughLocalClipboard, which Hyphen intercepts. If the editor has an active selection at the time (e.g. because a long-press on a link both selects it and opens a context menu), your custom value will be silently replaced with the Markdown-serialized selection unless you useLocalHyphenRawClipboard.On Desktop and Web the interception follows the same pattern but there are additional native clipboard APIs available as fallbacks.
| Parameter | Type | Default | Description |
|---|---|---|---|
state |
HyphenTextState |
Required | Holds text content, spans, selection, and undo/redo history. |
modifier |
Modifier |
Modifier |
Applied to the underlying BasicTextField. |
enabled |
Boolean |
true |
When false, the field is neither editable nor focusable. |
readOnly |
Boolean |
false |
When true, the field cannot be modified but can be focused and copied. |
textStyle |
TextStyle |
TextStyle(fontSize = 16.sp) |
Typographic style applied to the visible text. |
styleConfig |
HyphenStyleConfig |
HyphenStyleConfig() |
Visual appearance of each MarkupStyle. |
linkConfig |
HyphenLinkConfig |
HyphenLinkConfig() |
Interaction configuration for link spans (menus, dialogs, opening URLs). |
mentionConfig |
HyphenMentionConfig |
HyphenMentionConfig() |
Interaction configuration for mention spans (hover cards, dropdowns, click handlers). |
triggerPopup |
@Composable (TriggerState) -> Unit |
{} |
Composable content shown in a popup when a trigger is active. |
keyboardOptions |
KeyboardOptions |
Sentences, no autocorrect | Software keyboard options. |
lineLimits |
TextFieldLineLimits |
TextFieldLineLimits.Default |
Single-line or multi-line behaviour. |
scrollState |
ScrollState |
rememberScrollState() |
Controls vertical or horizontal scroll of the field content. |
interactionSource |
MutableInteractionSource? |
null |
Hoist to observe focus, hover, and press interactions externally. |
cursorBrush |
Brush |
SolidColor(Color.Black) |
Brush used to paint the cursor. |
decorator |
TextFieldDecorator? |
null |
Optional decorator for external visual styling. |
onTextLayout |
(Density.(...) -> Unit)? |
null |
Callback invoked on every text layout recalculation. |
clipboardLabel |
String |
"Markdown Text" |
Label attached to clipboard entries on copy/cut. |
onTextChange |
((String) -> Unit)? |
null |
Callback invoked whenever the plain text changes. |
onMarkdownChange |
((String) -> Unit)? |
null |
Callback invoked whenever text or formatting changes, providing the serialized Markdown string. |
| Property / Method | Type / Return | Description |
|---|---|---|
textFieldState |
TextFieldState |
The underlying Compose foundation state driving the editor. |
text |
String |
Plain text with all Markdown syntax stripped. |
selection |
TextRange |
Current cursor position or selected range. |
spans |
List<MarkupStyleRange> |
Snapshot-observable list of active formatting spans. |
pendingOverrides |
Map<MarkupStyle, Boolean> |
Transient style intent applied to the next typed characters. |
canUndo / canRedo
|
Boolean |
true if undo/redo actions are available in the history stack. |
activeLinkForEditing |
MarkupStyleRange? |
The link span currently being edited via the built-in dialog. |
isFocused |
Boolean |
Whether the text field currently has input focus. |
triggerConfigs |
List<TriggerConfig> |
Active autocomplete trigger configurations (e.g. @ or #). |
activeTrigger |
TriggerState? |
The current active trigger being typed by the user, if any. |
suggestionSelectedIndex |
Int |
The index of the currently highlighted suggestion in the autocomplete menu. |
suggestionCount |
Int |
Total number of suggestions currently available (must be set by the custom menu). |
suggestionSelectionRequested |
Boolean |
Set to true when Enter is pressed on an active trigger to request selection completion. |
toggleStyle(style) |
Unit |
Toggles an inline or block style on the current selection. |
toggleCheckbox(index?) |
Unit |
Toggles the checked/unchecked state of checkboxes in the selection or at a specific index. |
clearAllStyles() |
Unit |
Removes all inline formatting from the selection; suppresses at cursor. |
toggleLink() |
Unit |
Wraps selection in a link, or opens an existing link at the cursor for editing. |
updateLink(span, text, url) |
Unit |
Updates an existing link's display text and URL. |
completeMention(id, display, triggerEnd?, trigger?) |
Unit |
Completes the active trigger, replacing the query with a formatted mention span. |
dismissActiveTrigger() |
Unit |
Clears the currently active trigger state, dismissing suggestions popups. |
activateTrigger(config, query) |
Unit |
Programmatically activates an autocomplete trigger at the current cursor position. |
insertText(value) |
Unit |
Programmatically inserts text at the current cursor, running full Markdown/trigger checks. |
insertMention(display, scheme, id) |
Unit |
Programmatically inserts a formatted mention at the current cursor position. |
hasStyle(style) |
Boolean |
true if the style is active at the current selection or cursor. |
isStyleAt(index, style) |
Boolean |
Point query against the span list (ignores selection / overrides). |
clearPendingOverrides() |
Unit |
Resets transient typing intent. |
undo() / redo()
|
Unit |
Navigates the undo / redo history stack. |
toMarkdown(start?, end?) |
String |
Serializes content (or a substring range) to a Markdown formatted string. |
setMarkdown(markdown) |
Unit |
Replaces all content programmatically, parses it, and resets history. |
markdownFlow |
Flow<String> |
Emits the serialized Markdown string on every text or formatting change. |
| Property | Default Value |
|---|---|
boldStyle |
SpanStyle(fontWeight = FontWeight.Bold) |
italicStyle |
SpanStyle(fontStyle = FontStyle.Italic) |
underlineStyle |
SpanStyle(textDecoration = TextDecoration.Underline) |
strikethroughStyle |
SpanStyle(textDecoration = TextDecoration.LineThrough) |
highlightStyle |
SpanStyle(background = Color(0xFFFFEB3B).copy(alpha = 0.4f)) |
inlineCodeStyle |
SpanStyle(background = Color.Gray.copy(alpha = 0.15f), fontFamily = FontFamily.Monospace) |
blockquoteSpanStyle |
SpanStyle(fontStyle = FontStyle.Italic, color = Color.Gray, background = Color.Gray.copy(0.05f)) |
bulletListStyle |
ListItemStyle() |
orderedListStyle |
ListItemStyle() |
checkboxCheckedStyle |
SpanStyle(textDecoration = TextDecoration.LineThrough) |
checkboxUncheckedStyle |
null |
h1Style |
SpanStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold) |
h2Style |
SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold) |
h3Style |
SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold) |
h4Style |
SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold) |
h5Style |
SpanStyle(fontSize = 17.sp, fontWeight = FontWeight.Bold) |
h6Style |
SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) |
linkStyle |
SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline) |
mentionStyle |
SpanStyle(color = Color(0xFF1976D2), fontWeight = FontWeight.Medium) |
mentionStyles |
emptyMap<String, SpanStyle>() |
Interaction configuration for mention spans, controlling what happens when they are clicked, hovered, or right-clicked/long-pressed.
| Property | Type | Default | Description |
|---|---|---|---|
onMentionClick |
(MarkupStyle.Mention) -> Unit |
{} |
Callback invoked when a mention span is clicked or tapped. |
hoverCardContent |
@Composable (MarkupStyle.Mention) -> Unit |
{} |
Composable content displayed in a popup hover card when hovered. |
dropdownContent |
@Composable (span: MarkupStyleRange, menuOffset: Offset, onDismiss: () -> Unit) -> Unit |
null |
Optional context DropdownMenu composable displayed on right-click or long-press. |
Built-in Material 3 helper popup for displaying autocomplete items and managing selection index synchronization.
TriggerSuggestions(
state = state,
trigger = triggerState,
items = listOf(
SuggestionItem(id = "alice", display = "Alice"),
SuggestionItem(id = "bob", display = "Bob")
),
onSelect = { item ->
state.completeMention(id = item.id, display = item.display, trigger = triggerState)
}
)| Property | Type | Default | Description |
|---|---|---|---|
id |
String |
Required | The unique identifier of the target entity. |
display |
String |
Required | The text displayed in the suggestion list and inserted into the editor. |
subtitle |
String? |
null |
Optional descriptive label displayed below the main text. |
icon |
@Composable (() -> Unit)? |
null |
Optional leading composable slot (e.g. user avatar or icon). |
// Inline styles
MarkupStyle.Bold
MarkupStyle.Italic
MarkupStyle.Underline
MarkupStyle.Strikethrough
MarkupStyle.InlineCode
MarkupStyle.Highlight
// Heading styles
MarkupStyle.H1
MarkupStyle.H2
MarkupStyle.H3
MarkupStyle.H4
MarkupStyle.H5
MarkupStyle.H6
// Block styles
MarkupStyle.BulletList
MarkupStyle.OrderedList
MarkupStyle.Blockquote
MarkupStyle.CheckboxUnchecked
MarkupStyle.CheckboxChecked
// Mention style
MarkupStyle.Mention(display, scheme, id)Controls the prefix marker and content text of a list item independently. Used by bulletListStyle
and orderedListStyle on HyphenStyleConfig.
| Property | Type | Default | Description |
|---|---|---|---|
prefixStyle |
SpanStyle? |
null |
Applied to the marker (-, 1., - [ ], - [x]). |
contentStyle |
SpanStyle? |
null |
Applied to the text after the marker. |
Checkboxes in Hyphen are rendered as native Material3 widgets overlaid on the editor. This ensures
they always match your theme and remain perfectly aligned regardless of font size. Use
checkboxCheckedStyle and checkboxUncheckedStyle to style the label text of the checklist
items:
HyphenBasicTextEditor(
state = state,
styleConfig = HyphenStyleConfig(
checkboxCheckedStyle = SpanStyle(
textDecoration = TextDecoration.LineThrough,
color = Color.Gray,
),
),
)| Platform | Status |
|---|---|
| Android | ✅ |
| Desktop (JVM) | ✅ |
| Web (WasmJS) | ✅ |
| Web (JS / IR) | ✅ |
| iOS | 🚧 Planned |