Build a Markdown Editor with Live Preview — Kotlin Compose Desktop | Lesson 17
Video: Build a Markdown Editor with Live Preview — Kotlin Compose Desktop | Lesson 17 by Taught by Celeste AI - AI Coding Coach
Markdown Editor with Live Preview
CommonMark for parsing,
AnnotatedStringfor styled rendering, two-pane layout. Type markdown on the left, see the rendered output on the right — fully real-time.
Today's app is a small markdown editor with a live preview pane. Type **bold** on the left → see bold on the right, instantly. Toolbar buttons and keyboard shortcuts (Cmd+B, Cmd+I) wrap the selection in formatting characters.
This builds on the simple markdown editor from Lesson 5, adding a real CommonMark parser and styled rendering instead of regex hacks.
What we are building
A 1100×700 window split 50/50:
- Left: an
OutlinedTextFieldwith a small toolbar (Bold, Italic, Heading, Code). - Right: the rendered output as styled text — headings sized and coloured, bold and italic applied, code blocks in monospace, links underlined.
Project layout
src/main/kotlin/
├── Main.kt
├── AppContent.kt # two-pane layout
├── MarkdownEditor.kt # left pane: textfield + toolbar + shortcuts
├── MarkdownPreview.kt # right pane: AnnotatedString rendering
└── MarkdownParser.kt # CommonMark → AnnotatedString
Why CommonMark, not regex
Lesson 5 parsed markdown with regex. That works for **bold** and # heading, but fails on nested formatting (**bold *italic* text**), code blocks containing markdown-like characters, and tons of edge cases.
CommonMark is the standard markdown spec; org.commonmark:commonmark is a JVM library that implements it correctly. Add to build.gradle.kts:
implementation("org.commonmark:commonmark:0.21.0")
The parser produces an AST. We walk the AST and emit Compose AnnotatedString spans.
Parser walk
fun parseMarkdown(text: String): AnnotatedString {
val parser = Parser.builder().build()
val document = parser.parse(text)
val builder = AnnotatedString.Builder()
visitNode(document, builder, emptyList())
return builder.toAnnotatedString()
}
private fun visitNode(node: Node, builder: AnnotatedString.Builder, activeStyles: List<SpanStyle>) {
when (node) {
is Document -> visitChildren(node, builder, activeStyles)
is Heading -> {
val style = when (node.level) {
1 -> SpanStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, color = HeadingColor)
2 -> SpanStyle(fontSize = 26.sp, fontWeight = FontWeight.Bold, color = HeadingColor)
else -> SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = HeadingColor)
}
builder.pushStyle(style)
visitChildren(node, builder, activeStyles + style)
builder.pop()
builder.append("\n\n")
}
is StrongEmphasis -> {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
visitChildren(node, builder, activeStyles + style)
builder.pop()
}
// ... Paragraph, Emphasis, Code, Link, BlockQuote, etc.
is Text -> builder.append(node.literal)
}
}
A classic visitor pattern. Recurse through the AST; for each node type, apply the right SpanStyle and append text.
AnnotatedString basics
val builder = AnnotatedString.Builder()
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append("Hello ")
builder.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
builder.append("nested ")
builder.pop() // pops italic
builder.append("text")
builder.pop() // pops bold
val annotated = builder.toAnnotatedString()
pushStyle / pop is a stack — perfect for nested markdown formatting where bold can contain italic. Each text segment gets all the active styles merged.
AnnotatedString is what Text(...) accepts when you want styled spans. Renders the same way as a plain String but with per-span styling.
Heading levels
val style = when (node.level) {
1 -> SpanStyle(fontSize = 32.sp, fontWeight = FontWeight.Bold, color = HeadingColor)
2 -> SpanStyle(fontSize = 26.sp, fontWeight = FontWeight.Bold, color = HeadingColor)
3 -> SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = HeadingColor)
else -> SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = HeadingColor)
}
Different sizes per level. \n\n after the heading separates it from following content. The exact sizes are taste — MaterialTheme.typography.headlineLarge.fontSize reads from the theme if you want consistency with the rest of your app.
Code blocks vs inline code
is Code -> {
builder.pushStyle(SpanStyle(
fontFamily = FontFamily.Monospace,
color = CodeColor,
background = Color(0xFF2D2D2D),
))
builder.append(node.literal)
builder.pop()
}
is FencedCodeBlock -> {
builder.pushStyle(SpanStyle(
fontFamily = FontFamily.Monospace,
color = CodeColor,
background = Color(0xFF2D2D2D),
))
builder.append(node.literal.trimEnd())
builder.pop()
builder.append("\n\n")
}
Code is inline (backtick-wrapped). FencedCodeBlock is the triple-backtick block. Same monospace + dark background; the block adds a trailing newline gap.
For real syntax highlighting, you'd integrate a library like Compose Highlight or run a tokeniser per language. Out of scope here — the monochrome code block is fine for a preview.
Bullet and ordered lists
is BulletList -> {
visitChildren(node, builder, activeStyles)
if (node.next != null) builder.append("\n")
}
is OrderedList -> {
var index = node.startNumber
var child = node.firstChild
while (child != null) {
builder.append("${index}. ")
visitChildren(child, builder, activeStyles)
builder.append("\n")
index++
child = child.next
}
}
is ListItem -> {
if (node.parent is BulletList) builder.append(" • ")
visitChildren(node, builder, activeStyles)
builder.append("\n")
}
Bullets render as a literal • character. Ordered lists track an index manually. Real Compose UI rendering of a list would build a Column with bullet markers — but for AnnotatedString-based preview, inline characters are simpler and good enough.
Editor: TextFieldValue, not String
@Composable
fun MarkdownEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
) { /* ... */ }
TextFieldValue carries text and selection (TextRange). Toolbar buttons need the selection to wrap the right span; a plain String would lose it.
Inserting markdown around the selection
fun insertMarkdown(
value: TextFieldValue,
prefix: String,
suffix: String,
): TextFieldValue {
val selection = value.selection
val text = value.text
val selectedText = text.substring(selection.min, selection.max)
val newText = text.substring(0, selection.min) +
prefix + selectedText + suffix +
text.substring(selection.max)
val newCursor = if (selectedText.isEmpty()) {
selection.min + prefix.length
} else {
selection.min + prefix.length + selectedText.length + suffix.length
}
return TextFieldValue(text = newText, selection = TextRange(newCursor))
}
Three substrings: before the selection, the wrapped middle, after. Cursor goes to:
- After the prefix if the user had no selection (so they can type inside
**|**). - After the suffix if there was a selection (so they can keep writing).
The same helper handles bold (**/**), italic (*/*), code (`/`), and heading (##/empty).
Toolbar
Row {
IconButton(onClick = { onValueChange(insertMarkdown(value, "**", "**")) }) {
Icon(Icons.Default.FormatBold, "Bold")
}
IconButton(onClick = { onValueChange(insertMarkdown(value, "*", "*")) }) {
Icon(Icons.Default.FormatItalic, "Italic")
}
IconButton(onClick = { onValueChange(insertMarkdown(value, "## ", "")) }) {
Icon(Icons.Default.Title, "Heading")
}
IconButton(onClick = { onValueChange(insertMarkdown(value, "`", "`")) }) {
Icon(Icons.Default.Code, "Code")
}
}
Four icon buttons, each calling insertMarkdown with the right prefix/suffix. The materialIconsExtended artifact has hundreds of these icons.
Keyboard shortcuts
modifier = Modifier.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.isMetaPressed) {
when {
event.key == Key.B -> { onValueChange(insertMarkdown(value, "**", "**")); true }
event.key == Key.I -> { onValueChange(insertMarkdown(value, "*", "*")); true }
event.key == Key.E -> { onValueChange(insertMarkdown(value, "`", "`")); true }
event.isShiftPressed && event.key == Key.H -> {
onValueChange(insertMarkdown(value, "## ", "")); true
}
else -> false
}
} else false
}
event.isMetaPressed is Cmd on macOS, Win on Windows. For Ctrl-based shortcuts on Windows/Linux, check event.isCtrlPressed. Cross-platform: check both.
Returning true from onPreviewKeyEvent consumes the event so the field doesn't insert the letter B/I/etc.
Live preview
@Composable
fun MarkdownPreview(markdown: String, modifier: Modifier = Modifier) {
val annotated = remember(markdown) { parseMarkdown(markdown) }
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp)) {
Text(annotated)
}
}
}
remember(markdown) { parseMarkdown(markdown) } re-parses only when the source changes — typing 100 characters parses 100 times, but that's fine; CommonMark parsing is fast. For very long documents you'd debounce: parse 200ms after the user stops typing.
The two-pane layout
Row(modifier = Modifier.fillMaxSize()) {
MarkdownEditor(value = value, onValueChange = onValueChange, modifier = Modifier.weight(1f))
MarkdownPreview(markdown = value.text, modifier = Modifier.weight(1f).padding(start = 8.dp))
}
Modifier.weight(1f) on each pane gives them equal width. To make the divider draggable, replace with a custom split-pane composable that updates a weight state — Compose has no built-in SplitPane, so it's a small custom component.
Single source of truth
var textFieldValue by remember { mutableStateOf(TextFieldValue(sampleMarkdown)) }
AppContent(value = textFieldValue, onValueChange = { textFieldValue = it })
The state lives in the parent. Both panes read from the same textFieldValue. The editor writes; the preview reads value.text. Classic state hoisting.
Common mistakes
Re-parsing on every recomposition. A naked parseMarkdown(text) runs on every render — wasteful for keypress-rate updates. remember(text) { ... } caches.
Using String for the editor instead of TextFieldValue. Loses selection. The toolbar can't wrap selections without it.
Returning false from a consumed shortcut. The field then also inserts the letter. Always return true if you handled the key.
Forgetting \n\n after block elements. Headings, paragraphs, blockquotes need trailing whitespace; otherwise the next block crashes against them.
Trying to render headings as Composables. AnnotatedString is a single text run — can't include Cards or Images. For mixed content, build a list of (kind, content) pairs and render each in the appropriate Composable.
What's next
Lesson 18: system integration. Clipboard read/write, run shell commands, capture stdout/stderr in real-time, and trigger desktop notifications. The bridge from "self-contained app" to "OS citizen."
Recap
CommonMark Parser.builder().build().parse(text) produces an AST. Visit it recursively; for each node type, push the matching SpanStyle onto an AnnotatedString.Builder. TextFieldValue(text, selection) lets the toolbar wrap the current selection. Modifier.onPreviewKeyEvent for keyboard shortcuts. remember(text) { parseMarkdown(text) } to memoise the parse.
Next lesson: clipboard, commands, and notifications.