Skip to content

Instantly share code, notes, and snippets.

@ErikHellman
Last active November 22, 2023 12:16
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ErikHellman/3389d90dda98975a7a59dc3e11a34ee7 to your computer and use it in GitHub Desktop.
Save ErikHellman/3389d90dda98975a7a59dc3e11a34ee7 to your computer and use it in GitHub Desktop.
A simple rendered for Markdown text parsed using CommonMarks
package se.hellsoft.markdowncomposer
import android.util.Log
import androidx.compose.foundation.Box
import androidx.compose.foundation.ContentGravity
import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.gesture.tapGestureFilter
import androidx.compose.ui.platform.UriHandlerAmbient
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.annotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import dev.chrisbanes.accompanist.coil.CoilImage
import org.commonmark.node.BlockQuote
import org.commonmark.node.BulletList
import org.commonmark.node.Code
import org.commonmark.node.Document
import org.commonmark.node.Emphasis
import org.commonmark.node.FencedCodeBlock
import org.commonmark.node.HardLineBreak
import org.commonmark.node.Heading
import org.commonmark.node.Image
import org.commonmark.node.IndentedCodeBlock
import org.commonmark.node.Link
import org.commonmark.node.ListBlock
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.node.ThematicBreak
/**
* These functions will render a tree of Markdown nodes parsed with CommonMark.
* Images will be rendered using Chris Banes Accompanist library (which uses Coil)
*
* To use this, you need the following two dependencies:
* implementation "com.atlassian.commonmark:commonmark:0.15.2"
* implementation "dev.chrisbanes.accompanist:accompanist-coil:0.2.0"
*
* The following is an example of how to use this:
* ```
* val parser = Parser.builder().build()
* val root = parser.parse(MIXED_MD) as Document
* val markdownComposer = MarkdownComposer()
*
* MarkdownComposerTheme {
* MDDocument(root)
* }
* ```
*/
private const val TAG_URL = "url"
private const val TAG_IMAGE_URL = "imageUrl"
@Composable
fun MDDocument(document: Document) {
MDBlockChildren(document)
}
@Composable
fun MDHeading(heading: Heading) {
val style = when (heading.level) {
1 -> MaterialTheme.typography.h1
2 -> MaterialTheme.typography.h2
3 -> MaterialTheme.typography.h3
4 -> MaterialTheme.typography.h4
5 -> MaterialTheme.typography.h5
6 -> MaterialTheme.typography.h6
else -> {
// Invalid header...
MDBlockChildren(heading)
return
}
}
val padding = if (heading.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding) {
val text = annotatedString {
inlineChildren(heading, this, MaterialTheme.colors)
}
MarkdownText(text, style)
}
}
@Composable
fun MDParagraph(paragraph: Paragraph) {
if (paragraph.firstChild is Image && paragraph.firstChild == paragraph.lastChild) {
// Paragraph with single image
MDImage(paragraph.firstChild as Image)
} else {
val padding = if (paragraph.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding) {
val styledText = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
inlineChildren(paragraph, this, MaterialTheme.colors)
pop()
}
MarkdownText(styledText, MaterialTheme.typography.body1)
}
}
}
@Composable
fun MDImage(image: Image) {
Box(modifier = Modifier.fillMaxWidth(), gravity = ContentGravity.Center) {
CoilImage(data = image.destination)
}
}
@Composable
fun MDBulletList(bulletList: BulletList) {
val marker = bulletList.bulletMarker
MDListItems(bulletList) {
val text = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("$marker ")
inlineChildren(it, this, MaterialTheme.colors)
pop()
}
MarkdownText(text, MaterialTheme.typography.body1)
}
}
@Composable
fun MDOrderedList(orderedList: OrderedList) {
var number = orderedList.startNumber
val delimiter = orderedList.delimiter
MDListItems(orderedList) {
val text = annotatedString {
pushStyle(MaterialTheme.typography.body1.toSpanStyle())
append("${number++}$delimiter ")
inlineChildren(it, this, MaterialTheme.colors)
pop()
}
MarkdownText(text, MaterialTheme.typography.body1)
}
}
@Composable
fun MDListItems(listBlock: ListBlock, item: @Composable (node: Node) -> Unit) {
val bottom = if (listBlock.parent is Document) 8.dp else 0.dp
val start = if (listBlock.parent is Document) 0.dp else 8.dp
Box(paddingBottom = bottom, paddingStart = start) {
var listItem = listBlock.firstChild
while (listItem != null) {
var child = listItem.firstChild
while (child != null) {
when (child) {
is BulletList -> MDBulletList(child)
is OrderedList -> MDOrderedList(child)
else -> item(child)
}
child = child.next
}
listItem = listItem.next
}
}
}
@Composable
fun MDBlockQuote(blockQuote: BlockQuote) {
val color = MaterialTheme.colors.onBackground
Box(modifier = Modifier.drawBehind {
drawLine(
color = color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height)
)
}, paddingStart = 16.dp, paddingTop = 4.dp, paddingBottom = 4.dp) {
val text = annotatedString {
pushStyle(
MaterialTheme.typography.body1.toSpanStyle()
.plus(SpanStyle(fontStyle = FontStyle.Italic))
)
inlineChildren(blockQuote, this, MaterialTheme.colors)
pop()
}
Text(text)
}
}
@Composable
fun MDFencedCodeBlock(fencedCodeBlock: FencedCodeBlock) {
val padding = if (fencedCodeBlock.parent is Document) 8.dp else 0.dp
Box(paddingBottom = padding, paddingStart = 8.dp) {
Text(
text = fencedCodeBlock.literal,
style = TextStyle(fontFamily = FontFamily.Monospace)
)
}
}
@Composable
fun MDIndentedCodeBlock(indentedCodeBlock: IndentedCodeBlock) {
// Ignored
}
@Composable
fun MDThematicBreak(thematicBreak: ThematicBreak) {
//Ignored
}
@Composable
fun MDBlockChildren(parent: Node) {
var child = parent.firstChild
while (child != null) {
when (child) {
is Document -> MDDocument(child)
is BlockQuote -> MDBlockQuote(child)
is ThematicBreak -> MDThematicBreak(child)
is Heading -> MDHeading(child)
is Paragraph -> MDParagraph(child)
is FencedCodeBlock -> MDFencedCodeBlock(child)
is IndentedCodeBlock -> MDIndentedCodeBlock(child)
is Image -> MDImage(child)
is BulletList -> MDBulletList(child)
is OrderedList -> MDOrderedList(child)
}
child = child.next
}
}
fun inlineChildren(
parent: Node,
annotatedString: AnnotatedString.Builder,
colors: Colors
) {
var child = parent.firstChild
while (child != null) {
when (child) {
is Paragraph -> inlineChildren(
child,
annotatedString,
colors
)
is Text -> annotatedString.append(child.literal)
is Image -> {
annotatedString.appendInlineContent(TAG_IMAGE_URL, child.destination)
}
is Emphasis -> {
annotatedString.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
}
is StrongEmphasis -> {
annotatedString.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
}
is Code -> {
annotatedString.pushStyle(TextStyle(fontFamily = FontFamily.Monospace).toSpanStyle())
annotatedString.append(child.literal)
annotatedString.pop()
}
is HardLineBreak -> {
annotatedString.pushStyle(TextStyle(fontFamily = FontFamily.Monospace).toSpanStyle())
annotatedString.append("\n")
annotatedString.pop()
}
is Link -> {
annotatedString.pushStyle(
SpanStyle(
color = colors.primary,
textDecoration = TextDecoration.Underline
)
)
annotatedString.pushStringAnnotation(TAG_URL, child.destination)
inlineChildren(
child,
annotatedString,
colors
)
annotatedString.pop()
annotatedString.pop()
}
}
child = child.next
}
}
@Composable
fun MarkdownText(text: AnnotatedString, style: TextStyle) {
val uriHandler = UriHandlerAmbient.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
Text(text = text,
modifier = Modifier.tapGestureFilter { pos ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(pos)
text.getStringAnnotations(position, position)
.firstOrNull()
?.let { sa ->
if (sa.tag == TAG_URL) {
uriHandler.openUri(sa.item)
}
}
}
},
style = style,
inlineContent = mapOf(
TAG_IMAGE_URL to InlineTextContent(
Placeholder(
width = style.fontSize,
height = style.fontSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom
)
) {
CoilImage(it, alignment = Alignment.Center)
}
),
onTextLayout = { layoutResult.value = it }
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment