├── 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 |
4 |
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 | [](https://developer.android.com/studio/build/dependencies)
4 | [](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 | [](https://developer.android.com/studio/build/dependencies)
4 | [](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 | [](https://search.maven.org/search?q=g:%22com.halilibo.compose-richtext%22)
4 | [](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 | [](https://search.maven.org/search?q=g:%22com.halilibo.compose-richtext%22)
4 | [](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 | [](https://developer.android.com/studio/build/dependencies)
4 | [](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 | [](https://developer.android.com/studio/build/dependencies)
4 | [](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` | 
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 | 
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 | [](https://developer.android.com/studio/build/dependencies)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------