From 3da185ab484427304ff71f0b2ee1e82c3373ff7e Mon Sep 17 00:00:00 2001 From: Halil Ozercan Date: Sun, 11 Feb 2024 02:14:25 +0000 Subject: [PATCH] Add Lazy Markdown Sample New API allows apps to render long Markdown documents lazily at the top level. --- .../richtext/sample/LazyMarkdownSample.kt | 435 ++++++++++++++++++ .../richtext/sample/SampleLauncher.kt | 1 + .../com/halilibo/richtext/desktop/Main.kt | 101 +++- 3 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt new file mode 100644 index 00000000..b454c50f --- /dev/null +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt @@ -0,0 +1,435 @@ +package com.zachklipp.richtext.sample + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.Checkbox +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser +import com.halilibo.richtext.commonmark.MarkdownParseOptions +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.markdown.node.AstDocument +import com.halilibo.richtext.markdown.node.AstNode +import com.halilibo.richtext.ui.RichTextScope +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.currentRichTextStyle +import com.halilibo.richtext.ui.material.RichText +import com.halilibo.richtext.ui.resolveDefaults + +@Preview +@Composable private fun LazyMarkdownSamplePreview() { + LazyMarkdownSample() +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable fun LazyMarkdownSample() { + var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) } + var isDarkModeEnabled by remember { mutableStateOf(false) } + var isWordWrapEnabled by remember { mutableStateOf(true) } + var markdownParseOptions by remember { mutableStateOf(MarkdownParseOptions.Default) } + var isAutolinkEnabled by remember { mutableStateOf(true) } + + LaunchedEffect(isWordWrapEnabled) { + richTextStyle = richTextStyle.copy( + codeBlockStyle = richTextStyle.codeBlockStyle!!.copy( + wordWrap = isWordWrapEnabled + ) + ) + } + LaunchedEffect(isAutolinkEnabled) { + markdownParseOptions = markdownParseOptions.copy( + autolink = isAutolinkEnabled + ) + } + + val colors = if (isDarkModeEnabled) darkColors() else lightColors() + val context = LocalContext.current + + MaterialTheme(colors = colors) { + Surface { + Column { + // Config + Card(elevation = 4.dp) { + Column { + FlowRow { + CheckboxPreference( + onClick = { + isDarkModeEnabled = !isDarkModeEnabled + }, + checked = isDarkModeEnabled, + label = "Dark Mode" + ) + CheckboxPreference( + onClick = { + isWordWrapEnabled = !isWordWrapEnabled + }, + checked = isWordWrapEnabled, + label = "Word Wrap" + ) + CheckboxPreference( + onClick = { + isAutolinkEnabled = !isAutolinkEnabled + }, + checked = isAutolinkEnabled, + label = "Autolink" + ) + } + + RichTextStyleConfig( + richTextStyle = richTextStyle, + onChanged = { richTextStyle = it } + ) + } + } + + SelectionContainer { + ProvideTextStyle(TextStyle(lineHeight = 1.3.em)) { + val parser = remember(markdownParseOptions) { + CommonmarkAstNodeParser(markdownParseOptions) + } + + val astNode = remember(parser) { + parser.parse(sampleMarkdown) + } + + RichText( + style = richTextStyle, + linkClickHandler = { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.padding(8.dp), + ) { + LazyMarkdown(astNode) + } + } + } + } + } + } +} + +/** + * A function that renders Markdown content lazily at the top level. All markdown trees start with + * an AstDocument. If a document is long enough, usually there are more than hundred child nodes + * under the root. Then in turn rendering the whole content into the internal column of + * `BasicRichText` becomes extremely inefficient. Instead, this renderer at least relieves the top + * level rendering by turning the internal column into a LazyColumn. All other nodes below the + * first level are rendered as usual. + * + * @param astNode Root node of Markdown tree. This can be obtained via a parser. + */ +@Composable +fun RichTextScope.LazyMarkdown(astNode: AstNode) { + require(astNode.type == AstDocument) { + "Lazy Markdown rendering requires root level node to have a type of AstDocument." + } + // keep the same blockSpacing + val currentStyle = currentRichTextStyle + val resolvedStyle = remember(currentStyle) { currentStyle.resolveDefaults() } + val blockSpacing = with(LocalDensity.current) { + resolvedStyle.paragraphSpacing!!.toDp() + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(blockSpacing)) { + var iter = astNode.links.firstChild + while (iter != null) { + // We need to store iter in a final variable because composition of `item` happens after + // iteration + val node = iter + item { + BasicMarkdown(node) + } + iter = iter.links.next + } + } +} + +@Composable +private fun CheckboxPreference( + onClick: () -> Unit, + checked: Boolean, + label: String +) { + Row( + Modifier.clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = { onClick() }, + ) + Text(label) + } +} + +private val sampleMarkdown = """ + # Demo + Based on [this cheatsheet][cheatsheet] + + --- + + ## Headers + --- + # Header 1 + ## Header 2 + ### Header 3 + #### Header 4 + ##### Header 5 + ###### Header 6 + --- + + ## Full-bleed Image + ![](https://upload.wikimedia.org/wikipedia/commons/thumb/b/b6/Image_created_with_a_mobile_phone.png/1920px-Image_created_with_a_mobile_phone.png) + + ## Images smaller than the width should center + ![](https://cdn.nostr.build/p/4a84.png) + + On LineHeight bug, the image below goes over this text. + ![](https://cdn.nostr.build/p/PxZ0.jpg) + + ## Emphasis + + Emphasis, aka italics, with *asterisks* or _underscores_. + + Strong emphasis, aka bold, with **asterisks** or __underscores__. + + Combined emphasis with **asterisks and _underscores_**. + + --- + + ## Lists + 1. First ordered list item + 2. Another item + * Unordered sub-list. + 1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list + 4. And another item. + + You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + + To have a line break without a paragraph, you will need to use two trailing spaces. + Note that this line is separate, but within the same paragraph. + (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + + * Unordered list can use asterisks + - Or minuses + + Or pluses + + 2. Ordered list starting with `2.` + 3. Another item + + 0. Ordered list starting with `0.` + + 003. Ordered list starting with `003.` + + -1. Starting with `-1.` should not be list + + + --- + + ## Links + + [I'm an inline-style link](https://www.google.com) + + [I'm a reference-style link][Arbitrary case-insensitive reference text] + + [I'm a relative reference to a repository file](../blob/master/LICENSE) + + [You can use numbers for reference-style link definitions][1] + + Or leave it empty and use the [link text itself]. + + Autolink option will detect text links like https://www.google.com and turn them into Markdown links automatically. + + --- + + ## Code + + Inline `code` has `back-ticks around` it. + + ```javascript + var s = "JavaScript syntax highlighting"; + alert(s); + ``` + + ```python + s = "Python syntax highlighting" + print s + ``` + + ```java + /** + * Helper method to obtain a Parser with registered strike-through & table extensions + * & task lists (added in 1.0.1) + * + * @return a Parser instance that is supported by this library + * @since 1.0.0 + */ + @NonNull + public static Parser createParser() { + return new Parser.Builder() + .extensions(Arrays.asList( + StrikethroughExtension.create(), + TablesExtension.create(), + TaskListExtension.create() + )) + .build(); + } + ``` + + ```xml + + + + + + ``` + + ``` + No language indicated, so no syntax highlighting. + But let's throw in a tag. + ``` + + --- + + ## Images + + Inline-style: + + ![random image](https://picsum.photos/seed/picsum/400/400) + + ![random image](https://picsum.photos/seed/picsum/400/400 "Text 1") + + Reference-style: + + ![random image][logo] + + [logo]: https://picsum.photos/seed/picsum2/400/400 "Text 2" + + --- + + ## Tables + + Colons can be used to align columns. + + | Tables | Are | Cool | + | ------------- |:-------------:| -----:| + | col 3 is | right-aligned | ${'$'}1600 | + | col 2 is | centered | ${'$'}12 | + | zebra stripes | are neat | ${'$'}1 | + + There must be at least 3 dashes separating each header cell. + The outer pipes (|) are optional, and you don't need to make the + raw Markdown line up prettily. You can also use inline Markdown. + + Markdown | Less | Pretty + --- | --- | --- + *Still* | `renders` | ![random image](https://picsum.photos/seed/picsum/400/400 "Text 1") + 1 | 2 | 3 + + --- + + ## Blockquotes + + > Blockquotes are very handy in email to emulate reply text. + > This line is part of the same quote. + + Quote break. + + > This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + + Nested quotes + > Hello! + >> And to you! + + --- + + ## Inline HTML + + ```html + HTML + ``` + + HTML + + --- + + ## Horizontal Rule + + Three or more... + + --- + + Hyphens (`-`) + + *** + + Asterisks (`*`) + + ___ + + Underscores (`_`) + + + ## License + + ``` + Copyright 2019 Dimitry Ivanov (legal@noties.io) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ``` + + [cheatsheet]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet + + [arbitrary case-insensitive reference text]: https://www.mozilla.org + [1]: http://slashdot.org + [link text itself]: http://www.reddit.com +""".trimIndent() \ No newline at end of file diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt index 886f8ac6..3ddfa416 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp private val Samples = listOf Unit>>( "RichText Demo" to @Composable { RichTextSample() }, "Markdown Demo" to @Composable { MarkdownSample() }, + "Lazy Markdown Demo" to @Composable { LazyMarkdownSample() }, "Pagination" to @Composable { PagedSample() }, "Printable Document" to @Composable { DocumentSample() }, "Slideshow" to @Composable { SlideshowSample() }, diff --git a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt index 594663de..24994c90 100644 --- a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt +++ b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/Main.kt @@ -10,14 +10,23 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.selection.DisableSelection 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 +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Info import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -26,14 +35,21 @@ import androidx.compose.runtime.remember 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 import com.halilibo.richtext.commonmark.Markdown +import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.markdown.node.AstDocument +import com.halilibo.richtext.markdown.node.AstNode import com.halilibo.richtext.ui.CodeBlockStyle +import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.currentRichTextStyle import com.halilibo.richtext.ui.material.RichText import com.halilibo.richtext.ui.resolveDefaults @@ -76,16 +92,48 @@ fun main(): Unit = singleWindowApplication( ) } ProvideTextStyle(TextStyle(lineHeight = 1.3.em)) { - RichText( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), - style = richTextStyle, - linkClickHandler = { - println("Link clicked destination=$it") + var selectedTab by remember { mutableStateOf(0) } + Column(Modifier.weight(1f)) { + DisableSelection { + TabRow(selectedTab) { + LeadingIconTab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Normal") }, + icon = { Icon(Icons.Default.Info, "") }) + LeadingIconTab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Lazy") }, + icon = { Icon(Icons.Default.Favorite, "") }) + } + } + if (selectedTab == 0) { + RichText( + modifier = Modifier.verticalScroll(rememberScrollState()), + style = richTextStyle, + linkClickHandler = { + println("Link clicked destination=$it") + } + ) { + Markdown(content = text) + } + } else { + val parser = remember { CommonmarkAstNodeParser() } + + val astNode = remember(parser) { + parser.parse(sampleMarkdown) + } + + RichText( + style = richTextStyle, + linkClickHandler = { + println("Link clicked destination=$it") + } + ) { + LazyMarkdown(astNode) + } } - ) { - Markdown(content = text) } } } @@ -94,6 +142,41 @@ fun main(): Unit = singleWindowApplication( } } +/** + * A function that renders Markdown content lazily at the top level. All markdown trees start with + * an AstDocument. If a document is long enough, usually there are more than hundred child nodes + * under the root. Then in turn rendering the whole content into the internal column of + * `BasicRichText` becomes extremely inefficient. Instead, this renderer at least relieves the top + * level rendering by turning the internal column into a LazyColumn. All other nodes below the + * first level are rendered as usual. + * + * @param astNode Root node of Markdown tree. This can be obtained via a parser. + */ +@Composable +fun RichTextScope.LazyMarkdown(astNode: AstNode) { + require(astNode.type == AstDocument) { + "Lazy Markdown rendering requires root level node to have a type of AstDocument." + } + // keep the same blockSpacing + val currentStyle = currentRichTextStyle + val resolvedStyle = remember(currentStyle) { currentStyle.resolveDefaults() } + val blockSpacing = with(LocalDensity.current) { + resolvedStyle.paragraphSpacing!!.toDp() + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(blockSpacing)) { + var iter = astNode.links.firstChild + while (iter != null) { + // We need to store iter in a final variable because composition of `item` happens after + // iteration + val node = iter + item { + BasicMarkdown(node) + } + iter = iter.links.next + } + } +} + @Composable fun RichTextStyleConfig( richTextStyle: RichTextStyle,