├── richtext-ui ├── src │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── ui │ │ │ └── CodeBlock.android.kt │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── ui │ │ │ ├── util │ │ │ ├── UUID.kt │ │ │ └── AnnotatedStringSegmenter.kt │ │ │ ├── RichTextScope.kt │ │ │ ├── string │ │ │ ├── RichTextRenderOptions.kt │ │ │ └── InlineContent.kt │ │ │ ├── BasicRichText.kt │ │ │ ├── MarkdownAnimation.kt │ │ │ ├── HorizontalRule.kt │ │ │ ├── RichTextThemeConfiguration.kt │ │ │ ├── RichTextThemeProvider.kt │ │ │ ├── TableLayout.kt │ │ │ ├── TableMeasurer.kt │ │ │ ├── Heading.kt │ │ │ ├── RichTextLocals.kt │ │ │ ├── RichTextStyle.kt │ │ │ ├── InfoPanel.kt │ │ │ ├── CodeBlock.kt │ │ │ ├── BlockQuote.kt │ │ │ └── Table.kt │ ├── commonJvmAndroid │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── ui │ │ │ └── util │ │ │ └── UUID.kt │ ├── commonJvmAndroidTest │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── ui │ │ │ └── util │ │ │ └── AnnotatedStringSegmenterTest.kt │ └── jvmMain │ │ └── kotlin │ │ └── com │ │ └── halilibo │ │ └── richtext │ │ └── ui │ │ └── CodeBlock.desktop.kt ├── gradle.properties └── build.gradle.kts ├── richtext-markdown ├── src │ ├── androidMain │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── markdown │ │ │ ├── HtmlBlock.kt │ │ │ └── MarkdownImage.kt │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── markdown │ │ │ ├── HtmlBlock.kt │ │ │ ├── MarkdownImage.kt │ │ │ ├── node │ │ │ ├── AstTable.kt │ │ │ ├── AstNode.kt │ │ │ ├── AstNodeLinks.kt │ │ │ └── AstNodeType.kt │ │ │ ├── TraverseUtils.kt │ │ │ ├── RenderTable.kt │ │ │ └── MarkdownRichText.kt │ └── jvmMain │ │ └── kotlin │ │ └── com │ │ └── halilibo │ │ └── richtext │ │ └── markdown │ │ ├── HtmlBlock.kt │ │ └── RemoteImage.kt ├── gradle.properties └── build.gradle.kts ├── richtext-ui-material ├── src │ ├── androidMain │ │ └── AndroidManifest.xml │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── halilibo │ │ └── richtext │ │ └── ui │ │ └── material │ │ └── RichText.kt ├── gradle.properties └── build.gradle.kts ├── richtext-ui-material3 ├── src │ ├── androidMain │ │ └── AndroidManifest.xml │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── halilibo │ │ └── richtext │ │ └── ui │ │ └── material3 │ │ └── RichText.kt ├── gradle.properties └── build.gradle.kts ├── docs ├── img │ ├── markdown-demo.png │ ├── printing-demo.gif │ ├── richtext-demo.png │ ├── slideshow-demo.gif │ └── slideshow-scrubbing-demo.gif ├── richtext-ui-material.md ├── richtext-ui-material3.md ├── index.md ├── richtext-markdown.md ├── richtext-commonmark.md └── richtext-ui.md ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── android-sample ├── src │ └── main │ │ ├── res │ │ ├── values │ │ │ ├── strings.xml │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ └── drawable │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ └── com │ │ │ └── zachklipp │ │ │ └── richtext │ │ │ └── sample │ │ │ ├── SampleActivity.kt │ │ │ ├── SampleTheme.kt │ │ │ ├── RichTextSample.kt │ │ │ ├── SampleLauncher.kt │ │ │ ├── ScreenPreview.kt │ │ │ ├── Demo.kt │ │ │ └── TextDemo.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle.kts ├── richtext-commonmark ├── gradle.properties ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── commonmark │ │ │ ├── ParsedMarkdown.kt │ │ │ ├── CommonMarkdownParseOptions.kt │ │ │ └── Markdown.kt │ ├── commonJvmAndroidTest │ │ └── kotlin │ │ │ └── com │ │ │ └── halilibo │ │ │ └── richtext │ │ │ └── commonmark │ │ │ └── AstNodeConvertKtTest.kt │ └── commonJvmAndroid │ │ └── kotlin │ │ └── com │ │ └── halilibo │ │ └── richtext │ │ └── commonmark │ │ └── AstNodeConvert.kt └── build.gradle.kts ├── jitpack.yml ├── .idea └── codeStyles │ └── codeStyleConfig.xml ├── settings.gradle.kts ├── .gitignore ├── gen_dokka_docs.sh ├── .github ├── workflows │ ├── android.yml │ ├── docs.yml │ └── publish.yml └── FUNDING.yml ├── desktop-sample └── build.gradle.kts ├── mkdocs.yml ├── gradle.properties ├── README.md ├── gradlew.bat ├── CHANGELOG.md └── gradlew /richtext-ui/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /richtext-markdown/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /richtext-ui-material/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /richtext-ui-material3/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/img/markdown-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/docs/img/markdown-demo.png -------------------------------------------------------------------------------- /docs/img/printing-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/docs/img/printing-demo.gif -------------------------------------------------------------------------------- /docs/img/richtext-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/docs/img/richtext-demo.png -------------------------------------------------------------------------------- /docs/img/slideshow-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/docs/img/slideshow-demo.gif -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /android-sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Rich Text Sample 3 | -------------------------------------------------------------------------------- /docs/img/slideshow-scrubbing-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/docs/img/slideshow-scrubbing-demo.gif -------------------------------------------------------------------------------- /richtext-ui/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Compose Richtext UI 2 | POM_DESCRIPTION=A library for rendering high-level text formatting in Compose. -------------------------------------------------------------------------------- /richtext-markdown/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Compose Richtext Markdown 2 | POM_DESCRIPTION=A library for rendering markdown represented as an AST in Compose. -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/util/UUID.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.util 2 | 3 | internal expect fun randomUUID(): String -------------------------------------------------------------------------------- /richtext-commonmark/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Compose Richtext Commonmark 2 | POM_DESCRIPTION=A library for rendering markdown in Compose using the Commonmark library. -------------------------------------------------------------------------------- /richtext-ui-material/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Compose Richtext UI Material 2 | POM_DESCRIPTION=An extension library for RichText UI to easily bind with Material apps. -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /richtext-ui-material3/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_NAME=Compose Richtext UI Material3 2 | POM_DESCRIPTION=An extension library for RichText UI to easily bind with Material3 apps. -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openai/compose-richtext/HEAD/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | before_install: 4 | - sdk install java 17.0.1-open 5 | - sdk use java 17.0.1-open 6 | env: 7 | GRADLE_OPTS: "-Dorg.gradle.configuration-cache=true" 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /richtext-ui/src/commonJvmAndroid/kotlin/com/halilibo/richtext/ui/util/UUID.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.util 2 | 3 | import java.util.UUID 4 | 5 | internal actual fun randomUUID(): String { 6 | return UUID.randomUUID().toString() 7 | } -------------------------------------------------------------------------------- /android-sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #eeeeee 4 | #111111 5 | #2079c7 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /android-sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.halilibo.richtext.ui.RichTextScope 5 | 6 | /** 7 | * Android and JVM can have different WebView or HTML rendering implementations. 8 | * We are leaving HTML rendering to platform side. 9 | */ 10 | @Composable 11 | internal expect fun RichTextScope.HtmlBlock(content: String) 12 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | mavenCentral() 6 | maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } 7 | } 8 | } 9 | 10 | include(":richtext-ui") 11 | include(":richtext-ui-material") 12 | include(":richtext-ui-material3") 13 | include(":richtext-commonmark") 14 | include(":richtext-markdown") 15 | include(":android-sample") 16 | include(":desktop-sample") 17 | rootProject.name = "compose-richtext" 18 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/SampleActivity.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.richtext.sample 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.activity.enableEdgeToEdge 6 | import androidx.appcompat.app.AppCompatActivity 7 | 8 | class MainActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | enableEdgeToEdge() 13 | setContent { 14 | SampleLauncher() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | /.idea/compiler.xml 11 | /.idea/deploymentTargetDropDown.xml 12 | /.idea/deploymentTargetSelector.xml 13 | /.idea/gradle.xml 14 | /.idea/kotlinc.xml 15 | /.idea/migrations.xml 16 | /.idea/misc.xml 17 | /.idea/artifacts 18 | /.idea/vcs.xml 19 | /.idea/other.xml 20 | .DS_Store 21 | build/ 22 | /captures 23 | .externalNativeBuild 24 | .cxx 25 | site/ 26 | docs-gen/ 27 | -------------------------------------------------------------------------------- /richtext-markdown/src/jvmMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.foundation.text.BasicText 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import com.halilibo.richtext.ui.RichTextScope 7 | 8 | @Composable 9 | internal actual fun RichTextScope.HtmlBlock(content: String) { 10 | DisposableEffect(Unit) { 11 | println("Html blocks are rendered literally in Compose Desktop!") 12 | onDispose { } 13 | } 14 | BasicText(content) 15 | } 16 | -------------------------------------------------------------------------------- /gen_dokka_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Fail on any error 4 | set -ex 5 | 6 | DOCS_ROOT=docs-gen 7 | 8 | [ -d $DOCS_ROOT ] && rm -r $DOCS_ROOT 9 | mkdir $DOCS_ROOT 10 | 11 | # Clear out the old API docs 12 | [ -d docs/api ] && rm -r docs/api 13 | # Build the docs with dokka 14 | ./gradlew dokkaHtmlMultiModule --stacktrace 15 | 16 | # Create a copy of our docs at our $DOCS_ROOT 17 | cp -a docs/* $DOCS_ROOT 18 | 19 | # Convert docs/xxx.md links to just xxx/ 20 | sed -i.bak 's/docs\/\([a-zA-Z-]*\).md/\1/' $DOCS_ROOT/index.md 21 | 22 | # Finally delete all of the backup files 23 | find . -name '*.bak' -delete -------------------------------------------------------------------------------- /richtext-ui/src/androidMain/kotlin/com/halilibo/richtext/ui/CodeBlock.android.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.horizontalScroll 4 | import androidx.compose.foundation.rememberScrollState 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | 8 | @Composable 9 | internal actual fun RichTextScope.CodeBlockLayout( 10 | wordWrap: Boolean, 11 | children: @Composable RichTextScope.(Modifier) -> Unit 12 | ) { 13 | if (!wordWrap) { 14 | val scrollState = rememberScrollState() 15 | children(Modifier.horizontalScroll(scrollState)) 16 | } else { 17 | children(Modifier) 18 | } 19 | } -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownImage.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.ContentScale 6 | 7 | //TODO(halilozercan): This should be provided from consumer side. 8 | /** 9 | * Image rendering is highly platform dependent. Coil is the desired 10 | * way to show images but it doesn't exist in desktop. 11 | */ 12 | @Composable 13 | internal expect fun MarkdownImage( 14 | url: String, 15 | contentDescription: String?, 16 | modifier: Modifier = Modifier, 17 | contentScale: ContentScale 18 | ) 19 | -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: gradle/wrapper-validation-action@v1 14 | - name: set up JDK 21 15 | uses: actions/setup-java@v1 16 | with: 17 | java-version: 21 18 | - uses: actions/cache@v4 19 | with: 20 | path: ~/.gradle/caches 21 | key: gradle-${{ runner.os }}-${{ hashFiles('buildSrc/**') }}-${{ hashFiles('**/*.gradle*') }} 22 | restore-keys: gradle-${{ runner.os }}- 23 | - run: ./gradlew build 24 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextScope.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.halilibo.richtext.ui 4 | 5 | import androidx.compose.runtime.CompositionLocal 6 | import androidx.compose.runtime.Immutable 7 | import androidx.compose.runtime.State 8 | 9 | /** 10 | * Scope object for composables that can draw rich text. 11 | * 12 | * RichTextScope facilitates a context for RichText elements. It does not 13 | * behave like a [State] or a [CompositionLocal]. Starting from [BasicRichText], 14 | * this scope carries information that should not be passed down as a state. 15 | */ 16 | @Immutable 17 | public object RichTextScope 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: halilozercan 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/node/AstTable.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown.node 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | public object AstTableRoot: AstContainerBlockNodeType() 7 | 8 | @Immutable 9 | public object AstTableBody: AstContainerBlockNodeType() 10 | 11 | @Immutable 12 | public object AstTableHeader: AstContainerBlockNodeType() 13 | 14 | @Immutable 15 | public object AstTableRow: AstContainerBlockNodeType() 16 | 17 | @Immutable 18 | public data class AstTableCell( 19 | val header: Boolean, 20 | val alignment: AstTableCellAlignment 21 | ) : AstContainerBlockNodeType() 22 | 23 | public enum class AstTableCellAlignment { 24 | LEFT, 25 | CENTER, 26 | RIGHT 27 | } 28 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextRenderOptions.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.string 2 | 3 | /** 4 | * Allows configuration of the Markdown renderer 5 | */ 6 | public data class RichTextRenderOptions( 7 | val animate: Boolean = false, 8 | val textFadeInMs: Int = 500, 9 | val debounceMs: Int = 100050, 10 | val delayMs: Int = 70, 11 | val delayExponent: Double = 0.7, 12 | val maxPhraseLength: Int = 30, 13 | val phraseMarkersOverride: List? = null, 14 | val onlyRenderVisibleText: Boolean = false, 15 | val onTextAnimate: () -> Unit = {}, 16 | val onPhraseAnimate: () -> Unit = {}, 17 | ) { 18 | public companion object { 19 | public val Default: RichTextRenderOptions = RichTextRenderOptions() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/ParsedMarkdown.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.commonmark 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.halilibo.richtext.markdown.node.AstNode 5 | import org.commonmark.node.Node 6 | 7 | /** 8 | * Convert CommonMark [Node] to [AstNode]. 9 | */ 10 | @Composable 11 | internal expect fun Node.toAstNode(): AstNode? 12 | 13 | /** 14 | * Parse markdown content and return Abstract Syntax Tree(AST). 15 | * Composable is efficient thanks to remember construct. 16 | * 17 | * @param text Markdown text to be parsed. 18 | * @param options Options for the Markdown parser. 19 | */ 20 | @Composable 21 | internal expect fun parsedMarkdown(text: String, options: CommonMarkdownParseOptions): AstNode? 22 | -------------------------------------------------------------------------------- /richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.compose.ui.text.AnnotatedString 6 | import androidx.compose.ui.text.fromHtml 7 | import com.halilibo.richtext.ui.RichTextScope 8 | import com.halilibo.richtext.ui.string.Text 9 | import com.halilibo.richtext.ui.string.richTextString 10 | 11 | @Composable 12 | internal actual fun RichTextScope.HtmlBlock(content: String) { 13 | val richTextString = remember(content) { 14 | richTextString { 15 | withAnnotatedString { 16 | append(AnnotatedString.Companion.fromHtml(content)) 17 | } 18 | } 19 | } 20 | Text(richTextString) 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Doc Site 2 | 3 | on: 4 | release: 5 | # Build docs when a new release is created, so the API docs don't get out of date. 6 | types: [ published ] 7 | push: 8 | branches: 9 | # A specific branch to only update docs without going through a release cycle 10 | - docs 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-java@v1 18 | with: 19 | java-version: 21 20 | - uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.x 23 | - name: Install dependencies 24 | run: pip install mkdocs-material 25 | - name: Generate docs 26 | run: ./gen_dokka_docs.sh 27 | - run: mkdocs gh-deploy --force 28 | -------------------------------------------------------------------------------- /desktop-sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 2 | 3 | plugins { 4 | kotlin("jvm") 5 | id("org.jetbrains.compose") version Compose.desktopVersion 6 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 7 | } 8 | 9 | dependencies { 10 | implementation(project(":richtext-commonmark")) 11 | implementation(project(":richtext-ui-material")) 12 | implementation(compose.desktop.currentOs) 13 | implementation(compose.materialIconsExtended) 14 | } 15 | 16 | compose.desktop { 17 | application { 18 | mainClass = "com.halilibo.richtext.desktop.MainKt" 19 | nativeDistributions { 20 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 21 | packageName = "jvm" 22 | packageVersion = "1.0.0" 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /android-sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /richtext-ui-material/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("richtext-kmp-library") 3 | id("org.jetbrains.compose") version Compose.desktopVersion 4 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 5 | id("org.jetbrains.dokka") 6 | } 7 | 8 | repositories { 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | 12 | android { 13 | namespace = "com.halilibo.richtext.ui.material" 14 | } 15 | 16 | kotlin { 17 | sourceSets { 18 | val commonMain by getting { 19 | dependencies { 20 | implementation(compose.runtime) 21 | implementation(compose.foundation) 22 | implementation(compose.material) 23 | api(project(":richtext-ui")) 24 | } 25 | } 26 | val commonTest by getting 27 | 28 | val androidMain by getting 29 | val jvmMain by getting 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /richtext-ui-material3/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("richtext-kmp-library") 3 | id("org.jetbrains.compose") version Compose.desktopVersion 4 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 5 | id("org.jetbrains.dokka") 6 | } 7 | 8 | repositories { 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | 12 | android { 13 | namespace = "com.halilibo.richtext.ui.material3" 14 | } 15 | 16 | kotlin { 17 | sourceSets { 18 | val commonMain by getting { 19 | dependencies { 20 | implementation(compose.runtime) 21 | implementation(compose.foundation) 22 | implementation(compose.material3) 23 | 24 | api(project(":richtext-ui")) 25 | } 26 | } 27 | val commonTest by getting 28 | 29 | val androidMain by getting 30 | val jvmMain by getting 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/node/AstNode.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown.node 2 | 3 | /** 4 | * Generic AstNode implementation that can define any node in Abstract Syntax Tree. 5 | * 6 | * @param type A sealed class which is categorized into block and inline nodes. 7 | * @param links Pointers to parent, sibling, child nodes. 8 | */ 9 | public class AstNode( 10 | public val type: AstNodeType, 11 | public val links: AstNodeLinks 12 | ) { 13 | override fun equals(other: Any?): Boolean { 14 | if (this === other) return true 15 | if (other !is AstNode) return false 16 | 17 | if (type != other.type) return false 18 | if (links != other.links) return false 19 | 20 | return true 21 | } 22 | 23 | override fun hashCode(): Int { 24 | var result = type.hashCode() 25 | result = 31 * result + links.hashCode() 26 | return result 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /android-sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /richtext-commonmark/src/commonJvmAndroidTest/kotlin/com/halilibo/richtext/commonmark/AstNodeConvertKtTest.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import com.halilibo.richtext.commonmark.convert 4 | import com.halilibo.richtext.markdown.node.AstImage 5 | import com.halilibo.richtext.markdown.node.AstNode 6 | import com.halilibo.richtext.markdown.node.AstNodeLinks 7 | import org.commonmark.node.Image 8 | import org.junit.Test 9 | import kotlin.test.assertEquals 10 | 11 | internal class AstNodeConvertKtTest { 12 | 13 | @Test 14 | fun `when image without title is converted, then the content description is empty`() { 15 | val destination = "/url" 16 | val image = Image(destination, null) 17 | 18 | val result = convert(image) 19 | 20 | assertEquals( 21 | expected = AstNode( 22 | type = AstImage(title = "", destination = destination), 23 | links = AstNodeLinks() 24 | ), 25 | actual = result 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /richtext-ui/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("richtext-kmp-library") 3 | id("org.jetbrains.compose") version Compose.desktopVersion 4 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 5 | id("org.jetbrains.dokka") 6 | } 7 | 8 | repositories { 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | 12 | android { 13 | namespace = "com.halilibo.richtext.ui" 14 | } 15 | dependencies { 16 | implementation("androidx.lifecycle:lifecycle-runtime-compose-android:2.8.4") 17 | } 18 | 19 | kotlin { 20 | sourceSets { 21 | val commonMain by getting { 22 | dependencies { 23 | implementation(compose.runtime) 24 | implementation(compose.foundation) 25 | } 26 | } 27 | val commonTest by getting 28 | 29 | val androidMain by getting { 30 | kotlin.srcDir("src/commonJvmAndroid/kotlin") 31 | } 32 | val jvmMain by getting { 33 | kotlin.srcDir("src/commonJvmAndroid/kotlin") 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Compose Richtext 2 | repo_name: compose-richtext 3 | repo_url: https://github.com/halilozercan/compose-richtext 4 | site_description: "A collection of Compose libraries for advanced text formatting and alternative display types." 5 | site_author: Halil Ozercan 6 | site_url: https://halilibo.com/compose-richtext 7 | remote_branch: gh-pages 8 | edit_uri: edit/main/docs/ 9 | 10 | docs_dir: docs-gen 11 | 12 | theme: 13 | name: material 14 | icon: 15 | repo: fontawesome/brands/github 16 | features: 17 | - navigation.instant 18 | - toc.autohide 19 | 20 | markdown_extensions: 21 | - toc: 22 | permalink: true 23 | - pymdownx.superfences 24 | - pymdownx.tabbed 25 | - admonition 26 | 27 | nav: 28 | - index.md 29 | - richtext-ui-material.md 30 | - richtext-ui-material3.md 31 | - richtext-ui.md 32 | - richtext-markdown.md 33 | - richtext-commonmark.md 34 | - printing.md 35 | - slideshow.md 36 | - 'API Reference': api/ 37 | - Changelog: https://github.com/halilozercan/compose-richtext/releases 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [released] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish: 10 | name: Release build and publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: gradle/wrapper-validation-action@v1 15 | - name: set up JDK 17 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 17 19 | - uses: actions/cache@v4 20 | with: 21 | path: ~/.gradle/caches 22 | key: gradle-${{ runner.os }}-${{ hashFiles('buildSrc/**') }}-${{ hashFiles('**/*.gradle*') }} 23 | restore-keys: gradle-${{ runner.os }}- 24 | - name: Release build 25 | run: ./gradlew :build 26 | - name: Publish to MavenCentral 27 | run: ./gradlew publishAllPublicationsToMavenRepository 28 | env: 29 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 30 | GPG_PRIVATE_PASSWORD: ${{ secrets.GPG_PRIVATE_PASSWORD }} 31 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 32 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 33 | -------------------------------------------------------------------------------- /richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/CommonMarkdownParseOptions.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.commonmark 2 | 3 | /** 4 | * Allows configuration of the Markdown parser 5 | * 6 | * @param autolink Detect plain text links and turn them into Markdown links. 7 | */ 8 | public class CommonMarkdownParseOptions( 9 | public val autolink: Boolean 10 | ) { 11 | 12 | override fun toString(): String { 13 | return "CommonMarkdownParseOptions(autolink=$autolink)" 14 | } 15 | 16 | override fun equals(other: Any?): Boolean { 17 | if (this === other) return true 18 | if (other !is CommonMarkdownParseOptions) return false 19 | 20 | return autolink == other.autolink 21 | } 22 | 23 | override fun hashCode(): Int { 24 | return autolink.hashCode() 25 | } 26 | 27 | public fun copy( 28 | autolink: Boolean = this.autolink 29 | ): CommonMarkdownParseOptions = CommonMarkdownParseOptions( 30 | autolink = autolink 31 | ) 32 | 33 | public companion object { 34 | public val Default: CommonMarkdownParseOptions = CommonMarkdownParseOptions( 35 | autolink = true 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BasicRichText.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.halilibo.richtext.ui 4 | 5 | import androidx.compose.foundation.layout.Arrangement.spacedBy 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.platform.LocalDensity 10 | 11 | /** 12 | * Draws some rich text. Entry point to the compose-richtext library. 13 | */ 14 | @Composable 15 | public fun BasicRichText( 16 | modifier: Modifier = Modifier, 17 | style: RichTextStyle? = null, 18 | children: @Composable RichTextScope.() -> Unit 19 | ) { 20 | with(RichTextScope) { 21 | RestartListLevel { 22 | WithStyle(style) { 23 | val resolvedStyle = currentRichTextStyle.resolveDefaults() 24 | val blockSpacing = with(LocalDensity.current) { 25 | resolvedStyle.paragraphSpacing!!.toDp() 26 | } 27 | 28 | Column(modifier = modifier, verticalArrangement = spacedBy(blockSpacing)) { 29 | children() 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /android-sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | id("com.android.application") 5 | kotlin("android") 6 | id("org.jetbrains.compose") version Compose.desktopVersion 7 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 8 | } 9 | 10 | android { 11 | namespace = "com.zachklipp.richtext.sample" 12 | compileSdk = AndroidConfiguration.compileSdk 13 | 14 | defaultConfig { 15 | minSdk = AndroidConfiguration.minSdk 16 | targetSdk = AndroidConfiguration.targetSdk 17 | } 18 | 19 | buildFeatures { 20 | compose = true 21 | } 22 | 23 | compileOptions { 24 | sourceCompatibility = JavaVersion.VERSION_11 25 | targetCompatibility = JavaVersion.VERSION_11 26 | } 27 | } 28 | 29 | kotlin { 30 | compilerOptions { 31 | jvmTarget = JvmTarget.JVM_11 32 | } 33 | } 34 | 35 | dependencies { 36 | implementation(project(":richtext-commonmark")) 37 | implementation(project(":richtext-ui-material3")) 38 | implementation(AndroidX.appcompat) 39 | implementation(Compose.activity) 40 | implementation(compose.foundation) 41 | implementation(compose.materialIconsExtended) 42 | implementation(compose.material3) 43 | implementation(compose.uiTooling) 44 | } 45 | -------------------------------------------------------------------------------- /docs/richtext-ui-material.md: -------------------------------------------------------------------------------- 1 | # Richtext UI Material 2 | 3 | [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) 4 | [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) 5 | 6 | Library that makes RichText compatible with Material design in Compose. 7 | 8 | ## Gradle 9 | 10 | ```kotlin 11 | dependencies { 12 | implementation("com.halilibo.compose-richtext:richtext-ui-material:${richtext_version}") 13 | } 14 | ``` 15 | 16 | ## Usage 17 | 18 | Material RichText library provides a single composable called `RichText` which automatically passes 19 | down Material theming attributes to `BasicRichText`. 20 | 21 | ### [`RichText`](../api/richtext-ui-material/com.halilibo.richtext.ui.material/-rich-text.html) 22 | 23 | `RichText` composable wraps around regular `BasicRichText` while introducing the necessary integration 24 | dependencies. `RichText` shares the exact arguments with regular `BasicRichText`. 25 | 26 | ```kotlin 27 | RichText(modifier = Modifier.background(color = Color.White)) { 28 | Heading(0, "Paragraphs") 29 | Text("Simple paragraph.") 30 | ... 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/richtext-ui-material3.md: -------------------------------------------------------------------------------- 1 | # Richtext UI Material 3 2 | 3 | [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) 4 | [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) 5 | 6 | Library that makes RichText compatible with Material design in Compose. 7 | 8 | ## Gradle 9 | 10 | ```kotlin 11 | dependencies { 12 | implementation("com.halilibo.compose-richtext:richtext-ui-material3:${richtext_version}") 13 | } 14 | ``` 15 | 16 | ## Usage 17 | 18 | Material3 RichText library provides a single composable called `RichText` which automatically passes 19 | down Material3 theming attributes to `BasicRichText`. 20 | 21 | ### [`RichText`](../api/richtext-ui-material/com.halilibo.richtext.ui.material3/-rich-text.html) 22 | 23 | `RichText` composable wraps around regular `BasicRichText` while introducing the necessary integration 24 | dependencies. `RichText` shares the exact arguments with regular `BasicRichText`. 25 | 26 | ```kotlin 27 | RichText(modifier = Modifier.background(color = Color.White)) { 28 | Heading(0, "Paragraphs") 29 | Text("Simple paragraph.") 30 | ... 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /richtext-markdown/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("richtext-kmp-library") 3 | id("org.jetbrains.compose") version Compose.desktopVersion 4 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 5 | id("org.jetbrains.dokka") 6 | } 7 | 8 | repositories { 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | 12 | android { 13 | namespace = "com.halilibo.richtext.markdown" 14 | } 15 | 16 | kotlin { 17 | sourceSets { 18 | val commonMain by getting { 19 | dependencies { 20 | implementation(Commonmark.core) 21 | implementation(compose.runtime) 22 | implementation(compose.foundation) 23 | api(project(":richtext-ui")) 24 | } 25 | } 26 | val commonTest by getting 27 | 28 | val androidMain by getting { 29 | dependencies { 30 | implementation(Compose.coil) 31 | implementation(Compose.coilHttp) 32 | } 33 | } 34 | 35 | val jvmMain by getting { 36 | dependencies { 37 | implementation(compose.desktop.currentOs) 38 | implementation(Network.okHttp) 39 | } 40 | } 41 | 42 | val jvmTest by getting { 43 | dependencies { 44 | implementation(Kotlin.Test.jdk) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/node/AstNodeLinks.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown.node 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | /** 6 | * All the pointers that can exist for a node in an AST. 7 | * 8 | * Links are mutable to make it possible to instantiate a Node which can then reconfigure its 9 | * children and siblings. Please do not modify the links after an ASTNode is created and the scope 10 | * is finished. 11 | */ 12 | @Immutable 13 | public class AstNodeLinks( 14 | public var parent: AstNode? = null, 15 | public var firstChild: AstNode? = null, 16 | public var lastChild: AstNode? = null, 17 | public var previous: AstNode? = null, 18 | public var next: AstNode? = null 19 | ) { 20 | 21 | override fun equals(other: Any?): Boolean { 22 | if (other !is AstNodeLinks) return false 23 | 24 | return parent === other.parent && 25 | firstChild === other.firstChild && 26 | lastChild === other.lastChild && 27 | previous === other.previous && 28 | next === other.next 29 | } 30 | 31 | /** 32 | * Stop infinite loop and only calculate towards bottom-right direction 33 | */ 34 | override fun hashCode(): Int { 35 | return (firstChild ?: 0).hashCode() * 11 + (next ?: 0).hashCode() * 7 36 | } 37 | } -------------------------------------------------------------------------------- /richtext-ui/src/commonJvmAndroidTest/kotlin/com/halilibo/richtext/ui/util/AnnotatedStringSegmenterTest.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.util 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | 8 | class AnnotatedStringSegmenterTest { 9 | 10 | @Test 11 | fun cjkSegmentsAfterFiveCharacters() { 12 | val text = "天地玄黄宇宙洪荒日月盈昃" // 12 CJK characters 13 | val result = AnnotatedString(text).segmentIntoPhrases( 14 | renderOptions = RichTextRenderOptions(maxPhraseLength = 100), 15 | isComplete = true 16 | ) 17 | assertEquals(listOf(0, 6, text.length), result.phraseSegments) 18 | } 19 | 20 | @Test 21 | fun thaiSegmentsAfterFifteenCharacters() { 22 | val text = "ภาษาไทยไม่มีเว้นวรรคและทดสอบยาว" // > 15 Thai characters 23 | val result = AnnotatedString(text).segmentIntoPhrases( 24 | renderOptions = RichTextRenderOptions(maxPhraseLength = 100), 25 | isComplete = true 26 | ) 27 | assertEquals(listOf(0, 16, text.length), result.phraseSegments) 28 | } 29 | 30 | @Test 31 | fun hindiSegmentsAfterFifteenCharacters() { 32 | val text = "यहपाठदेवनागरीलिपिमेंहैकॉफायलंबाऔरबिनाविरामचिह्न" // > 15 Devanagari characters 33 | val result = AnnotatedString(text).segmentIntoPhrases( 34 | renderOptions = RichTextRenderOptions(maxPhraseLength = 100), 35 | isComplete = true 36 | ) 37 | assertEquals(listOf(0, 16, text.length), result.phraseSegments) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /richtext-ui/src/jvmMain/kotlin/com/halilibo/richtext/ui/CodeBlock.desktop.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.HorizontalScrollbar 4 | import androidx.compose.foundation.horizontalScroll 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.rememberScrollbarAdapter 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.CompositionLocalProvider 10 | import androidx.compose.runtime.compositionLocalOf 11 | import androidx.compose.ui.Modifier 12 | 13 | private val LocalScrollbarEnabled = compositionLocalOf { true } 14 | 15 | @Composable 16 | internal actual fun RichTextScope.CodeBlockLayout( 17 | wordWrap: Boolean, 18 | children: @Composable RichTextScope.(Modifier) -> Unit 19 | ) { 20 | if (!wordWrap) { 21 | val scrollState = rememberScrollState() 22 | Column { 23 | children(Modifier.horizontalScroll(scrollState)) 24 | if (LocalScrollbarEnabled.current) { 25 | val horizontalScrollbarAdapter = rememberScrollbarAdapter(scrollState) 26 | HorizontalScrollbar(adapter = horizontalScrollbarAdapter) 27 | } 28 | } 29 | } else { 30 | children(Modifier) 31 | } 32 | } 33 | 34 | /** 35 | * Contextually disables scrollbar for Desktop CodeBlocks under [content] tree. 36 | */ 37 | @Composable 38 | public fun DisableScrollbar( 39 | content: @Composable () -> Unit 40 | ) { 41 | CompositionLocalProvider(LocalScrollbarEnabled provides false) { 42 | content() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=768m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | 21 | # Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308) 22 | systemProp.org.gradle.internal.publish.checksums.insecure=true 23 | 24 | GROUP=com.halilibo.compose-richtext 25 | VERSION_NAME=1.0.0-alpha03 26 | 27 | POM_DESCRIPTION=A collection of Compose libraries for advanced text formatting and alternative display types. 28 | 29 | kotlin.mpp.stability.nowarn=true 30 | kotlin.mpp.androidSourceSetLayoutVersion=2 31 | -------------------------------------------------------------------------------- /richtext-commonmark/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("richtext-kmp-library") 3 | id("org.jetbrains.compose") version Compose.desktopVersion 4 | id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version 5 | id("org.jetbrains.dokka") 6 | } 7 | 8 | repositories { 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | } 11 | 12 | android { 13 | namespace = "com.halilibo.richtext.commonmark" 14 | } 15 | 16 | kotlin { 17 | sourceSets { 18 | val commonMain by getting { 19 | dependencies { 20 | implementation(compose.runtime) 21 | api(Commonmark.core) 22 | api(project(":richtext-ui")) 23 | api(project(":richtext-markdown")) 24 | } 25 | } 26 | val commonTest by getting 27 | 28 | val androidMain by getting { 29 | kotlin.srcDir("src/commonJvmAndroid/kotlin") 30 | dependencies { 31 | implementation(Commonmark.core) 32 | implementation(Commonmark.tables) 33 | implementation(Commonmark.strikethrough) 34 | implementation(Commonmark.autolink) 35 | } 36 | } 37 | 38 | val jvmMain by getting { 39 | kotlin.srcDir("src/commonJvmAndroid/kotlin") 40 | dependencies { 41 | implementation(Commonmark.core) 42 | implementation(Commonmark.tables) 43 | implementation(Commonmark.strikethrough) 44 | implementation(Commonmark.autolink) 45 | } 46 | } 47 | 48 | val jvmTest by getting { 49 | kotlin.srcDir("src/commonJvmAndroidTest/kotlin") 50 | dependencies { 51 | implementation(Kotlin.Test.jdk) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/MarkdownAnimation.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.animateFloatAsState 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.State 9 | import androidx.compose.runtime.mutableFloatStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.rememberCoroutineScope 12 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 13 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 14 | import kotlinx.coroutines.delay 15 | import kotlinx.coroutines.launch 16 | import kotlin.time.Duration.Companion.milliseconds 17 | 18 | @Composable 19 | internal fun rememberMarkdownFade( 20 | richTextRenderOptions: RichTextRenderOptions, 21 | markdownAnimationState: MarkdownAnimationState, 22 | ): State { 23 | val coroutineScope = rememberCoroutineScope() 24 | val targetAlpha = remember { 25 | Animatable(if (richTextRenderOptions.animate) 0f else 1f) 26 | } 27 | LaunchedEffect(Unit) { 28 | if (richTextRenderOptions.animate) { 29 | coroutineScope.launch { 30 | markdownAnimationState.addAnimation(richTextRenderOptions) 31 | delay(markdownAnimationState.toDelayMs().milliseconds) 32 | targetAlpha.animateTo( 33 | 1f, 34 | tween( 35 | durationMillis = richTextRenderOptions.textFadeInMs, 36 | ) 37 | ) 38 | } 39 | } 40 | } 41 | return targetAlpha.asState() 42 | } 43 | -------------------------------------------------------------------------------- /android-sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/TraverseUtils.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import com.halilibo.richtext.markdown.node.AstCode 4 | import com.halilibo.richtext.markdown.node.AstHardLineBreak 5 | import com.halilibo.richtext.markdown.node.AstImage 6 | import com.halilibo.richtext.markdown.node.AstNode 7 | import com.halilibo.richtext.markdown.node.AstNodeType 8 | import com.halilibo.richtext.markdown.node.AstSoftLineBreak 9 | import com.halilibo.richtext.markdown.node.AstText 10 | 11 | internal fun AstNode.childrenSequence( 12 | reverse: Boolean = false 13 | ): Sequence { 14 | return if (!reverse) { 15 | generateSequence(this.links.firstChild) { it.links.next } 16 | } else { 17 | generateSequence(this.links.lastChild) { it.links.previous } 18 | } 19 | } 20 | 21 | /** 22 | * Markdown rendering is susceptible to have assumptions. Hence, some rendering rules 23 | * may force restrictions on children. So, valid children nodes should be selected 24 | * before traversing. This function returns a LinkedList of children which conforms to 25 | * [filter] function. 26 | * 27 | * @param filter A lambda to select valid children. 28 | */ 29 | internal fun AstNode.filterChildren( 30 | reverse: Boolean = false, 31 | filter: (AstNode) -> Boolean 32 | ): Sequence { 33 | return childrenSequence(reverse).filter(filter) 34 | } 35 | 36 | internal inline fun AstNode.filterChildrenType(): Sequence { 37 | return filterChildren { it.type is T } 38 | } 39 | 40 | /** 41 | * These ASTNode types should never have any children. If any exists, ignore them. 42 | */ 43 | internal fun AstNode.isRichTextTerminal(): Boolean { 44 | return type is AstText 45 | || type is AstCode 46 | || type is AstImage 47 | || type is AstSoftLineBreak 48 | || type is AstHardLineBreak 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Compose Markdown and Rich Text 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.halilibo.compose-richtext/richtext-ui.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.halilibo.compose-richtext%22) 4 | [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) 5 | 6 | > **Warning** 7 | > compose-richtext library and all its modules are very experimental. The roadmap is unclear at the moment. Thanks for your patience. Fork option is available as always. 8 | 9 | A collection of Compose libraries for working with Markdown rendering and rich text formatting. 10 | 11 | All modules are Compose Multiplatform compatible but lacks iOS support. 12 | 13 | ---- 14 | 15 | **Documentation is available at [halilibo.com/compose-richtext](https://halilibo.com/compose-richtext).** 16 | 17 | ---- 18 | 19 | ```kotlin 20 | @Composable fun App() { 21 | RichText(Modifier.background(color = Color.LightGray)) { 22 | Heading(0, "Title") 23 | Text("Summary paragraph.") 24 | 25 | HorizontalRule() 26 | 27 | BlockQuote { 28 | Text("A wise person once said…") 29 | } 30 | 31 | Markdown("**Hello** `World`") 32 | } 33 | } 34 | ``` 35 | 36 | ## License 37 | ``` 38 | Copyright 2025 Halil Ozercan 39 | 40 | Licensed under the Apache License, Version 2.0 (the "License"); 41 | you may not use this file except in compliance with the License. 42 | You may obtain a copy of the License at 43 | 44 | http://www.apache.org/licenses/LICENSE-2.0 45 | 46 | Unless required by applicable law or agreed to in writing, software 47 | distributed under the License is distributed on an "AS IS" BASIS, 48 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 49 | See the License for the specific language governing permissions and 50 | limitations under the License. 51 | ``` 52 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/SampleTheme.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.richtext.sample 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.ColorScheme 6 | import androidx.compose.material3.LocalTextStyle 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Shapes 9 | import androidx.compose.material3.Typography 10 | import androidx.compose.material3.darkColorScheme 11 | import androidx.compose.material3.dynamicDarkColorScheme 12 | import androidx.compose.material3.dynamicLightColorScheme 13 | import androidx.compose.material3.lightColorScheme 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.CompositionLocalProvider 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.unit.TextUnit 19 | 20 | @Composable 21 | fun SampleTheme( 22 | isDarkTheme: Boolean = isSystemInDarkTheme(), 23 | shapes: Shapes = MaterialTheme.shapes, 24 | typography: Typography = MaterialTheme.typography, 25 | content: @Composable () -> Unit 26 | ) { 27 | val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S 28 | 29 | val lightColorScheme = lightColorScheme(primary = Color(0xFF1EB980)) 30 | 31 | val darkColorScheme = darkColorScheme(primary = Color(0xFF66ffc7)) 32 | 33 | val colorScheme = 34 | when { 35 | supportsDynamicColor && isDarkTheme -> { 36 | dynamicDarkColorScheme(LocalContext.current) 37 | } 38 | supportsDynamicColor && !isDarkTheme -> { 39 | dynamicLightColorScheme(LocalContext.current) 40 | } 41 | isDarkTheme -> darkColorScheme 42 | else -> lightColorScheme 43 | } 44 | MaterialTheme(colorScheme, shapes, typography) { 45 | val textStyle = LocalTextStyle.current.copy(lineHeight = TextUnit.Unspecified) 46 | CompositionLocalProvider(LocalTextStyle provides textStyle) { 47 | content() 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/com.halilibo.compose-richtext/richtext-ui.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.halilibo.compose-richtext%22) 4 | [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) 5 | 6 | Compose Richtext is a collection of Compose libraries for working with rich text formatting and 7 | Markdown rendering. 8 | 9 | `richtext-ui`, `richtext-markdown`, `richtext-commonmark`, and `richtext-ui-material`|`richtext-ui-material3` are Kotlin Multiplatform(KMP) Compose Libraries with the exception of iOS. 10 | All these modules can be used in Android and Desktop Compose apps. 11 | 12 | Each library is documented separately, see the navigation menu for the list. This site also includes 13 | an API reference. 14 | 15 | !!! warning 16 | This project is currently on its way to reach `1.0.0` release. The timeline is not clear and the release date will remain TBD for a while. 17 | There are no tests and some things might be broken or very non-performant. 18 | 19 | The API may also change between releases without deprecation cycles. 20 | 21 | ## Getting started 22 | 23 | These libraries are published to Maven Central, so just add a Gradle dependency: 24 | 25 | ```kotlin 26 | dependencies { 27 | implementation("com.halilibo.compose-richtext::${richtext_version}") 28 | } 29 | ``` 30 | 31 | There is no difference for KMP artifacts. For instance, if you are adding `richtext-ui` to a Kotlin Multiplatform module 32 | 33 | ```kotlin 34 | val commonMain by getting { 35 | dependencies { 36 | implementation("com.halilibo.compose-richtext:richtext-ui:${richtext_version}") 37 | } 38 | } 39 | ``` 40 | 41 | ### Library Artifacts 42 | 43 | The `LIBRARY_ARTIFACT`s for each individual library can be found on their respective pages. 44 | 45 | ## Samples 46 | 47 | Please check out [Android](https://github.com/halilozercan/compose-richtext/tree/main/android-sample) and [Desktop](https://github.com/halilozercan/compose-richtext/tree/main/desktop-sample) 48 | projects to see various use cases of RichText in both platforms. -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/RenderTable.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.MutableState 5 | import com.halilibo.richtext.markdown.node.AstNode 6 | import com.halilibo.richtext.markdown.node.AstTableBody 7 | import com.halilibo.richtext.markdown.node.AstTableCell 8 | import com.halilibo.richtext.markdown.node.AstTableHeader 9 | import com.halilibo.richtext.markdown.node.AstTableRow 10 | import com.halilibo.richtext.ui.RichTextScope 11 | import com.halilibo.richtext.ui.Table 12 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 13 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 14 | 15 | @Composable 16 | internal fun RichTextScope.RenderTable( 17 | node: AstNode, 18 | inlineContentOverride: InlineContentOverride?, 19 | richtextRenderOptions: RichTextRenderOptions, 20 | markdownAnimationState: MarkdownAnimationState, 21 | ) { 22 | Table( 23 | markdownAnimationState = markdownAnimationState, 24 | richTextRenderOptions = richtextRenderOptions, 25 | headerRow = { 26 | node.filterChildrenType() 27 | .firstOrNull() 28 | ?.filterChildrenType() 29 | ?.firstOrNull() 30 | ?.filterChildrenType() 31 | ?.forEach { tableCell -> 32 | cell { 33 | MarkdownRichText( 34 | tableCell, 35 | inlineContentOverride, 36 | richtextRenderOptions, 37 | markdownAnimationState, 38 | ) 39 | } 40 | } 41 | } 42 | ) { 43 | node.filterChildrenType() 44 | .firstOrNull() 45 | ?.filterChildrenType() 46 | ?.forEach { tableRow -> 47 | row { 48 | tableRow.filterChildrenType() 49 | .forEach { tableCell -> 50 | cell { 51 | MarkdownRichText( 52 | tableCell, 53 | inlineContentOverride, 54 | richtextRenderOptions, 55 | markdownAnimationState, 56 | ) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/richtext-markdown.md: -------------------------------------------------------------------------------- 1 | # Markdown 2 | 3 | [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) 4 | [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) 5 | 6 | Library for rendering Markdown tree that is defined as an `AstNode`. This module would be useless 7 | for someone who is looking to just render a Markdown string. Please check out 8 | `richtext-commonmark` for such features. `richtext-markdown` behaves as sort of a building block. 9 | You can create your own parser or use 3rd party ones that converts any Markdown string to an 10 | `AstNode` tree. 11 | 12 | ## Gradle 13 | 14 | ```kotlin 15 | dependencies { 16 | implementation("com.halilibo.compose-richtext:richtext-markdown:${richtext_version}") 17 | } 18 | ``` 19 | 20 | ## Rendering 21 | 22 | The simplest way to render markdown is just pass an `AstNode` to the [`BasicMarkdown`](../api/richtext-markdown/com.halilibo.richtext.markdown/-basic-markdown.html) 23 | composable under RichText scope: 24 | 25 | ~~~kotlin 26 | RichText( 27 | modifier = Modifier.padding(16.dp) 28 | ) { 29 | // requires richtext-commonmark module. 30 | val parser = remember(options) { CommonmarkAstNodeParser(options) } 31 | val astNode = remember(parser) { 32 | parser.parse( 33 | """ 34 | # Demo 35 | 36 | Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. Combined emphasis with **asterisks and _underscores_**. [Links with two blocks, text in square-brackets, destination is in parentheses.](https://www.example.com). Inline `code` has `back-ticks around` it. 37 | 38 | 1. First ordered list item 39 | 2. Another item 40 | * Unordered sub-list. 41 | 3. And another item. 42 | 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). 43 | 44 | * Unordered list can use asterisks 45 | - Or minuses 46 | + Or pluses 47 | """.trimIndent() 48 | ) 49 | } 50 | BasicMarkdown(astNode) 51 | } 52 | ~~~ 53 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/HorizontalRule.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.Immutable 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.draw.alpha 14 | import androidx.compose.ui.graphics.graphicsLayer 15 | import androidx.compose.ui.platform.LocalDensity 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 19 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 20 | 21 | @Immutable 22 | public data class HorizontalRuleStyle( 23 | val color: Color? = null, 24 | val spacing: Dp? = null, 25 | ) { 26 | public companion object { 27 | public val Default: HorizontalRuleStyle = HorizontalRuleStyle() 28 | } 29 | } 30 | 31 | internal fun HorizontalRuleStyle.resolveDefaults() = HorizontalRuleStyle( 32 | color = color, 33 | spacing = spacing, 34 | ) 35 | 36 | /** 37 | * A simple horizontal line drawn with the current content color. 38 | */ 39 | @Composable public fun RichTextScope.HorizontalRule( 40 | markdownAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, 41 | richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions(), 42 | ) { 43 | val resolvedStyle = currentRichTextStyle.resolveDefaults() 44 | val horizontalRuleStyle = resolvedStyle.horizontalRuleStyle 45 | val color = horizontalRuleStyle?.color ?: currentContentColor.copy(alpha = .2f) 46 | val spacing = horizontalRuleStyle?.spacing ?: with(LocalDensity.current) { 47 | resolvedStyle.paragraphSpacing!!.toDp() 48 | } 49 | val alpha = rememberMarkdownFade(richTextRenderOptions, markdownAnimationState) 50 | Box( 51 | Modifier 52 | .graphicsLayer{ this.alpha = alpha.value } 53 | .padding(top = spacing, bottom = spacing) 54 | .fillMaxWidth() 55 | .height(1.dp) 56 | .background(color) 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /richtext-markdown/src/jvmMain/kotlin/com/halilibo/richtext/markdown/RemoteImage.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.produceState 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.ImageBitmap 10 | import androidx.compose.ui.graphics.toComposeImageBitmap 11 | import androidx.compose.ui.layout.ContentScale 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.withContext 14 | import org.jetbrains.skia.Image.Companion.makeFromEncoded 15 | import java.awt.image.BufferedImage 16 | import java.io.ByteArrayOutputStream 17 | import java.io.InputStream 18 | import java.net.HttpURLConnection 19 | import java.net.URL 20 | import java.util.Base64 21 | import javax.imageio.ImageIO 22 | 23 | @Composable 24 | internal actual fun MarkdownImage( 25 | url: String, 26 | contentDescription: String?, 27 | modifier: Modifier, 28 | contentScale: ContentScale 29 | ) { 30 | val image by produceState(null, url) { 31 | if (url.startsWith("data:image") && url.contains("base64")) { 32 | val base64ImageString = url.substringAfter("base64,") 33 | value = makeFromEncoded(Base64.getDecoder().decode(base64ImageString)).toComposeImageBitmap() 34 | } else { 35 | loadFullImage(url)?.let { 36 | value = makeFromEncoded(toByteArray(it)).toComposeImageBitmap() 37 | } 38 | } 39 | } 40 | 41 | if (image != null) { 42 | Image( 43 | bitmap = image!!, 44 | contentDescription = contentDescription, 45 | modifier = modifier, 46 | contentScale = contentScale 47 | ) 48 | } 49 | } 50 | 51 | private fun toByteArray(bitmap: BufferedImage): ByteArray { 52 | val baos = ByteArrayOutputStream() 53 | ImageIO.write(bitmap, "png", baos) 54 | return baos.toByteArray() 55 | } 56 | 57 | private suspend fun loadFullImage(source: String): BufferedImage? = withContext(Dispatchers.IO) { 58 | runCatching { 59 | val url = URL(source) 60 | val connection: HttpURLConnection = url.openConnection() as HttpURLConnection 61 | connection.connectTimeout = 5000 62 | connection.connect() 63 | 64 | val input: InputStream = connection.inputStream 65 | val bitmap: BufferedImage? = ImageIO.read(input) 66 | bitmap 67 | }.getOrNull() 68 | } 69 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextThemeConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.ProvidableCompositionLocal 6 | import androidx.compose.runtime.compositionLocalOf 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.text.TextStyle 9 | 10 | internal typealias TextStyleProvider = @Composable () -> TextStyle 11 | internal typealias TextStyleBackProvider = @Composable (TextStyle, @Composable () -> Unit) -> Unit 12 | internal typealias ContentColorProvider = @Composable () -> Color 13 | internal typealias ContentColorBackProvider = @Composable (Color, @Composable () -> Unit) -> Unit 14 | 15 | internal data class RichTextThemeConfiguration( 16 | val textStyleProvider: TextStyleProvider = { LocalInternalTextStyle.current }, 17 | val textStyleBackProvider: TextStyleBackProvider = { newTextStyle, content -> 18 | CompositionLocalProvider(LocalInternalTextStyle provides newTextStyle) { 19 | content() 20 | } 21 | }, 22 | val contentColorProvider: ContentColorProvider = { LocalInternalContentColor.current }, 23 | val contentColorBackProvider: ContentColorBackProvider = { newColor, content -> 24 | CompositionLocalProvider(LocalInternalContentColor provides newColor) { 25 | content() 26 | } 27 | } 28 | ) { 29 | companion object { 30 | internal val Default = RichTextThemeConfiguration() 31 | } 32 | } 33 | 34 | internal val LocalRichTextThemeConfiguration: ProvidableCompositionLocal = 35 | compositionLocalOf { RichTextThemeConfiguration() } 36 | 37 | /** 38 | * Easy access delegations for [RichTextThemeProvider] within [RichTextScope] 39 | */ 40 | internal val RichTextScope.textStyleProvider: @Composable () -> TextStyle 41 | @Composable get() = LocalRichTextThemeConfiguration.current.textStyleProvider 42 | 43 | internal val RichTextScope.textStyleBackProvider: @Composable (TextStyle, @Composable () -> Unit) -> Unit 44 | @Composable get() = LocalRichTextThemeConfiguration.current.textStyleBackProvider 45 | 46 | internal val RichTextScope.contentColorProvider: @Composable () -> Color 47 | @Composable get() = LocalRichTextThemeConfiguration.current.contentColorProvider 48 | 49 | internal val RichTextScope.contentColorBackProvider: @Composable (Color, @Composable () -> Unit) -> Unit 50 | @Composable get() = LocalRichTextThemeConfiguration.current.contentColorBackProvider -------------------------------------------------------------------------------- /richtext-ui-material/src/commonMain/kotlin/com/halilibo/richtext/ui/material/RichText.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.material 2 | 3 | import androidx.compose.material.LocalContentColor 4 | import androidx.compose.material.LocalTextStyle 5 | import androidx.compose.material.ProvideTextStyle 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.CompositionLocalProvider 8 | import androidx.compose.runtime.compositionLocalOf 9 | import androidx.compose.ui.Modifier 10 | import com.halilibo.richtext.ui.BasicRichText 11 | import com.halilibo.richtext.ui.RichTextScope 12 | import com.halilibo.richtext.ui.RichTextStyle 13 | import com.halilibo.richtext.ui.RichTextThemeProvider 14 | 15 | /** 16 | * RichText implementation that integrates with Material design. 17 | * 18 | * If the consumer app has small composition trees or only uses RichText in 19 | * a single place, it would be ideal to call this function instead of wrapping 20 | * everything under [RichTextMaterialTheme]. 21 | */ 22 | @Composable 23 | public fun RichText( 24 | modifier: Modifier = Modifier, 25 | style: RichTextStyle? = null, 26 | children: @Composable RichTextScope.() -> Unit 27 | ) { 28 | RichTextMaterialTheme { 29 | BasicRichText( 30 | modifier = modifier, 31 | style = style, 32 | children = children 33 | ) 34 | } 35 | } 36 | 37 | /** 38 | * Wraps the given [child] with Material Theme integration for [BasicRichText]. 39 | * 40 | * This function also keeps track of the parent context by using CompositionLocals 41 | * to not apply Material Theming if it already exists in the current composition. 42 | */ 43 | @Composable 44 | internal fun RichTextMaterialTheme( 45 | child: @Composable () -> Unit 46 | ) { 47 | val isApplied = LocalMaterialThemingApplied.current 48 | 49 | if (!isApplied) { 50 | RichTextThemeProvider( 51 | textStyleProvider = { LocalTextStyle.current }, 52 | contentColorProvider = { LocalContentColor.current }, 53 | textStyleBackProvider = { textStyle, content -> 54 | ProvideTextStyle(textStyle, content) 55 | }, 56 | contentColorBackProvider = { color, content -> 57 | CompositionLocalProvider(LocalContentColor provides color) { 58 | content() 59 | } 60 | } 61 | ) { 62 | CompositionLocalProvider(LocalMaterialThemingApplied provides true) { 63 | child() 64 | } 65 | } 66 | } else { 67 | child() 68 | } 69 | } 70 | 71 | private val LocalMaterialThemingApplied = compositionLocalOf { false } 72 | -------------------------------------------------------------------------------- /richtext-ui-material3/src/commonMain/kotlin/com/halilibo/richtext/ui/material3/RichText.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.material3 2 | 3 | import androidx.compose.material3.LocalContentColor 4 | import androidx.compose.material3.LocalTextStyle 5 | import androidx.compose.material3.ProvideTextStyle 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.CompositionLocalProvider 8 | import androidx.compose.runtime.compositionLocalOf 9 | import androidx.compose.ui.Modifier 10 | import com.halilibo.richtext.ui.BasicRichText 11 | import com.halilibo.richtext.ui.RichTextScope 12 | import com.halilibo.richtext.ui.RichTextStyle 13 | import com.halilibo.richtext.ui.RichTextThemeProvider 14 | 15 | /** 16 | * RichText implementation that integrates with Material 3 design. 17 | * 18 | * If the consumer app has small composition trees or only uses RichText in 19 | * a single place, it would be ideal to call this function instead of wrapping 20 | * everything under [RichTextMaterialTheme]. 21 | */ 22 | @Composable 23 | public fun RichText( 24 | modifier: Modifier = Modifier, 25 | style: RichTextStyle? = null, 26 | children: @Composable RichTextScope.() -> Unit 27 | ) { 28 | RichTextMaterialTheme { 29 | BasicRichText( 30 | modifier = modifier, 31 | style = style, 32 | children = children 33 | ) 34 | } 35 | } 36 | 37 | /** 38 | * Wraps the given [child] with Material Theme integration for [BasicRichText]. 39 | * 40 | * This function also keeps track of the parent context by using CompositionLocals 41 | * to not apply Material Theming if it already exists in the current composition. 42 | */ 43 | @Composable 44 | internal fun RichTextMaterialTheme( 45 | child: @Composable () -> Unit 46 | ) { 47 | val isApplied = LocalMaterialThemingApplied.current 48 | 49 | if (!isApplied) { 50 | RichTextThemeProvider( 51 | textStyleProvider = { LocalTextStyle.current }, 52 | contentColorProvider = { LocalContentColor.current }, 53 | textStyleBackProvider = { textStyle, content -> 54 | ProvideTextStyle(textStyle, content) 55 | }, 56 | contentColorBackProvider = { color, content -> 57 | CompositionLocalProvider(LocalContentColor provides color) { 58 | content() 59 | } 60 | } 61 | ) { 62 | CompositionLocalProvider(LocalMaterialThemingApplied provides true) { 63 | child() 64 | } 65 | } 66 | } else { 67 | child() 68 | } 69 | } 70 | 71 | private val LocalMaterialThemingApplied = compositionLocalOf { false } -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextThemeProvider.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.text.TextStyle 7 | 8 | /** 9 | * Entry point for integrating app's own typography and theme system with RichText. 10 | * 11 | * API for this integration is highly influenced by how compose-material theming 12 | * is designed. RichText library assumes that almost all Theme/Design systems would 13 | * have composition locals that provide text style downstream. 14 | * 15 | * Moreover, text style should not include text color by best practice. Content color 16 | * exists to figure text color in current context. Light/Dark theming leverages content 17 | * color to influence not just text but other parts of theming as well. 18 | * 19 | * @param textStyleProvider Returns the current text style. 20 | * @param textStyleBackProvider RichText sometimes updates the current text style 21 | * e.g. Heading, CodeBlock, and etc. New style should be passed to the outer 22 | * theming to indicate that there is a need for update, so that children Text 23 | * composables use the correct styling. 24 | * @param contentColorProvider Returns the current content color. 25 | * @param contentColorBackProvider Similar to [textStyleBackProvider], does the same job 26 | * for content color. 27 | */ 28 | @Composable 29 | public fun RichTextThemeProvider( 30 | textStyleProvider: @Composable (() -> TextStyle)? = null, 31 | textStyleBackProvider: @Composable ((TextStyle, @Composable () -> Unit) -> Unit)? = null, 32 | contentColorProvider: @Composable (() -> Color)? = null, 33 | contentColorBackProvider: @Composable ((Color, @Composable () -> Unit) -> Unit)? = null, 34 | content: @Composable () -> Unit 35 | ) { 36 | CompositionLocalProvider( 37 | LocalRichTextThemeConfiguration provides 38 | RichTextThemeConfiguration( 39 | textStyleProvider = textStyleProvider 40 | ?: RichTextThemeConfiguration.Default.textStyleProvider, 41 | textStyleBackProvider = textStyleBackProvider 42 | ?: RichTextThemeConfiguration.Default.textStyleBackProvider, 43 | contentColorProvider = contentColorProvider 44 | ?: RichTextThemeConfiguration.Default.contentColorProvider, 45 | contentColorBackProvider = contentColorBackProvider 46 | ?: RichTextThemeConfiguration.Default.contentColorBackProvider, 47 | ) 48 | ) { 49 | content() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/TableLayout.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.Immutable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.layout.SubcomposeLayout 8 | import androidx.compose.ui.unit.Constraints 9 | import kotlin.math.roundToInt 10 | 11 | 12 | /** 13 | * The offsets of rows and columns of a [TableLayout], centered inside their spacing. 14 | * 15 | * E.g. If a table is given a cell spacing of 2px, then the first column and row offset will each 16 | * be 1px. 17 | */ 18 | @Immutable 19 | internal data class TableLayoutResult( 20 | val rowOffsets: List, 21 | val columnOffsets: List 22 | ) 23 | 24 | @Composable 25 | internal fun TableLayout( 26 | columns: Int, 27 | rows: List Unit>>, 28 | hasHeader: Boolean, 29 | drawDecorations: (TableLayoutResult) -> Modifier, 30 | cellSpacing: Float, 31 | tableMeasurer: TableMeasurer, 32 | modifier: Modifier = Modifier 33 | ) { 34 | SubcomposeLayout(modifier = modifier) { constraints -> 35 | // Subcompose all cells in one pass. 36 | val measurables = subcompose("cells") { 37 | rows.forEach { row -> 38 | check(row.size == columns) 39 | row.forEach { cell -> cell() } 40 | } 41 | } 42 | 43 | val rowMeasurables = measurables.chunked(columns) 44 | val measurements = tableMeasurer.measure(rowMeasurables, constraints) 45 | 46 | val tableWidth = measurements.columnWidths.sum() + 47 | (cellSpacing * (columns + 1)).roundToInt() 48 | 49 | val tableHeight = measurements.rowHeights.sum() + 50 | (cellSpacing * (measurements.rowHeights.size + 1)).roundToInt() 51 | 52 | layout(tableWidth, tableHeight) { 53 | var y = cellSpacing 54 | val rowOffsets = mutableListOf() 55 | val columnOffsets = mutableListOf() 56 | 57 | measurements.rowPlaceables.forEachIndexed { rowIndex, cellPlaceables -> 58 | rowOffsets += y - cellSpacing / 2f 59 | var x = cellSpacing 60 | 61 | cellPlaceables.forEachIndexed { columnIndex, cell -> 62 | if (rowIndex == 0) { 63 | columnOffsets.add(x - cellSpacing / 2f) 64 | } 65 | 66 | val cellY = if (hasHeader && rowIndex == 0) { 67 | // Header is bottom-aligned 68 | y + (measurements.rowHeights[0] - cell.height) 69 | } else { 70 | y 71 | } 72 | cell.place(x.roundToInt(), cellY.roundToInt()) 73 | x += measurements.columnWidths[columnIndex] + cellSpacing 74 | } 75 | 76 | if (rowIndex == 0) { 77 | // Add the right-most edge. 78 | columnOffsets.add(x - cellSpacing / 2f) 79 | } 80 | 81 | y += measurements.rowHeights[rowIndex] + cellSpacing 82 | } 83 | 84 | rowOffsets.add(y - cellSpacing / 2f) 85 | 86 | // Compose and draw the borders. 87 | val layoutResult = TableLayoutResult(rowOffsets, columnOffsets) 88 | subcompose(true) { 89 | Box(modifier = drawDecorations(layoutResult)) 90 | }.single() 91 | .measure(Constraints.fixed(tableWidth, tableHeight)) 92 | .placeRelative(0, 0) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/MarkdownImage.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.Base64 5 | import androidx.compose.foundation.Image 6 | import androidx.compose.foundation.layout.BoxWithConstraints 7 | import androidx.compose.foundation.layout.BoxWithConstraintsScope 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.collectAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.geometry.isSpecified 15 | import androidx.compose.ui.layout.ContentScale 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.unit.dp 19 | import coil3.compose.rememberAsyncImagePainter 20 | import coil3.request.ImageRequest 21 | import coil3.request.crossfade 22 | import coil3.size.Size 23 | 24 | private val DEFAULT_IMAGE_SIZE = 64.dp 25 | 26 | /** 27 | * Implementation of MarkdownImage by using Coil library for Android. 28 | */ 29 | @Composable 30 | internal actual fun MarkdownImage( 31 | url: String, 32 | contentDescription: String?, 33 | modifier: Modifier, 34 | contentScale: ContentScale 35 | ) { 36 | val data = if (url.startsWith("data:image") && url.contains("base64")) { 37 | val base64ImageString = url.substringAfter("base64,") 38 | Base64.decode(base64ImageString, Base64.DEFAULT) 39 | } else { 40 | url 41 | } 42 | val painter = rememberAsyncImagePainter( 43 | ImageRequest.Builder(LocalContext.current) 44 | .data(data = data) 45 | .size(Size.ORIGINAL) 46 | .crossfade(true) 47 | .build() 48 | ) 49 | 50 | @SuppressLint("UnusedBoxWithConstraintsScope") 51 | BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { 52 | val painterState by painter.state.collectAsState() 53 | val sizeModifier = renderInSize(painterState.painter?.intrinsicSize) 54 | 55 | Image( 56 | painter = painter, 57 | contentDescription = contentDescription, 58 | modifier = sizeModifier, 59 | contentScale = contentScale 60 | ) 61 | } 62 | } 63 | 64 | @Composable 65 | public fun BoxWithConstraintsScope.renderInSize( 66 | painterIntrinsicSize: androidx.compose.ui.geometry.Size?, 67 | ): Modifier { 68 | val density = LocalDensity.current 69 | 70 | val sizeModifier = if (painterIntrinsicSize != null && 71 | painterIntrinsicSize.isSpecified && 72 | painterIntrinsicSize.width != Float.POSITIVE_INFINITY && 73 | painterIntrinsicSize.height != Float.POSITIVE_INFINITY 74 | ) { 75 | val width = painterIntrinsicSize.width 76 | val height = painterIntrinsicSize.height 77 | val scale = if (width > constraints.maxWidth) { 78 | constraints.maxWidth.toFloat() / width 79 | } else { 80 | 1f 81 | } 82 | 83 | with(density) { 84 | Modifier.size( 85 | (width * scale).toDp(), 86 | (height * scale).toDp() 87 | ) 88 | } 89 | } else { 90 | // if size is not defined at all, Coil fails to render the image 91 | // here, we give a default size for images until they are loaded. 92 | Modifier.size(DEFAULT_IMAGE_SIZE) 93 | } 94 | 95 | return sizeModifier 96 | } 97 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/TableMeasurer.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.ui.layout.Measurable 4 | import androidx.compose.ui.layout.Placeable 5 | import androidx.compose.ui.unit.Constraints 6 | import androidx.compose.ui.unit.constrain 7 | import com.halilibo.richtext.ui.TableMeasurer.Measurements 8 | import kotlin.math.roundToInt 9 | 10 | private const val MinCellWidth = 10 11 | 12 | internal interface TableMeasurer { 13 | fun measure( 14 | rowMeasurables: List>, 15 | constraints: Constraints, 16 | ): Measurements 17 | 18 | data class Measurements( 19 | val rowPlaceables: List>, 20 | val columnWidths: List, 21 | val rowHeights: List, 22 | ) 23 | } 24 | 25 | internal class AdaptiveTableMeasurer( 26 | private val maxCellWidthPx: Int, 27 | ): TableMeasurer { 28 | override fun measure( 29 | rowMeasurables: List>, 30 | constraints: Constraints, 31 | ): Measurements { 32 | val columns = rowMeasurables[0].size 33 | val cellConstraints = Constraints(maxWidth = maxCellWidthPx) 34 | val rowPlaceables = rowMeasurables.map { row -> 35 | row.map { measurable -> 36 | measurable.measure(cellConstraints) 37 | } 38 | } 39 | 40 | // Determine the width for each column 41 | val columnWidths = (0 until columns).map { colIndex -> 42 | val measuredMax = rowPlaceables.maxOfOrNull { it[colIndex].width } ?: 0 43 | maxOf(measuredMax, MinCellWidth) 44 | } 45 | 46 | // Each row’s height is the maximum cell height in that row. 47 | val rowHeights = rowPlaceables.map { row -> 48 | row.maxOf { it.height } 49 | } 50 | 51 | return Measurements(rowPlaceables, columnWidths, rowHeights) 52 | } 53 | } 54 | 55 | internal class UniformTableMeasurer( 56 | private val cellSpacing: Float 57 | ) : TableMeasurer { 58 | override fun measure( 59 | rowMeasurables: List>, 60 | constraints: Constraints, 61 | ): Measurements { 62 | check(constraints.hasBoundedWidth) { "Uniform tables must have bounded width" } 63 | 64 | val columns = rowMeasurables[0].size 65 | // Divide the width by the number of columns, then leave room for the padding. 66 | val cellSpacingWidth = cellSpacing * (columns + 1) 67 | val cellWidth = maxOf( 68 | (constraints.maxWidth - cellSpacingWidth) / columns, 69 | MinCellWidth.toFloat() 70 | ) 71 | // TODO Handle bounded height constraints. 72 | //val cellSpacingHeight = cellSpacing * (rowMeasurables.size + 1) 73 | // val cellMaxHeight = if (!constraints.hasBoundedHeight) { 74 | // Float.MAX_VALUE 75 | // } else { 76 | // // Divide the height by the number of rows, then leave room for the padding. 77 | // (constraints.maxHeight - cellSpacingHeight) / rowMeasurables.size 78 | // } 79 | val cellConstraints = Constraints(maxWidth = cellWidth.roundToInt()).constrain(constraints) 80 | 81 | val rowPlaceables = rowMeasurables.map { cellMeasurables -> 82 | cellMeasurables.map { cell -> 83 | cell.measure(cellConstraints) 84 | } 85 | } 86 | val rowHeights = rowPlaceables.map { row -> row.maxByOrNull { it.height }!!.height } 87 | val columnWidths = List(columns) { cellWidth.roundToInt() } 88 | 89 | return Measurements(rowPlaceables, columnWidths, rowHeights) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/Heading.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.halilibo.richtext.ui 4 | 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.takeOrElse 8 | import androidx.compose.ui.platform.LocalLayoutDirection 9 | import androidx.compose.ui.semantics.heading 10 | import androidx.compose.ui.semantics.semantics 11 | import androidx.compose.ui.text.TextStyle 12 | import androidx.compose.ui.text.font.FontStyle.Companion.Italic 13 | import androidx.compose.ui.text.font.FontWeight 14 | import androidx.compose.ui.text.resolveDefaults 15 | import androidx.compose.ui.unit.sp 16 | 17 | 18 | /** 19 | * Function that computes the [TextStyle] for the given header level, given the current [TextStyle] 20 | * for this point in the composition. Note that the [TextStyle] passed into this function will be 21 | * fully resolved. The returned style will then be _merged_ with the passed-in text style, so any 22 | * unspecified properties will be inherited. 23 | */ 24 | // TODO factor a generic "block style" thing out, use for code block, quote block, and this, to 25 | // also allow controlling top/bottom space. 26 | public typealias HeadingStyle = (level: Int, textStyle: TextStyle) -> TextStyle 27 | 28 | internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> 29 | when (level) { 30 | 0 -> TextStyle( 31 | fontSize = 36.sp, 32 | fontWeight = FontWeight.Bold 33 | ) 34 | 1 -> TextStyle( 35 | fontSize = 26.sp, 36 | fontWeight = FontWeight.Bold 37 | ) 38 | 2 -> TextStyle( 39 | fontSize = 22.sp, 40 | fontWeight = FontWeight.Bold, 41 | color = textStyle.color.copy(alpha = .7F) 42 | ) 43 | 3 -> TextStyle( 44 | fontSize = 20.sp, 45 | fontWeight = FontWeight.Bold, 46 | fontStyle = Italic 47 | ) 48 | 4 -> TextStyle( 49 | fontSize = 18.sp, 50 | fontWeight = FontWeight.Bold, 51 | color = textStyle.color.copy(alpha = .7F) 52 | ) 53 | 5 -> TextStyle( 54 | fontWeight = FontWeight.Bold, 55 | color = textStyle.color.copy(alpha = .5f) 56 | ) 57 | else -> textStyle 58 | } 59 | } 60 | 61 | /** 62 | * A section heading. 63 | * 64 | * @param level The non-negative rank of the header, with 0 being the most important. 65 | */ 66 | @Composable public fun RichTextScope.Heading( 67 | level: Int, 68 | text: String 69 | ) { 70 | Heading(level) { 71 | Text(text, Modifier.semantics { heading() }) 72 | } 73 | } 74 | 75 | /** 76 | * A section heading. 77 | * 78 | * @param level The non-negative rank of the header, with 0 being the most important. 79 | */ 80 | @Composable public fun RichTextScope.Heading( 81 | level: Int, 82 | children: @Composable RichTextScope.() -> Unit 83 | ) { 84 | require(level >= 0) { "Level must be at least 0" } 85 | 86 | val incomingStyle = currentTextStyle.let { 87 | it.copy(color = it.color.takeOrElse { currentContentColor }) 88 | } 89 | val currentTextStyle = resolveDefaults(incomingStyle, LocalLayoutDirection.current) 90 | 91 | val headingStyleFunction = currentRichTextStyle.resolveDefaults().headingStyle!! 92 | val headingTextStyle = headingStyleFunction(level, currentTextStyle) 93 | val mergedTextStyle = currentTextStyle.merge(headingTextStyle) 94 | 95 | textStyleBackProvider(mergedTextStyle) { 96 | children() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.text.BasicText 4 | import androidx.compose.foundation.text.InlineTextContent 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.compositionLocalOf 7 | import androidx.compose.runtime.mutableStateOf 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.geometry.Offset 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.graphics.takeOrElse 13 | import androidx.compose.ui.input.pointer.pointerInput 14 | import androidx.compose.ui.text.AnnotatedString 15 | import androidx.compose.ui.text.TextLayoutResult 16 | import androidx.compose.ui.text.TextStyle 17 | import androidx.compose.ui.text.style.TextOverflow 18 | import com.halilibo.richtext.ui.util.detectTapGesturesIf 19 | 20 | /** 21 | * Carries the text style in Composition tree. [Heading], [CodeBlock], 22 | * [BlockQuote] are designed to change the ongoing [TextStyle] in composition, 23 | * so that their children can use the modified text style implicitly. 24 | * 25 | * LocalTextStyle also exists in Material package but this one is internal 26 | * to RichText. 27 | */ 28 | internal val LocalInternalTextStyle = compositionLocalOf { TextStyle.Default } 29 | 30 | /** 31 | * Carries the content color in Composition tree. Default TextStyle 32 | * does not have text color specified. It defaults to [Color.Black] 33 | * in the "resolve chain" but Dark Mode is an exception. To also resolve 34 | * for Dark Mode, content color should be passed to [RichTextScope]. 35 | */ 36 | internal val LocalInternalContentColor = compositionLocalOf { Color.Black } 37 | 38 | /** 39 | * The current [TextStyle]. 40 | */ 41 | internal val RichTextScope.currentTextStyle: TextStyle 42 | @Composable get() = textStyleProvider() 43 | 44 | /** 45 | * The current content [Color]. 46 | */ 47 | internal val RichTextScope.currentContentColor: Color 48 | @Composable get() = contentColorProvider() 49 | 50 | /** 51 | * Intended for preview composables. 52 | */ 53 | @Composable 54 | internal fun RichTextScope.Text( 55 | text: String, 56 | modifier: Modifier = Modifier, 57 | onTextLayout: (TextLayoutResult) -> Unit = {}, 58 | overflow: TextOverflow = TextOverflow.Clip, 59 | softWrap: Boolean = true, 60 | maxLines: Int = Int.MAX_VALUE 61 | ) { 62 | val textColor = currentTextStyle.color.takeOrElse { currentContentColor } 63 | val style = currentTextStyle.copy(color = textColor) 64 | 65 | BasicText( 66 | text = text, 67 | modifier = modifier, 68 | style = style, 69 | onTextLayout = onTextLayout, 70 | overflow = overflow, 71 | softWrap = softWrap, 72 | maxLines = maxLines 73 | ) 74 | } 75 | 76 | @Composable 77 | internal fun RichTextScope.Text( 78 | text: AnnotatedString, 79 | modifier: Modifier = Modifier, 80 | onTextLayout: (TextLayoutResult) -> Unit = {}, 81 | overflow: TextOverflow = TextOverflow.Clip, 82 | softWrap: Boolean = true, 83 | maxLines: Int = Int.MAX_VALUE, 84 | inlineContent: Map = mapOf(), 85 | ) { 86 | val textColor = currentTextStyle.color.takeOrElse { currentContentColor } 87 | val style = currentTextStyle.copy(color = textColor) 88 | 89 | BasicText( 90 | text = text, 91 | modifier = modifier, 92 | style = style, 93 | onTextLayout = onTextLayout, 94 | overflow = overflow, 95 | softWrap = softWrap, 96 | maxLines = maxLines, 97 | inlineContent = inlineContent 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.commonmark 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.produceState 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.rememberUpdatedState 9 | import com.halilibo.richtext.markdown.AstBlockNodeComposer 10 | import com.halilibo.richtext.markdown.BasicMarkdown 11 | import com.halilibo.richtext.markdown.ContentOverride 12 | import com.halilibo.richtext.markdown.InlineContentOverride 13 | import com.halilibo.richtext.markdown.node.AstNode 14 | import com.halilibo.richtext.ui.RichTextScope 15 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 16 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 17 | import org.commonmark.node.Node 18 | 19 | /** 20 | * A composable that renders Markdown content according to Commonmark specification using RichText. 21 | * 22 | * @param content Markdown text. No restriction on length. 23 | * @param markdownParseOptions Options for the Markdown parser. 24 | * @param astBlockNodeComposer An interceptor to take control of composing any block type node's 25 | * rendering. Use it to render images, html text, tables with your own components. 26 | */ 27 | @Composable 28 | public fun RichTextScope.Markdown( 29 | content: String, 30 | markdownParseOptions: CommonMarkdownParseOptions = CommonMarkdownParseOptions.Default, 31 | richtextRenderOptions: RichTextRenderOptions = RichTextRenderOptions.Default, 32 | contentOverride: ContentOverride? = null, 33 | inlineContentOverride: InlineContentOverride? = null, 34 | astBlockNodeComposer: AstBlockNodeComposer? = null 35 | ) { 36 | val commonmarkAstNodeParser = remember(markdownParseOptions) { 37 | CommonmarkAstNodeParser(markdownParseOptions) 38 | } 39 | 40 | val astRootNode by produceState( 41 | initialValue = null, 42 | key1 = commonmarkAstNodeParser, 43 | key2 = content 44 | ) { 45 | value = commonmarkAstNodeParser.parse(content) 46 | } 47 | 48 | astRootNode?.let { 49 | BasicMarkdown( 50 | astNode = it, 51 | contentOverride = contentOverride, 52 | inlineContentOverride = inlineContentOverride, 53 | richTextRenderOptions = richtextRenderOptions, 54 | astBlockNodeComposer = astBlockNodeComposer, 55 | ) 56 | } 57 | } 58 | 59 | /** 60 | * A composable that renders Markdown node using RichText. 61 | * 62 | * @param content CommonMark node to render. 63 | * @param onLinkClicked A function to invoke when a link is clicked from rendered content. 64 | */ 65 | @Composable 66 | public fun RichTextScope.Markdown( 67 | content: Node, 68 | richtextRenderOptions: RichTextRenderOptions = RichTextRenderOptions.Default, 69 | contentOverride: ContentOverride? = null, 70 | inlineContentOverride: InlineContentOverride? = null, 71 | astBlockNodeComposer: AstBlockNodeComposer? = null 72 | ) { 73 | val astNode = content.toAstNode() ?: return 74 | BasicMarkdown( 75 | astNode, 76 | contentOverride, 77 | inlineContentOverride, 78 | richtextRenderOptions, 79 | astBlockNodeComposer, 80 | ) 81 | } 82 | 83 | /** 84 | * A helper class that can convert any text content into an ASTNode tree and return its root. 85 | */ 86 | public expect class CommonmarkAstNodeParser( 87 | options: CommonMarkdownParseOptions = CommonMarkdownParseOptions.Default 88 | ) { 89 | 90 | /** 91 | * Parse markdown content and return Abstract Syntax Tree(AST). 92 | * 93 | * @param text Markdown text to be parsed. 94 | * @param options Options for the Commonmark Markdown parser. 95 | */ 96 | public fun parse(text: String): AstNode 97 | } 98 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextStyle.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.Immutable 6 | import androidx.compose.runtime.compositionLocalOf 7 | import androidx.compose.ui.unit.TextUnit 8 | import androidx.compose.ui.unit.sp 9 | import com.halilibo.richtext.ui.string.RichTextStringStyle 10 | 11 | internal val LocalRichTextStyle = compositionLocalOf { RichTextStyle.Default } 12 | internal val DefaultParagraphSpacing: TextUnit = 8.sp 13 | 14 | /** 15 | * Configures all formatting attributes for drawing rich text. 16 | * 17 | * @param paragraphSpacing The amount of space in between blocks of text. 18 | * @param headingStyle The [HeadingStyle] that defines how [Heading]s are drawn. 19 | * @param listStyle The [ListStyle] used to format [FormattedList]s. 20 | * @param blockQuoteGutter The [BlockQuoteGutter] used to draw [BlockQuote]s. 21 | * @param codeBlockStyle The [CodeBlockStyle] that defines how [CodeBlock]s are drawn. 22 | * @param tableStyle The [TableStyle] used to render [Table]s. 23 | * @param stringStyle The [RichTextStringStyle] used to render 24 | * [RichTextString][com.halilibo.richtext.ui.string.RichTextString]s 25 | */ 26 | @Immutable 27 | public data class RichTextStyle( 28 | val paragraphSpacing: TextUnit? = null, 29 | val headingStyle: HeadingStyle? = null, 30 | val listStyle: ListStyle? = null, 31 | val blockQuoteGutter: BlockQuoteGutter? = null, 32 | val codeBlockStyle: CodeBlockStyle? = null, 33 | val tableStyle: TableStyle? = null, 34 | val horizontalRuleStyle: HorizontalRuleStyle? = null, 35 | val infoPanelStyle: InfoPanelStyle? = null, 36 | val stringStyle: RichTextStringStyle? = null, 37 | ) { 38 | public companion object { 39 | public val Default: RichTextStyle = RichTextStyle() 40 | } 41 | } 42 | 43 | public fun RichTextStyle.merge(otherStyle: RichTextStyle?): RichTextStyle = RichTextStyle( 44 | paragraphSpacing = otherStyle?.paragraphSpacing ?: paragraphSpacing, 45 | headingStyle = otherStyle?.headingStyle ?: headingStyle, 46 | listStyle = otherStyle?.listStyle ?: listStyle, 47 | blockQuoteGutter = otherStyle?.blockQuoteGutter ?: blockQuoteGutter, 48 | codeBlockStyle = otherStyle?.codeBlockStyle ?: codeBlockStyle, 49 | tableStyle = otherStyle?.tableStyle ?: tableStyle, 50 | horizontalRuleStyle = otherStyle?.horizontalRuleStyle ?: horizontalRuleStyle, 51 | infoPanelStyle = otherStyle?.infoPanelStyle ?: infoPanelStyle, 52 | stringStyle = stringStyle?.merge(otherStyle?.stringStyle) ?: otherStyle?.stringStyle, 53 | ) 54 | 55 | public fun RichTextStyle.resolveDefaults(): RichTextStyle = RichTextStyle( 56 | paragraphSpacing = paragraphSpacing ?: DefaultParagraphSpacing, 57 | headingStyle = headingStyle ?: DefaultHeadingStyle, 58 | listStyle = (listStyle ?: ListStyle.Default).resolveDefaults(), 59 | blockQuoteGutter = blockQuoteGutter ?: DefaultBlockQuoteGutter, 60 | codeBlockStyle = (codeBlockStyle ?: CodeBlockStyle.Default).resolveDefaults(), 61 | tableStyle = (tableStyle ?: TableStyle.Default).resolveDefaults(), 62 | horizontalRuleStyle = (horizontalRuleStyle ?: HorizontalRuleStyle.Default).resolveDefaults(), 63 | infoPanelStyle = (infoPanelStyle ?: InfoPanelStyle.Default).resolveDefaults(), 64 | stringStyle = (stringStyle ?: RichTextStringStyle.Default).resolveDefaults() 65 | ) 66 | 67 | /** 68 | * The current [RichTextStyle]. 69 | */ 70 | public val RichTextScope.currentRichTextStyle: RichTextStyle 71 | @Composable get() = LocalRichTextStyle.current 72 | 73 | /** 74 | * Sets the [RichTextStyle] for its [children]. 75 | */ 76 | @Composable 77 | public fun RichTextScope.WithStyle( 78 | style: RichTextStyle?, 79 | children: @Composable RichTextScope.() -> Unit 80 | ) { 81 | if (style == null) { 82 | children() 83 | } else { 84 | val mergedStyle = LocalRichTextStyle.current.merge(style) 85 | CompositionLocalProvider(LocalRichTextStyle provides mergedStyle) { 86 | children() 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/InfoPanel.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.Stable 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.unit.dp 16 | import com.halilibo.richtext.ui.InfoPanelType.Danger 17 | import com.halilibo.richtext.ui.InfoPanelType.Primary 18 | import com.halilibo.richtext.ui.InfoPanelType.Secondary 19 | import com.halilibo.richtext.ui.InfoPanelType.Success 20 | import com.halilibo.richtext.ui.InfoPanelType.Warning 21 | 22 | @Stable 23 | public data class InfoPanelStyle( 24 | val contentPadding: PaddingValues? = null, 25 | val background: @Composable ((InfoPanelType) -> Modifier)? = null, 26 | val textStyle: @Composable ((InfoPanelType) -> TextStyle)? = null 27 | ) { 28 | public companion object { 29 | public val Default: InfoPanelStyle = InfoPanelStyle() 30 | } 31 | } 32 | 33 | public enum class InfoPanelType { 34 | Primary, 35 | Secondary, 36 | Success, 37 | Danger, 38 | Warning 39 | } 40 | 41 | private val DefaultContentPadding = PaddingValues(8.dp) 42 | private val DefaultInfoPanelBackground = @Composable { infoPanelType: InfoPanelType -> 43 | remember { 44 | val (borderColor, backgroundColor) = when (infoPanelType) { 45 | Primary -> Color(0xffb8daff) to Color(0xffcce5ff) 46 | Secondary -> Color(0xffd6d8db) to Color(0xffe2e3e5) 47 | Success -> Color(0xffc3e6cb) to Color(0xffd4edda) 48 | Danger -> Color(0xfff5c6cb) to Color(0xfff8d7da) 49 | Warning -> Color(0xffffeeba) to Color(0xfffff3cd) 50 | } 51 | 52 | Modifier 53 | .border(1.dp, borderColor, RoundedCornerShape(4.dp)) 54 | .background(backgroundColor, RoundedCornerShape(4.dp)) 55 | } 56 | } 57 | 58 | private val DefaultInfoPanelTextStyle = @Composable { infoPanelType: InfoPanelType -> 59 | remember { 60 | val color = when(infoPanelType) { 61 | Primary -> Color(0xff004085) 62 | Secondary -> Color(0xff383d41) 63 | Success -> Color(0xff155724) 64 | Danger -> Color(0xff721c24) 65 | Warning -> Color(0xff856404) 66 | } 67 | TextStyle(color = color) 68 | } 69 | } 70 | 71 | internal fun InfoPanelStyle.resolveDefaults() = InfoPanelStyle( 72 | contentPadding = contentPadding ?: DefaultContentPadding, 73 | background = background ?: DefaultInfoPanelBackground, 74 | textStyle = textStyle ?: DefaultInfoPanelTextStyle 75 | ) 76 | 77 | /** 78 | * A panel to show content similar to Bootstrap alerts, categorized as [InfoPanelType]. 79 | * This composable is a shortcut to show only [text] in an info panel. 80 | */ 81 | @Composable 82 | public fun RichTextScope.InfoPanel( 83 | infoPanelType: InfoPanelType, 84 | text: String 85 | ) { 86 | InfoPanel(infoPanelType) { 87 | Text(text) 88 | } 89 | } 90 | 91 | /** 92 | * A panel to show content similar to Bootstrap alerts, categorized as [InfoPanelType]. 93 | */ 94 | @Composable 95 | public fun RichTextScope.InfoPanel( 96 | infoPanelType: InfoPanelType, 97 | content: @Composable () -> Unit 98 | ) { 99 | val infoPanelStyle = currentRichTextStyle.resolveDefaults().infoPanelStyle!! 100 | val backgroundModifier = infoPanelStyle.background!!.invoke(infoPanelType) 101 | val infoPanelTextStyle = infoPanelStyle.textStyle!!.invoke(infoPanelType) 102 | 103 | val resolvedTextStyle = currentTextStyle.merge(infoPanelTextStyle) 104 | 105 | textStyleBackProvider(resolvedTextStyle) { 106 | Box(modifier = backgroundModifier.padding(infoPanelStyle.contentPadding!!)) { 107 | content() 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /docs/richtext-commonmark.md: -------------------------------------------------------------------------------- 1 | # Commonmark Markdown 2 | 3 | [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) 4 | [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) 5 | 6 | Library for parsing and rendering Markdown in Compose using [CommonMark](https://github.com/commonmark/commonmark-java) 7 | library/spec to parse, and [richtext-markdown](../richtext-markdown/) to render. 8 | 9 | ## Gradle 10 | 11 | ```kotlin 12 | dependencies { 13 | implementation("com.halilibo.compose-richtext:richtext-commonmark:${richtext_version}") 14 | } 15 | ``` 16 | 17 | ## Parsing 18 | 19 | `richtext-markdown` module renders a given Markdown Abstract Syntax Tree. It accepts a root 20 | `AstNode`. This library gives you a parser called `CommonmarkAstNodeParser` to easily convert any 21 | String to an `AstNode` that represents the Markdown tree. 22 | 23 | ```kotlin 24 | val parser = CommonmarkAstNodeParser() 25 | val astNode = parser.parse( 26 | """ 27 | # Demo 28 | 29 | Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. Combined emphasis with **asterisks and _underscores_**. [Links with two blocks, text in square-brackets, destination is in parentheses.](https://www.example.com). Inline `code` has `back-ticks around` it. 30 | 31 | 1. First ordered list item 32 | 2. Another item 33 | * Unordered sub-list. 34 | 3. And another item. 35 | 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). 36 | 37 | * Unordered list can use asterisks 38 | - Or minuses 39 | + Or pluses 40 | """.trimIndent() 41 | ) 42 | // ... 43 | 44 | RichTextScope.BasicMarkdown(astNode) 45 | ``` 46 | 47 | ## Rendering 48 | 49 | The simplest way to render markdown is just pass a string to the [`Markdown`](../api/richtext-commonmark/com.halilibo.richtext.markdown/-markdown.html) 50 | composable under RichText scope: 51 | 52 | ~~~kotlin 53 | RichText( 54 | modifier = Modifier.padding(16.dp) 55 | ) { 56 | Markdown( 57 | """ 58 | # Demo 59 | 60 | Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. Combined emphasis with **asterisks and _underscores_**. [Links with two blocks, text in square-brackets, destination is in parentheses.](https://www.example.com). Inline `code` has `back-ticks around` it. 61 | 62 | 1. First ordered list item 63 | 2. Another item 64 | * Unordered sub-list. 65 | 3. And another item. 66 | 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). 67 | 68 | * Unordered list can use asterisks 69 | - Or minuses 70 | + Or pluses 71 | --- 72 | 73 | ```javascript 74 | var s = "code blocks use monospace font"; 75 | alert(s); 76 | ``` 77 | 78 | Markdown | Table | Extension 79 | --- | --- | --- 80 | *renders* | `beautiful images` | ![random image](https://picsum.photos/seed/picsum/400/400 "Text 1") 81 | 1 | 2 | 3 82 | 83 | > Blockquotes are very handy in email to emulate reply text. 84 | > This line is part of the same quote. 85 | """.trimIndent() 86 | ) 87 | } 88 | ~~~ 89 | 90 | Which produces something like this: 91 | 92 | ![markdown demo](img/markdown-demo.png) 93 | 94 | ## [`MarkdownParseOptions`](../api/richtext-commonmark/com.halilibo.richtext.commonmark/-markdown-parse-options.html) 95 | 96 | Passing `MarkdownParseOptions` into either `Markdown` composable or `CommonmarkAstNodeParser.parse` method provides the ability to control some aspects of the markdown parser: 97 | 98 | ```kotlin 99 | val markdownParseOptions = MarkdownParseOptions( 100 | autolink = false 101 | ) 102 | 103 | Markdown( 104 | markdownParseOptions = markdownParseOptions 105 | ) 106 | ``` 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.0.0-alpha03 5 | ------- 6 | 7 | This release removes the `printing` and `slideshow` modules to focus on the core Markdown and RichText functionalities. It also adds support for inline base64 images. 8 | 9 | ### Breaking Changes 10 | - The `printing` and `slideshow` modules have been removed. If you were using them, you will need to find an alternative or use a previous version of the library. 11 | 12 | ### New Features 13 | - **Inline Base64 Image Rendering**: Markdown images can now be rendered from inline base64-encoded data URIs. 14 | 15 | ### Updates & Maintenance 16 | - **Dependencies Updated**: 17 | - Compose Multiplatform updated to `1.8.2`. 18 | - Commonmark updated to `0.25.0`. 19 | - Dokka updated to `2.0.0`. 20 | - **Build & CI**: 21 | - Android Gradle Plugin and other dependencies have been updated. 22 | - CI now uses `actions/cache@v4`. 23 | - **Sample App**: 24 | - The Android sample app has been updated to reflect the removal of the `printing` and `slideshow` modules. 25 | - Theme handling in the sample app has been simplified. 26 | 27 | v0.11.0 28 | ------ 29 | 30 | _2022_02_09_ 31 | 32 | * Upgrade Coil to 2.0.0-alpha06 by @msfjarvis in https://github.com/halilozercan/compose-richtext/pull/72 33 | 34 | ## New Contributors 35 | * @msfjarvis made their first contribution in https://github.com/halilozercan/compose-richtext/pull/72 36 | 37 | **Full Changelog**: https://github.com/halilozercan/compose-richtext/compare/v0.10.0...v0.11.0 38 | 39 | v0.10.0 40 | ------ 41 | 42 | _2021_12_05_ 43 | 44 | This release celebrates the release of Compose Multiplatform 1.0.0 🎉🥳 45 | 46 | v0.9.0 47 | ------ 48 | 49 | _2021_11_20_ 50 | 51 | This release is mostly a version bump. 52 | - Jetpack Compose: 1.1.0-beta03 53 | - Jetbrains Compose: 1.0.0-beta5 54 | - Kotlin: 1.5.31 55 | 56 | Other changes: 57 | 58 | * Fix link formatting in index page of docs by in https://github.com/halilozercan/compose-richtext/pull/60 59 | * CodeBlock fixes in https://github.com/halilozercan/compose-richtext/pull/62 60 | * Update CHANGELOG.md to include releases after the transfer in https://github.com/halilozercan/compose-richtext/pull/64 61 | * Add info panels similar to bootstrap alerts #54 in https://github.com/halilozercan/compose-richtext/pull/63 62 | 63 | 64 | **Full Changelog**: https://github.com/halilozercan/compose-richtext/compare/v0.8.1...v0.9.0 65 | 66 | v0.8.1 67 | ------ 68 | 69 | _2021-9-11_ 70 | 71 | This release fixes JVM artifact issue #59 72 | 73 | v0.8.0 74 | ------ 75 | 76 | _2021-9-8_ 77 | 78 | Compose Richtext goes KMP, opening RichText UI and its extensions to both Android and Desktop (#50) 79 | 80 | Special thanks @zach-klippenstein @LouisCAD @russhwolf for their reviews and help. 81 | 82 | * Richtext UI, Richtext UI Material, and RichText Commonmark are now KMP Compose libraries 83 | * Slideshow, Printing remains Android only for the foreseeable future 84 | * Updated docs 85 | * A new CI compatible release configuration 86 | 87 | v0.7.0 88 | ------ 89 | 90 | _2021-8-6_ 91 | 92 | * RichText UI no longer depends on Material (#45) 93 | * A new artifact richtext-ui-material is published to easily integrate RichText for apps that use Material design. 94 | * Upgraded compose to 1.0.1 and kotlin to 1.5.21 95 | 96 | v0.6.0 97 | ------ 98 | 99 | _2021-7-29_ 100 | 101 | * **Compose 1.0.0 support** (#43) 102 | * Upgrade to Gradle 7.0.2 (#40) 103 | * Fix wrong word used. portrait -> landscape (#37 - thanks @LouisCAD) 104 | * Repository has migrated from @zach-klippenstein to @halilozercan. 105 | * Artifacts have moved from com.zachklipp.compose-richtext to com.halilibo.compose-richtext. 106 | * Similarly, documentation is also now available at halilibo.com/compose-richtext 107 | 108 | v0.5.0 109 | ------ 110 | 111 | _2021-5-18_ 112 | 113 | * **Compose Beta 7 support!** (#36) 114 | * Fix several bugs in Table, RichTextStyle and improve InlineContent (#35 – thanks @halilozercan!) 115 | 116 | v0.2.0 117 | ------ 118 | 119 | _2021-2-27_ 120 | 121 | * **Compose Beta 1 support!** 122 | * Remove BulletList styling for different leading characters - Update markdown-demo.png to show new 123 | BulletList rendering (#28 – thanks @halilozercan!) 124 | 125 | v0.1.0+alpha06 126 | -------------- 127 | 128 | _2020-11-06_ 129 | 130 | * Initial release. 131 | 132 | Thanks to @halilozercan for implementing Markdown support! -------------------------------------------------------------------------------- /docs/richtext-ui.md: -------------------------------------------------------------------------------- 1 | # Richtext UI 2 | 3 | [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) 4 | [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) 5 | 6 | A library of Composables for formatting text using higher-level concepts than are not supported by 7 | compose foundation, such as "ordered lists" and "headings". 8 | 9 | Richtext UI is a base library that is non-opinionated about higher level design requirements. 10 | If you are already using `MaterialTheme` in your compose app, you can jump to [RichText UI Material](../richtext-ui-material/index.html) 11 | for a quick start. There is also Material3 flavor at [RichText UI Material3](../richtext-ui-material3/index.html) 12 | 13 | ## Gradle 14 | 15 | ```kotlin 16 | dependencies { 17 | implementation("com.halilibo.compose-richtext:richtext-ui:${richtext_version}") 18 | } 19 | ``` 20 | 21 | ## [`BasicRichText`](../api/richtext-ui/com.halilibo.richtext.ui/-basic-rich-text.html) 22 | 23 | Richtext UI does not depend on Material artifact of Compose. Design agnostic API allows anyone 24 | to adopt Richtext UI and its extensions like Markdown to their own design and typography systems. 25 | Hence, just like `foundation` and `material` modules of Compose, this library also names the 26 | building block with `Basic` prefix. 27 | 28 | If you are planning to adopt RichText within your design system, please go ahead and check out [`RichText Material`](../richtext-ui-material/index.html) 29 | for inspiration. 30 | 31 | ## [`RichTextScope`](../api/richtext-ui/com.halilibo.richtext.ui/-rich-text-scope/index.html) 32 | 33 | `RichTextScope` is a context wrapper around Composables that integrate and play well within Richtext 34 | content. 35 | 36 | ## [`RichTextThemeProvider`](../api/richtext-ui/com.halilibo.richtext.ui/-rich-text-theme-provider.html) 37 | 38 | Entry point for integrating app's own typography and theme system with BasicRichText. 39 | 40 | API for this integration is highly influenced by how compose-material theming 41 | is designed. RichText library assumes that almost all Theme/Design systems would 42 | have composition locals that provide a TextStyle downstream. 43 | 44 | Moreover, text style should not include text color by best practice. Content color 45 | exists to figure out text color in the current context. Light/Dark theming leverages content 46 | color to influence not just text but other parts of theming as well. 47 | 48 | ## Example 49 | 50 | Open the `Demo.kt` file in the `sample` module to play with this. Although the mentioned demo 51 | uses Material integrated version of `RichText`, they share exactly the same API. 52 | 53 | ```kotlin 54 | BasicRichText( 55 | modifier = Modifier.background(color = Color.White) 56 | ) { 57 | Heading(0, "Paragraphs") 58 | Text("Simple paragraph.") 59 | Text("Paragraph with\nmultiple lines.") 60 | Text("Paragraph with really long line that should be getting wrapped.") 61 | 62 | Heading(0, "Lists") 63 | Heading(1, "Unordered") 64 | ListDemo(listType = Unordered) 65 | Heading(1, "Ordered") 66 | ListDemo(listType = Ordered) 67 | 68 | Heading(0, "Horizontal Line") 69 | Text("Above line") 70 | HorizontalRule() 71 | Text("Below line") 72 | 73 | Heading(0, "Code Block") 74 | CodeBlock( 75 | """ 76 | { 77 | "Hello": "world!" 78 | } 79 | """.trimIndent() 80 | ) 81 | 82 | Heading(0, "Block Quote") 83 | BlockQuote { 84 | Text("These paragraphs are quoted.") 85 | Text("More text.") 86 | BlockQuote { 87 | Text("Nested block quote.") 88 | } 89 | } 90 | 91 | Heading(0, "Info Panel") 92 | InfoPanel(InfoPanelType.Primary, "Only text primary info panel") 93 | InfoPanel(InfoPanelType.Success) { 94 | Column { 95 | Text("Successfully sent some data") 96 | HorizontalRule() 97 | BlockQuote { 98 | Text("This is a quote") 99 | } 100 | } 101 | } 102 | 103 | Heading(0, "Table") 104 | Table(headerRow = { 105 | cell { Text("Column 1") } 106 | cell { Text("Column 2") } 107 | }) { 108 | row { 109 | cell { Text("Hello") } 110 | cell { 111 | CodeBlock("Foo bar") 112 | } 113 | } 114 | row { 115 | cell { 116 | BlockQuote { 117 | Text("Stuff") 118 | } 119 | } 120 | cell { Text("Hello world this is a really long line that is going to wrap hopefully") } 121 | } 122 | } 123 | } 124 | ``` 125 | 126 | Looks like this: 127 | 128 | ![demo rendering](img/richtext-demo.png) 129 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/CodeBlock.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.Immutable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.platform.LocalDensity 12 | import androidx.compose.ui.text.TextStyle 13 | import androidx.compose.ui.text.font.FontFamily 14 | import androidx.compose.ui.draw.alpha 15 | import androidx.compose.ui.graphics.graphicsLayer 16 | import androidx.compose.ui.unit.TextUnit 17 | import androidx.compose.ui.unit.sp 18 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 19 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 20 | 21 | /** 22 | * Defines how [CodeBlock]s are rendered. 23 | * 24 | * @param textStyle The [TextStyle] to use for the block. 25 | * @param modifier The [Modifier] to use for the block. 26 | * @param padding The amount of space between the edge of the text and the edge of the background. 27 | * @param wordWrap Whether a code block breaks the lines or scrolls horizontally. 28 | */ 29 | @Immutable 30 | public data class CodeBlockStyle( 31 | val textStyle: TextStyle? = null, 32 | val modifier: Modifier? = null, 33 | val padding: TextUnit? = null, 34 | val wordWrap: Boolean? = null 35 | ) { 36 | public companion object { 37 | public val Default: CodeBlockStyle = CodeBlockStyle() 38 | } 39 | } 40 | 41 | private val DefaultCodeBlockTextStyle = TextStyle( 42 | fontFamily = FontFamily.Monospace 43 | ) 44 | internal val DefaultCodeBlockBackgroundColor: Color = Color.LightGray.copy(alpha = .5f) 45 | private val DefaultCodeBlockModifier: Modifier = 46 | Modifier.background(color = DefaultCodeBlockBackgroundColor) 47 | private val DefaultCodeBlockPadding: TextUnit = 16.sp 48 | private const val DefaultCodeWordWrap: Boolean = true 49 | 50 | internal fun CodeBlockStyle.resolveDefaults() = CodeBlockStyle( 51 | textStyle = textStyle ?: DefaultCodeBlockTextStyle, 52 | modifier = modifier ?: DefaultCodeBlockModifier, 53 | padding = padding ?: DefaultCodeBlockPadding, 54 | wordWrap = wordWrap ?: DefaultCodeWordWrap 55 | ) 56 | 57 | /** 58 | * A specially-formatted block of text that typically uses a monospace font with a tinted 59 | * background. 60 | * 61 | * @param wordWrap Overrides word wrap preference coming from [CodeBlockStyle] 62 | */ 63 | @Composable public fun RichTextScope.CodeBlock( 64 | text: String, 65 | markdownAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, 66 | richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions(), 67 | wordWrap: Boolean? = null 68 | ) { 69 | CodeBlock( 70 | wordWrap = wordWrap, 71 | markdownAnimationState = markdownAnimationState, 72 | richTextRenderOptions = richTextRenderOptions, 73 | ) { 74 | Text(text) 75 | } 76 | } 77 | 78 | /** 79 | * A specially-formatted block of text that typically uses a monospace font with a tinted 80 | * background. 81 | * 82 | * @param wordWrap Overrides word wrap preference coming from [CodeBlockStyle] 83 | */ 84 | @Composable public fun RichTextScope.CodeBlock( 85 | wordWrap: Boolean? = null, 86 | markdownAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, 87 | richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions(), 88 | children: @Composable RichTextScope.() -> Unit 89 | ) { 90 | val codeBlockStyle = currentRichTextStyle.resolveDefaults().codeBlockStyle!! 91 | val textStyle = currentTextStyle.merge(codeBlockStyle.textStyle) 92 | val modifier = codeBlockStyle.modifier!! 93 | val blockPadding = with(LocalDensity.current) { 94 | codeBlockStyle.padding!!.toDp() 95 | } 96 | val resolvedWordWrap = wordWrap ?: codeBlockStyle.wordWrap!! 97 | val alpha = rememberMarkdownFade(richTextRenderOptions, markdownAnimationState) 98 | 99 | CodeBlockLayout( 100 | wordWrap = resolvedWordWrap 101 | ) { layoutModifier -> 102 | Box( 103 | modifier = layoutModifier 104 | .graphicsLayer{ this.alpha = alpha.value } 105 | .then(modifier) 106 | .padding(blockPadding) 107 | ) { 108 | textStyleBackProvider(textStyle) { 109 | children() 110 | } 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Desktop composable adds an optional horizontal scrollbar. 117 | */ 118 | @Composable 119 | internal expect fun RichTextScope.CodeBlockLayout( 120 | wordWrap: Boolean, 121 | children: @Composable RichTextScope.(Modifier) -> Unit 122 | ) 123 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/RichTextSample.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.richtext.sample 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.text.selection.SelectionContainer 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material3.Card 14 | import androidx.compose.material3.CardDefaults 15 | import androidx.compose.material3.Checkbox 16 | import androidx.compose.material3.ExperimentalMaterial3Api 17 | import androidx.compose.material3.Slider 18 | import androidx.compose.material3.SliderColors 19 | import androidx.compose.material3.SliderDefaults 20 | import androidx.compose.material3.Surface 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.darkColorScheme 23 | import androidx.compose.material3.lightColorScheme 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.getValue 26 | import androidx.compose.runtime.mutableStateOf 27 | import androidx.compose.runtime.remember 28 | import androidx.compose.runtime.setValue 29 | import androidx.compose.ui.Alignment 30 | import androidx.compose.ui.Modifier 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import androidx.compose.ui.unit.DpSize 33 | import androidx.compose.ui.unit.dp 34 | import androidx.compose.ui.unit.sp 35 | import com.halilibo.richtext.ui.RichTextStyle 36 | import com.halilibo.richtext.ui.resolveDefaults 37 | 38 | @Preview 39 | @Composable private fun RichTextSamplePreview() { 40 | RichTextSample() 41 | } 42 | 43 | @Composable fun RichTextSample() { 44 | var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) } 45 | 46 | Column { 47 | // Config 48 | Card(elevation = CardDefaults.elevatedCardElevation()) { 49 | Column { 50 | RichTextStyleConfig( 51 | richTextStyle = richTextStyle, 52 | onChanged = { richTextStyle = it } 53 | ) 54 | } 55 | } 56 | 57 | SelectionContainer { 58 | Column(Modifier.verticalScroll(rememberScrollState())) { 59 | RichTextDemo(style = richTextStyle) 60 | } 61 | } 62 | } 63 | } 64 | 65 | @Composable 66 | fun RichTextStyleConfig( 67 | richTextStyle: RichTextStyle, 68 | onChanged: (RichTextStyle) -> Unit 69 | ) { 70 | Text("Paragraph spacing: ${richTextStyle.paragraphSpacing}") 71 | SliderForHumans( 72 | value = richTextStyle.paragraphSpacing!!.value, 73 | valueRange = 0f..20f, 74 | onValueChange = { 75 | onChanged(richTextStyle.copy(paragraphSpacing = it.sp)) 76 | } 77 | ) 78 | 79 | Text("Table cell padding: ${richTextStyle.tableStyle!!.cellPadding}") 80 | SliderForHumans( 81 | value = richTextStyle.tableStyle!!.cellPadding!!.value, 82 | valueRange = 0f..20f, 83 | onValueChange = { 84 | onChanged( 85 | richTextStyle.copy( 86 | tableStyle = richTextStyle.tableStyle!!.copy( 87 | cellPadding = it.sp 88 | ) 89 | ) 90 | ) 91 | } 92 | ) 93 | 94 | Text("Table border width padding: ${richTextStyle.tableStyle!!.borderStrokeWidth!!}") 95 | SliderForHumans( 96 | value = richTextStyle.tableStyle!!.borderStrokeWidth!!, 97 | valueRange = 0f..20f, 98 | onValueChange = { 99 | onChanged( 100 | richTextStyle.copy( 101 | tableStyle = richTextStyle.tableStyle!!.copy( 102 | borderStrokeWidth = it 103 | ) 104 | ) 105 | ) 106 | } 107 | ) 108 | } 109 | 110 | @OptIn(ExperimentalMaterial3Api::class) 111 | @Composable 112 | fun SliderForHumans( 113 | value: Float, 114 | onValueChange: (Float) -> Unit, 115 | modifier: Modifier = Modifier, 116 | enabled: Boolean = true, 117 | valueRange: ClosedFloatingPointRange = 0f..1f, 118 | @IntRange(from = 0) steps: Int = 0, 119 | onValueChangeFinished: (() -> Unit)? = null, 120 | colors: SliderColors = SliderDefaults.colors(), 121 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } 122 | ) { 123 | Slider( 124 | value = value, 125 | onValueChange = onValueChange, 126 | modifier = modifier, 127 | enabled = enabled, 128 | valueRange = valueRange, 129 | steps = steps, 130 | onValueChangeFinished = onValueChangeFinished, 131 | colors = colors, 132 | interactionSource = interactionSource, 133 | thumb = { 134 | SliderDefaults.Thumb( 135 | interactionSource = interactionSource, 136 | thumbSize = DpSize(4.dp, 20.dp) 137 | ) 138 | } 139 | ) 140 | } -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BlockQuote.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.halilibo.richtext.ui 4 | 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.width 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.Immutable 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.layout.Layout 16 | import androidx.compose.ui.draw.alpha 17 | import androidx.compose.ui.graphics.graphicsLayer 18 | import androidx.compose.ui.platform.LocalDensity 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.IntOffset 21 | import androidx.compose.ui.unit.TextUnit 22 | import androidx.compose.ui.unit.offset 23 | import androidx.compose.ui.unit.sp 24 | import com.halilibo.richtext.ui.BlockQuoteGutter.BarGutter 25 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 26 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 27 | 28 | internal val DefaultBlockQuoteGutter = BarGutter() 29 | 30 | /** 31 | * A composable function that draws the gutter beside a [BlockQuote]. 32 | * 33 | * [BarGutter] is provided as the reasonable default of a simple vertical line. 34 | */ 35 | public interface BlockQuoteGutter { 36 | @Composable public fun RichTextScope.drawGutter() 37 | 38 | public val verticalContentPadding: Dp? 39 | 40 | @Immutable 41 | public data class BarGutter( 42 | val startMargin: TextUnit = 6.sp, 43 | val barWidth: TextUnit = 3.sp, 44 | val endMargin: TextUnit = 6.sp, 45 | override val verticalContentPadding: Dp? = null, 46 | val color: (contentColor: Color) -> Color = { it.copy(alpha = .25f) } 47 | ) : BlockQuoteGutter { 48 | 49 | @Composable 50 | override fun RichTextScope.drawGutter() { 51 | with(LocalDensity.current) { 52 | val color = color(currentContentColor) 53 | 54 | val modifier = remember(startMargin, endMargin, barWidth, color) { 55 | // Padding must come before width. 56 | Modifier 57 | .padding( 58 | start = startMargin.toDp(), 59 | end = endMargin.toDp() 60 | ) 61 | .width(barWidth.toDp()) 62 | .background(color, RoundedCornerShape(50)) 63 | } 64 | 65 | Box(modifier) 66 | } 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Draws a block quote, with a [BlockQuoteGutter] drawn beside the children on the start side. 73 | */ 74 | @Composable public fun RichTextScope.BlockQuote( 75 | markdownAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, 76 | richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions(), 77 | children: @Composable RichTextScope.() -> Unit 78 | ) { 79 | val gutter = currentRichTextStyle.resolveDefaults().blockQuoteGutter!! 80 | val spacing = gutter.verticalContentPadding ?: with(LocalDensity.current) { 81 | currentRichTextStyle.resolveDefaults().paragraphSpacing!!.toDp() / 2 82 | } 83 | 84 | val alpha = rememberMarkdownFade(richTextRenderOptions, markdownAnimationState) 85 | Layout(modifier = Modifier.graphicsLayer { this.alpha = alpha.value }, content = { 86 | with(gutter) { drawGutter() } 87 | BasicRichText( 88 | modifier = Modifier.padding(top = spacing, bottom = spacing), 89 | children = children 90 | ) 91 | }) { measurables, constraints -> 92 | val gutterMeasurable = measurables[0] 93 | val contentsMeasurable = measurables[1] 94 | 95 | // First get the width of the gutter, so we can measure the contents with 96 | // the smaller width if bounded. 97 | val gutterWidth = gutterMeasurable.minIntrinsicWidth(constraints.maxHeight) 98 | 99 | // Measure the contents with the confined width. 100 | // This must be done before measuring the gutter so that the gutter gets 101 | // the correct height. 102 | val contentsConstraints = constraints.offset(horizontal = -gutterWidth) 103 | val contentsPlaceable = contentsMeasurable.measure(contentsConstraints) 104 | val layoutWidth = contentsPlaceable.width + gutterWidth 105 | val layoutHeight = contentsPlaceable.height 106 | 107 | // Measure the gutter to fit in its min intrinsic width and exactly the 108 | // height of the contents. 109 | val gutterConstraints = constraints.copy( 110 | maxWidth = gutterWidth, 111 | minHeight = layoutHeight, 112 | maxHeight = layoutHeight 113 | ) 114 | val gutterPlaceable = gutterMeasurable.measure(gutterConstraints) 115 | 116 | layout(layoutWidth, layoutHeight) { 117 | gutterPlaceable.place(IntOffset.Zero) 118 | contentsPlaceable.place(gutterWidth, 0) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/node/AstNodeType.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown.node 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.halilibo.richtext.ui.string.RichTextString 5 | import org.commonmark.node.Node 6 | 7 | /** 8 | * Refer to https://spec.commonmark.org/0.30/#precedence 9 | * 10 | * Commonmark specification defines 3 different types of AST nodes; 11 | * 12 | * - Container Block 13 | * - Leaf Block 14 | * - Inline Content 15 | * 16 | * Container blocks are the most generic nodes. They define a structure for their children but 17 | * do not impose any major restrictions, meaning that container blocks can contain any 18 | * type of child node. 19 | * 20 | * Leaf blocks are self-explanatory, they should not have any children. All the necessary content 21 | * to render a leaf block should already exist in its payload 22 | * 23 | * Inline Content is analogous to [RichTextString] and its styles. Most of the inline content 24 | * nodes are about styling(bold, italic, strikethrough, code). The rest contains links, images, 25 | * html content, and of course raw text. 26 | */ 27 | public sealed class AstNodeType 28 | 29 | //region AstBlockNodeType 30 | 31 | public sealed class AstBlockNodeType: AstNodeType() 32 | 33 | //region AstContainerBlockNodeType 34 | 35 | /** 36 | * Defines a subtype of Block Node that can contain other nodes. 37 | */ 38 | public sealed class AstContainerBlockNodeType: AstBlockNodeType() 39 | 40 | /** 41 | * Usually defines the root of a markdown document. 42 | */ 43 | @Immutable 44 | public object AstDocument : AstContainerBlockNodeType() 45 | 46 | /** 47 | * A block quote container that will indent its contents relative to its own indentation. 48 | */ 49 | @Immutable 50 | public object AstBlockQuote : AstContainerBlockNodeType() 51 | 52 | /** 53 | * Ordered or Unordered list item. 54 | */ 55 | @Immutable 56 | public object AstListItem : AstContainerBlockNodeType() 57 | 58 | /** 59 | * A list type that marks its items with bullets to signify a lack of order. 60 | */ 61 | @Immutable 62 | public data class AstUnorderedList( 63 | val bulletMarker: Char 64 | ) : AstContainerBlockNodeType() 65 | 66 | /** 67 | * A list type that uses numbers to mark its items. 68 | */ 69 | @Immutable 70 | public data class AstOrderedList( 71 | val startNumber: Int, 72 | val delimiter: Char 73 | ) : AstContainerBlockNodeType() 74 | 75 | //endregion 76 | 77 | //region AstLeafBlockNodeType 78 | 79 | /** 80 | * Defines a subtype of Block Node that can only contain plain text and full-length annotations. 81 | */ 82 | public sealed class AstLeafBlockNodeType: AstBlockNodeType() 83 | 84 | @Immutable 85 | public object AstThematicBreak : AstLeafBlockNodeType() 86 | 87 | @Immutable 88 | public data class AstHeading( 89 | val level: Int 90 | ) : AstLeafBlockNodeType() 91 | 92 | @Immutable 93 | public data class AstIndentedCodeBlock( 94 | val literal: String 95 | ) : AstLeafBlockNodeType() 96 | 97 | @Immutable 98 | public data class AstFencedCodeBlock( 99 | val fenceChar: Char, 100 | val fenceLength: Int, 101 | val fenceIndent: Int, 102 | val info: String, 103 | val literal: String 104 | ) : AstLeafBlockNodeType() 105 | 106 | @Immutable 107 | public data class AstHtmlBlock( 108 | val literal: String 109 | ) : AstLeafBlockNodeType() 110 | 111 | @Immutable 112 | public data class AstLinkReferenceDefinition( 113 | val label: String, 114 | val destination: String, 115 | val title: String 116 | ) : AstLeafBlockNodeType() 117 | 118 | @Immutable 119 | public object AstParagraph : AstLeafBlockNodeType() 120 | 121 | //endregion 122 | 123 | //endregion 124 | 125 | //region AstInlineNodeType 126 | 127 | /** 128 | * Defines a node type that can only apply to inline content. 129 | */ 130 | public sealed class AstInlineNodeType: AstNodeType() 131 | 132 | @Immutable 133 | public data class AstCode( 134 | val literal: String 135 | ) : AstInlineNodeType() 136 | 137 | @Immutable 138 | public data class AstEmphasis( 139 | private val delimiter: String 140 | ) : AstInlineNodeType() 141 | 142 | @Immutable 143 | public data class AstStrongEmphasis( 144 | private val delimiter: String 145 | ) : AstInlineNodeType() 146 | 147 | @Immutable 148 | public data class AstStrikethrough( 149 | val delimiter: String 150 | ) : AstInlineNodeType() 151 | 152 | @Immutable 153 | public data class AstLink( 154 | val destination: String, 155 | val title: String 156 | ) : AstInlineNodeType() 157 | 158 | @Immutable 159 | public data class AstImage( 160 | val title: String, 161 | val destination: String 162 | ) : AstInlineNodeType() 163 | 164 | @Immutable 165 | public data class AstHtmlInline( 166 | val literal: String 167 | ) : AstInlineNodeType() 168 | 169 | @Immutable 170 | public object AstHardLineBreak : AstInlineNodeType() 171 | 172 | @Immutable 173 | public object AstSoftLineBreak : AstInlineNodeType() 174 | 175 | @Immutable 176 | public data class AstText( 177 | val literal: String 178 | ) : AstInlineNodeType() 179 | 180 | @Immutable 181 | public data class AstCustomNode( 182 | val node: Node 183 | ) : AstInlineNodeType() 184 | 185 | @Immutable 186 | public data class AstCustomBlock( 187 | val node: Node 188 | ) : AstInlineNodeType() 189 | 190 | //endregion 191 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/InlineContent.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "FunctionName") 2 | 3 | package com.halilibo.richtext.ui.string 4 | 5 | import androidx.compose.foundation.text.InlineTextContent 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.State 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.runtime.structuralEqualityPolicy 13 | import androidx.compose.ui.layout.Layout 14 | import androidx.compose.ui.platform.LocalDensity 15 | import androidx.compose.ui.text.Placeholder 16 | import androidx.compose.ui.text.PlaceholderVerticalAlign 17 | import androidx.compose.ui.text.PlaceholderVerticalAlign.Companion.AboveBaseline 18 | import androidx.compose.ui.unit.Constraints 19 | import androidx.compose.ui.unit.Density 20 | import androidx.compose.ui.unit.IntSize 21 | import androidx.compose.ui.unit.sp 22 | 23 | /** 24 | * A Composable that can be embedded inline in a [RichTextString] by passing to 25 | * [RichTextString.Builder.appendInlineContent]. 26 | * 27 | * @param initialSize Optional function to calculate the initial size of the content. Not specifying 28 | * this may cause flicker. 29 | * @param placeholderVerticalAlign Used to specify how a placeholder is vertically aligned within a 30 | * text line. 31 | */ 32 | public class InlineContent( 33 | internal val renderOnNewLine: Boolean = false, 34 | internal val initialSize: (Density.() -> IntSize)? = null, 35 | internal val placeholderVerticalAlign: PlaceholderVerticalAlign = AboveBaseline, 36 | internal val content: @Composable Density.(alternateText: String) -> Unit 37 | ) 38 | 39 | /** 40 | * Converts a map of [InlineContent]s into a map of [InlineTextContent] that is ready to pass to 41 | * the core Text composable. Whenever any of the contents resize themselves, or if the map changes, 42 | * a new map will be returned with updated [Placeholder]s. 43 | */ 44 | @Composable internal fun manageInlineTextContents( 45 | inlineContents: Map, 46 | textConstraints: State, 47 | ): Map { 48 | val density = LocalDensity.current 49 | 50 | return inlineContents.mapValues { (_, content) -> 51 | reifyInlineContent(content, textConstraints, density) 52 | } 53 | } 54 | 55 | /** 56 | * Given an [InlineContent] function, wraps it in a [InlineTextContent] that will allow the content 57 | * to measure itself inside the enclosing layout's maximum constraints, and automatically return a 58 | * new [InlineTextContent] whenever the content changes size to update how much space is reserved 59 | * in the text layout for the content. 60 | */ 61 | @Composable private fun reifyInlineContent( 62 | content: InlineContent, 63 | contentConstraints: State, 64 | density: Density, 65 | ): InlineTextContent { 66 | var size by remember { 67 | mutableStateOf( 68 | content.initialSize?.invoke(density), 69 | structuralEqualityPolicy() 70 | ) 71 | } 72 | 73 | // If size is null, content hasn't been measured yet, so just draw with zero width for now. 74 | // Set the height to 1 em so we can calculate how many pixels in an EM. 75 | val placeholder = with(density) { 76 | Placeholder( 77 | width = size?.width?.toSp() ?: 0.sp, 78 | height = size?.height?.toSp() ?: 1.sp, 79 | placeholderVerticalAlign = content.placeholderVerticalAlign 80 | ) 81 | } 82 | 83 | return InlineTextContent(placeholder) { alternateText -> 84 | Layout(content = { content.content(density, alternateText) }) { measurables, _ -> 85 | // Measure the content with the constraints for the parent Text layout, not the actual. 86 | // This allows it to determine exactly how large it needs to be so we can update the 87 | // placeholder. 88 | val contentPlaceable = measurables.singleOrNull()?.measure(contentConstraints.value) 89 | ?: return@Layout layout(0, 0) {} 90 | 91 | // If the inline content changes in size, there will be one layout pass in which 92 | // the text layout placeholder and composable size will be out of sync. 93 | // In some cases, the composable will be larger than the placeholder. 94 | // If that happens, we need to offset the layout so that the composable starts 95 | // in the right place and overhangs the end. Without this, Compose will place the 96 | // composable centered inside of its smaller placeholder which makes the content shift 97 | // left for one frame and is more jarring. 98 | val extraWidth = (contentPlaceable.width - (size?.width ?: contentPlaceable.width)) 99 | .coerceAtLeast(0) 100 | val extraHeight = (contentPlaceable.height - (size?.height ?: contentPlaceable.height)) 101 | .coerceAtLeast(0) 102 | 103 | if (contentPlaceable.width != size?.width 104 | || contentPlaceable.height != size?.height 105 | ) { 106 | size = IntSize(contentPlaceable.width, contentPlaceable.height) 107 | } 108 | 109 | layout(contentPlaceable.width, contentPlaceable.height) { 110 | contentPlaceable.placeRelative(extraWidth / 2, extraHeight / 2) 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.richtext.sample 2 | 3 | import android.widget.FrameLayout 4 | import androidx.activity.compose.BackHandler 5 | import androidx.compose.animation.Crossfade 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.isSystemInDarkTheme 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.aspectRatio 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.systemBarsPadding 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.foundation.lazy.LazyColumn 16 | import androidx.compose.foundation.lazy.itemsIndexed 17 | import androidx.compose.material.icons.Icons 18 | import androidx.compose.material.icons.filled.DarkMode 19 | import androidx.compose.material.icons.filled.LightMode 20 | import androidx.compose.material3.ExperimentalMaterial3Api 21 | import androidx.compose.material3.Icon 22 | import androidx.compose.material3.IconButton 23 | import androidx.compose.material3.ListItem 24 | import androidx.compose.material3.Scaffold 25 | import androidx.compose.material3.Surface 26 | import androidx.compose.material3.Text 27 | import androidx.compose.material3.TopAppBar 28 | import androidx.compose.material3.darkColorScheme 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.CompositionLocalProvider 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableStateOf 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.draw.clipToBounds 37 | import androidx.compose.ui.graphics.TransformOrigin 38 | import androidx.compose.ui.graphics.graphicsLayer 39 | import androidx.compose.ui.platform.LocalContext 40 | import androidx.compose.ui.platform.LocalView 41 | import androidx.compose.ui.tooling.preview.Preview 42 | import androidx.compose.ui.unit.dp 43 | 44 | private val Samples = listOf Unit>>( 45 | "RichText Demo" to @Composable { RichTextSample() }, 46 | "Markdown Demo" to @Composable { MarkdownSample() }, 47 | "Lazy Markdown Demo" to @Composable { LazyMarkdownSample() }, 48 | "Text Animation" to @Composable { AnimatedRichTextSample() }, 49 | "Chinese Text Animation" to @Composable { ChineseAnimatedRichTextSample() }, 50 | "Thai Text Animation" to @Composable { ThaiAnimatedRichTextSample() }, 51 | "Hindi Text Animation" to @Composable { HindiAnimatedRichTextSample() }, 52 | ) 53 | 54 | @Preview(showBackground = true) 55 | @Composable private fun SampleLauncherPreview() { 56 | SamplesListScreen(isDarkTheme = true, onSampleClicked = {}, onThemeToggleClicked = {}) 57 | } 58 | 59 | @OptIn(ExperimentalMaterial3Api::class) 60 | @Composable fun SampleLauncher() { 61 | val systemDarkTheme = isSystemInDarkTheme() 62 | var isDarkTheme by remember(systemDarkTheme) { mutableStateOf(systemDarkTheme) } 63 | var currentSampleIndex: Int? by remember { mutableStateOf(null) } 64 | 65 | SampleTheme(isDarkTheme) { 66 | Crossfade(currentSampleIndex) { index -> 67 | if (index != null) { 68 | BackHandler(onBack = { currentSampleIndex = null }) 69 | Scaffold( 70 | topBar = { 71 | TopAppBar(title = { Text(Samples[index].first) }, actions = { 72 | val icon = if (isDarkTheme) Icons.Filled.LightMode else Icons.Filled.DarkMode 73 | IconButton(onClick = { isDarkTheme = !isDarkTheme }) { 74 | Icon(icon, contentDescription = "Change color scheme") 75 | } 76 | }) 77 | } 78 | ) { 79 | Surface(Modifier.padding(it)) { 80 | Samples[index].second() 81 | } 82 | } 83 | } else { 84 | SamplesListScreen( 85 | isDarkTheme, 86 | onSampleClicked = { currentSampleIndex = it }, 87 | onThemeToggleClicked = { isDarkTheme = !isDarkTheme } 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | 94 | @OptIn(ExperimentalMaterial3Api::class) 95 | @Composable private fun SamplesListScreen( 96 | isDarkTheme: Boolean, 97 | onSampleClicked: (Int) -> Unit, 98 | onThemeToggleClicked: () -> Unit, 99 | ) { 100 | Scaffold( 101 | topBar = { 102 | TopAppBar(title = { Text("Samples") }, actions = { 103 | val icon = if (isDarkTheme) Icons.Filled.LightMode else Icons.Filled.DarkMode 104 | IconButton(onClick = onThemeToggleClicked) { 105 | Icon(icon, contentDescription = "Change color scheme") 106 | } 107 | }) 108 | } 109 | ) { contentPadding -> 110 | LazyColumn(modifier = Modifier.padding(contentPadding)) { 111 | itemsIndexed(Samples) { index, (title, sampleContent) -> 112 | ListItem( 113 | headlineContent = { Text(title) }, 114 | modifier = Modifier.clickable(onClick = { onSampleClicked(index) }), 115 | leadingContent = { SamplePreview(sampleContent) } 116 | ) 117 | } 118 | } 119 | } 120 | } 121 | 122 | @Composable private fun SamplePreview(content: @Composable () -> Unit) { 123 | ScreenPreview( 124 | Modifier 125 | .size(50.dp) 126 | .aspectRatio(1f) 127 | .clipToBounds() 128 | // "Zoom in" to the top-start corner to make the preview more legible. 129 | .graphicsLayer( 130 | scaleX = 1.5f, scaleY = 1.5f, 131 | transformOrigin = TransformOrigin(0f, 0f) 132 | ), 133 | ) { 134 | SampleTheme { 135 | Surface(content = content) 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /android-sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/ScreenPreview.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package com.zachklipp.richtext.sample 4 | 5 | import android.content.Context 6 | import android.content.Context.DISPLAY_SERVICE 7 | import android.content.Context.WINDOW_SERVICE 8 | import android.hardware.display.DisplayManager 9 | import android.hardware.display.DisplayManager.DisplayListener 10 | import android.os.Handler 11 | import android.os.Looper 12 | import android.util.DisplayMetrics 13 | import android.view.WindowManager 14 | import android.widget.FrameLayout 15 | import androidx.compose.foundation.layout.BoxWithConstraints 16 | import androidx.compose.foundation.layout.aspectRatio 17 | import androidx.compose.foundation.text.selection.DisableSelection 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.runtime.RememberObserver 21 | import androidx.compose.runtime.mutableStateOf 22 | import androidx.compose.runtime.remember 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.input.pointer.PointerEvent 25 | import androidx.compose.ui.input.pointer.PointerEventPass 26 | import androidx.compose.ui.input.pointer.PointerEventPass.Initial 27 | import androidx.compose.ui.input.pointer.PointerInputFilter 28 | import androidx.compose.ui.input.pointer.PointerInputModifier 29 | import androidx.compose.ui.input.pointer.consumeAllChanges 30 | import androidx.compose.ui.platform.LocalContext 31 | import androidx.compose.ui.platform.LocalDensity 32 | import androidx.compose.ui.platform.LocalView 33 | import androidx.compose.ui.semantics.disabled 34 | import androidx.compose.ui.semantics.semantics 35 | import androidx.compose.ui.unit.Density 36 | import androidx.compose.ui.unit.IntSize 37 | 38 | /** 39 | * Displays [content] according to the current layout constraints, but with the density adjusted so 40 | * that the content things it's rendering inside a full-size screen, where "full-size" is defined 41 | * by [screenSize]. The default [screenSize] is read from the current window's default display. 42 | */ 43 | // TODO Disable focus 44 | // TODO Disable key events (maybe covered by focus?) 45 | // TODO use this for Slideshow as well. 46 | @Composable fun ScreenPreview( 47 | modifier: Modifier = Modifier, 48 | screenSize: IntSize = rememberDefaultDisplaySize(), 49 | content: @Composable () -> Unit 50 | ) { 51 | val aspectRatio = screenSize.width.toFloat() / screenSize.height.toFloat() 52 | BoxWithConstraints( 53 | modifier 54 | .aspectRatio(aspectRatio) 55 | // Disable touch input. 56 | .then(PassthroughTouchToParentModifier) 57 | .semantics(mergeDescendants = true) { 58 | // TODO Block semantics. Is this enough? 59 | disabled() 60 | } 61 | ) { 62 | val actualDensity = LocalDensity.current.density 63 | // Can use width or height to do the calculation, since the aspect ratio is enforced. 64 | val previewDensityScale = constraints.maxWidth / screenSize.width.toFloat() 65 | val previewDensity = actualDensity * previewDensityScale 66 | 67 | DisableSelection { 68 | CompositionLocalProvider( 69 | LocalDensity provides Density(previewDensity), 70 | content = content 71 | ) 72 | } 73 | } 74 | } 75 | 76 | /** 77 | * Returns the size of the default display for the window manager of the window this composable is 78 | * currently attached to. Will also recompose if the display size changes, e.g. when the device is 79 | * rotated. 80 | * 81 | * If the display reports an empty size (0x0), e.g. when running in a preview, then a reasonable 82 | * fake size of a phone display in portrait orientation is returned instead. 83 | */ 84 | @Composable private fun rememberDefaultDisplaySize(): IntSize { 85 | val context = LocalContext.current 86 | val state = remember { DisplaySizeCalculator(context) } 87 | return state.displaySize.value 88 | } 89 | 90 | private class DisplaySizeCalculator(context: Context) : RememberObserver, 91 | DisplayListener { 92 | private val windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager 93 | private val displayManager = context.getSystemService(DISPLAY_SERVICE) as DisplayManager 94 | private val display = windowManager.defaultDisplay 95 | 96 | val displaySize = mutableStateOf(getDisplaySize()) 97 | 98 | override fun onAbandoned() { 99 | // Noop 100 | } 101 | 102 | override fun onRemembered() { 103 | // Update the preview on device rotation, for example. 104 | displayManager.registerDisplayListener(this, Handler(Looper.getMainLooper())) 105 | } 106 | 107 | override fun onForgotten() { 108 | displayManager.unregisterDisplayListener(this) 109 | } 110 | 111 | override fun onDisplayChanged(displayId: Int) { 112 | if (displayId != display.displayId) return 113 | displaySize.value = getDisplaySize() 114 | } 115 | 116 | override fun onDisplayAdded(displayId: Int) = Unit 117 | override fun onDisplayRemoved(displayId: Int) = Unit 118 | 119 | private fun getDisplaySize(): IntSize { 120 | val metrics = DisplayMetrics().also(display::getMetrics) 121 | return if (metrics.widthPixels != 0 && metrics.heightPixels != 0) { 122 | IntSize(metrics.widthPixels, metrics.heightPixels) 123 | } else { 124 | // Zero-sized display? Probably in a preview. Return some fake reasonable default. 125 | IntSize(1080, 1920) 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * A [PointerInputModifier] that blocks all touch events to children of the composable to which it's 132 | * applied, and instead allows all those events to flow to any filters defined on the parent 133 | * composable. 134 | */ 135 | private object PassthroughTouchToParentModifier : PointerInputModifier, PointerInputFilter() { 136 | override val pointerInputFilter: PointerInputFilter get() = this 137 | 138 | override fun onPointerEvent( 139 | pointerEvent: PointerEvent, 140 | pass: PointerEventPass, 141 | bounds: IntSize 142 | ) { 143 | if (pass == Initial) { 144 | // On the initial pass (ancestors -> descendants), mark all pointer events as completely 145 | // consumed. This prevents children from handling any pointer events. 146 | // These events are all marked as unconsumed by default. 147 | pointerEvent.changes.forEach { 148 | it.consumeAllChanges() 149 | } 150 | } 151 | } 152 | 153 | override fun onCancel() { 154 | // Noop. 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/Demo.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.zachklipp.richtext.sample 4 | 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.LocalContentColor 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import com.halilibo.richtext.ui.BlockQuote 19 | import com.halilibo.richtext.ui.CodeBlock 20 | import com.halilibo.richtext.ui.ColumnArrangement.Adaptive 21 | import com.halilibo.richtext.ui.DividerStyle 22 | import com.halilibo.richtext.ui.FormattedList 23 | import com.halilibo.richtext.ui.Heading 24 | import com.halilibo.richtext.ui.HorizontalRule 25 | import com.halilibo.richtext.ui.InfoPanel 26 | import com.halilibo.richtext.ui.InfoPanelType 27 | import com.halilibo.richtext.ui.ListType 28 | import com.halilibo.richtext.ui.ListType.Ordered 29 | import com.halilibo.richtext.ui.ListType.Unordered 30 | import com.halilibo.richtext.ui.RichTextScope 31 | import com.halilibo.richtext.ui.RichTextStyle 32 | import com.halilibo.richtext.ui.Table 33 | import com.halilibo.richtext.ui.WithStyle 34 | import com.halilibo.richtext.ui.material3.RichText 35 | 36 | @Preview(widthDp = 300, heightDp = 1000) 37 | @Composable fun RichTextDemoOnWhite() { 38 | Box(Modifier.background(color = Color.White)) { 39 | RichTextDemo() 40 | } 41 | } 42 | 43 | @Preview(widthDp = 300, heightDp = 1000) 44 | @Composable fun RichTextDemoOnBlack() { 45 | CompositionLocalProvider(LocalContentColor provides Color.White) { 46 | Box(Modifier.background(color = Color.Black)) { 47 | RichTextDemo() 48 | } 49 | } 50 | } 51 | 52 | @Composable fun RichTextDemo( 53 | style: RichTextStyle? = null, 54 | header: String = "" 55 | ) { 56 | RichText( 57 | modifier = Modifier.padding(8.dp), 58 | style = style 59 | ) { 60 | Heading(0, "Paragraphs $header") 61 | Text("Simple paragraph.") 62 | Text("Paragraph with\nmultiple lines.") 63 | Text("Paragraph with really long line that should be getting wrapped.") 64 | TextPreview() 65 | 66 | Heading(0, "Lists") 67 | Heading(1, "Unordered") 68 | ListDemo(listType = Unordered) 69 | Heading(1, "Ordered") 70 | ListDemo(listType = Ordered) 71 | 72 | Heading(0, "Horizontal Line") 73 | Text("Above line") 74 | HorizontalRule() 75 | Text("Below line") 76 | 77 | Heading(0, "Code Block") 78 | CodeBlock( 79 | """ 80 | { 81 | "Hello": "world!" 82 | } 83 | """.trimIndent() 84 | ) 85 | 86 | Heading(0, "Block Quote") 87 | BlockQuote { 88 | Text("These paragraphs are quoted.") 89 | Text("More text.") 90 | BlockQuote { 91 | Text("Nested block quote.") 92 | } 93 | } 94 | 95 | Heading(0, "Info Panel") 96 | InfoPanel(InfoPanelType.Primary, "Only text primary info panel") 97 | InfoPanel(InfoPanelType.Success) { 98 | Column { 99 | Text("Successfully sent some data") 100 | HorizontalRule() 101 | BlockQuote { 102 | Text("This is a quote") 103 | } 104 | } 105 | } 106 | 107 | Heading(0, "Simple Table") 108 | Table( 109 | modifier = Modifier.fillMaxWidth(), 110 | headerRow = { 111 | cell { Text("Column 1") } 112 | cell { Text("Column 2") } 113 | }) { 114 | row { 115 | cell { Text("Hello") } 116 | cell { 117 | CodeBlock("Foo bar") 118 | } 119 | } 120 | row { 121 | cell { 122 | BlockQuote { 123 | Text("Stuff") 124 | } 125 | } 126 | cell { Text("Hello world this is a really long line that is going to wrap hopefully") } 127 | } 128 | } 129 | 130 | val currentStyle = style!! 131 | WithStyle( 132 | currentStyle.copy( 133 | tableStyle = currentStyle.tableStyle!!.copy( 134 | columnArrangement = Adaptive(200.dp), 135 | dividerStyle = DividerStyle.Minimal, 136 | headerBorderColor = Color.DarkGray, 137 | borderColor = Color.LightGray 138 | ) 139 | ) 140 | ) { 141 | Heading(0, "Scrollable Table") 142 | Table( 143 | modifier = Modifier.fillMaxWidth(), 144 | headerRow = { 145 | cell { Text("Column 1") } 146 | cell { Text("Column 2 has a pretty long title") } 147 | cell { Text("Column 3") } 148 | cell { Text("Column 4") } 149 | }) { 150 | row { 151 | cell { Text("Hello") } 152 | cell { 153 | CodeBlock("Foo bar") 154 | } 155 | cell { Text("Hey") } 156 | cell { 157 | CodeBlock("Baz buzz") 158 | } 159 | } 160 | row { 161 | cell { 162 | BlockQuote { 163 | Text("Stuff") 164 | } 165 | } 166 | cell { Text("Hello world this is a really long line that is going to wrap hopefully") } 167 | cell { } 168 | cell { Text("The quick brown fox jumped over the lazy dog") } 169 | } 170 | row { 171 | cell { 172 | BlockQuote { 173 | Text("More") 174 | } 175 | } 176 | cell { ListDemo(listType = Unordered) } 177 | cell { 178 | BlockQuote { 179 | Text("More") 180 | } 181 | } 182 | cell { Text("Hello world this is another really long line and it too should do the wrapping") } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | 189 | @Composable private fun RichTextScope.ListDemo(listType: ListType) { 190 | FormattedList(listType, 191 | @Composable { 192 | Text("First list item") 193 | FormattedList(listType, 194 | @Composable { Text("Indented 1") } 195 | ) 196 | }, 197 | @Composable { 198 | Text("Second list item.") 199 | FormattedList(listType, 200 | @Composable { Text("Indented 2") } 201 | ) 202 | } 203 | ) 204 | } 205 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/util/AnnotatedStringSegmenter.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.ui.util 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 5 | 6 | 7 | 8 | public data class PhraseAnnotatedString( 9 | val annotatedString: AnnotatedString = AnnotatedString(""), 10 | val phraseSegments: List = emptyList(), 11 | val isComplete: Boolean = false 12 | ) { 13 | public fun makeCompletePhraseString(isComplete: Boolean): AnnotatedString { 14 | val shouldShowFullText = isComplete || phraseSegments.size <= 1 15 | return when { 16 | shouldShowFullText -> annotatedString 17 | else -> annotatedString.subSequence( 18 | 0, 19 | annotatedString.length.coerceAtMost(phraseSegments.lastOrNull() ?: annotatedString.length) 20 | ) 21 | } 22 | } 23 | 24 | public fun hasNewPhrasesFrom(other: PhraseAnnotatedString): Boolean { 25 | return phraseSegments.lastOrNull() != other.phraseSegments.lastOrNull() 26 | } 27 | } 28 | 29 | public fun AnnotatedString.segmentIntoPhrases( 30 | renderOptions: RichTextRenderOptions, 31 | isComplete: Boolean = false, 32 | ): PhraseAnnotatedString { 33 | val markers = (renderOptions.phraseMarkersOverride ?: DefaultPhraseMarkers).toSet() 34 | val segments = mutableListOf(0) 35 | val source = text 36 | val state = BoundaryState() 37 | 38 | source.forEachCodePoint { codePoint, index, charCount -> 39 | state.record(codePoint) 40 | if (state.shouldCreateBoundary(source[index], markers, renderOptions.maxPhraseLength)) { 41 | segments.addBoundary(index + charCount, source.length, state) 42 | } 43 | } 44 | 45 | if (isComplete && segments.last() != source.length) { 46 | segments.addBoundary(source.length, source.length, state) 47 | } 48 | 49 | return PhraseAnnotatedString( 50 | annotatedString = this, 51 | phraseSegments = segments.distinct().filter { it <= source.length }, 52 | isComplete = isComplete, 53 | ) 54 | } 55 | 56 | private class BoundaryState { 57 | var codePointsSinceBoundary: Int = 0 58 | private set 59 | var nonAsciiRun: Int = 0 60 | private set 61 | 62 | private var currentCategory: CodePointCategory = CodePointCategory.ASCII 63 | 64 | fun record(codePoint: Int) { 65 | val category = categorizeCodePoint(codePoint) 66 | if (category == CodePointCategory.ASCII) { 67 | nonAsciiRun = 0 68 | } else { 69 | if (category != currentCategory) { 70 | nonAsciiRun = 0 71 | } 72 | nonAsciiRun += 1 73 | } 74 | currentCategory = category 75 | codePointsSinceBoundary += 1 76 | } 77 | 78 | fun shouldCreateBoundary(currentChar: Char, markers: Set, maxPhraseLength: Int): Boolean { 79 | val nonAsciiLimit = when (currentCategory) { 80 | CodePointCategory.CJK -> CjkNonAsciiRunThreshold 81 | CodePointCategory.OTHER_NON_ASCII -> OtherNonAsciiRunThreshold 82 | CodePointCategory.ASCII -> Int.MAX_VALUE 83 | } 84 | val nonAsciiExceeded = currentCategory != CodePointCategory.ASCII && nonAsciiRun > nonAsciiLimit 85 | return currentChar in markers || nonAsciiExceeded || codePointsSinceBoundary >= maxPhraseLength 86 | } 87 | 88 | fun reset() { 89 | codePointsSinceBoundary = 0 90 | nonAsciiRun = 0 91 | currentCategory = CodePointCategory.ASCII 92 | } 93 | } 94 | 95 | private fun MutableList.addBoundary(index: Int, sourceLength: Int, state: BoundaryState) { 96 | if (index > last() && index <= sourceLength) { 97 | add(index) 98 | } 99 | state.reset() 100 | } 101 | 102 | private inline fun String.forEachCodePoint(action: (codePoint: Int, index: Int, charCount: Int) -> Unit) { 103 | var i = 0 104 | while (i < length) { 105 | val codePoint = Character.codePointAt(this, i) 106 | val count = Character.charCount(codePoint) 107 | action(codePoint, i, count) 108 | i += count 109 | } 110 | } 111 | 112 | private fun categorizeCodePoint(codePoint: Int): CodePointCategory { 113 | if (codePoint <= AsciiMaxCodePoint) return CodePointCategory.ASCII 114 | val block = Character.UnicodeBlock.of(codePoint) 115 | return if (block in CJK_UNICODE_BLOCKS) CodePointCategory.CJK else CodePointCategory.OTHER_NON_ASCII 116 | } 117 | 118 | private val DefaultPhraseMarkers = listOf( 119 | ' ', // Space 120 | '.', // Period 121 | '!', // Exclamation mark 122 | '?', // Question mark 123 | ',', // Comma 124 | ';', // Semicolon 125 | ':', // Colon 126 | '\n', // Newline 127 | '\r', // Carriage return 128 | '\t', // Tab 129 | '…', // Ellipsis 130 | '—', // Em dash 131 | '·', // Interpunct 132 | '¡', // Inverted exclamation mark 133 | '¿', // Inverted question mark 134 | '。', // Chinese/Japanese period 135 | '、', // Chinese/Japanese comma 136 | ',', // Chinese/Japanese full-width comma 137 | '?', // Full-width question mark 138 | '!', // Full-width exclamation mark 139 | ':', // Full-width colon 140 | ';', // Full-width semicolon 141 | '…', // Ellipsis 142 | '¡', // Inverted exclamation mark 143 | '¿', // Inverted question mark 144 | '።', // Ethiopic full stop 145 | '፣', // Ethiopic comma 146 | '፤', // Ethiopic semicolon 147 | '፥', // Ethiopic colon 148 | '፦', // Ethiopic preface colon 149 | '፧', // Ethiopic question mark 150 | '፨' // Ethiopic paragraph separator 151 | ) 152 | 153 | private const val AsciiMaxCodePoint = 0x7F 154 | private const val CjkNonAsciiRunThreshold = 5 155 | private const val OtherNonAsciiRunThreshold = 15 156 | 157 | private val CJK_UNICODE_BLOCKS = setOf( 158 | Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, 159 | Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A, 160 | Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B, 161 | Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C, 162 | Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D, 163 | Character.UnicodeBlock.CJK_COMPATIBILITY, 164 | Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS, 165 | Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS, 166 | Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT, 167 | Character.UnicodeBlock.CJK_RADICALS_SUPPLEMENT, 168 | Character.UnicodeBlock.KANGXI_RADICALS, 169 | Character.UnicodeBlock.IDEOGRAPHIC_DESCRIPTION_CHARACTERS, 170 | Character.UnicodeBlock.HIRAGANA, 171 | Character.UnicodeBlock.KATAKANA, 172 | Character.UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS, 173 | Character.UnicodeBlock.BOPOMOFO, 174 | Character.UnicodeBlock.BOPOMOFO_EXTENDED, 175 | Character.UnicodeBlock.HANGUL_SYLLABLES, 176 | Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, 177 | Character.UnicodeBlock.HANGUL_JAMO, 178 | Character.UnicodeBlock.HANGUL_JAMO_EXTENDED_A, 179 | Character.UnicodeBlock.HANGUL_JAMO_EXTENDED_B 180 | ) 181 | 182 | private enum class CodePointCategory { ASCII, CJK, OTHER_NON_ASCII } 183 | -------------------------------------------------------------------------------- /richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.markdown 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.MutableState 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.layout.ContentScale 9 | import androidx.compose.ui.unit.IntSize 10 | import androidx.compose.ui.unit.dp 11 | import com.halilibo.richtext.markdown.node.AstBlockQuote 12 | import com.halilibo.richtext.markdown.node.AstCode 13 | import com.halilibo.richtext.markdown.node.AstEmphasis 14 | import com.halilibo.richtext.markdown.node.AstFencedCodeBlock 15 | import com.halilibo.richtext.markdown.node.AstHardLineBreak 16 | import com.halilibo.richtext.markdown.node.AstHeading 17 | import com.halilibo.richtext.markdown.node.AstImage 18 | import com.halilibo.richtext.markdown.node.AstIndentedCodeBlock 19 | import com.halilibo.richtext.markdown.node.AstLink 20 | import com.halilibo.richtext.markdown.node.AstLinkReferenceDefinition 21 | import com.halilibo.richtext.markdown.node.AstListItem 22 | import com.halilibo.richtext.markdown.node.AstNode 23 | import com.halilibo.richtext.markdown.node.AstParagraph 24 | import com.halilibo.richtext.markdown.node.AstSoftLineBreak 25 | import com.halilibo.richtext.markdown.node.AstStrikethrough 26 | import com.halilibo.richtext.markdown.node.AstStrongEmphasis 27 | import com.halilibo.richtext.markdown.node.AstText 28 | import com.halilibo.richtext.ui.BlockQuote 29 | import com.halilibo.richtext.ui.FormattedList 30 | import com.halilibo.richtext.ui.RichTextScope 31 | import com.halilibo.richtext.ui.string.InlineContent 32 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 33 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 34 | import com.halilibo.richtext.ui.string.RichTextString 35 | import com.halilibo.richtext.ui.string.Text 36 | import com.halilibo.richtext.ui.string.withFormat 37 | 38 | /** 39 | * Only render the text content that exists below [astNode]. All the content blocks 40 | * like [AstBlockQuote] or [AstFencedCodeBlock] are ignored. This composable is 41 | * suited for [AstHeading] and [AstParagraph] since they are strictly text blocks. 42 | * 43 | * Some notes about commonmark and in general Markdown parsing. 44 | * 45 | * - Paragraph and Heading are the only RichTextString containers in base implementation. 46 | * - RichTextString is build by traversing the children of Heading or Paragraph. 47 | * - RichTextString can include; 48 | * - Emphasis 49 | * - StrongEmphasis 50 | * - Image 51 | * - Link 52 | * - Code 53 | * - Code blocks should not have any children. Their whole content must reside in 54 | * [AstIndentedCodeBlock.literal] or [AstFencedCodeBlock.literal]. 55 | * - Blocks like [BlockQuote], [FormattedList], [AstListItem] must have an [AstParagraph] 56 | * as a child to include any further RichText. 57 | * - CustomNode and CustomBlock can have their own scope, no idea about that. 58 | * 59 | * @param astNode Root node to accept as Text Content container. 60 | */ 61 | @Composable 62 | internal fun RichTextScope.MarkdownRichText( 63 | astNode: AstNode, 64 | inlineContentOverride: InlineContentOverride?, 65 | richTextRenderOptions: RichTextRenderOptions, 66 | markdownAnimationState: MarkdownAnimationState, 67 | modifier: Modifier = Modifier, 68 | ) { 69 | // Assume that only RichText nodes reside below this level. 70 | val richText = remember(astNode) { 71 | computeRichTextString(astNode, inlineContentOverride) 72 | } 73 | 74 | Text( 75 | text = richText, 76 | modifier = modifier, 77 | isLeafText = astNode.isLastInTree(), 78 | renderOptions = richTextRenderOptions, 79 | sharedAnimationState = markdownAnimationState, 80 | ) 81 | } 82 | 83 | private fun AstNode?.isLastInTree(): Boolean = this?.links?.parent == null || 84 | (links.next == null && links.parent.isLastInTree()) 85 | 86 | private fun RichTextScope.computeRichTextString( 87 | astNode: AstNode, 88 | inlineContentOverride: InlineContentOverride?, 89 | ): RichTextString { 90 | val richTextStringBuilder = RichTextString.Builder() 91 | 92 | // Modified pre-order traversal with pushFormat, popFormat support. 93 | var iteratorStack = listOf( 94 | AstNodeTraversalEntry( 95 | astNode = astNode, 96 | isVisited = false, 97 | formatIndex = null 98 | ) 99 | ) 100 | 101 | fun defaultContent(currentNode: AstNode): Int? { 102 | return when (val currentNodeType = currentNode.type) { 103 | is AstCode -> { 104 | richTextStringBuilder.withFormat(RichTextString.Format.Code) { 105 | append(currentNodeType.literal) 106 | } 107 | null 108 | } 109 | 110 | is AstEmphasis -> richTextStringBuilder.pushFormat(RichTextString.Format.Italic) 111 | is AstStrikethrough -> richTextStringBuilder.pushFormat( 112 | RichTextString.Format.Strikethrough 113 | ) 114 | 115 | is AstImage -> { 116 | richTextStringBuilder.appendInlineContent( 117 | content = InlineContent( 118 | initialSize = { 119 | IntSize(128.dp.roundToPx(), 128.dp.roundToPx()) 120 | } 121 | ) { 122 | MarkdownImage( 123 | url = currentNodeType.destination, 124 | contentDescription = currentNodeType.title, 125 | modifier = Modifier.fillMaxWidth(), 126 | contentScale = ContentScale.Inside, 127 | ) 128 | 129 | } 130 | ) 131 | null 132 | } 133 | 134 | is AstLink -> richTextStringBuilder.pushFormat(RichTextString.Format.Link( 135 | destination = currentNodeType.destination 136 | )) 137 | 138 | is AstSoftLineBreak -> { 139 | richTextStringBuilder.append(" ") 140 | null 141 | } 142 | 143 | is AstHardLineBreak -> { 144 | richTextStringBuilder.append("\n") 145 | null 146 | } 147 | 148 | is AstStrongEmphasis -> richTextStringBuilder.pushFormat(RichTextString.Format.Bold) 149 | is AstText -> { 150 | richTextStringBuilder.append(currentNodeType.literal) 151 | null 152 | } 153 | 154 | is AstLinkReferenceDefinition -> richTextStringBuilder.pushFormat( 155 | RichTextString.Format.Link(destination = currentNodeType.destination)) 156 | 157 | else -> null 158 | } 159 | } 160 | 161 | while (iteratorStack.isNotEmpty()) { 162 | val (currentNode, isVisited, formatIndex) = iteratorStack.first().copy() 163 | iteratorStack = iteratorStack.drop(1) 164 | 165 | if (!isVisited) { 166 | val newFormatIndex = when (inlineContentOverride) { 167 | null -> defaultContent(currentNode) 168 | else -> inlineContentOverride.invoke( 169 | this, 170 | currentNode, 171 | richTextStringBuilder, 172 | { defaultContent(currentNode) }, 173 | ) 174 | } 175 | 176 | iteratorStack = iteratorStack.addFirst( 177 | AstNodeTraversalEntry( 178 | astNode = currentNode, 179 | isVisited = true, 180 | formatIndex = newFormatIndex 181 | ) 182 | ) 183 | 184 | // Do not visit children of terminals such as Text, Image, etc. 185 | if (!currentNode.isRichTextTerminal()) { 186 | currentNode.childrenSequence(reverse = true).forEach { 187 | iteratorStack = iteratorStack.addFirst( 188 | AstNodeTraversalEntry( 189 | astNode = it, 190 | isVisited = false, 191 | formatIndex = null 192 | ) 193 | ) 194 | } 195 | } 196 | } 197 | 198 | if (formatIndex != null) { 199 | richTextStringBuilder.pop(formatIndex) 200 | } 201 | } 202 | 203 | return richTextStringBuilder.toRichTextString() 204 | } 205 | 206 | private data class AstNodeTraversalEntry( 207 | val astNode: AstNode, 208 | val isVisited: Boolean, 209 | val formatIndex: Int? 210 | ) 211 | 212 | private inline fun List.addFirst(item: T): List { 213 | return listOf(item) + this 214 | } 215 | -------------------------------------------------------------------------------- /android-sample/src/main/java/com/zachklipp/richtext/sample/TextDemo.kt: -------------------------------------------------------------------------------- 1 | package com.zachklipp.richtext.sample 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | import androidx.compose.animation.Animatable 6 | import androidx.compose.animation.core.Animatable 7 | import androidx.compose.animation.core.LinearEasing 8 | import androidx.compose.animation.core.infiniteRepeatable 9 | import androidx.compose.animation.core.keyframes 10 | import androidx.compose.animation.core.tween 11 | import androidx.compose.foundation.Canvas 12 | import androidx.compose.foundation.clickable 13 | import androidx.compose.foundation.layout.Box 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.wrapContentSize 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.CompositionLocalProvider 20 | import androidx.compose.runtime.LaunchedEffect 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableStateOf 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.runtime.saveable.rememberSaveable 25 | import androidx.compose.runtime.setValue 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.geometry.Offset 29 | import androidx.compose.ui.graphics.Color 30 | import androidx.compose.ui.graphics.StrokeCap.Companion.Round 31 | import androidx.compose.ui.graphics.drawscope.withTransform 32 | import androidx.compose.ui.graphics.graphicsLayer 33 | import androidx.compose.ui.platform.LocalContext 34 | import androidx.compose.ui.platform.LocalUriHandler 35 | import androidx.compose.ui.platform.UriHandler 36 | import androidx.compose.ui.text.TextStyle 37 | import androidx.compose.ui.tooling.preview.Preview 38 | import androidx.compose.ui.unit.dp 39 | import androidx.compose.ui.unit.em 40 | import androidx.compose.ui.unit.sp 41 | import com.halilibo.richtext.ui.material3.RichText 42 | import com.halilibo.richtext.ui.string.InlineContent 43 | import com.halilibo.richtext.ui.string.RichTextString.Builder 44 | import com.halilibo.richtext.ui.string.RichTextString.Format 45 | import com.halilibo.richtext.ui.string.RichTextString.Format.Bold 46 | import com.halilibo.richtext.ui.string.RichTextString.Format.Code 47 | import com.halilibo.richtext.ui.string.RichTextString.Format.Italic 48 | import com.halilibo.richtext.ui.string.RichTextString.Format.Link 49 | import com.halilibo.richtext.ui.string.RichTextString.Format.Strikethrough 50 | import com.halilibo.richtext.ui.string.RichTextString.Format.Subscript 51 | import com.halilibo.richtext.ui.string.RichTextString.Format.Superscript 52 | import com.halilibo.richtext.ui.string.RichTextString.Format.Underline 53 | import com.halilibo.richtext.ui.string.Text 54 | import com.halilibo.richtext.ui.string.richTextString 55 | import com.halilibo.richtext.ui.string.withFormat 56 | import kotlinx.coroutines.delay 57 | import kotlinx.coroutines.launch 58 | 59 | @Preview(showBackground = true) 60 | @Composable fun TextPreview() { 61 | val context = LocalContext.current 62 | var toggleLink by remember { mutableStateOf(false) } 63 | val text = remember(context, toggleLink) { 64 | richTextString { 65 | appendPreviewSentence(Bold) 66 | appendPreviewSentence(Italic) 67 | appendPreviewSentence(Underline) 68 | appendPreviewSentence(Strikethrough) 69 | appendPreviewSentence(Subscript) 70 | appendPreviewSentence(Superscript) 71 | appendPreviewSentence(Code) 72 | appendPreviewSentence( 73 | Link("") { toggleLink = !toggleLink }, 74 | if (toggleLink) "clicked link" else "link" 75 | ) 76 | append("Here, ") 77 | appendInlineContent(content = spinningCross) 78 | append(", is an inline image. ") 79 | append("And here, ") 80 | appendInlineContent(content = slowLoadingImage) 81 | append(", is an inline image that loads after some delay.") 82 | append("\n\n") 83 | 84 | append("Here ") 85 | withFormat(Underline) { 86 | append("is a ") 87 | withFormat(Italic) { 88 | append("longer sentence ") 89 | withFormat(Bold) { 90 | append("with many ") 91 | withFormat(Code) { 92 | append("different ") 93 | withFormat(Strikethrough) { 94 | append("nested") 95 | } 96 | append(" ") 97 | } 98 | } 99 | append("styles.") 100 | } 101 | } 102 | } 103 | } 104 | RichText { 105 | Text(text) 106 | } 107 | } 108 | 109 | private val spinningCross = InlineContent { 110 | val angle = remember { Animatable(0f) } 111 | val color = remember { Animatable(Color.Red) } 112 | LaunchedEffect(Unit) { 113 | val angleAnim = infiniteRepeatable( 114 | animation = tween(durationMillis = 1000, easing = LinearEasing) 115 | ) 116 | launch { angle.animateTo(360f, angleAnim) } 117 | 118 | val colorAnim = infiniteRepeatable( 119 | animation = keyframes { 120 | durationMillis = 2500 121 | Color.Blue at 500 122 | Color.Cyan at 1000 123 | Color.Green at 1500 124 | Color.Magenta at 2000 125 | } 126 | ) 127 | launch { color.animateTo(Color.Yellow, colorAnim) } 128 | } 129 | 130 | Canvas(modifier = Modifier 131 | .size(12.sp.toDp(), 12.sp.toDp()) 132 | .padding(2.dp)) { 133 | withTransform({ rotate(angle.value, center) }) { 134 | val strokeWidth = 3.dp.toPx() 135 | val strokeCap = Round 136 | drawLine( 137 | color.value, 138 | start = Offset(0f, size.height / 2), 139 | end = Offset(size.width, size.height / 2), 140 | strokeWidth = strokeWidth, 141 | cap = strokeCap 142 | ) 143 | drawLine( 144 | color.value, 145 | start = Offset(size.width / 2, 0f), 146 | end = Offset(size.width / 2, size.height), 147 | strokeWidth = strokeWidth, 148 | cap = strokeCap 149 | ) 150 | } 151 | } 152 | } 153 | 154 | val slowLoadingImage = InlineContent { 155 | var loaded by rememberSaveable { mutableStateOf(false) } 156 | LaunchedEffect(loaded) { 157 | if (!loaded) { 158 | delay(3000) 159 | loaded = true 160 | } 161 | } 162 | 163 | if (!loaded) { 164 | LoadingSpinner() 165 | } else { 166 | Box(Modifier.clickable(onClick = { loaded = false })) { 167 | val size = remember { Animatable(16f) } 168 | LaunchedEffect(Unit) { size.animateTo(100f) } 169 | Picture(Modifier.size(size.value.sp.toDp())) 170 | Text( 171 | "click to refresh", 172 | modifier = Modifier 173 | .padding(3.dp) 174 | .align(Alignment.Center), 175 | fontSize = 8.sp, 176 | style = TextStyle(background = Color.LightGray) 177 | ) 178 | } 179 | } 180 | } 181 | 182 | @Composable private fun LoadingSpinner() { 183 | val alpha = remember { Animatable(1f) } 184 | LaunchedEffect(Unit) { 185 | val anim = infiniteRepeatable( 186 | animation = keyframes { 187 | durationMillis = 500 188 | 0f at 250 189 | 1f at 500 190 | }) 191 | alpha.animateTo(0f, anim) 192 | } 193 | Text( 194 | "⏳", 195 | fontSize = 3.em, 196 | modifier = Modifier 197 | .wrapContentSize(Alignment.Center) 198 | .graphicsLayer(alpha = alpha.value) 199 | ) 200 | } 201 | 202 | @Composable private fun Picture(modifier: Modifier) { 203 | Canvas(modifier) { 204 | drawRect(Color.LightGray) 205 | drawLine(Color.Red, Offset(0f, 0f), Offset(size.width, size.height)) 206 | drawLine(Color.Red, Offset(0f, size.height), Offset(size.width, 0f)) 207 | } 208 | } 209 | 210 | @OptIn(ExperimentalStdlibApi::class) 211 | private fun Builder.appendPreviewSentence( 212 | format: Format, 213 | text: String = format.javaClass.simpleName.replaceFirstChar { it.lowercase() } 214 | ) { 215 | append("Here is some ") 216 | withFormat(format) { 217 | append(text) 218 | } 219 | append(" text. ") 220 | } 221 | 222 | @Composable 223 | fun ProvideToastUriHandler(context: Context, content: @Composable () -> Unit) { 224 | val uriHandler = remember(context) { 225 | object : UriHandler { 226 | override fun openUri(uri: String) { 227 | Toast.makeText(context, uri, Toast.LENGTH_SHORT).show() 228 | } 229 | } 230 | } 231 | 232 | CompositionLocalProvider(LocalUriHandler provides uriHandler, content) 233 | } 234 | -------------------------------------------------------------------------------- /richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt: -------------------------------------------------------------------------------- 1 | package com.halilibo.richtext.commonmark 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.produceState 6 | import androidx.compose.runtime.remember 7 | import com.halilibo.richtext.markdown.node.AstBlockQuote 8 | import com.halilibo.richtext.markdown.node.AstCode 9 | import com.halilibo.richtext.markdown.node.AstCustomBlock 10 | import com.halilibo.richtext.markdown.node.AstCustomNode 11 | import com.halilibo.richtext.markdown.node.AstDocument 12 | import com.halilibo.richtext.markdown.node.AstEmphasis 13 | import com.halilibo.richtext.markdown.node.AstFencedCodeBlock 14 | import com.halilibo.richtext.markdown.node.AstHardLineBreak 15 | import com.halilibo.richtext.markdown.node.AstHeading 16 | import com.halilibo.richtext.markdown.node.AstHtmlBlock 17 | import com.halilibo.richtext.markdown.node.AstHtmlInline 18 | import com.halilibo.richtext.markdown.node.AstImage 19 | import com.halilibo.richtext.markdown.node.AstIndentedCodeBlock 20 | import com.halilibo.richtext.markdown.node.AstLink 21 | import com.halilibo.richtext.markdown.node.AstLinkReferenceDefinition 22 | import com.halilibo.richtext.markdown.node.AstListItem 23 | import com.halilibo.richtext.markdown.node.AstNode 24 | import com.halilibo.richtext.markdown.node.AstNodeLinks 25 | import com.halilibo.richtext.markdown.node.AstNodeType 26 | import com.halilibo.richtext.markdown.node.AstOrderedList 27 | import com.halilibo.richtext.markdown.node.AstParagraph 28 | import com.halilibo.richtext.markdown.node.AstSoftLineBreak 29 | import com.halilibo.richtext.markdown.node.AstStrikethrough 30 | import com.halilibo.richtext.markdown.node.AstStrongEmphasis 31 | import com.halilibo.richtext.markdown.node.AstTableBody 32 | import com.halilibo.richtext.markdown.node.AstTableCell 33 | import com.halilibo.richtext.markdown.node.AstTableCellAlignment 34 | import com.halilibo.richtext.markdown.node.AstTableHeader 35 | import com.halilibo.richtext.markdown.node.AstTableRoot 36 | import com.halilibo.richtext.markdown.node.AstTableRow 37 | import com.halilibo.richtext.markdown.node.AstText 38 | import com.halilibo.richtext.markdown.node.AstThematicBreak 39 | import com.halilibo.richtext.markdown.node.AstUnorderedList 40 | import org.commonmark.ext.autolink.AutolinkExtension 41 | import org.commonmark.ext.gfm.strikethrough.Strikethrough 42 | import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension 43 | import org.commonmark.ext.gfm.tables.TableBlock 44 | import org.commonmark.ext.gfm.tables.TableBody 45 | import org.commonmark.ext.gfm.tables.TableCell 46 | import org.commonmark.ext.gfm.tables.TableCell.Alignment.CENTER 47 | import org.commonmark.ext.gfm.tables.TableCell.Alignment.LEFT 48 | import org.commonmark.ext.gfm.tables.TableCell.Alignment.RIGHT 49 | import org.commonmark.ext.gfm.tables.TableHead 50 | import org.commonmark.ext.gfm.tables.TableRow 51 | import org.commonmark.ext.gfm.tables.TablesExtension 52 | import org.commonmark.node.BlockQuote 53 | import org.commonmark.node.BulletList 54 | import org.commonmark.node.Code 55 | import org.commonmark.node.CustomBlock 56 | import org.commonmark.node.CustomNode 57 | import org.commonmark.node.Document 58 | import org.commonmark.node.Emphasis 59 | import org.commonmark.node.FencedCodeBlock 60 | import org.commonmark.node.HardLineBreak 61 | import org.commonmark.node.Heading 62 | import org.commonmark.node.HtmlBlock 63 | import org.commonmark.node.HtmlInline 64 | import org.commonmark.node.Image 65 | import org.commonmark.node.IndentedCodeBlock 66 | import org.commonmark.node.Link 67 | import org.commonmark.node.LinkReferenceDefinition 68 | import org.commonmark.node.ListItem 69 | import org.commonmark.node.Node 70 | import org.commonmark.node.OrderedList 71 | import org.commonmark.node.Paragraph 72 | import org.commonmark.node.SoftLineBreak 73 | import org.commonmark.node.StrongEmphasis 74 | import org.commonmark.node.Text 75 | import org.commonmark.node.ThematicBreak 76 | import org.commonmark.parser.Parser 77 | 78 | /** 79 | * Converts common-markdown tree to AstNode tree in a recursive fashion. 80 | */ 81 | internal fun convert( 82 | node: Node?, 83 | parentNode: AstNode? = null, 84 | previousNode: AstNode? = null, 85 | ): AstNode? { 86 | node ?: return null 87 | 88 | val nodeLinks = AstNodeLinks( 89 | parent = parentNode, 90 | previous = previousNode, 91 | ) 92 | 93 | val newNodeType: AstNodeType? = when (node) { 94 | is BlockQuote -> AstBlockQuote 95 | is BulletList -> AstUnorderedList(bulletMarker = node.bulletMarker) 96 | is Code -> AstCode(literal = node.literal) 97 | is Document -> AstDocument 98 | is Emphasis -> AstEmphasis(delimiter = node.openingDelimiter) 99 | is FencedCodeBlock -> AstFencedCodeBlock( 100 | literal = node.literal, 101 | fenceChar = node.fenceChar, 102 | fenceIndent = node.fenceIndent, 103 | fenceLength = node.fenceLength, 104 | info = node.info 105 | ) 106 | is HardLineBreak -> AstHardLineBreak 107 | is Heading -> AstHeading( 108 | level = node.level 109 | ) 110 | is ThematicBreak -> AstThematicBreak 111 | is HtmlInline -> AstHtmlInline( 112 | literal = node.literal 113 | ) 114 | is HtmlBlock -> AstHtmlBlock( 115 | literal = node.literal 116 | ) 117 | is Image -> { 118 | if (node.destination == null) { 119 | null 120 | } 121 | else { 122 | AstImage( 123 | title = node.title ?: "", 124 | destination = node.destination 125 | ) 126 | } 127 | } 128 | is IndentedCodeBlock -> AstIndentedCodeBlock( 129 | literal = node.literal 130 | ) 131 | is Link -> AstLink( 132 | title = node.title ?: "", 133 | destination = node.destination 134 | ) 135 | is ListItem -> AstListItem 136 | is OrderedList -> AstOrderedList( 137 | startNumber = node.startNumber, 138 | delimiter = node.delimiter 139 | ) 140 | is Paragraph -> AstParagraph 141 | is SoftLineBreak -> AstSoftLineBreak 142 | is StrongEmphasis -> AstStrongEmphasis( 143 | delimiter = node.openingDelimiter 144 | ) 145 | is Text -> AstText( 146 | literal = node.literal 147 | ) 148 | is LinkReferenceDefinition -> AstLinkReferenceDefinition( 149 | title = node.title ?: "", 150 | destination = node.destination, 151 | label = node.label 152 | ) 153 | is TableBlock -> AstTableRoot 154 | is TableHead -> AstTableHeader 155 | is TableBody -> AstTableBody 156 | is TableRow -> AstTableRow 157 | is TableCell -> AstTableCell( 158 | header = node.isHeader, 159 | alignment = when (node.alignment) { 160 | LEFT -> AstTableCellAlignment.LEFT 161 | CENTER -> AstTableCellAlignment.CENTER 162 | RIGHT -> AstTableCellAlignment.RIGHT 163 | null -> AstTableCellAlignment.LEFT 164 | else -> AstTableCellAlignment.LEFT 165 | } 166 | ) 167 | is Strikethrough -> AstStrikethrough( 168 | node.openingDelimiter 169 | ) 170 | is CustomNode -> AstCustomNode(node) 171 | is CustomBlock -> AstCustomBlock(node) 172 | else -> null 173 | } 174 | 175 | val newNode = newNodeType?.let { 176 | AstNode(newNodeType, nodeLinks) 177 | } 178 | 179 | if (newNode != null) { 180 | newNode.links.firstChild = convert(node.firstChild, parentNode = newNode, previousNode = null) 181 | newNode.links.next = convert(node.next, parentNode = parentNode, previousNode = newNode) 182 | } 183 | 184 | if (node.next == null) { 185 | parentNode?.links?.lastChild = newNode 186 | } 187 | 188 | return newNode 189 | } 190 | 191 | @Composable 192 | internal actual fun Node.toAstNode() = convert(this) 193 | 194 | @Composable 195 | public actual fun parsedMarkdown(text: String, options: CommonMarkdownParseOptions): AstNode? { 196 | val parser = remember(options) { 197 | CommonmarkAstNodeParser(options) 198 | } 199 | 200 | val rootNode by produceState(null, text, parser) { 201 | value = parser.parse(text) 202 | } 203 | 204 | return rootNode 205 | } 206 | 207 | public actual class CommonmarkAstNodeParser actual constructor( 208 | options: CommonMarkdownParseOptions 209 | ) { 210 | 211 | private val parser = Parser.builder() 212 | .extensions( 213 | listOfNotNull( 214 | TablesExtension.create(), 215 | StrikethroughExtension.create(), 216 | if (options.autolink) AutolinkExtension.create() else null 217 | ) 218 | ) 219 | .build() 220 | 221 | public actual fun parse(text: String): AstNode { 222 | val commonmarkNode = parser.parse(text) 223 | ?: throw IllegalArgumentException( 224 | "Could not parse the given text content into a meaningful Markdown representation!" 225 | ) 226 | 227 | return convert(commonmarkNode) 228 | ?: throw IllegalArgumentException( 229 | "Could not convert the generated Commonmark Node into an ASTNode!" 230 | ) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/Table.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") 2 | 3 | package com.halilibo.richtext.ui 4 | 5 | import androidx.compose.foundation.horizontalScroll 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.Immutable 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.alpha 13 | import androidx.compose.ui.draw.clipToBounds 14 | import androidx.compose.ui.draw.drawBehind 15 | import androidx.compose.ui.geometry.Offset 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.graphics.graphicsLayer 18 | import androidx.compose.ui.graphics.takeOrElse 19 | import androidx.compose.ui.platform.LocalDensity 20 | import androidx.compose.ui.text.TextStyle 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.unit.Dp 23 | import androidx.compose.ui.unit.TextUnit 24 | import androidx.compose.ui.unit.sp 25 | import com.halilibo.richtext.ui.ColumnArrangement.Adaptive 26 | import com.halilibo.richtext.ui.ColumnArrangement.Uniform 27 | import com.halilibo.richtext.ui.string.MarkdownAnimationState 28 | import com.halilibo.richtext.ui.string.RichTextRenderOptions 29 | import kotlin.math.max 30 | import kotlin.math.roundToInt 31 | 32 | /** 33 | * Defines the visual style for a [Table]. 34 | * 35 | * @param headerTextStyle The [TextStyle] used for header rows. 36 | * @param cellPadding The spacing between the contents of each cell and the borders. 37 | * @param borderColor The [Color] of the table border. 38 | * @param borderStrokeWidth The width of the table border. 39 | * @param headerBorderColor Optional override of the header's [Color], defaulting to borderColor. 40 | * @param drawVerticalDividers Whether to draw vertical dividers. 41 | */ 42 | @Immutable 43 | public data class TableStyle( 44 | val headerTextStyle: TextStyle? = null, 45 | val cellPadding: TextUnit? = null, 46 | val columnArrangement: ColumnArrangement? = null, 47 | val borderColor: Color? = null, 48 | val borderStrokeWidth: Float? = null, 49 | val headerBorderColor: Color? = null, 50 | val dividerStyle: DividerStyle? = null, 51 | ) { 52 | public companion object { 53 | public val Default: TableStyle = TableStyle() 54 | } 55 | } 56 | 57 | public sealed interface ColumnArrangement { 58 | public object Uniform : ColumnArrangement 59 | public class Adaptive(public val maxWidth: Dp) : ColumnArrangement 60 | } 61 | 62 | public sealed interface DividerStyle { 63 | public object Full : DividerStyle 64 | public object Minimal : DividerStyle 65 | } 66 | 67 | private val DefaultTableHeaderTextStyle = TextStyle(fontWeight = FontWeight.Bold) 68 | private val DefaultCellPadding = 8.sp 69 | private val DefaultBorderColor = Color.Unspecified 70 | private val DefaultColumnArrangement = ColumnArrangement.Uniform 71 | private const val DefaultBorderStrokeWidth = 1f 72 | private val DefaultDividerStyle = DividerStyle.Full 73 | 74 | internal fun TableStyle.resolveDefaults() = TableStyle( 75 | headerTextStyle = headerTextStyle ?: DefaultTableHeaderTextStyle, 76 | cellPadding = cellPadding ?: DefaultCellPadding, 77 | columnArrangement = columnArrangement ?: DefaultColumnArrangement, 78 | borderColor = borderColor ?: DefaultBorderColor, 79 | borderStrokeWidth = borderStrokeWidth ?: DefaultBorderStrokeWidth, 80 | headerBorderColor = headerBorderColor, 81 | dividerStyle = dividerStyle ?: DefaultDividerStyle, 82 | ) 83 | 84 | public interface RichTextTableRowScope { 85 | public fun row(children: RichTextTableCellScope.() -> Unit) 86 | } 87 | 88 | public interface RichTextTableCellScope { 89 | public fun cell(children: @Composable RichTextScope.() -> Unit) 90 | } 91 | 92 | @Immutable 93 | private data class TableRow(val cells: List<@Composable RichTextScope.() -> Unit>) 94 | 95 | private class TableBuilder : RichTextTableRowScope { 96 | val rows = mutableListOf() 97 | 98 | override fun row(children: RichTextTableCellScope.() -> Unit) { 99 | rows += RowBuilder().apply(children) 100 | } 101 | } 102 | 103 | private class RowBuilder : RichTextTableCellScope { 104 | var row = TableRow(emptyList()) 105 | 106 | override fun cell(children: @Composable RichTextScope.() -> Unit) { 107 | row = TableRow(row.cells + children) 108 | } 109 | } 110 | 111 | /** 112 | * Draws a table with an optional header row, and an arbitrary number of body rows. 113 | * 114 | * The style of the table is defined by the [RichTextStyle.tableStyle] [TableStyle]. 115 | */ 116 | @Composable 117 | public fun RichTextScope.Table( 118 | modifier: Modifier = Modifier, 119 | markdownAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, 120 | richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions(), 121 | headerRow: (RichTextTableCellScope.() -> Unit)? = null, 122 | bodyRows: RichTextTableRowScope.() -> Unit 123 | ) { 124 | val tableStyle = currentRichTextStyle.resolveDefaults().tableStyle!! 125 | val alpha = rememberMarkdownFade(richTextRenderOptions, markdownAnimationState) 126 | val contentColor = currentContentColor 127 | val header = remember(headerRow) { 128 | headerRow?.let { RowBuilder().apply(headerRow).row } 129 | } 130 | val rows = remember(bodyRows) { 131 | TableBuilder().apply(bodyRows).rows.map { it.row } 132 | } 133 | val columns = remember(header, rows) { 134 | max( 135 | header?.cells?.size ?: 0, 136 | rows.maxByOrNull { it.cells.size }?.cells?.size ?: 0 137 | ) 138 | } 139 | val headerStyle = currentTextStyle.merge(tableStyle.headerTextStyle) 140 | val cellPadding = with(LocalDensity.current) { 141 | tableStyle.cellPadding!!.toDp() 142 | } 143 | val cellModifier = Modifier 144 | .clipToBounds() 145 | .padding(cellPadding) 146 | 147 | val styledRows = remember(header, rows, cellModifier) { 148 | buildList { 149 | header?.let { headerRow -> 150 | // Type inference seems to puke without explicit parameters. 151 | @Suppress("RemoveExplicitTypeArguments") 152 | add(headerRow.cells.map<@Composable RichTextScope.() -> Unit, @Composable () -> Unit> { cell -> 153 | @Composable { 154 | textStyleBackProvider(headerStyle) { 155 | BasicRichText( 156 | modifier = cellModifier, 157 | children = cell 158 | ) 159 | } 160 | } 161 | }) 162 | } 163 | 164 | rows.mapTo(this) { row -> 165 | @Suppress("RemoveExplicitTypeArguments") 166 | row.cells.map<@Composable RichTextScope.() -> Unit, @Composable () -> Unit> { cell -> 167 | @Composable { 168 | BasicRichText( 169 | modifier = cellModifier, 170 | children = cell 171 | ) 172 | } 173 | } 174 | } 175 | } 176 | } 177 | 178 | // For some reason borders don't get drawn in the Preview, but they work on-device. 179 | val columnArrangement = tableStyle.columnArrangement!! 180 | val cellSpacing = tableStyle.borderStrokeWidth!! 181 | val density = LocalDensity.current 182 | val measurer = remember(columnArrangement, cellSpacing, density) { 183 | when (columnArrangement) { 184 | is Uniform -> UniformTableMeasurer(cellSpacing) 185 | is Adaptive -> { 186 | val maxWidth = with(density) { columnArrangement.maxWidth.toPx() }.roundToInt() 187 | AdaptiveTableMeasurer(maxWidth) 188 | } 189 | } 190 | } 191 | 192 | val baseModifier = modifier.graphicsLayer { this.alpha = alpha.value } 193 | val tableModifier = if (columnArrangement is Adaptive) { 194 | baseModifier.horizontalScroll(rememberScrollState()) 195 | } else { 196 | baseModifier 197 | } 198 | 199 | val borderColor = tableStyle.borderColor!!.takeOrElse { contentColor } 200 | TableLayout( 201 | columns = columns, 202 | rows = styledRows, 203 | hasHeader = header != null, 204 | cellSpacing = tableStyle.borderStrokeWidth, 205 | tableMeasurer = measurer, 206 | drawDecorations = { layoutResult -> 207 | Modifier.drawTableBorders( 208 | rowOffsets = layoutResult.rowOffsets, 209 | columnOffsets = layoutResult.columnOffsets, 210 | borderColor = borderColor, 211 | borderStrokeWidth = tableStyle.borderStrokeWidth, 212 | headerBorderColor = tableStyle.headerBorderColor ?: borderColor, 213 | dividerStyle = tableStyle.dividerStyle!!, 214 | ) 215 | }, 216 | modifier = tableModifier 217 | ) 218 | } 219 | 220 | private fun Modifier.drawTableBorders( 221 | rowOffsets: List, 222 | columnOffsets: List, 223 | borderColor: Color, 224 | borderStrokeWidth: Float, 225 | headerBorderColor: Color, 226 | dividerStyle: DividerStyle, 227 | ) = drawBehind { 228 | // Draw horizontal borders. 229 | rowOffsets.forEachIndexed { i, position -> 230 | if (dividerStyle is DividerStyle.Full || (i > 0 && i < rowOffsets.size - 1)) { 231 | drawLine( 232 | if (i == 1) headerBorderColor else borderColor, 233 | start = Offset(0f, position), 234 | end = Offset(size.width, position), 235 | borderStrokeWidth 236 | ) 237 | } 238 | } 239 | 240 | // Draw vertical borders. 241 | if (dividerStyle is DividerStyle.Full) { 242 | columnOffsets.forEach { position -> 243 | drawLine( 244 | borderColor, 245 | Offset(position, 0f), 246 | Offset(position, size.height), 247 | borderStrokeWidth 248 | ) 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | --------------------------------------------------------------------------------