Back to Blog

Build a Markdown Editor with Live Preview — Kotlin Compose Desktop | Lesson 17

Sandy LaneSandy Lane

Video: Build a Markdown Editor with Live Preview — Kotlin Compose Desktop | Lesson 17 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Markdown Editor with Live Preview

CommonMark for parsing, AnnotatedString for 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 OutlinedTextField with 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.