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

Watch full page →

Build a Markdown Editor with Live Preview — Kotlin Compose Desktop

This tutorial demonstrates how to create a split-pane Markdown editor using Kotlin Compose Desktop. It covers parsing markdown text into an abstract syntax tree (AST) with commonmark-java, rendering styled text with AnnotatedString, and implementing live preview alongside editing features like toolbar buttons, keyboard shortcuts, and file operations.

Code

import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.commonmark.node.*
import org.commonmark.parser.Parser
import java.awt.FileDialog
import java.awt.Frame

@Composable
@Preview
fun MarkdownEditorApp() {
  var markdownText by remember { mutableStateOf("") }
  val parser = remember { Parser.builder().build() }
  val document = remember(markdownText) { parser.parse(markdownText) }
  val scrollState = rememberScrollState()

  // Convert AST to AnnotatedString with styles
  fun renderMarkdown(node: Node): AnnotatedString {
    val builder = AnnotatedString.Builder()

    fun visit(node: Node) {
      when (node) {
        is Text -> builder.append(node.literal)
        is Emphasis -> {
          builder.pushStyle(SpanStyle(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic))
          visitChildren(node)
          builder.pop()
        }
        is StrongEmphasis -> {
          builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
          visitChildren(node)
          builder.pop()
        }
        is Heading -> {
          val size = when (node.level) {
            1 -> 30.sp
            2 -> 24.sp
            3 -> 20.sp
            else -> 16.sp
          }
          builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = size))
          visitChildren(node)
          builder.pop()
          builder.append("\n\n")
        }
        is Code -> {
          builder.pushStyle(SpanStyle(background = Color.LightGray, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace))
          builder.append(node.literal)
          builder.pop()
        }
        is Paragraph -> {
          visitChildren(node)
          builder.append("\n\n")
        }
        is Link -> {
          builder.pushStyle(SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline))
          visitChildren(node)
          builder.pop()
        }
        else -> visitChildren(node)
      }
    }

    fun visitChildren(parent: Node) {
      var child = parent.firstChild
      while (child != null) {
        visit(child)
        child = child.next
      }
    }

    visit(node)
    return builder.toAnnotatedString()
  }

  val annotatedPreview = remember(document) { renderMarkdown(document) }

  // Toolbar buttons insert markdown syntax at cursor
  var textFieldValue by remember { mutableStateOf(TextFieldValue(markdownText)) }

  fun insertAtCursor(insertText: String) {
    val selection = textFieldValue.selection
    val text = textFieldValue.text
    val newText = text.substring(0, selection.start) + insertText + text.substring(selection.end)
    val newCursor = selection.start + insertText.length
    textFieldValue = textFieldValue.copy(text = newText, selection = TextRange(newCursor, newCursor))
    markdownText = newText
  }

  // Keyboard shortcuts for bold (Cmd+B) and italic (Cmd+I)
  fun handleKeyEvent(keyEvent: KeyEvent): Boolean {
    if (keyEvent.isMetaPressed && keyEvent.type == KeyEventType.KeyDown) {
      when (keyEvent.key) {
        Key.B -> {
          insertAtCursor("****")
          // Move cursor between the two pairs of asterisks
          val pos = textFieldValue.selection.start - 2
          textFieldValue = textFieldValue.copy(selection = TextRange(pos, pos))
          return true
        }
        Key.I -> {
          insertAtCursor("**")
          val pos = textFieldValue.selection.start - 1
          textFieldValue = textFieldValue.copy(selection = TextRange(pos, pos))
          return true
        }
        else -> return false
      }
    }
    return false
  }

  // File dialogs for New/Open/Save
  fun openFile(): String? {
    val fd = FileDialog(null as Frame?, "Open Markdown File", FileDialog.LOAD)
    fd.isVisible = true
    val file = fd.file ?: return null
    val dir = fd.directory ?: return null
    return java.io.File(dir, file).readText()
  }

  fun saveFile(text: String) {
    val fd = FileDialog(null as Frame?, "Save Markdown File", FileDialog.SAVE)
    fd.isVisible = true
    val file = fd.file ?: return
    val dir = fd.directory ?: return
    java.io.File(dir, file).writeText(text)
  }

  MaterialTheme {
    Column {
      MenuBar {
        Menu("File") {
          Item("New", onClick = {
            markdownText = ""
            textFieldValue = TextFieldValue("")
          })
          Item("Open...", onClick = {
            openFile()?.let {
              markdownText = it
              textFieldValue = TextFieldValue(it)
            }
          })
          Item("Save", onClick = {
            saveFile(markdownText)
          })
        }
      }
      Row(Modifier.fillMaxSize()) {
        Column(Modifier.weight(1f).fillMaxHeight().padding(8.dp)) {
          Row {
            Button(onClick = { insertAtCursor("**bold**") }) { Text("Bold") }
            Spacer(Modifier.width(4.dp))
            Button(onClick = { insertAtCursor("*italic*") }) { Text("Italic") }
            Spacer(Modifier.width(4.dp))
            Button(onClick = { insertAtCursor("`code`") }) { Text("Code") }
          }
          Spacer(Modifier.height(8.dp))
          BasicTextField(
            value = textFieldValue,
            onValueChange = {
              textFieldValue = it
              markdownText = it.text
            },
            modifier = Modifier.fillMaxSize(),
            textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
            onPreviewKeyEvent = { handleKeyEvent(it) }
          )
        }
        Spacer(Modifier.width(8.dp))
        SelectionContainer {
          Box(
            Modifier.weight(1f).fillMaxHeight().padding(8.dp).verticalScroll(scrollState)
          ) {
            Text(annotatedPreview)
          }
          VerticalScrollbar(
            modifier = Modifier.fillMaxHeight().align(Alignment.CenterEnd),
            adapter = rememberScrollbarAdapter(scrollState)
          )
        }
      }
    }
  }
}

Key Points

  • Use commonmark-java to parse markdown text into an AST for structured traversal.
  • Render markdown by recursively visiting AST nodes and applying styles via AnnotatedString.Builder.
  • Manage text editing with TextFieldValue to track cursor and selection for inserting markdown syntax.
  • Implement keyboard shortcuts and toolbar buttons to enhance markdown editing experience.
  • Use MenuBar and java.awt.FileDialog for file operations like New, Open, and Save within Compose Desktop.