diff --git a/README.md b/README.md index ef6e3964..d89c599d 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) > **Warning** -> compose-richtext library and all its modules are very experimental and undermaintained. The roadmap is unclear at the moment. Thanks for your patience. Fork option is available as always. +> 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. A collection of Compose libraries for working with rich text formatting and documents. -`richtext-ui`, `richtext-commonmark`, and `richtext-material-ui` are Kotlin Multiplatform Compose Libraries. +Aside from `printing`, and `slideshow`, all modules are Kotlin Multiplatform Compose Libraries. This repo is currently very experimental and really just proofs-of-concept: there are no tests and some things might be broken or very non-performant. diff --git a/android-sample/build.gradle.kts b/android-sample/build.gradle.kts index 15a9135e..8b4c20f6 100644 --- a/android-sample/build.gradle.kts +++ b/android-sample/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") kotlin("android") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version } android { @@ -24,10 +25,6 @@ android { kotlinOptions { jvmTarget = "11" } - - composeOptions { - kotlinCompilerExtensionVersion = Compose.compilerVersion - } } dependencies { diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt index 463ffdfe..feaf31e8 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/LazyMarkdownSample.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,10 +28,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.markdown.node.AstDocument import com.halilibo.richtext.markdown.node.AstNode @@ -50,7 +53,7 @@ import com.halilibo.richtext.ui.resolveDefaults var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) } var isDarkModeEnabled by remember { mutableStateOf(false) } var isWordWrapEnabled by remember { mutableStateOf(true) } - var markdownParseOptions by remember { mutableStateOf(MarkdownParseOptions.Default) } + var markdownParseOptions by remember { mutableStateOf(CommonMarkdownParseOptions.Default) } var isAutolinkEnabled by remember { mutableStateOf(true) } LaunchedEffect(isWordWrapEnabled) { @@ -115,14 +118,13 @@ import com.halilibo.richtext.ui.resolveDefaults parser.parse(sampleMarkdown) } - RichText( - style = richTextStyle, - linkClickHandler = { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - }, - modifier = Modifier.padding(8.dp), - ) { - LazyMarkdown(astNode) + ProvideToastUriHandler(context) { + RichText( + style = richTextStyle, + modifier = Modifier.padding(8.dp), + ) { + LazyMarkdown(astNode) + } } } } diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt index fa3fb03d..4d7d589b 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt @@ -32,9 +32,16 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions +import com.halilibo.richtext.markdown.AstBlockNodeComposer import com.halilibo.richtext.markdown.BasicMarkdown +import com.halilibo.richtext.markdown.node.AstBlockNodeType +import com.halilibo.richtext.markdown.node.AstHeading +import com.halilibo.richtext.markdown.node.AstNode +import com.halilibo.richtext.ui.Heading +import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults @@ -49,7 +56,7 @@ import com.halilibo.richtext.ui.resolveDefaults var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) } var isDarkModeEnabled by remember { mutableStateOf(false) } var isWordWrapEnabled by remember { mutableStateOf(true) } - var markdownParseOptions by remember { mutableStateOf(MarkdownParseOptions.Default) } + var markdownParseOptions by remember { mutableStateOf(CommonMarkdownParseOptions.Default) } var isAutolinkEnabled by remember { mutableStateOf(true) } var isRtl by remember { mutableStateOf(false) } @@ -126,14 +133,13 @@ import com.halilibo.richtext.ui.resolveDefaults parser.parse(sampleMarkdown) } - RichText( - style = richTextStyle, - linkClickHandler = { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - }, - modifier = Modifier.padding(8.dp), - ) { - BasicMarkdown(astNode) + ProvideToastUriHandler(context) { + RichText( + style = richTextStyle, + modifier = Modifier.padding(8.dp), + ) { + BasicMarkdown(astNode, HeadingAstBlockNodeComposer) + } } } } @@ -143,6 +149,25 @@ import com.halilibo.richtext.ui.resolveDefaults } } +val HeadingAstBlockNodeComposer = object : AstBlockNodeComposer { + override fun predicate(astBlockNodeType: AstBlockNodeType): Boolean { + return astBlockNodeType is AstHeading + } + + @Composable override fun RichTextScope.Compose( + astNode: AstNode, + visitChildren: @Composable (AstNode) -> Unit + ) { + val headingNode = astNode.type as? AstHeading ?: return + Column { + Heading(level = headingNode.level) { + visitChildren(astNode) + } + Text("Custom rendering is used for this heading!", fontSize = 8.sp) + } + } +} + @Composable private fun CheckboxPreference( onClick: () -> Unit, diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/RichTextSample.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/RichTextSample.kt index 9eed5811..1739241c 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/RichTextSample.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/RichTextSample.kt @@ -1,6 +1,8 @@ package com.zachklipp.richtext.sample +import androidx.annotation.IntRange import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,7 +13,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme @@ -24,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.RichTextStyle @@ -83,7 +89,7 @@ fun RichTextStyleConfig( onChanged: (RichTextStyle) -> Unit ) { Text("Paragraph spacing: ${richTextStyle.paragraphSpacing}") - Slider( + SliderForHumans( value = richTextStyle.paragraphSpacing!!.value, valueRange = 0f..20f, onValueChange = { @@ -92,7 +98,7 @@ fun RichTextStyleConfig( ) Text("Table cell padding: ${richTextStyle.tableStyle!!.cellPadding}") - Slider( + SliderForHumans( value = richTextStyle.tableStyle!!.cellPadding!!.value, valueRange = 0f..20f, onValueChange = { @@ -107,7 +113,7 @@ fun RichTextStyleConfig( ) Text("Table border width padding: ${richTextStyle.tableStyle!!.borderStrokeWidth!!}") - Slider( + SliderForHumans( value = richTextStyle.tableStyle!!.borderStrokeWidth!!, valueRange = 0f..20f, onValueChange = { @@ -121,3 +127,35 @@ fun RichTextStyleConfig( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SliderForHumans( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + @IntRange(from = 0) steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = { + SliderDefaults.Thumb( + interactionSource = interactionSource, + thumbSize = DpSize(4.dp, 20.dp) + ) + } + ) +} \ No newline at end of file diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt index 37e24f4d..dfb15b2a 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/SampleLauncher.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api @@ -78,7 +80,7 @@ private val Samples = listOf Unit>>( @Composable private fun SamplePreview(content: @Composable () -> Unit) { ScreenPreview( Modifier - .height(50.dp) + .size(50.dp) .aspectRatio(1f) .clipToBounds() // "Zoom in" to the top-start corner to make the preview more legible. diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/TextDemo.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/TextDemo.kt index 3f761d2b..550b3ce3 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/TextDemo.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/TextDemo.kt @@ -1,5 +1,7 @@ package com.zachklipp.richtext.sample +import android.content.Context +import android.widget.Toast import androidx.compose.animation.Animatable import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,6 +31,8 @@ import androidx.compose.ui.graphics.StrokeCap.Companion.Round import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,7 +70,7 @@ import kotlinx.coroutines.launch appendPreviewSentence(Superscript) appendPreviewSentence(Code) appendPreviewSentence( - Link(""), + Link("") { toggleLink = !toggleLink }, if (toggleLink) "clicked link" else "link" ) append("Here, ") @@ -96,7 +101,7 @@ import kotlinx.coroutines.launch } } } - RichText(linkClickHandler = { toggleLink = !toggleLink }) { + RichText { Text(text) } } @@ -213,3 +218,16 @@ private fun Builder.appendPreviewSentence( } append(" text. ") } + +@Composable +fun ProvideToastUriHandler(context: Context, content: @Composable () -> Unit) { + val uriHandler = remember(context) { + object : UriHandler { + override fun openUri(uri: String) { + Toast.makeText(context, uri, Toast.LENGTH_SHORT).show() + } + } + } + + CompositionLocalProvider(LocalUriHandler provides uriHandler, content) +} diff --git a/build.gradle.kts b/build.gradle.kts index 35ed7814..c5c6bc58 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -133,7 +133,7 @@ subprojects { extensions.findByType()?.apply { repositories { maven { - val localProperties = gradleLocalProperties(rootProject.rootDir) + val localProperties = gradleLocalProperties(rootProject.rootDir, providers) val sonatypeUsername = localProperties.getProperty("SONATYPE_USERNAME") ?: System.getenv("SONATYPE_USERNAME") @@ -165,7 +165,7 @@ subprojects { } extensions.findByType()?.apply { - val localProperties = gradleLocalProperties(rootProject.rootDir) + val localProperties = gradleLocalProperties(rootProject.rootDir, providers) val gpgPrivateKey = localProperties.getProperty("GPG_PRIVATE_KEY") diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 1eee73e1..aaf2e76d 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -10,8 +10,8 @@ plugins { dependencies { // keep in sync with Dependencies.BuildPlugins.androidGradlePlugin - implementation("com.android.tools.build:gradle:8.2.0") + implementation("com.android.tools.build:gradle:8.7.0") // keep in sync with Dependencies.Kotlin.gradlePlugin - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21") implementation(kotlin("script-runtime")) } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ef3463c7..e3ae5a03 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -1,13 +1,10 @@ object BuildPlugins { // keep in sync with buildSrc/build.gradle.kts - val androidGradlePlugin = "com.android.tools.build:gradle:8.2.0" + val androidGradlePlugin = "com.android.tools.build:gradle:8.7.0" } object AndroidX { - val activity = "androidx.activity:activity:1.5.0-rc01" - val annotations = "androidx.annotation:annotation:1.1.0" val appcompat = "androidx.appcompat:appcompat:1.3.0" - val material = "com.google.android.material:material:1.1.0" } object Network { @@ -16,7 +13,7 @@ object Network { object Kotlin { // keep in sync with buildSrc/build.gradle.kts - val version = "1.9.22" + val version = "2.0.21" val binaryCompatibilityValidatorPlugin = "org.jetbrains.kotlinx:binary-compatibility-validator:0.9.0" val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version" @@ -30,13 +27,9 @@ object Kotlin { val ktlint = "org.jlleitschuh.gradle:ktlint-gradle:10.0.0" object Compose { - val version = "1.5.4" - val compilerVersion = "1.5.8" - val desktopVersion = "1.6.0" + val desktopVersion = "1.7.1" val activity = "androidx.activity:activity-compose:1.8.2" - val annotatedText = "io.github.aghajari:AnnotatedText:1.0.3" - val toolingData = "androidx.compose.ui:ui-tooling-data:$version" - val multiplatformUiUtil = "org.jetbrains.compose.ui:ui-util:$desktopVersion" + val toolingData = "androidx.compose.ui:ui-tooling-data:1.6.0" val coil = "io.coil-kt:coil-compose:2.5.0" } diff --git a/buildSrc/src/main/kotlin/richtext-android-library.gradle.kts b/buildSrc/src/main/kotlin/richtext-android-library.gradle.kts index 847099a6..0d72f74b 100644 --- a/buildSrc/src/main/kotlin/richtext-android-library.gradle.kts +++ b/buildSrc/src/main/kotlin/richtext-android-library.gradle.kts @@ -1,8 +1,6 @@ plugins { id("com.android.library") kotlin("android") - id("maven-publish") - id("signing") } kotlin { @@ -29,10 +27,6 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = Compose.compilerVersion - } - publishing { singleVariant("release") { withSourcesJar() diff --git a/desktop-sample/build.gradle.kts b/desktop-sample/build.gradle.kts index 4f5b0b6c..e90c939f 100644 --- a/desktop-sample/build.gradle.kts +++ b/desktop-sample/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { kotlin("jvm") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version } dependencies { diff --git a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt index b108d660..9f050e80 100644 --- a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt +++ b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/MarkdownSampleApp.kt @@ -34,6 +34,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.singleWindowApplication @@ -103,30 +105,26 @@ fun main(): Unit = singleWindowApplication( icon = { Icon(Icons.Default.Favorite, "") }) } } - if (selectedTab == 0) { - RichText( - modifier = Modifier.verticalScroll(rememberScrollState()), - style = richTextStyle, - linkClickHandler = { - println("Link clicked destination=$it") + ProvidePrintUriHandler { + if (selectedTab == 0) { + RichText( + modifier = Modifier.verticalScroll(rememberScrollState()), + style = richTextStyle, + ) { + Markdown(content = text) } - ) { - Markdown(content = text) - } - } else { - val parser = remember { CommonmarkAstNodeParser() } + } else { + val parser = remember { CommonmarkAstNodeParser() } - val astNode = remember(parser) { - parser.parse(sampleMarkdown) - } + val astNode = remember(parser) { + parser.parse(sampleMarkdown) + } - RichText( - style = richTextStyle, - linkClickHandler = { - println("Link clicked destination=$it") + RichText( + style = richTextStyle, + ) { + LazyMarkdown(astNode) } - ) { - LazyMarkdown(astNode) } } } @@ -242,6 +240,19 @@ fun RichTextStyleConfig( } } +@Composable +fun ProvidePrintUriHandler(content: @Composable () -> Unit) { + val uriHandler = remember { + object : UriHandler { + override fun openUri(uri: String) { + println("Link clicked destination=$uri") + } + } + } + + CompositionLocalProvider(LocalUriHandler provides uriHandler, content) +} + private val sampleMarkdown = """ # Demo Based on [this cheatsheet][cheatsheet] diff --git a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt index cf3e63fa..895d04bf 100644 --- a/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt +++ b/desktop-sample/src/main/kotlin/com/halilibo/richtext/desktop/RichTextSampleApp.kt @@ -229,7 +229,7 @@ fun main(): Unit = singleWindowApplication( appendPreviewSentence(Superscript) appendPreviewSentence(Code) appendPreviewSentence( - Link(""), + Link("") { toggleLink = !toggleLink }, if (toggleLink) "clicked link" else "link" ) append("Here, ") @@ -260,7 +260,7 @@ fun main(): Unit = singleWindowApplication( } } } - RichText(linkClickHandler = { toggleLink = !toggleLink }) { + RichText { Text(text) } } diff --git a/docs/index.md b/docs/index.md index 896282d4..81720c47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,16 +4,16 @@ [![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) Compose Richtext is a collection of Compose libraries for working with rich text formatting and -documents. +documents. It includes a full feature markdown rendering library that conforms to commonmark specification. -`richtext-ui`, `richtext-markdown`, `richtext-commonmark`, and `richtext-ui-material`|`richtext-ui-material3` are Kotlin Multiplatform(KMP) Compose Libraries. +`richtext-ui`, `richtext-markdown`, `richtext-commonmark`, and `richtext-ui-material`|`richtext-ui-material3` are Kotlin Multiplatform(KMP) Compose Libraries with the exception of iOS. All these modules can be used in Android and Desktop Compose apps. Each library is documented separately, see the navigation menu for the list. This site also includes an API reference. !!! warning - This project is currently experimental and mostly just a proof-of-concept at this point. + 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. There are no tests and some things might be broken or very non-performant. The API may also change between releases without deprecation cycles. diff --git a/docs/printing.md b/docs/printing.md index 1e6c16c6..cc89caf0 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -1,4 +1,8 @@ -# Printing +# Printing (DEPRECATED) + +!!! warning + This artifact is deprecated and will no longer be published. You can use the already published versions from maven central or just look at its implementation as + a reference. If you would like to contribute and maintain this library, please reach out to us by creating an issue on Github. [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) diff --git a/docs/richtext-commonmark.md b/docs/richtext-commonmark.md index fa149aae..625b4732 100644 --- a/docs/richtext-commonmark.md +++ b/docs/richtext-commonmark.md @@ -4,7 +4,7 @@ [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) Library for parsing and rendering Markdown in Compose using [CommonMark](https://github.com/commonmark/commonmark-java) -library/spec to parse, and `richtext-ui` to render. +library/spec to parse, and `richtext-markdown` to render. ## Gradle diff --git a/docs/richtext-markdown.md b/docs/richtext-markdown.md index 085e61a2..0cf88c1e 100644 --- a/docs/richtext-markdown.md +++ b/docs/richtext-markdown.md @@ -4,7 +4,7 @@ [![JVM Library](https://img.shields.io/badge/Platform-JVM-red.svg?style=for-the-badge)](https://kotlinlang.org/docs/mpp-intro.html) Library for rendering Markdown tree that is defined as an `AstNode`. This module would be useless -for someone who is looking to just render a Markdown string. Please take a look to +for someone who is looking to just render a Markdown string. Please check out `richtext-commonmark` for such features. `richtext-markdown` behaves as sort of a building block. You can create your own parser or use 3rd party ones that converts any Markdown string to an `AstNode` tree. diff --git a/docs/slideshow.md b/docs/slideshow.md index eba5060e..71fb354d 100644 --- a/docs/slideshow.md +++ b/docs/slideshow.md @@ -1,4 +1,8 @@ -# Slideshow +# Slideshow (DEPRECATED) + +!!! warning + This artifact is deprecated and will no longer be published. You can use the already published versions from maven central or just look at its implementation as + a reference. If you would like to contribute and maintain this library, please reach out to us by creating an issue on Github. [![Android Library](https://img.shields.io/badge/Platform-Android-green.svg?style=for-the-badge)](https://developer.android.com/studio/build/dependencies) diff --git a/gradle.properties b/gradle.properties index c0c3203c..f15f59cd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ kotlin.code.style=official systemProp.org.gradle.internal.publish.checksums.insecure=true GROUP=com.halilibo.compose-richtext -VERSION_NAME=1.0.0-alpha01 +VERSION_NAME=1.0.0-alpha02 POM_DESCRIPTION=A collection of Compose libraries for advanced text formatting and alternative display types. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..d64cd491 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b93c46a5..09523c0e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..1aa94a42 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..6689b85b 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/printing/build.gradle.kts b/printing/build.gradle.kts index 5dba13d9..2e5275dd 100644 --- a/printing/build.gradle.kts +++ b/printing/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("richtext-android-library") id("org.jetbrains.dokka") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version } android { diff --git a/richtext-commonmark/build.gradle.kts b/richtext-commonmark/build.gradle.kts index 44cd6e9f..91bf0a77 100644 --- a/richtext-commonmark/build.gradle.kts +++ b/richtext-commonmark/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("richtext-kmp-library") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version id("org.jetbrains.dokka") } diff --git a/richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt b/richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt index 7877270b..9ee52b40 100644 --- a/richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt +++ b/richtext-commonmark/src/commonJvmAndroid/kotlin/com/halilibo/richtext/commonmark/AstNodeConvert.kt @@ -183,7 +183,7 @@ internal fun convert( } public actual class CommonmarkAstNodeParser actual constructor( - options: MarkdownParseOptions + options: CommonMarkdownParseOptions ) { private val parser = Parser.builder() diff --git a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/CommonMarkdownParseOptions.kt b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/CommonMarkdownParseOptions.kt new file mode 100644 index 00000000..df62303d --- /dev/null +++ b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/CommonMarkdownParseOptions.kt @@ -0,0 +1,38 @@ +package com.halilibo.richtext.commonmark + +/** + * Allows configuration of the Markdown parser + * + * @param autolink Detect plain text links and turn them into Markdown links. + */ +public class CommonMarkdownParseOptions( + public val autolink: Boolean +) { + + override fun toString(): String { + return "CommonMarkdownParseOptions(autolink=$autolink)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CommonMarkdownParseOptions) return false + + return autolink == other.autolink + } + + override fun hashCode(): Int { + return autolink.hashCode() + } + + public fun copy( + autolink: Boolean = this.autolink + ): CommonMarkdownParseOptions = CommonMarkdownParseOptions( + autolink = autolink + ) + + public companion object { + public val Default: CommonMarkdownParseOptions = CommonMarkdownParseOptions( + autolink = true + ) + } +} diff --git a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt index 6e81522c..8a4f1252 100644 --- a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt +++ b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import com.halilibo.richtext.commonmark.MarkdownParseOptions.Companion +import com.halilibo.richtext.markdown.AstBlockNodeComposer import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.markdown.node.AstNode import com.halilibo.richtext.ui.RichTextScope @@ -14,12 +14,14 @@ import com.halilibo.richtext.ui.RichTextScope * * @param content Markdown text. No restriction on length. * @param markdownParseOptions Options for the Markdown parser. - * @param onLinkClicked A function to invoke when a link is clicked from rendered content. + * @param astBlockNodeComposer An interceptor to take control of composing any block type node's + * rendering. Use it to render images, html text, tables with your own components. */ @Composable public fun RichTextScope.Markdown( content: String, - markdownParseOptions: MarkdownParseOptions = Companion.Default + markdownParseOptions: CommonMarkdownParseOptions = CommonMarkdownParseOptions.Default, + astBlockNodeComposer: AstBlockNodeComposer? = null ) { val commonmarkAstNodeParser = remember(markdownParseOptions) { CommonmarkAstNodeParser(markdownParseOptions) @@ -34,7 +36,7 @@ public fun RichTextScope.Markdown( } astRootNode?.let { - BasicMarkdown(astNode = it) + BasicMarkdown(astNode = it, astBlockNodeComposer = astBlockNodeComposer) } } @@ -42,7 +44,7 @@ public fun RichTextScope.Markdown( * A helper class that can convert any text content into an ASTNode tree and return its root. */ public expect class CommonmarkAstNodeParser( - options: MarkdownParseOptions = MarkdownParseOptions.Default + options: CommonMarkdownParseOptions = CommonMarkdownParseOptions.Default ) { /** diff --git a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/MarkdownParseOptions.kt b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/MarkdownParseOptions.kt deleted file mode 100644 index 30756823..00000000 --- a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/MarkdownParseOptions.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.halilibo.richtext.commonmark - -/** - * Allows configuration of the Markdown parser - * - * @param autolink Detect plain text links and turn them into Markdown links. - */ -public data class MarkdownParseOptions( - val autolink: Boolean -) { - public companion object { - public val Default: MarkdownParseOptions = MarkdownParseOptions( - autolink = true - ) - } -} diff --git a/richtext-markdown/build.gradle.kts b/richtext-markdown/build.gradle.kts index 68343267..264c41d6 100644 --- a/richtext-markdown/build.gradle.kts +++ b/richtext-markdown/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("richtext-kmp-library") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version id("org.jetbrains.dokka") } @@ -26,12 +27,6 @@ kotlin { val androidMain by getting { dependencies { implementation(Compose.coil) - implementation(Compose.annotatedText) - - implementation(Commonmark.core) - implementation(Commonmark.tables) - implementation(Commonmark.strikethrough) - implementation(Commonmark.autolink) } } @@ -39,11 +34,6 @@ kotlin { dependencies { implementation(compose.desktop.currentOs) implementation(Network.okHttp) - - implementation(Commonmark.core) - implementation(Commonmark.tables) - implementation(Commonmark.strikethrough) - implementation(Commonmark.autolink) } } diff --git a/richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt b/richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt index 4f749f35..f3a008fb 100644 --- a/richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt +++ b/richtext-markdown/src/androidMain/kotlin/com/halilibo/richtext/markdown/HtmlBlock.kt @@ -1,15 +1,9 @@ package com.halilibo.richtext.markdown -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES -import android.text.Html -import android.widget.TextView import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.viewinterop.AndroidView -import com.aghajari.compose.text.BasicAnnotatedText -import com.aghajari.compose.text.fromHtml +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.string.Text import com.halilibo.richtext.ui.string.richTextString @@ -19,7 +13,7 @@ internal actual fun RichTextScope.HtmlBlock(content: String) { val richTextString = remember(content) { richTextString { withAnnotatedString { - append(content.fromHtml().annotatedString) + append(AnnotatedString.Companion.fromHtml(content)) } } } diff --git a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt index 36c590b5..c7d06e6c 100644 --- a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt +++ b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import com.halilibo.richtext.markdown.node.AstBlockNodeType import com.halilibo.richtext.markdown.node.AstBlockQuote import com.halilibo.richtext.markdown.node.AstDocument import com.halilibo.richtext.markdown.node.AstFencedCodeBlock @@ -42,10 +43,40 @@ import com.halilibo.richtext.ui.string.richTextString * Designed to be a building block that should be wrapped with a specific parser. * * @param astNode Root node of Markdown tree. This can be obtained via a parser. + * @param astBlockNodeComposer An interceptor to take control of composing any block type node's + * rendering. Use it to render images, html text, tables with your own components. */ @Composable -public fun RichTextScope.BasicMarkdown(astNode: AstNode) { - RecursiveRenderMarkdownAst(astNode) +public fun RichTextScope.BasicMarkdown( + astNode: AstNode, + astBlockNodeComposer: AstBlockNodeComposer? = null +) { + RecursiveRenderMarkdownAst(astNode, astBlockNodeComposer) +} + +/** + * An interface used to intercept block type AstNode rendering logic to inject custom composables + * for nodes that satisfy [predicate]. + */ +public interface AstBlockNodeComposer { + + /** + * Returns true if [Compose] function would handle this [astBlockNodeType]. + */ + public fun predicate(astBlockNodeType: AstBlockNodeType): Boolean + + /** + * A composable that's responsible for composing the given [astNode] if its [AstNode.type] + * returned true from [predicate]. This composable should also decide when and where to render + * its children, then call [visitChildren] with a reference to which node's children to visit. + * This is not an enforced behavior but unknowingly failing to do so can cause loss of + * information during rendering. + */ + @Composable + public fun RichTextScope.Compose( + astNode: AstNode, + visitChildren: @Composable (AstNode) -> Unit + ) } /** @@ -73,99 +104,144 @@ public fun RichTextScope.BasicMarkdown(astNode: AstNode) { * * @param astNode Root node to start rendering. */ -@Suppress("IMPLICIT_CAST_TO_ANY") @Composable -internal fun RichTextScope.RecursiveRenderMarkdownAst(astNode: AstNode?) { +internal fun RichTextScope.RecursiveRenderMarkdownAst( + astNode: AstNode?, + astNodeComposer: AstBlockNodeComposer? +) { astNode ?: return - when (val astNodeType = astNode.type) { - is AstDocument -> visitChildren(node = astNode) - is AstBlockQuote -> { - BlockQuote { - visitChildren(astNode) + if (astNodeComposer != null && + astNode.type is AstBlockNodeType && + astNodeComposer.predicate(astNode.type) + ) { + with(astNodeComposer) { + Compose(astNode) { + renderChildren(it, astNodeComposer) } } - is AstUnorderedList -> { - FormattedList( - listType = Unordered, - items = astNode.filterChildrenType().toList() - ) { astListItem -> - // if this list item has no child, it should at least emit a single pixel layout. - if (astListItem.links.firstChild == null) { - BasicText("") - } else { - visitChildren(astListItem) + } else { + with(DefaultAstNodeComposer) { + Compose( + astNode = astNode, + visitChildren = { + renderChildren(it, astNodeComposer) } - } + ) } - is AstOrderedList -> { - FormattedList( - listType = Ordered, - items = astNode.childrenSequence().toList(), - startIndex = astNodeType.startNumber - 1, - ) { astListItem -> - // if this list item has no child, it should at least emit a single pixel layout. - if (astListItem.links.firstChild == null) { - BasicText("") - } else { - visitChildren(astListItem) + } +} + +private val DefaultAstNodeComposer = object : AstBlockNodeComposer { + override fun predicate(astBlockNodeType: AstBlockNodeType): Boolean = true + + @Composable + override fun RichTextScope.Compose( + astNode: AstNode, + visitChildren: @Composable (AstNode) -> Unit + ) { + when (val astNodeType = astNode.type) { + is AstDocument -> visitChildren(astNode) + is AstBlockQuote -> { + BlockQuote { + visitChildren(astNode) } } - } - is AstThematicBreak -> { - HorizontalRule() - } - is AstHeading -> { - Heading(level = astNodeType.level) { - MarkdownRichText(astNode, Modifier.semantics { heading() } ) + + is AstUnorderedList -> { + FormattedList( + listType = Unordered, + items = astNode.filterChildrenType().toList() + ) { astListItem -> + // if this list item has no child, it should at least emit a single pixel layout. + if (astListItem.links.firstChild == null) { + BasicText("") + } else { + visitChildren(astListItem) + } + } } - } - is AstIndentedCodeBlock -> { - CodeBlock(text = astNodeType.literal.trim()) - } - is AstFencedCodeBlock -> { - CodeBlock(text = astNodeType.literal.trim()) - } - is AstHtmlBlock -> { - Text(text = richTextString { - appendInlineContent(content = InlineContent { - HtmlBlock(astNodeType.literal) + + is AstOrderedList -> { + FormattedList( + listType = Ordered, + items = astNode.childrenSequence().toList(), + startIndex = astNodeType.startNumber - 1, + ) { astListItem -> + // if this list item has no child, it should at least emit a single pixel layout. + if (astListItem.links.firstChild == null) { + BasicText("") + } else { + visitChildren(astListItem) + } + } + } + + is AstThematicBreak -> { + HorizontalRule() + } + + is AstHeading -> { + Heading(level = astNodeType.level) { + MarkdownRichText(astNode, Modifier.semantics { heading() }) + } + } + + is AstIndentedCodeBlock -> { + CodeBlock(text = astNodeType.literal.trim()) + } + + is AstFencedCodeBlock -> { + CodeBlock(text = astNodeType.literal.trim()) + } + + is AstHtmlBlock -> { + Text(text = richTextString { + appendInlineContent(content = InlineContent { + HtmlBlock(astNodeType.literal) + }) }) - }) - } - is AstLinkReferenceDefinition -> { - // TODO(halilozercan) - /* no-op */ - } - is AstParagraph -> { - MarkdownRichText(astNode) - } - is AstTableRoot -> { - RenderTable(astNode) - } - // This should almost never happen. All the possible text - // nodes must be under either Heading, Paragraph or CustomNode - // In any case, we should include it here to prevent any - // non-rendered text problems. - is AstText -> { - // TODO(halilozercan) use multiplatform compatible stderr logging - println("Unexpected raw text while traversing the Abstract Syntax Tree.") - Text(richTextString { append(astNodeType.literal) }) - } - is AstListItem -> { - println("MarkdownRichText: Unexpected AstListItem while traversing the Abstract Syntax Tree.") - } - is AstInlineNodeType -> { - // ignore - println("MarkdownRichText: Unexpected AstInlineNodeType $astNodeType while traversing the Abstract Syntax Tree.") - } - AstTableBody, - AstTableHeader, - AstTableRow, - is AstTableCell -> { - println("MarkdownRichText: Unexpected Table node while traversing the Abstract Syntax Tree.") - } - }.let {} + } + + is AstLinkReferenceDefinition -> { + // TODO(halilozercan) + /* no-op */ + } + + is AstParagraph -> { + MarkdownRichText(astNode) + } + + is AstTableRoot -> { + RenderTable(astNode) + } + // This should almost never happen. All the possible text + // nodes must be under either Heading, Paragraph or CustomNode + // In any case, we should include it here to prevent any + // non-rendered text problems. + is AstText -> { + // TODO(halilozercan) use multiplatform compatible stderr logging + println("Unexpected raw text while traversing the Abstract Syntax Tree.") + Text(richTextString { append(astNodeType.literal) }) + } + + is AstListItem -> { + println("MarkdownRichText: Unexpected AstListItem while traversing the Abstract Syntax Tree.") + } + + is AstInlineNodeType -> { + // ignore + println("MarkdownRichText: Unexpected AstInlineNodeType $astNodeType while traversing the Abstract Syntax Tree.") + } + + AstTableBody, + AstTableHeader, + AstTableRow, + is AstTableCell -> { + println("MarkdownRichText: Unexpected Table node while traversing the Abstract Syntax Tree.") + } + }.let {} + } } /** @@ -174,8 +250,11 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst(astNode: AstNode?) { * @param node Root ASTNode whose children will be visited. */ @Composable -internal fun RichTextScope.visitChildren(node: AstNode?) { +internal fun RichTextScope.renderChildren( + node: AstNode?, + astNodeComposer: AstBlockNodeComposer? +) { node?.childrenSequence()?.forEach { - RecursiveRenderMarkdownAst(astNode = it) + RecursiveRenderMarkdownAst(astNode = it, astNodeComposer = astNodeComposer) } } diff --git a/richtext-ui-material/build.gradle.kts b/richtext-ui-material/build.gradle.kts index 5d07cc34..b54b23fe 100644 --- a/richtext-ui-material/build.gradle.kts +++ b/richtext-ui-material/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("richtext-kmp-library") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version id("org.jetbrains.dokka") } diff --git a/richtext-ui-material/src/commonMain/kotlin/com/halilibo/richtext/ui/material/RichText.kt b/richtext-ui-material/src/commonMain/kotlin/com/halilibo/richtext/ui/material/RichText.kt index a66e2c6e..4b76ed08 100644 --- a/richtext-ui-material/src/commonMain/kotlin/com/halilibo/richtext/ui/material/RichText.kt +++ b/richtext-ui-material/src/commonMain/kotlin/com/halilibo/richtext/ui/material/RichText.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import com.halilibo.richtext.ui.BasicRichText -import com.halilibo.richtext.ui.LinkClickHandler import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextThemeProvider @@ -24,14 +23,12 @@ import com.halilibo.richtext.ui.RichTextThemeProvider public fun RichText( modifier: Modifier = Modifier, style: RichTextStyle? = null, - linkClickHandler: LinkClickHandler? = null, children: @Composable RichTextScope.() -> Unit ) { RichTextMaterialTheme { BasicRichText( modifier = modifier, style = style, - linkClickHandler = linkClickHandler, children = children ) } diff --git a/richtext-ui-material3/build.gradle.kts b/richtext-ui-material3/build.gradle.kts index ef0a16fa..75607c80 100644 --- a/richtext-ui-material3/build.gradle.kts +++ b/richtext-ui-material3/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("richtext-kmp-library") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version id("org.jetbrains.dokka") } diff --git a/richtext-ui-material3/src/commonMain/kotlin/com/halilibo/richtext/ui/material3/RichText.kt b/richtext-ui-material3/src/commonMain/kotlin/com/halilibo/richtext/ui/material3/RichText.kt index df131a48..2834220a 100644 --- a/richtext-ui-material3/src/commonMain/kotlin/com/halilibo/richtext/ui/material3/RichText.kt +++ b/richtext-ui-material3/src/commonMain/kotlin/com/halilibo/richtext/ui/material3/RichText.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import com.halilibo.richtext.ui.BasicRichText -import com.halilibo.richtext.ui.LinkClickHandler import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextThemeProvider @@ -24,14 +23,12 @@ import com.halilibo.richtext.ui.RichTextThemeProvider public fun RichText( modifier: Modifier = Modifier, style: RichTextStyle? = null, - linkClickHandler: LinkClickHandler? = null, children: @Composable RichTextScope.() -> Unit ) { RichTextMaterialTheme { BasicRichText( modifier = modifier, style = style, - linkClickHandler = linkClickHandler, children = children ) } diff --git a/richtext-ui/build.gradle.kts b/richtext-ui/build.gradle.kts index 3c960c21..6129cf8a 100644 --- a/richtext-ui/build.gradle.kts +++ b/richtext-ui/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("richtext-kmp-library") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version id("org.jetbrains.dokka") } @@ -18,7 +19,6 @@ kotlin { dependencies { implementation(compose.runtime) implementation(compose.foundation) - implementation(Compose.multiplatformUiUtil) } } val commonTest by getting diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BasicRichText.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BasicRichText.kt index cd7c8641..58e380e1 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BasicRichText.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/BasicRichText.kt @@ -5,7 +5,6 @@ package com.halilibo.richtext.ui import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -16,21 +15,18 @@ import androidx.compose.ui.platform.LocalDensity public fun BasicRichText( modifier: Modifier = Modifier, style: RichTextStyle? = null, - linkClickHandler: LinkClickHandler? = null, children: @Composable RichTextScope.() -> Unit ) { with(RichTextScope) { RestartListLevel { WithStyle(style) { - CompositionLocalProvider(LocalLinkClickHandler provides linkClickHandler) { - val resolvedStyle = currentRichTextStyle.resolveDefaults() - val blockSpacing = with(LocalDensity.current) { - resolvedStyle.paragraphSpacing!!.toDp() - } + val resolvedStyle = currentRichTextStyle.resolveDefaults() + val blockSpacing = with(LocalDensity.current) { + resolvedStyle.paragraphSpacing!!.toDp() + } - Column(modifier = modifier, verticalArrangement = spacedBy(blockSpacing)) { - children() - } + Column(modifier = modifier, verticalArrangement = spacedBy(blockSpacing)) { + children() } } } diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/FormattedList.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/FormattedList.kt index dae68f92..4247963b 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/FormattedList.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/FormattedList.kt @@ -204,7 +204,6 @@ private val LocalListLevel = compositionLocalOf { 0 } val contentsIndent = with(density) { listStyle.contentsIndent!!.toDp() } val itemSpacing = with(density) { listStyle.itemSpacing!!.toDp() } val currentLevel = LocalListLevel.current - val currentLinkClickHandler = LocalLinkClickHandler.current PrefixListLayout( count = items.size, @@ -219,7 +218,6 @@ private val LocalListLevel = compositionLocalOf { 0 } itemForIndex = { index -> BasicRichText( style = currentRichTextStyle.copy(paragraphSpacing = listStyle.itemSpacing), - linkClickHandler = currentLinkClickHandler ) { CompositionLocalProvider(LocalListLevel provides currentLevel + 1) { drawItem(items[index]) diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/LinkClickHandler.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/LinkClickHandler.kt deleted file mode 100644 index 8da43ecb..00000000 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/LinkClickHandler.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.halilibo.richtext.ui - -import androidx.compose.runtime.compositionLocalOf - -/** - * Handler that will be triggered when a [Format.Link] is clicked. - */ -public fun interface LinkClickHandler { - - public fun onClick(url: String) -} - -/** - * An internal composition local to pass through LinkClickHandler from root [BasicRichText] - * composable to children that render links. - */ -internal val LocalLinkClickHandler = compositionLocalOf { null } diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt index 7b44555f..566eff12 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt @@ -97,46 +97,3 @@ internal fun RichTextScope.Text( inlineContent = inlineContent ) } - -/** - * Default ClickableText implementation from Compose does not take inlineContent for some reason. - * Instead of replicating the same behavior inside [Text] that takes [RichTextString], we separate - * the logic into our own [ClickableText]. - */ -@Composable -internal fun RichTextScope.ClickableText( - text: AnnotatedString, - modifier: Modifier = Modifier, - softWrap: Boolean = true, - overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE, - onTextLayout: (TextLayoutResult) -> Unit = {}, - inlineContent: Map = mapOf(), - isOffsetClickable: (Int) -> Boolean, - onClick: (Int) -> Unit -) { - val layoutResult = remember { mutableStateOf(null) } - val shouldHandle = { pos: Offset -> - layoutResult.value?.getOffsetForPosition(pos)?.let { isOffsetClickable(it) } ?: false - } - val pressIndicator = Modifier.pointerInput(onClick) { - detectTapGesturesIf(predicate = shouldHandle) { pos -> - layoutResult.value?.let { layoutResult -> - onClick(layoutResult.getOffsetForPosition(pos)) - } - } - } - - Text( - text = text, - modifier = modifier.then(pressIndicator), - softWrap = softWrap, - overflow = overflow, - maxLines = maxLines, - onTextLayout = { - layoutResult.value = it - onTextLayout(it) - }, - inlineContent = inlineContent - ) -} diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt index 4f70f9f4..0c913d60 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle @@ -38,15 +40,15 @@ internal const val REPLACEMENT_CHAR: String = "\uFFFD" * Defines the [SpanStyle]s that are used for various [RichTextString] formatting directives. */ @Immutable -public data class RichTextStringStyle( - val boldStyle: SpanStyle? = null, - val italicStyle: SpanStyle? = null, - val underlineStyle: SpanStyle? = null, - val strikethroughStyle: SpanStyle? = null, - val subscriptStyle: SpanStyle? = null, - val superscriptStyle: SpanStyle? = null, - val codeStyle: SpanStyle? = null, - val linkStyle: SpanStyle? = null +public class RichTextStringStyle( + public val boldStyle: SpanStyle? = null, + public val italicStyle: SpanStyle? = null, + public val underlineStyle: SpanStyle? = null, + public val strikethroughStyle: SpanStyle? = null, + public val subscriptStyle: SpanStyle? = null, + public val superscriptStyle: SpanStyle? = null, + public val codeStyle: SpanStyle? = null, + public val linkStyle: TextLinkStyles? = null ) { internal fun merge(otherStyle: RichTextStringStyle?): RichTextStringStyle { if (otherStyle == null) return this @@ -58,7 +60,7 @@ public data class RichTextStringStyle( subscriptStyle = subscriptStyle.merge(otherStyle.subscriptStyle), superscriptStyle = superscriptStyle.merge(otherStyle.superscriptStyle), codeStyle = codeStyle.merge(otherStyle.codeStyle), - linkStyle = linkStyle.merge(otherStyle.linkStyle) + linkStyle = linkStyle?.merge(otherStyle.linkStyle) ?: otherStyle.linkStyle ) } @@ -74,6 +76,45 @@ public data class RichTextStringStyle( linkStyle = linkStyle ?: Link.DefaultStyle ) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RichTextStringStyle) return false + + if (boldStyle != other.boldStyle) return false + if (italicStyle != other.italicStyle) return false + if (underlineStyle != other.underlineStyle) return false + if (strikethroughStyle != other.strikethroughStyle) return false + if (subscriptStyle != other.subscriptStyle) return false + if (superscriptStyle != other.superscriptStyle) return false + if (codeStyle != other.codeStyle) return false + if (linkStyle != other.linkStyle) return false + + return true + } + + override fun hashCode(): Int { + var result = boldStyle?.hashCode() ?: 0 + result = 31 * result + (italicStyle?.hashCode() ?: 0) + result = 31 * result + (underlineStyle?.hashCode() ?: 0) + result = 31 * result + (strikethroughStyle?.hashCode() ?: 0) + result = 31 * result + (subscriptStyle?.hashCode() ?: 0) + result = 31 * result + (superscriptStyle?.hashCode() ?: 0) + result = 31 * result + (codeStyle?.hashCode() ?: 0) + result = 31 * result + (linkStyle?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "RichTextStringStyle(boldStyle=$boldStyle, " + + "italicStyle=$italicStyle, " + + "underlineStyle=$underlineStyle, " + + "strikethroughStyle=$strikethroughStyle, " + + "subscriptStyle=$subscriptStyle, " + + "superscriptStyle=$superscriptStyle, " + + "codeStyle=$codeStyle, " + + "linkStyle=$linkStyle)" + } + public companion object { public val Default: RichTextStringStyle = RichTextStringStyle() @@ -94,13 +135,12 @@ public inline fun richTextString(builder: Builder.() -> Unit): RichTextString = * configured using a [RichTextStringStyle]. */ @Immutable -public data class RichTextString internal constructor( +public class RichTextString internal constructor( private val taggedString: AnnotatedString, internal val formatObjects: Map ) { - private val length: Int get() = taggedString.length - val text: String get() = taggedString.text + public val text: String get() = taggedString.text public operator fun plus(other: RichTextString): RichTextString = Builder(length + other.length).run { @@ -121,8 +161,14 @@ public data class RichTextString internal constructor( // And apply their actual SpanStyles to the string. tags.forEach { range -> val format = Format.findTag(range.item, formatObjects) ?: return@forEach - format.getStyle(style, contentColor) - ?.let { spanStyle -> addStyle(spanStyle, range.start, range.end) } + format.getAnnotation(style, contentColor) + ?.let { annotation -> + if (annotation is SpanStyle) { + addStyle(annotation, range.start, range.end) + } else if (annotation is LinkAnnotation.Url) { + addLink(annotation, range.start, range.end) + } + } } } @@ -133,22 +179,42 @@ public data class RichTextString internal constructor( // If no prefix was found then we ignore it. .takeUnless { it === tag } ?.let { - @Suppress("UNCHECKED_CAST") Pair(it, format as InlineContent) } } .toMap() + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RichTextString) return false + + if (taggedString != other.taggedString) return false + if (formatObjects != other.formatObjects) return false + + return true + } + + override fun hashCode(): Int { + var result = taggedString.hashCode() + result = 31 * result + formatObjects.hashCode() + return result + } + public sealed class Format(private val simpleTag: String? = null) { - internal open fun getStyle( + /** + * This function should either return [SpanStyle] or [LinkAnnotation.Url]. In future releases of + * Compose these classes will have a common supertype called `AnnotatedString.Annotation`. Then + * we can stop returning [Any]. + */ + internal open fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color - ): SpanStyle? = null + ): Any? = null public object Italic : Format("italic") { internal val DefaultStyle = SpanStyle(fontStyle = FontStyle.Italic) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.italicStyle @@ -156,7 +222,7 @@ public data class RichTextString internal constructor( public object Bold : Format(simpleTag = "foo") { internal val DefaultStyle = SpanStyle(fontWeight = FontWeight.Bold) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.boldStyle @@ -164,7 +230,7 @@ public data class RichTextString internal constructor( public object Underline : Format("underline") { internal val DefaultStyle = SpanStyle(textDecoration = TextDecoration.Underline) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.underlineStyle @@ -172,7 +238,7 @@ public data class RichTextString internal constructor( public object Strikethrough : Format("strikethrough") { internal val DefaultStyle = SpanStyle(textDecoration = TextDecoration.LineThrough) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.strikethroughStyle @@ -185,7 +251,7 @@ public data class RichTextString internal constructor( fontSize = 10.sp ) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.subscriptStyle @@ -197,7 +263,7 @@ public data class RichTextString internal constructor( fontSize = 10.sp ) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.superscriptStyle @@ -210,22 +276,52 @@ public data class RichTextString internal constructor( background = DefaultCodeBlockBackgroundColor ) - override fun getStyle( + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color ) = richTextStyle.codeStyle } - public data class Link(val destination: String) : Format() { - override fun getStyle( + public class Link( + public val destination: String, + public val linkInteractionListener: LinkInteractionListener? = null + ) : Format() { + override fun getAnnotation( richTextStyle: RichTextStringStyle, contentColor: Color - ) = richTextStyle.linkStyle + ) = LinkAnnotation.Url( + url = destination, + styles = richTextStyle.linkStyle, + linkInteractionListener = linkInteractionListener + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Link) return false + + if (destination != other.destination) return false + if (linkInteractionListener != other.linkInteractionListener) return false + + return true + } + + override fun hashCode(): Int { + var result = destination.hashCode() + result = 31 * result + linkInteractionListener.hashCode() + return result + } + + override fun toString(): String { + return "Link(destination='$destination', linkInteractionListener=$linkInteractionListener)" + } internal companion object { - val DefaultStyle = SpanStyle( - textDecoration = TextDecoration.Underline, - color = Color.Blue + val DefaultStyle = TextLinkStyles( + style = SpanStyle(color = Color.Blue), + hoveredStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + color = Color.Blue + ) ) } } @@ -321,3 +417,16 @@ public inline fun Builder.withFormat( block() pop(index) } + +private fun TextLinkStyles.merge(other: TextLinkStyles?): TextLinkStyles { + return if (other == null) { + TextLinkStyles() + } else { + TextLinkStyles( + style = this.style?.merge(other.style) ?: other.style, + focusedStyle = this.style?.merge(other.focusedStyle) ?: other.focusedStyle, + hoveredStyle = this.style?.merge(other.hoveredStyle) ?: other.hoveredStyle, + pressedStyle = this.style?.merge(other.pressedStyle) ?: other.pressedStyle, + ) + } +} \ No newline at end of file diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt index 4f7b13fd..d95b6c82 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt @@ -4,15 +4,11 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.style.TextOverflow -import com.halilibo.richtext.ui.ClickableText -import com.halilibo.richtext.ui.LinkClickHandler -import com.halilibo.richtext.ui.LocalLinkClickHandler import com.halilibo.richtext.ui.RichTextScope +import com.halilibo.richtext.ui.Text import com.halilibo.richtext.ui.currentContentColor import com.halilibo.richtext.ui.currentRichTextStyle import com.halilibo.richtext.ui.string.RichTextString.Format @@ -41,27 +37,12 @@ public fun RichTextScope.Text( val inlineContents = remember(text) { text.getInlineContents() } if (inlineContents.isEmpty()) { - // cheap path - val linkClickHandler = LocalLinkClickHandler.current ?: LocalUriHandler.current - ClickableText( + Text( text = annotated, onTextLayout = onTextLayout, softWrap = softWrap, overflow = overflow, - maxLines = maxLines, - isOffsetClickable = { offset -> - annotated.getConsumableAnnotations(text.formatObjects, offset).any() - }, - onClick = { offset -> - annotated.getConsumableAnnotations(text.formatObjects, offset) - .firstOrNull() - ?.let { link -> - when (linkClickHandler) { - is LinkClickHandler -> linkClickHandler.onClick(link.destination) - is UriHandler -> linkClickHandler.openUri(link.destination) - } - } - } + maxLines = maxLines ) } else { // expensive constraints reading path @@ -71,28 +52,13 @@ public fun RichTextScope.Text( textConstraints = constraints ) - val linkClickHandler = LocalLinkClickHandler.current ?: LocalUriHandler.current - - ClickableText( + Text( text = annotated, onTextLayout = onTextLayout, inlineContent = inlineTextContents, softWrap = softWrap, overflow = overflow, maxLines = maxLines, - isOffsetClickable = { offset -> - annotated.getConsumableAnnotations(text.formatObjects, offset).any() - }, - onClick = { offset -> - annotated.getConsumableAnnotations(text.formatObjects, offset) - .firstOrNull() - ?.let { link -> - when (linkClickHandler) { - is LinkClickHandler -> linkClickHandler.onClick(link.destination) - is UriHandler -> linkClickHandler.openUri(link.destination) - } - } - } ) } } diff --git a/slideshow/build.gradle.kts b/slideshow/build.gradle.kts index 373ca7df..04aae45f 100644 --- a/slideshow/build.gradle.kts +++ b/slideshow/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("richtext-android-library") id("org.jetbrains.dokka") id("org.jetbrains.compose") version Compose.desktopVersion + id("org.jetbrains.kotlin.plugin.compose") version Kotlin.version } android {