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