From 62624aa0256a8f25d7cc9f072e40578494281b37 Mon Sep 17 00:00:00 2001 From: Halil Ozercan Date: Mon, 19 Feb 2024 00:58:06 +0000 Subject: [PATCH] Fix #96 When rendering markdown lists, some list items may return empty an empty child set. This can be due to markdown content like the following ``` * hello * * world ``` In this case, markdown renderer should just put an empty Text in the second item so that `PrefixListLayout` has something to place and report its size correctly. --- .../desktop/{Main.kt => MarkdownSampleApp.kt} | 4 - .../richtext/desktop/RichTextSampleApp.kt | 380 ++++++++++++++++++ .../richtext/markdown/BasicMarkdown.kt | 17 +- 3 files changed, 394 insertions(+), 7 deletions(-) rename desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/{Main.kt => MarkdownSampleApp.kt} (98%) create mode 100644 desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt diff --git a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt similarity index 98% rename from desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt rename to desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt index 903c3e5e..b108d660 100644 --- a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt +++ b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt @@ -18,10 +18,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.Icon import androidx.compose.material.LeadingIconTab -import androidx.compose.material.ProvideTextStyle import androidx.compose.material.Slider import androidx.compose.material.Surface -import androidx.compose.material.Tab import androidx.compose.material.TabRow import androidx.compose.material.Text import androidx.compose.material.icons.Icons @@ -36,9 +34,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import androidx.compose.ui.window.singleWindowApplication import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser diff --git a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt new file mode 100644 index 00000000..cf3e63fa --- /dev/null +++ b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt @@ -0,0 +1,380 @@ +package com.halilibo.richtext.desktop + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.clickable +import androidx.compose.foundation.defaultScrollbarStyle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.singleWindowApplication +import com.halilibo.richtext.ui.BlockQuote +import com.halilibo.richtext.ui.CodeBlock +import com.halilibo.richtext.ui.CodeBlockStyle +import com.halilibo.richtext.ui.FormattedList +import com.halilibo.richtext.ui.Heading +import com.halilibo.richtext.ui.HorizontalRule +import com.halilibo.richtext.ui.InfoPanel +import com.halilibo.richtext.ui.InfoPanelType +import com.halilibo.richtext.ui.ListType +import com.halilibo.richtext.ui.ListType.Ordered +import com.halilibo.richtext.ui.ListType.Unordered +import com.halilibo.richtext.ui.RichTextScope +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.Table +import com.halilibo.richtext.ui.material.RichText +import com.halilibo.richtext.ui.resolveDefaults +import com.halilibo.richtext.ui.string.InlineContent +import com.halilibo.richtext.ui.string.RichTextString.Builder +import com.halilibo.richtext.ui.string.RichTextString.Format +import com.halilibo.richtext.ui.string.RichTextString.Format.Bold +import com.halilibo.richtext.ui.string.RichTextString.Format.Code +import com.halilibo.richtext.ui.string.RichTextString.Format.Italic +import com.halilibo.richtext.ui.string.RichTextString.Format.Link +import com.halilibo.richtext.ui.string.RichTextString.Format.Strikethrough +import com.halilibo.richtext.ui.string.RichTextString.Format.Subscript +import com.halilibo.richtext.ui.string.RichTextString.Format.Superscript +import com.halilibo.richtext.ui.string.RichTextString.Format.Underline +import com.halilibo.richtext.ui.string.Text +import com.halilibo.richtext.ui.string.richTextString +import com.halilibo.richtext.ui.string.withFormat +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun main(): Unit = singleWindowApplication( + title = "RichText KMP" +) { + var richTextStyle by remember { + mutableStateOf( + RichTextStyle( + codeBlockStyle = CodeBlockStyle(wordWrap = true) + ).resolveDefaults() + ) + } + + Surface { + CompositionLocalProvider( + LocalScrollbarStyle provides defaultScrollbarStyle().copy( + hoverColor = Color.DarkGray, + unhoverColor = Color.Gray + ) + ) { + SelectionContainer { + Row( + modifier = Modifier + .padding(32.dp) + .fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + RichTextStyleConfig(richTextStyle = richTextStyle, onChanged = { richTextStyle = it }) + } + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + RichTextDemo(style = richTextStyle) + } + } + } + } + } +} + +@Composable fun RichTextDemo( + style: RichTextStyle? = null, + header: String = "" +) { + RichText( + modifier = Modifier.padding(8.dp), + style = style + ) { + Heading(0, "Paragraphs $header") + Text("Simple paragraph.") + Text("Paragraph with\nmultiple lines.") + Text("Paragraph with really long line that should be getting wrapped.") + TextPreview() + + Heading(0, "Lists") + Heading(1, "Unordered") + ListDemo(listType = Unordered) + Heading(1, "Ordered") + ListDemo(listType = Ordered) + + Heading(0, "Horizontal Line") + Text("Above line") + HorizontalRule() + Text("Below line") + + Heading(0, "Code Block") + CodeBlock( + """ + { + "Hello": "world!" + } + """.trimIndent() + ) + + Heading(0, "Block Quote") + BlockQuote { + Text("These paragraphs are quoted.") + Text("More text.") + BlockQuote { + Text("Nested block quote.") + } + } + + Heading(0, "Info Panel") + InfoPanel(InfoPanelType.Primary, "Only text primary info panel") + InfoPanel(InfoPanelType.Success) { + Column { + Text("Successfully sent some data") + HorizontalRule() + BlockQuote { + Text("This is a quote") + } + } + } + + Heading(0, "Table") + Table( + modifier = Modifier.fillMaxWidth(), + headerRow = { + cell { Text("Column 1") } + cell { Text("Column 2") } + }) { + row { + cell { Text("Hello") } + cell { + CodeBlock("Foo bar") + } + } + row { + cell { + BlockQuote { + Text("Stuff") + } + } + cell { Text("Hello world this is a really long line that is going to wrap hopefully") } + } + } + } +} + +@Composable private fun RichTextScope.ListDemo(listType: ListType) { + FormattedList(listType, + @Composable { + Text("First list item") + FormattedList(listType, + @Composable { Text("Indented 1") } + ) + }, + @Composable { + Text("") + }, + @Composable { + Text("hello") + }, + @Composable { + Text("Second list item.") + FormattedList(listType, + @Composable { Text("Indented 2") } + ) + } + ) +} + +@Composable fun TextPreview() { + var toggleLink by remember { mutableStateOf(false) } + val text = remember(toggleLink) { + richTextString { + appendPreviewSentence(Bold) + appendPreviewSentence(Italic) + appendPreviewSentence(Underline) + appendPreviewSentence(Strikethrough) + appendPreviewSentence(Subscript) + appendPreviewSentence(Superscript) + appendPreviewSentence(Code) + appendPreviewSentence( + Link(""), + if (toggleLink) "clicked link" else "link" + ) + append("Here, ") + appendInlineContent(content = spinningCross) + append(", is an inline image. ") + append("And here, ") + appendInlineContent(content = slowLoadingImage) + append(", is an inline image that loads after some delay.") + append("\n\n") + + append("Here ") + withFormat(Underline) { + append("is a ") + withFormat(Italic) { + append("longer sentence ") + withFormat(Bold) { + append("with many ") + withFormat(Code) { + append("different ") + withFormat(Strikethrough) { + append("nested") + } + append(" ") + } + } + append("styles.") + } + } + } + } + RichText(linkClickHandler = { toggleLink = !toggleLink }) { + Text(text) + } +} + +private val spinningCross = InlineContent { + val angle = remember { Animatable(0f) } + val color = remember { Animatable(Color.Red) } + LaunchedEffect(Unit) { + val angleAnim = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing) + ) + launch { angle.animateTo(360f, angleAnim) } + + val colorAnim = infiniteRepeatable( + animation = keyframes { + durationMillis = 2500 + Color.Blue at 500 + Color.Cyan at 1000 + Color.Green at 1500 + Color.Magenta at 2000 + } + ) + launch { color.animateTo(Color.Yellow, colorAnim) } + } + + Canvas(modifier = Modifier + .size(12.sp.toDp(), 12.sp.toDp()) + .padding(2.dp)) { + withTransform({ rotate(angle.value, center) }) { + val strokeWidth = 3.dp.toPx() + val strokeCap = StrokeCap.Round + drawLine( + color.value, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = strokeWidth, + cap = strokeCap + ) + drawLine( + color.value, + start = Offset(size.width / 2, 0f), + end = Offset(size.width / 2, size.height), + strokeWidth = strokeWidth, + cap = strokeCap + ) + } + } +} + +val slowLoadingImage = InlineContent { + var loaded by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(loaded) { + if (!loaded) { + delay(3000) + loaded = true + } + } + + if (!loaded) { + LoadingSpinner() + } else { + Box(Modifier.clickable(onClick = { loaded = false })) { + val size = remember { Animatable(16f) } + LaunchedEffect(Unit) { size.animateTo(100f) } + Picture(Modifier.size(size.value.sp.toDp())) + Text( + "click to refresh", + modifier = Modifier + .padding(3.dp) + .align(Alignment.Center), + fontSize = 8.sp, + style = TextStyle(background = Color.LightGray) + ) + } + } +} + +@Composable private fun LoadingSpinner() { + val alpha = remember { Animatable(1f) } + LaunchedEffect(Unit) { + val anim = infiniteRepeatable( + animation = keyframes { + durationMillis = 500 + 0f at 250 + 1f at 500 + }) + alpha.animateTo(0f, anim) + } + Text( + "⏳", + fontSize = 3.em, + modifier = Modifier + .wrapContentSize(Alignment.Center) + .graphicsLayer(alpha = alpha.value) + ) +} + +@Composable private fun Picture(modifier: Modifier) { + Canvas(modifier) { + drawRect(Color.LightGray) + drawLine(Color.Red, Offset(0f, 0f), Offset(size.width, size.height)) + drawLine(Color.Red, Offset(0f, size.height), Offset(size.width, 0f)) + } +} + +@OptIn(ExperimentalStdlibApi::class) +private fun Builder.appendPreviewSentence( + format: Format, + text: String = format.javaClass.simpleName.replaceFirstChar { it.lowercase() } +) { + append("Here is some ") + withFormat(format) { + append(text) + } + append(" text. ") +} + diff --git a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt index 7f3f0391..36c590b5 100644 --- a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt +++ b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt @@ -1,5 +1,6 @@ package com.halilibo.richtext.markdown +import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.heading @@ -88,8 +89,13 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst(astNode: AstNode?) { FormattedList( listType = Unordered, items = astNode.filterChildrenType().toList() - ) { - visitChildren(it) + ) { astListItem -> + // if this list item has no child, it should at least emit a single pixel layout. + if (astListItem.links.firstChild == null) { + BasicText("") + } else { + visitChildren(astListItem) + } } } is AstOrderedList -> { @@ -98,7 +104,12 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst(astNode: AstNode?) { items = astNode.childrenSequence().toList(), startIndex = astNodeType.startNumber - 1, ) { astListItem -> - visitChildren(astListItem) + // if this list item has no child, it should at least emit a single pixel layout. + if (astListItem.links.firstChild == null) { + BasicText("") + } else { + visitChildren(astListItem) + } } } is AstThematicBreak -> {