Skip to content

Repository files navigation

Hyphen

Hyphen — WYSIWYG Markdown editor for Compose Multiplatform

The Markdown-Native WYSIWYG Editor for Compose Multiplatform.
Type Markdown, see live formatting. Copy selections as clean Markdown, and build autocomplete triggers (like @mentions) in minutes.

Maven Central Kotlin Compose Multiplatform Platforms

→ Try the live web demo

Hyphen editor Demo screenshot

Key Pillars

  • 🚀 Markdown-Native State: Markdown is the source of truth. The editor state is built directly around Markdown, ensuring consistent serialization.
  • ✍️ Instant Inline Formatting: Type Markdown tags directly (**, _, #) and watch them transform instantly under the cursor. No preview split-panes, no mode-switching.
  • 📋 Smart Clipboard Serialization: Copying formatted content automatically serializes it into Markdown, allowing users to paste directly into Slack, Discord, GitHub, or Obsidian.
  • 🏷️ Triggers & Autocomplete: Define trigger symbols like @ or # to invoke suggestion overlays with built-in hardware keyboard navigation, hover cards, and context menus.

Features

✍️ Instant Inline Markdown Rendering

Type Markdown syntax directly and watch it convert as you write — no mode switching or preview pane required.

Syntax Style
**text** Bold
*text* Italic
__text__ Underline
[text](url) Link
`text` Inline code
~~text~~ Strikethrough
==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](scheme:id) Mention

📋 Zero-Loss Markdown Clipboard

Visual selections automatically serialize to standard Markdown on copy. Paste formatted content directly into Obsidian, GitHub, or Slack.

To bypass serialization programmatically, use LocalHyphenRawClipboard to access the raw system clipboard directly.

⌨️ Keyboard Shortcuts

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 Undo & Redo History

Saves state snapshots at word boundaries, clipboard actions, and markdown conversions. The redo history is preserved across both toolbar toggles and programmatic changes.

🏷️ Trigger-Based Autocomplete Framework

Define triggers like @ or # to invoke suggestions menu with built-in hardware keyboard arrow/enter key synchronization, hover cards, and context dropdowns.


Installation

Using libs.versions.toml (recommended)

Add the version and library entry to your version catalog:

gradle/libs.versions.toml

[versions]
hyphen = "0.7.0-alpha01"

[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)
        }
    }
}

commonMain is 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.

Using string notation

// shared/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.github.densermeerkat:hyphen:0.7.0-alpha01")
        }
    }
}

Quick Start

val state = rememberHyphenTextState(
    initialText = "**Hello**, *Hyphen*!"
)

HyphenTextField(
    state = state,
    label = { Text("Notes") },
)

// Read the result at any time
val markdown = state.toMarkdown()

Choosing an Editor Component

Hyphen ships two editor composables. Use whichever fits your design:

HyphenBasicTextEditor

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 */ },
)

HyphenTextField (Material 3)

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, layoutDirection, and clipboardLabel parameters. The Material3 variant additionally accepts colors, shape, labelPosition, contentPadding, and all standard decoration slots.


🌐 Right-to-Left (RTL) & Bi-directional (Bidi) Support

Hyphen supports Right-to-Left (RTL) languages (Arabic, Hebrew, Persian):

// Inherit direction from local context (Default)
HyphenBasicTextEditor(
    state = state,
)

// Explicit direction override
HyphenBasicTextEditor(
    state = state,
    layoutDirection = LayoutDirection.Rtl,
)

Usage

Toolbar buttons — keeping focus on Desktop & Web

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.

Custom style config

HyphenBasicTextEditor(
    state = state,
    styleConfig = HyphenStyleConfig(
        indentSpaces = 4,
        boldStyle = SpanStyle(
            fontWeight = FontWeight.ExtraBold,
            color = Color(0xFF1A73E8),
        ),
        highlightStyle = SpanStyle(
            background = Color(0xFFFFF176),
        ),
        inlineCodeStyle = SpanStyle(
            background = Color(0xFFF1F3F4),
            fontFamily = FontFamily.Monospace,
            color = Color(0xFFD93025),
        ),
    ),
)

