Last active
August 27, 2024 23:04
-
-
Save ErikHellman/3389d90dda98975a7a59dc3e11a34ee7 to your computer and use it in GitHub Desktop.
A simple rendered for Markdown text parsed using CommonMarks
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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