Programmatic control

// 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()

Mentions & Autocomplete

Hyphen supports trigger-based autocomplete and interaction for mentions (such as @users), hashtags (such as #features), or custom template variables.

1. Define Triggers

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)

2. Configure Interactions

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

3. Keyboard Navigation & Auto-Completion

When an autocomplete trigger is active, the editor automatically intercepts and routes arrow keys (Up/Down) and the Enter key to coordinate suggestion selection.

  • Watch state.suggestionSelectedIndex, state.suggestionCount, and state.suggestionSelectionRequested to control your suggestions popup.
  • Call state.completeMention(id, display) to insert a completed mention, which replaces the active query and registers a MarkupStyle.Mention style range.
  • Use state.insertMention(display, scheme, id) to programmatically insert a formatted mention at the current cursor index.

Tip

Hyphen includes a built-in TriggerSuggestions composable that handles standard Material3 list styling and keyboard selection synchronization for you out-of-the-box. Check the sample project for a full demonstration.

Reactive observation

// 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) }
}

Bypassing Markdown clipboard serialization

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, LocalHyphenRawClipboard is essential for any custom clipboard write originating from inside the editor. Android has no alternative clipboard path — all setClipEntry() calls go through LocalClipboard, 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 use LocalHyphenRawClipboard.

On Desktop and Web the interception follows the same pattern but there are additional native clipboard APIs available as fallbacks.


API Reference

HyphenBasicTextEditor

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.
horizontalPadding Dp 8.dp Internal horizontal padding required to draw blockquote background and border without text overlap.
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.

HyphenTextField (Material 3)

HyphenTextField inherits all parameters from HyphenBasicTextEditor while adding standard Material3 TextField decoration parameters:

Parameter Type Default Description
labelPosition TextFieldLabelPosition Attached() Controls label placement (attached vs above).
label @Composable (() -> Unit)? null Optional label composable.
placeholder @Composable (() -> Unit)? null Optional placeholder composable shown when field is empty.
leadingIcon @Composable (() -> Unit)? null Optional icon composable at start of field.
trailingIcon @Composable (() -> Unit)? null Optional icon composable at end of field.
prefix @Composable (() -> Unit)? null Optional prefix composable before input text.
suffix @Composable (() -> Unit)? null Optional suffix composable after input text.
supportingText @Composable (() -> Unit)? null Optional helper or error text below field.
isError Boolean false Error state flag.
shape Shape TextFieldDefaults.shape Shape of filled container.
colors TextFieldColors TextFieldDefaults.colors() Color mapping across states.
contentPadding PaddingValues contentPaddingWithoutLabel() Outer decoration padding (automatically adjusts for horizontalPadding).

HyphenTextState

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.

HyphenStyleConfig

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(color = Color.Gray)
blockquoteStyle BlockquoteStyle()
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>()

HyphenMentionConfig

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.

TriggerSuggestions

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

SuggestionItem

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).

MarkupStyle

// 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)

ListItemStyle

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.

BlockquoteStyle

Controls the background highlight and border styling of blockquotes.

Property Type Default Description
backgroundColor Color Color.Gray.copy(alpha = 0.08f) Background color of the blockquote highlight block.
borderColor Color Color.Gray.copy(alpha = 0.4f) Color of the thick left border.
borderWidth Dp 4.dp Thickness of the left border.
cornerRadius Dp 4.dp Corner radius of the blockquote highlight background.
borderCornerRadius Dp 2.dp Corner radius of the thick border.

Checklist Styling

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,
        ),
    ),
)

Supported Platforms

Platform Status
Android
Desktop (JVM)
Web (WasmJS)
Web (JS / IR)
iOS 🚧 Planned

About

⌨️ WYSIWYG markdown editor library for Compose Multiplatform. Live formatting, keyboard shortcuts, clipboard, and undo/redo history.

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages