refactor: new markdown rendering (#530)

closes #305
This commit is contained in:
Diego Beraldin 2024-02-18 10:04:03 +01:00 committed by GitHub
parent 787cfc94a8
commit edddbe113f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 431 additions and 2001 deletions

View File

@ -268,7 +268,8 @@ The modules can be grouped into five groups:
- **core modules** are the foundational layer of the application. They are included by unit modules,
feature modules, domain modules and top-level modules. They should not include anything except in
some rare occasions, other core modules (but never cyclically!). A notable example of this is the
`:core:md module` (Markdown rendering) that includes `:core:commonui:components` because Markdown
`:core:markdown` module (Markdown rendering) that includes `:core:commonui:components` because
Markdown
requires some custom UI components to be rendered.
For more detailed information about the contents of each group and the purpose of each module,

View File

@ -43,7 +43,7 @@ kotlin {
implementation(projects.core.appearance)
implementation(projects.core.commonui.components)
implementation(projects.core.md)
implementation(projects.core.markdown)
implementation(projects.core.l10n)
implementation(projects.core.navigation)
implementation(projects.core.persistence)

View File

@ -1,5 +1,6 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -8,14 +9,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.toTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.CustomMarkdown
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.markdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.CustomMarkdownWrapper
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.di.getSettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
import com.mikepenz.markdown.model.markdownColor
import com.mikepenz.markdown.model.markdownTypography
@Composable
fun PostCardBody(
@ -40,11 +42,10 @@ fun PostCardBody(
val typography = fontFamily.toTypography()
if (text.isNotEmpty()) {
CustomMarkdown(
CustomMarkdownWrapper(
modifier = modifier,
content = text,
maxLines = maxLines,
inlineImages = false,
autoLoadImages = autoLoadImages,
typography = markdownTypography(
h1 = typography.titleLarge,
@ -56,6 +57,13 @@ fun PostCardBody(
text = typography.bodyMedium,
paragraph = typography.bodyMedium,
),
colors = markdownColor(
text = MaterialTheme.colorScheme.onBackground,
linkText = MaterialTheme.colorScheme.primary,
codeText = MaterialTheme.colorScheme.onBackground,
codeBackground = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
),
onOpenUrl = rememberCallbackArgs { url ->
navigationCoordinator.handleUrl(
url = url,

View File

@ -1,5 +1,6 @@
package com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -9,14 +10,15 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.toTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.CustomMarkdown
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.markdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.CustomMarkdownWrapper
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.di.getSettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
import com.mikepenz.markdown.model.markdownColor
import com.mikepenz.markdown.model.markdownTypography
@Composable
fun PostCardTitle(
@ -39,7 +41,7 @@ fun PostCardTitle(
val fontFamily by themeRepository.contentFontFamily.collectAsState()
val typography = fontFamily.toTypography()
CustomMarkdown(
CustomMarkdownWrapper(
modifier = modifier,
content = text,
autoLoadImages = autoLoadImages,
@ -53,6 +55,12 @@ fun PostCardTitle(
text = typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
paragraph = typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
),
colors = markdownColor(
text = MaterialTheme.colorScheme.onBackground,
codeText = MaterialTheme.colorScheme.onBackground,
codeBackground = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
),
onOpenUrl = rememberCallbackArgs { url ->
navigationCoordinator.handleUrl(
url = url,

View File

@ -21,41 +21,25 @@ kotlin {
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "md"
baseName = "markdown"
}
}
sourceSets {
val androidMain by getting {
dependencies {
implementation(libs.markwon.core)
implementation(libs.markwon.strikethrough)
implementation(libs.markwon.tables)
implementation(libs.markwon.html)
implementation(libs.markwon.linkify)
implementation(libs.markwon.image.coil)
implementation(libs.coil)
implementation(libs.coil.gif)
implementation(libs.android.gif.drawable)
}
}
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(libs.koin.core)
implementation(libs.markdown)
api(libs.multiplatform.markdown.renderer)
implementation(projects.core.l10n)
implementation(projects.core.commonui.components)
implementation(projects.core.utils)
}
}
val iosMain by getting {
dependencies {
implementation(libs.markdown)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))

View File

@ -0,0 +1,77 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomImage
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.LocalXmlStrings
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
@Composable
internal fun CustomMarkdownImage(
node: ASTNode,
content: String,
onOpenImage: ((String) -> Unit)?,
autoLoadImages: Boolean,
) {
val link = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)
?.getTextInNode(content)
?.toString().orEmpty()
if (link.isNotEmpty()) {
CustomImage(
modifier = Modifier
.fillMaxWidth()
.onClick(
onClick = {
onOpenImage?.invoke(link)
},
),
url = link,
autoload = autoLoadImages,
quality = FilterQuality.Low,
contentScale = ContentScale.FillWidth,
onFailure = {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
text = LocalXmlStrings.current.messageImageLoadingError,
style = LocalMarkdownTypography.current.text,
)
},
onLoading = { progress ->
val prog = if (progress != null) {
progress
} else {
val transition = rememberInfiniteTransition()
val res by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1000)
),
)
res
}
CircularProgressIndicator(
progress = prog,
color = MaterialTheme.colorScheme.primary,
)
},
)
}
}

View File

@ -0,0 +1,114 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.mikepenz.markdown.compose.elements.MarkdownText
@Composable
internal fun CustomMarkdownSpoiler(
content: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
var lastIndex = 0
val matches = SpoilerRegex.spoilerOpenRegex.findAll(content)
for (match in matches) {
val openingStart = match.range.first
val openingEnd = match.range.last
if (openingStart > lastIndex) {
val subcontent = content.substring(
startIndex = lastIndex,
endIndex = openingStart,
)
MarkdownText(content = subcontent)
}
val spoilerTitle = match.groups["title"]?.value.orEmpty()
val closeMatch = SpoilerRegex.spoilerCloseRegex.find(
input = content,
startIndex = openingEnd,
)
val spoilerContent = closeMatch?.let {
content.substring(
startIndex = openingEnd + 1,
endIndex = it.range.last - 3,
)
} ?: content.substring(
startIndex = openingEnd + 1,
)
InnerSpoilerElement(
title = spoilerTitle,
content = spoilerContent,
)
lastIndex = closeMatch?.range?.last ?: content.lastIndex
}
if (lastIndex < content.lastIndex) {
val subcontent = content.substring(
startIndex = lastIndex,
)
MarkdownText(content = subcontent)
}
}
}
@Composable
private fun InnerSpoilerElement(
title: String,
content: String,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
Column(
modifier = modifier,
) {
if (!expanded) {
MarkdownText(
modifier = Modifier
.fillMaxWidth()
.onClick(onClick = { expanded = !expanded }),
content = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 20.sp)) {
append("▶︎ ")
}
append(title)
},
)
} else {
MarkdownText(
modifier = Modifier
.fillMaxWidth()
.onClick(onClick = { expanded = !expanded }),
content = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 20.sp)) {
append("▼︎ ")
}
append(title)
},
)
MarkdownText(
modifier = Modifier.padding(
start = 18.dp,
bottom = 10.dp,
),
content = content,
)
}
}
}

View File

@ -0,0 +1,109 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown
import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.Markdown
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownParagraph
import com.mikepenz.markdown.model.MarkdownColors
import com.mikepenz.markdown.model.MarkdownPadding
import com.mikepenz.markdown.model.MarkdownTypography
import com.mikepenz.markdown.model.markdownColor
import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.markdownTypography
private val String.containsSpoiler: Boolean
get() = SpoilerRegex.spoilerOpenRegex.containsMatchIn(this)
@Composable
fun CustomMarkdownWrapper(
content: String,
modifier: Modifier,
colors: MarkdownColors = markdownColor(),
typography: MarkdownTypography = markdownTypography(),
padding: MarkdownPadding = markdownPadding(),
autoLoadImages: Boolean,
maxLines: Int? = null,
onOpenUrl: ((String) -> Unit)?,
onOpenImage: ((String) -> Unit)?,
onClick: (() -> Unit)?,
onDoubleClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
val customUriHandler = remember {
object : UriHandler {
override fun openUri(uri: String) {
onOpenUrl?.invoke(uri)
}
}
}
val components = markdownComponents(
paragraph = { model ->
val substring = model.content.substring(
startIndex = model.node.startOffset,
endIndex = model.node.endOffset,
)
when {
substring.containsSpoiler -> {
CustomMarkdownSpoiler(content = substring)
}
else -> {
MarkdownParagraph(
modifier = if (maxLines != null) {
val maxHeightSp =
LocalMarkdownTypography.current.paragraph.lineHeight * maxLines
val maxHeightDp = with(LocalDensity.current) {
maxHeightSp.toDp()
}
Modifier.heightIn(max = maxHeightDp)
} else {
Modifier
},
content = model.content,
node = model.node
)
}
}
},
image = { model ->
CustomMarkdownImage(
node = model.node,
content = content,
onOpenImage = onOpenImage,
autoLoadImages = autoLoadImages,
)
},
)
CompositionLocalProvider(
LocalUriHandler provides customUriHandler
) {
Markdown(
modifier = modifier.onClick(
onClick = {
onClick?.invoke()
},
onLongClick = {
onLongClick?.invoke()
},
onDoubleClick = {
onDoubleClick?.invoke()
},
),
content = content.sanitize(),
colors = colors,
typography = typography,
padding = padding,
components = components,
)
}
}

View File

@ -0,0 +1,17 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown
internal object SpoilerRegex {
val spoilerOpenRegex = Regex("(:::\\s+spoiler\\s+)(?<title>.*)")
val spoilerCloseRegex = Regex(":::")
}
internal object LemmyLinkRegex {
private const val DETAIL_FRAGMENT: String = """[a-zA-Z0-9_]{3,}"""
private const val INSTANCE_FRAGMENT: String =
"""([a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\.)+[a-zA-Z]{2,}"""
val lemmyHandle: Regex =
Regex("(?<!\\S)!(?<detail>$DETAIL_FRAGMENT)(?:@(?<instance>$INSTANCE_FRAGMENT))?\\b")
}

View File

@ -0,0 +1,65 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown
import org.intellij.markdown.IElementType
import org.intellij.markdown.ast.ASTNode
internal fun ASTNode.findChildOfTypeRecursive(type: IElementType): ASTNode? {
children.forEach {
if (it.type == type) {
return it
} else {
val found = it.findChildOfTypeRecursive(type)
if (found != null) {
return found
}
}
}
return null
}
internal fun String.sanitize(): String = this
.replace("&amp;", "&")
.replace("&nbsp;", " ")
.fixBlankLinesForSpoilers()
.expandLemmyHandles()
private fun String.fixBlankLinesForSpoilers(): String = run {
val finalLines = mutableListOf<String>()
var finalLinesSizeAtLastSpoiler = 0
lines().forEach { line ->
val isSpoilerOnTopOfStack = finalLinesSizeAtLastSpoiler == finalLines.size
if (line.contains(SpoilerRegex.spoilerOpenRegex)) {
if (finalLines.lastOrNull()?.isEmpty() == false) {
finalLines += ""
}
finalLines += line
finalLinesSizeAtLastSpoiler = finalLines.size
} else if (line.isNotBlank()) {
finalLines += line
} else if (!isSpoilerOnTopOfStack) {
finalLines += ""
}
}
finalLines.joinToString("\n")
}
private fun String.expandLemmyHandles(): String = let { content ->
buildString {
val matches = LemmyLinkRegex.lemmyHandle.findAll(content)
var lastIndex = 0
for (match in matches) {
val start = match.range.first
val end = match.range.last
if (start > lastIndex) {
append(content.substring(startIndex = lastIndex, endIndex = start))
}
val detail = match.groups["detail"]?.value.orEmpty()
val instance = match.groups["instance"]?.value.orEmpty()
append("[$detail@$instance](!$detail@$instance)")
lastIndex = end + 1
}
if (lastIndex < content.lastIndex) {
append(content.substring(startIndex = lastIndex))
}
}
}

View File

@ -1,214 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose
import android.content.Context
import android.graphics.Typeface
import android.util.TypedValue
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.widget.TextView
import androidx.annotation.IdRes
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.viewinterop.AndroidView
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.ReferenceLinkHandlerImpl
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.di.getMarkwonProvider
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.provider.MarkwonProvider
import com.github.diegoberaldin.raccoonforlemmy.core.utils.datetime.epochMillis
import io.noties.markwon.image.AsyncDrawableSpan
import kotlinx.coroutines.delay
/*
* CREDITS:
* https://github.com/dessalines/jerboa/blob/main/app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt
*/
@Composable
actual fun CustomMarkdown(
content: String,
modifier: Modifier,
colors: MarkdownColors,
typography: MarkdownTypography,
padding: MarkdownPadding,
maxLines: Int?,
onOpenUrl: ((String) -> Unit)?,
inlineImages: Boolean,
autoLoadImages: Boolean,
onOpenImage: ((String) -> Unit)?,
onClick: (() -> Unit)?,
onDoubleClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
CompositionLocalProvider(
LocalReferenceLinkHandler provides ReferenceLinkHandlerImpl(),
LocalMarkdownPadding provides padding,
LocalMarkdownColors provides colors,
LocalMarkdownTypography provides typography,
) {
var imageRecompositionTrigger by remember { mutableStateOf(false) }
var hasImages by remember { mutableStateOf(false) }
val markwonProvider: MarkwonProvider = remember { getMarkwonProvider() }
markwonProvider.onOpenUrl = onOpenUrl
markwonProvider.onOpenImage = onOpenImage
BoxWithConstraints(
modifier = modifier
) {
val style = LocalMarkdownTypography.current.text
val fontScale = LocalDensity.current.fontScale * 1.3f
val canvasWidthMaybe = with(LocalDensity.current) { maxWidth.toPx() }.toInt()
val textSizeMaybe = with(LocalDensity.current) { (style.fontSize * fontScale).toPx() }
val defaultColor = LocalMarkdownColors.current.text
val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
val typeface: Typeface = remember(resolver, style) {
resolver.resolve(
fontFamily = style.fontFamily,
fontWeight = style.fontWeight ?: FontWeight.Normal,
fontStyle = style.fontStyle ?: FontStyle.Normal,
fontSynthesis = style.fontSynthesis ?: FontSynthesis.All,
)
}.value as Typeface
key(imageRecompositionTrigger) {
AndroidView(
factory = { ctx ->
createTextView(
context = ctx,
textColor = defaultColor,
style = style,
typeface = typeface,
maxLines = maxLines,
fontSize = style.fontSize * fontScale,
).apply {
val gestureDetector =
GestureDetector(
ctx,
object : GestureDetector.SimpleOnGestureListener() {
private var lastClickTime = 0L
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
val currentTime = epochMillis()
if ((currentTime - lastClickTime) < 300) return false
lastClickTime = currentTime
if (!markwonProvider.blockClickPropagation.value) {
cancelPendingInputEvents()
onClick?.invoke()
}
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
val currentTime = epochMillis()
if ((currentTime - lastClickTime) < 300) return false
lastClickTime = currentTime
if (!markwonProvider.blockClickPropagation.value) {
cancelPendingInputEvents()
onDoubleClick?.invoke()
}
return true
}
override fun onLongPress(e: MotionEvent) {
if (!markwonProvider.blockClickPropagation.value) {
cancelPendingInputEvents()
onLongClick?.invoke()
}
}
override fun onDown(e: MotionEvent): Boolean {
return true
}
}
)
setOnTouchListener { v, evt ->
if (evt.action == MotionEvent.ACTION_UP) {
v.performClick()
}
gestureDetector.onTouchEvent(evt)
}
}
},
update = { textView ->
val md = markwonProvider.markwon.toMarkdown(content)
val imageSpans = md.getSpans(0, md.length, AsyncDrawableSpan::class.java)
for (img in imageSpans) {
img.drawable.initWithKnownDimensions(canvasWidthMaybe, textSizeMaybe)
}
markwonProvider.markwon.setParsedMarkdown(textView, md)
if (imageSpans.isNotEmpty()) {
hasImages = true
}
},
)
}
LaunchedEffect(hasImages) {
if (hasImages && !imageRecompositionTrigger) {
delay(500)
imageRecompositionTrigger = true
}
}
}
}
}
private fun createTextView(
context: Context,
textColor: Color,
maxLines: Int? = null,
fontSize: TextUnit = TextUnit.Unspecified,
textAlign: TextAlign? = null,
typeface: Typeface? = null,
style: TextStyle,
@IdRes viewId: Int? = null,
): TextView {
val mergedStyle = style.merge(
TextStyle(
color = textColor,
fontSize = if (fontSize != TextUnit.Unspecified) fontSize else style.fontSize,
textAlign = textAlign ?: TextAlign.Start,
),
)
return TextView(context).apply {
setTextColor(textColor.toArgb())
setTextSize(TypedValue.COMPLEX_UNIT_SP, mergedStyle.fontSize.value)
width = maxWidth
if (maxLines != null) {
this.maxLines = maxLines
}
viewId?.let { id = viewId }
textAlign?.let { align ->
textAlignment = when (align) {
TextAlign.Left, TextAlign.Start -> View.TEXT_ALIGNMENT_TEXT_START
TextAlign.Right, TextAlign.End -> View.TEXT_ALIGNMENT_TEXT_END
TextAlign.Center -> View.TEXT_ALIGNMENT_CENTER
else -> View.TEXT_ALIGNMENT_TEXT_START
}
}
this.typeface = typeface
}
}

View File

@ -1,19 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.di
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.provider.DefaultMarkwonProvider
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.provider.MarkwonProvider
import org.koin.dsl.module
import org.koin.java.KoinJavaComponent.inject
val markwonModule = module {
single<MarkwonProvider> {
DefaultMarkwonProvider(
context = get(),
)
}
}
internal fun getMarkwonProvider(): MarkwonProvider {
val res: MarkwonProvider by inject(MarkwonProvider::class.java)
return res
}

View File

@ -1,39 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins
import android.content.Context
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonSpansFactory
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.core.spans.LinkSpan
import io.noties.markwon.image.ImageProps
import org.commonmark.node.Image
class ClickableImagesPlugin private constructor(
private val context: Context,
private val onOpenImage: (String) -> Unit,
) : AbstractMarkwonPlugin() {
companion object {
fun create(context: Context, onOpenImage: (String) -> Unit) =
ClickableImagesPlugin(context, onOpenImage)
}
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
val origin = builder.getFactory(Image::class.java)
if (origin != null) {
builder.setFactory(Image::class.java) { configuration, props ->
val url = ImageProps.DESTINATION.require(props)
val linkSpan = LinkSpan(
MarkwonTheme.create(context), url,
) { view, link ->
view.cancelPendingInputEvents()
onOpenImage(link)
}
arrayOf(
origin.getSpans(configuration, props),
linkSpan
)
}
}
}
}

View File

@ -1,75 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import android.text.util.Linkify
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonPlugin
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.SpannableBuilder
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.core.CoreProps
import org.commonmark.node.Link
import java.util.regex.Pattern
private const val COMMUNITY_FRAGMENT: String = """[a-zA-Z0-9_]{3,}"""
private const val INSTANCE_FRAGMENT: String =
"""([a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\.)+[a-zA-Z]{2,}"""
private const val USER_FRAGMENT: String = """[a-zA-Z0-9_]{3,}"""
private val lemmyCommunityPattern: Pattern =
Pattern.compile("(?<!\\S)!($COMMUNITY_FRAGMENT)(?:@($INSTANCE_FRAGMENT))?\\b")
private val lemmyUserPattern: Pattern =
Pattern.compile("(?<!\\S)@($USER_FRAGMENT)(?:@($INSTANCE_FRAGMENT))?\\b")
class MarkwonLemmyLinkPlugin private constructor() : AbstractMarkwonPlugin() {
companion object {
fun create() = MarkwonLemmyLinkPlugin()
}
override fun configure(registry: MarkwonPlugin.Registry) {
registry.require(CorePlugin::class.java) { it.addOnTextAddedListener(LemmyTextAddedListener()) }
}
private class LemmyTextAddedListener : CorePlugin.OnTextAddedListener {
override fun onTextAdded(visitor: MarkwonVisitor, text: String, start: Int) {
val spanFactory = visitor.configuration().spansFactory().get(
Link::class.java,
) ?: return
val builder = SpannableStringBuilder(text)
if (addLinks(builder)) {
// target URL span specifically
val spans = builder.getSpans(0, builder.length, URLSpan::class.java)
if (!spans.isNullOrEmpty()) {
val renderProps = visitor.renderProps()
val spannableBuilder = visitor.builder()
for (span in spans) {
CoreProps.LINK_DESTINATION[renderProps] = span.url
SpannableBuilder.setSpans(
spannableBuilder,
spanFactory.getSpans(visitor.configuration(), renderProps),
start + builder.getSpanStart(span),
start + builder.getSpanEnd(span),
)
}
}
}
}
fun addLinks(text: Spannable): Boolean {
val communityLinkAdded = Linkify.addLinks(text, lemmyCommunityPattern, null)
val userLinkAdded = Linkify.addLinks(text, lemmyUserPattern, null)
return communityLinkAdded || userLinkAdded
}
}
}

View File

@ -1,179 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
import android.widget.TextView
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonPlugin
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.image.AsyncDrawableScheduler
data class SpoilerTitleSpan(val title: CharSequence)
class SpoilerCloseSpan
/*
* Originally inspired by:
* https://github.com/dessalines/jerboa/blob/main/app/src/main/java/com/jerboa/util/markwon/MarkwonSpoilerPlugin.kt
*/
class MarkwonSpoilerPlugin private constructor(
private val enableInteraction: Boolean,
private val onInteraction: () -> Unit,
) :
AbstractMarkwonPlugin() {
companion object {
fun create(
enableInteraction: Boolean = true,
onInteraction: () -> Unit = {},
): MarkwonSpoilerPlugin {
return MarkwonSpoilerPlugin(
enableInteraction = enableInteraction,
onInteraction = onInteraction,
)
}
}
override fun configure(registry: MarkwonPlugin.Registry) {
registry.require(CorePlugin::class.java) {
it.addOnTextAddedListener(
SpoilerTextAddedListener(),
)
}
}
override fun afterSetText(textView: TextView) {
runCatching {
val spanned = SpannableStringBuilder(textView.text)
val startSpans =
spanned.getSpans(0, spanned.length, SpoilerTitleSpan::class.java)
.sortedBy { spanned.getSpanStart(it) }
val closeSpans =
spanned.getSpans(0, spanned.length, SpoilerCloseSpan::class.java)
.sortedBy { spanned.getSpanStart(it) }
startSpans
.zip(closeSpans)
.forEach { (startSpan, closeSpan) ->
val spoilerStart = spanned.getSpanStart(startSpan)
val spoilerEnd = spanned.getSpanEnd(closeSpan)
val spoilerTitle = getSpoilerTitle(false, startSpan.title.toString())
val spoilerContent = spanned.subSequence(
spanned.getSpanEnd(startSpan) + 1,
spoilerEnd - 3,
)
// Remove spoiler content from span
spanned.replace(spoilerStart, spoilerEnd, spoilerTitle)
// Set span block title
spanned.setSpan(
spoilerTitle,
spoilerStart,
spoilerStart + spoilerTitle.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
val wrapper = SpoilerClickableSpan(
enableInteraction = enableInteraction,
textView = textView,
spoilerTitle = startSpan.title.toString(),
spoilerContent = spoilerContent.toString(),
onInteraction = onInteraction,
)
// Set spoiler block type as ClickableSpan
spanned.setSpan(
wrapper,
spoilerStart,
spoilerStart + spoilerTitle.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
textView.text = spanned
}
}
}
}
private fun getSpoilerTitle(openParam: Boolean, title: String): String = if (openParam) {
"${title}\n"
} else {
// The space at the end is necessary for the lengths to be the same
// This reduces complexity as else it would need complex logic to determine the replacement length
"${title}\u200B"
}
private class SpoilerClickableSpan(
private val enableInteraction: Boolean,
private val textView: TextView,
private val spoilerTitle: String,
private val spoilerContent: String,
private val onInteraction: () -> Unit,
) : ClickableSpan() {
private var open = false
override fun onClick(view: View) {
if (enableInteraction) {
onInteraction()
textView.cancelPendingInputEvents()
val spanned = SpannableStringBuilder(textView.text)
val title = getSpoilerTitle(open, spoilerTitle)
val start = spanned.indexOf(title).coerceAtLeast(0)
open = !open
spanned.replace(
start,
start + title.length,
getSpoilerTitle(open, spoilerTitle),
)
if (open) {
spanned.insert(start + title.length, spoilerContent)
} else {
spanned.replace(
start + title.length,
start + title.length + spoilerContent.length,
"",
)
}
textView.text = spanned
AsyncDrawableScheduler.schedule(textView)
}
}
override fun updateDrawState(ds: TextPaint) {
}
}
private class SpoilerTextAddedListener : CorePlugin.OnTextAddedListener {
override fun onTextAdded(visitor: MarkwonVisitor, text: String, start: Int) {
val spoilerTitleRegex = Regex("(:::\\s+spoiler\\s+)(.*)")
// Find all spoiler "start" lines
val spoilerTitles = spoilerTitleRegex.findAll(text)
for (match in spoilerTitles) {
val spoilerTitle = match.groups[2]!!.value
visitor.builder().setSpan(
SpoilerTitleSpan(spoilerTitle),
start,
start + match.groups[2]!!.range.last,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
val spoilerCloseRegex = Regex("^(?!.*spoiler).*:::")
// Find all spoiler "end" lines
val spoilerCloses = spoilerCloseRegex.findAll(text)
for (match in spoilerCloses) {
visitor.builder()
.setSpan(SpoilerCloseSpan(), start, start + 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}

View File

@ -1,98 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.provider
import android.content.Context
import android.text.Spanned
import android.text.util.Linkify
import android.widget.TextView
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins.ClickableImagesPlugin
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins.MarkwonLemmyLinkPlugin
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.plugins.MarkwonSpoilerPlugin
import com.github.diegoberaldin.raccoonforlemmy.core.utils.imagepreload.getCoilImageLoader
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.AsyncDrawableScheduler
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.linkify.LinkifyPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
private const val OPEN_LINK_DELAY = 300L
class DefaultMarkwonProvider(
context: Context,
override var onOpenUrl: ((String) -> Unit)? = null,
override var onOpenImage: ((String) -> Unit)? = null,
) : MarkwonProvider {
override val markwon: Markwon
override val blockClickPropagation = MutableStateFlow(false)
private val scope = CoroutineScope(SupervisorJob())
init {
markwon = Markwon.builder(context)
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(MarkwonLemmyLinkPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(HtmlPlugin.create())
.usePlugin(
MarkwonSpoilerPlugin.create(
onInteraction = {
blockClickPropagation.value = true
scope.launch {
delay(OPEN_LINK_DELAY)
blockClickPropagation.value = false
}
}
)
)
.run {
val imageLoader = getCoilImageLoader(context)
usePlugin(CoilImagesPlugin.create(context, imageLoader))
}
.usePlugin(object : AbstractMarkwonPlugin() {
override fun beforeSetText(textView: TextView, markdown: Spanned) {
AsyncDrawableScheduler.unschedule(textView)
}
override fun afterSetText(textView: TextView) {
AsyncDrawableScheduler.schedule(textView)
}
})
.usePlugin(
ClickableImagesPlugin.create(
context = context,
onOpenImage = { url ->
blockClickPropagation.value = true
onOpenImage?.invoke(url)
scope.launch {
delay(OPEN_LINK_DELAY)
blockClickPropagation.value = false
}
},
)
).usePlugin(
object : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.linkResolver { view, link ->
view.cancelPendingInputEvents()
blockClickPropagation.value = true
onOpenUrl?.invoke(link)
scope.launch {
delay(OPEN_LINK_DELAY)
blockClickPropagation.value = false
}
}
}
},
)
.build()
}
}

View File

@ -1,11 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.provider
import io.noties.markwon.Markwon
import kotlinx.coroutines.flow.StateFlow
interface MarkwonProvider {
val markwon: Markwon
val blockClickPropagation: StateFlow<Boolean>
var onOpenUrl: ((String) -> Unit)?
var onOpenImage: ((String) -> Unit)?
}

View File

@ -1,51 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.BulletHandler
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.ReferenceLinkHandler
/**
* The CompositionLocal to provide functionality related to transforming the bullet of an ordered list
*/
val LocalBulletListHandler = staticCompositionLocalOf {
return@staticCompositionLocalOf BulletHandler { "" }
}
/**
* The CompositionLocal to provide functionality related to transforming the bullet of an ordered list
*/
val LocalOrderedListHandler = staticCompositionLocalOf {
return@staticCompositionLocalOf BulletHandler { "$it " }
}
/**
* Local [ReferenceLinkHandler] provider
*/
val LocalReferenceLinkHandler = staticCompositionLocalOf<ReferenceLinkHandler> {
error("CompositionLocal ReferenceLinkHandler not present")
}
/**
* Local [MarkdownColors] provider
*/
val LocalMarkdownColors = compositionLocalOf<MarkdownColors> {
error("No local MarkdownColors")
}
/**
* Local [MarkdownTypography] provider
*/
val LocalMarkdownTypography = compositionLocalOf<MarkdownTypography> {
error("No local MarkdownTypography")
}
/**
* Local [MarkdownPadding] provider
*/
val LocalMarkdownPadding = staticCompositionLocalOf<MarkdownPadding> {
error("No local Padding")
}

View File

@ -1,41 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.markdownColor
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.markdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.markdownTypography
@Composable
expect fun CustomMarkdown(
content: String,
modifier: Modifier = Modifier.fillMaxSize(),
colors: MarkdownColors = markdownColor(
text = MaterialTheme.colorScheme.onBackground,
backgroundCode = MaterialTheme.colorScheme.background,
),
typography: MarkdownTypography = markdownTypography(
h1 = MaterialTheme.typography.titleLarge,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleMedium,
h5 = MaterialTheme.typography.titleSmall,
h6 = MaterialTheme.typography.titleSmall,
text = MaterialTheme.typography.bodyMedium,
paragraph = MaterialTheme.typography.bodyMedium,
),
padding: MarkdownPadding = markdownPadding(),
maxLines: Int? = null,
onOpenUrl: ((String) -> Unit)? = null,
inlineImages: Boolean = true,
autoLoadImages: Boolean = true,
onOpenImage: ((String) -> Unit)? = null,
onClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
)

View File

@ -1,6 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model
/** An interface of providing use case specific un/ordered list handling.*/
fun interface BulletHandler {
fun transform(bullet: CharSequence?): String
}

View File

@ -1,37 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
@Stable
interface MarkdownColors {
/** Represents the color used for the text of this [Markdown] component. */
val text: Color
/** Represents the background color for this [Markdown] component. */
val backgroundCode: Color
/** Represents the linl color for this [Markdown] component. */
val linkColor: Color
}
@Immutable
private class DefaultMarkdownColors(
override val text: Color,
override val backgroundCode: Color,
override val linkColor: Color,
) : MarkdownColors
@Composable
fun markdownColor(
text: Color = MaterialTheme.colorScheme.onBackground,
backgroundCode: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f),
linkColor: Color = MaterialTheme.colorScheme.primary,
): MarkdownColors = DefaultMarkdownColors(
text = text,
backgroundCode = backgroundCode,
linkColor = linkColor,
)

View File

@ -1,30 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
interface MarkdownPadding {
val block: Dp
val list: Dp
val indentList: Dp
}
@Immutable
private class DefaultMarkdownPadding(
override val block: Dp,
override val list: Dp,
override val indentList: Dp,
) : MarkdownPadding
@Composable
fun markdownPadding(
block: Dp = 2.dp,
list: Dp = 8.dp,
indentList: Dp = 8.dp,
): MarkdownPadding = DefaultMarkdownPadding(
block = block,
list = list,
indentList = indentList,
)

View File

@ -1,63 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
interface MarkdownTypography {
val text: TextStyle
val code: TextStyle
val h1: TextStyle
val h2: TextStyle
val h3: TextStyle
val h4: TextStyle
val h5: TextStyle
val h6: TextStyle
val quote: TextStyle
val paragraph: TextStyle
val ordered: TextStyle
val bullet: TextStyle
val list: TextStyle
}
@Immutable
private class DefaultMarkdownTypography(
override val h1: TextStyle,
override val h2: TextStyle,
override val h3: TextStyle,
override val h4: TextStyle,
override val h5: TextStyle,
override val h6: TextStyle,
override val text: TextStyle,
override val code: TextStyle,
override val quote: TextStyle,
override val paragraph: TextStyle,
override val ordered: TextStyle,
override val bullet: TextStyle,
override val list: TextStyle,
) : MarkdownTypography
@Composable
fun markdownTypography(
h1: TextStyle = MaterialTheme.typography.titleLarge,
h2: TextStyle = MaterialTheme.typography.titleLarge,
h3: TextStyle = MaterialTheme.typography.titleMedium,
h4: TextStyle = MaterialTheme.typography.titleMedium,
h5: TextStyle = MaterialTheme.typography.titleSmall,
h6: TextStyle = MaterialTheme.typography.titleSmall,
text: TextStyle = MaterialTheme.typography.bodyMedium,
code: TextStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
quote: TextStyle = MaterialTheme.typography.bodyMedium.plus(SpanStyle(fontStyle = FontStyle.Italic)),
paragraph: TextStyle = MaterialTheme.typography.bodyMedium,
ordered: TextStyle = MaterialTheme.typography.bodyMedium,
bullet: TextStyle = MaterialTheme.typography.bodyMedium,
list: TextStyle = MaterialTheme.typography.bodyMedium,
): MarkdownTypography = DefaultMarkdownTypography(
h1 = h1, h2 = h2, h3 = h3, h4 = h4, h5 = h5, h6 = h6,
text = text, quote = quote, code = code, paragraph = paragraph,
ordered = ordered, bullet = bullet, list = list,
)

View File

@ -1,26 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model
/**
* Interface to describe the [ReferenceLinkHandler]
*/
interface ReferenceLinkHandler {
/** Keep the provided link */
fun store(label: String, destination: String?)
/** Returns the link for the provided label if it exists */
fun find(label: String): String
}
/**
* Implementation for [ReferenceLinkHandler] to resolve referenced link within the Markdown
*/
class ReferenceLinkHandlerImpl : ReferenceLinkHandler {
private val stored = mutableMapOf<String, String?>()
override fun store(label: String, destination: String?) {
stored[label] = destination
}
override fun find(label: String): String {
return stored[label] ?: label
}
}

View File

@ -1,218 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownBlockQuote
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownBulletList
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownCodeBlock
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownCodeFence
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownHeader
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownImage
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownOrderedList
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownParagraph
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements.MarkdownText
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.MarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.model.ReferenceLinkHandlerImpl
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownElementTypes.ATX_1
import org.intellij.markdown.MarkdownElementTypes.ATX_2
import org.intellij.markdown.MarkdownElementTypes.ATX_3
import org.intellij.markdown.MarkdownElementTypes.ATX_4
import org.intellij.markdown.MarkdownElementTypes.ATX_5
import org.intellij.markdown.MarkdownElementTypes.ATX_6
import org.intellij.markdown.MarkdownElementTypes.BLOCK_QUOTE
import org.intellij.markdown.MarkdownElementTypes.CODE_BLOCK
import org.intellij.markdown.MarkdownElementTypes.CODE_FENCE
import org.intellij.markdown.MarkdownElementTypes.IMAGE
import org.intellij.markdown.MarkdownElementTypes.LINK_DEFINITION
import org.intellij.markdown.MarkdownElementTypes.ORDERED_LIST
import org.intellij.markdown.MarkdownElementTypes.PARAGRAPH
import org.intellij.markdown.MarkdownElementTypes.UNORDERED_LIST
import org.intellij.markdown.MarkdownTokenTypes.Companion.EOL
import org.intellij.markdown.MarkdownTokenTypes.Companion.TEXT
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.parser.MarkdownParser
/*
* CREDITS:
* https://github.com/mikepenz/multiplatform-markdown-renderer
*/
@Composable
actual fun CustomMarkdown(
content: String,
modifier: Modifier,
colors: MarkdownColors,
typography: MarkdownTypography,
padding: MarkdownPadding,
maxLines: Int?,
onOpenUrl: ((String) -> Unit)?,
inlineImages: Boolean,
autoLoadImages: Boolean,
onOpenImage: ((String) -> Unit)?,
onClick: (() -> Unit)?,
onDoubleClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
val matches = Regex("::: spoiler (?<title>.*?)\\n(?<content>.*?)\\n:::\\n").findAll(content)
val mangledContent = buildString {
var lastIndex = -1
for (match in matches) {
val (start, end) = match.range.first to match.range.last
if (lastIndex == -1) {
append(content.substring(0, start))
} else {
append(content.substring(lastIndex, start))
}
val title = match.groups["title"]?.value.orEmpty()
val spoilerContent = match.groups["content"]?.value.orEmpty()
val replacement =
"<details>\\n<summary>\\n$title\\n</summary>\\n\\n$spoilerContent\\n</details>\\n"
append(replacement)
lastIndex = end
}
if (lastIndex >= 0) {
if (lastIndex < content.length) {
append(content.substring(lastIndex))
}
} else {
append(content)
}
}
.replace("&amp;", "&")
.replace("&nbsp;", " ")
CompositionLocalProvider(
LocalReferenceLinkHandler provides ReferenceLinkHandlerImpl(),
LocalMarkdownPadding provides padding,
LocalMarkdownColors provides colors,
LocalMarkdownTypography provides typography,
) {
Column(
modifier = modifier.onClick(
onClick = onClick ?: {},
onDoubleClick = onDoubleClick ?: {},
onLongClick = onLongClick ?: {},
)
) {
val parsedTree =
MarkdownParser(GFMFlavourDescriptor()).buildMarkdownTreeFromString(mangledContent)
parsedTree.children.forEach { node ->
if (!node.handleElement(
content = mangledContent,
maxLines = maxLines,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
autoLoadImages = autoLoadImages,
onOpenImage = onOpenImage,
)
) {
node.children.forEach { child ->
child.handleElement(
content = mangledContent,
maxLines = maxLines,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
autoLoadImages = autoLoadImages,
onOpenImage = onOpenImage,
)
}
}
}
}
}
}
@Composable
private fun ASTNode.handleElement(
content: String,
maxLines: Int? = null,
onOpenUrl: ((String) -> Unit)? = null,
inlineImages: Boolean = true,
autoLoadImages: Boolean = true,
onOpenImage: ((String) -> Unit)? = null,
): Boolean {
val typography = LocalMarkdownTypography.current
var handled = true
Spacer(Modifier.height(LocalMarkdownPadding.current.block))
when (type) {
TEXT -> {
val text = getTextInNode(content).toString()
MarkdownText(
content = text,
maxLines = maxLines,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
onOpenImage = onOpenImage,
)
}
EOL -> {}
CODE_FENCE -> MarkdownCodeFence(content, this)
CODE_BLOCK -> MarkdownCodeBlock(content, this)
ATX_1 -> MarkdownHeader(content, this, typography.h1)
ATX_2 -> MarkdownHeader(content, this, typography.h2)
ATX_3 -> MarkdownHeader(content, this, typography.h3)
ATX_4 -> MarkdownHeader(content, this, typography.h4)
ATX_5 -> MarkdownHeader(content, this, typography.h5)
ATX_6 -> MarkdownHeader(content, this, typography.h6)
BLOCK_QUOTE -> MarkdownBlockQuote(content, this)
PARAGRAPH -> MarkdownParagraph(
content = content,
maxLines = maxLines,
node = this,
style = typography.paragraph,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
autoLoadImages = autoLoadImages,
onOpenImage = onOpenImage,
)
ORDERED_LIST -> Column(modifier = Modifier) {
MarkdownOrderedList(
content = content,
node = this@handleElement,
style = typography.ordered,
onOpenUrl = onOpenUrl,
)
}
UNORDERED_LIST -> Column(modifier = Modifier) {
MarkdownBulletList(
content = content,
node = this@handleElement,
style = typography.bullet,
onOpenUrl = onOpenUrl
)
}
IMAGE -> MarkdownImage(
content = content,
node = this,
autoLoadImages = autoLoadImages
)
LINK_DEFINITION -> {
val linkLabel =
findChildOfType(MarkdownElementTypes.LINK_LABEL)?.getTextInNode(content)?.toString()
if (linkLabel != null) {
val destination =
findChildOfType(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content)
?.toString()
LocalReferenceLinkHandler.current.store(linkLabel, destination)
}
}
else -> handled = false
}
return handled
}

View File

@ -1,46 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
@Composable
internal fun MarkdownBlockQuote(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.quote,
) {
Box(
modifier = Modifier
.drawBehind {
drawLine(
color = style.color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height),
)
}
.padding(start = 16.dp, top = 16.dp, bottom = 16.dp),
) {
val text = buildAnnotatedString {
pushStyle(style.toSpanStyle())
val text = node.getTextInNode(content).toString()
.replace(Regex("^\\s*?>"), "")
.replace(Regex("\n\\s*?>"), "\n")
.trim()
append(text)
pop()
}
Text(text)
}
}

View File

@ -1,57 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import org.intellij.markdown.ast.ASTNode
@Composable
private fun MarkdownCode(
code: String,
style: TextStyle = LocalMarkdownTypography.current.code,
) {
val backgroundCodeColor = LocalMarkdownColors.current.backgroundCode
Surface(
color = backgroundCodeColor,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 8.dp),
) {
Text(
code,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(8.dp),
style = style,
color = LocalMarkdownColors.current.text,
)
}
}
@Composable
internal fun MarkdownCodeFence(
content: String,
node: ASTNode,
) {
// CODE_FENCE_START, FENCE_LANG, {content}, CODE_FENCE_END
val start = node.children[2].startOffset
val end = node.children[node.children.size - 2].endOffset
MarkdownCode(content.subSequence(start, end).toString().replaceIndent())
}
@Composable
internal fun MarkdownCodeBlock(
content: String,
node: ASTNode,
) {
val start = node.children[0].startOffset
val end = node.children[node.children.size - 1].endOffset
MarkdownCode(content.subSequence(start, end).toString().replaceIndent())
}

View File

@ -1,30 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownColors
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
@Composable
internal fun MarkdownHeader(
content: String,
node: ASTNode,
style: TextStyle,
) {
node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)?.let {
Text(
text = it.getTextInNode(content).trim().toString(),
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
style = style,
color = LocalMarkdownColors.current.text,
)
}
}

View File

@ -1,65 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomImage
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.LocalXmlStrings
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.findChildOfTypeRecursive
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
@Composable
internal fun MarkdownImage(content: String, node: ASTNode, autoLoadImages: Boolean = true) {
val link =
node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content)
?.toString() ?: return
CustomImage(
url = link,
autoload = autoLoadImages,
quality = FilterQuality.Low,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
onFailure = {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
text = LocalXmlStrings.current.messageImageLoadingError,
style = LocalMarkdownTypography.current.text,
)
},
onLoading = { progress ->
val prog = if (progress != null) {
progress
} else {
val transition = rememberInfiniteTransition()
val res by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1000)
)
)
res
}
CircularProgressIndicator(
progress = prog,
color = MaterialTheme.colorScheme.primary,
)
},
)
}

View File

@ -1,177 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalBulletListHandler
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownPadding
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalOrderedListHandler
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.buildMarkdownAnnotatedString
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.filterNonListTypes
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownElementTypes.ORDERED_LIST
import org.intellij.markdown.MarkdownElementTypes.UNORDERED_LIST
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
@Composable
private fun MarkdownListItems(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.list,
level: Int = 0,
autoLoadImages: Boolean = true,
item: @Composable (child: ASTNode) -> Unit,
) {
val listDp = LocalMarkdownPadding.current.list
val indentListDp = LocalMarkdownPadding.current.indentList
Column(
modifier = Modifier.padding(
start = (indentListDp) * level,
top = listDp,
bottom = listDp,
),
) {
node.children.forEach { child ->
when (child.type) {
MarkdownElementTypes.LIST_ITEM -> {
item(child)
when (child.children.last().type) {
ORDERED_LIST -> MarkdownOrderedList(
content = content,
node = child,
style = style,
level = level + 1,
autoLoadImages = autoLoadImages,
)
UNORDERED_LIST -> MarkdownBulletList(
content = content,
node = child,
style = style,
level = level + 1,
autoLoadImages = autoLoadImages,
)
}
}
ORDERED_LIST -> MarkdownOrderedList(
content = content,
node = child,
style = style,
level = level + 1,
autoLoadImages = autoLoadImages,
)
UNORDERED_LIST -> MarkdownBulletList(
content = content,
node = child,
style = style,
level = level + 1,
autoLoadImages = autoLoadImages,
)
}
}
}
}
@Composable
internal fun MarkdownOrderedList(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.ordered,
level: Int = 0,
autoLoadImages: Boolean = true,
onOpenUrl: ((String) -> Unit)? = null,
) {
val orderedListHandler = LocalOrderedListHandler.current
MarkdownListItems(
content = content,
node = node,
style = style,
level = level,
autoLoadImages = autoLoadImages,
) { child ->
Row(Modifier.fillMaxWidth()) {
Text(
text = orderedListHandler.transform(
child.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)?.getTextInNode(content),
),
style = style,
color = LocalMarkdownColors.current.text,
)
val text = buildAnnotatedString {
pushStyle(style.toSpanStyle())
buildMarkdownAnnotatedString(
content = content,
children = child.children.filterNonListTypes(),
linkColor = LocalMarkdownColors.current.linkColor,
)
pop()
}
MarkdownText(
text,
Modifier.padding(bottom = 4.dp),
style = style,
onOpenUrl = onOpenUrl,
autoLoadImages = autoLoadImages,
)
}
}
}
@Composable
internal fun MarkdownBulletList(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.bullet,
level: Int = 0,
autoLoadImages: Boolean = true,
onOpenUrl: ((String) -> Unit)? = null,
) {
val bulletHandler = LocalBulletListHandler.current
MarkdownListItems(
content = content,
node = node,
style = style,
level = level,
autoLoadImages = autoLoadImages,
) { child ->
Row(Modifier.fillMaxWidth()) {
Text(
bulletHandler.transform(
child.findChildOfType(MarkdownTokenTypes.LIST_BULLET)?.getTextInNode(content),
),
style = style,
color = LocalMarkdownColors.current.text,
)
val text = buildAnnotatedString {
pushStyle(style.toSpanStyle())
buildMarkdownAnnotatedString(
content = content,
children = child.children.filterNonListTypes(),
linkColor = LocalMarkdownColors.current.linkColor
)
pop()
}
MarkdownText(
text,
Modifier.padding(bottom = 4.dp),
style = style,
onOpenUrl = onOpenUrl,
autoLoadImages = autoLoadImages,
)
}
}
}

View File

@ -1,40 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.buildMarkdownAnnotatedString
import org.intellij.markdown.ast.ASTNode
@Composable
internal fun MarkdownParagraph(
content: String,
node: ASTNode,
maxLines: Int? = null,
style: TextStyle = LocalMarkdownTypography.current.paragraph,
onOpenUrl: ((String) -> Unit)? = null,
inlineImages: Boolean = true,
autoLoadImages: Boolean = true,
onOpenImage: ((String) -> Unit)? = null,
) {
val styledText = buildAnnotatedString {
pushStyle(style.toSpanStyle())
buildMarkdownAnnotatedString(
content = content,
node = node,
linkColor = LocalMarkdownColors.current.linkColor
)
pop()
}
MarkdownText(
content = styledText,
maxLines = maxLines,
style = style,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
onOpenImage = onOpenImage,
autoLoadImages = autoLoadImages,
)
}

View File

@ -1,214 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.elements
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomImage
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.LocalXmlStrings
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownColors
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalMarkdownTypography
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.compose.LocalReferenceLinkHandler
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.TAG_IMAGE_URL
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils.TAG_URL
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
@Composable
internal fun MarkdownText(
content: String,
maxLines: Int?,
modifier: Modifier = Modifier,
style: TextStyle = LocalMarkdownTypography.current.text,
onOpenUrl: ((String) -> Unit)? = null,
inlineImages: Boolean = true,
autoLoadImages: Boolean = true,
onOpenImage: ((String) -> Unit)? = null,
) {
MarkdownText(
content = AnnotatedString(content),
maxLines = maxLines,
modifier = modifier,
style = style,
onOpenUrl = onOpenUrl,
inlineImages = inlineImages,
autoLoadImages = autoLoadImages,
onOpenImage = onOpenImage,
)
}
@Composable
internal fun MarkdownText(
content: AnnotatedString,
maxLines: Int? = null,
modifier: Modifier = Modifier,
style: TextStyle = LocalMarkdownTypography.current.text,
onOpenUrl: ((String) -> Unit)? = null,
inlineImages: Boolean = true,
autoLoadImages: Boolean = true,
onOpenImage: ((String) -> Unit)? = null,
) {
val referenceLinkHandler = LocalReferenceLinkHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val hasUrl = content.getStringAnnotations(TAG_URL, 0, content.length).any()
val textModifier = if (hasUrl) {
modifier.pointerInput(Unit) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(pos)
content.getStringAnnotations(TAG_URL, position, position)
.firstOrNull()
?.let {
val url = referenceLinkHandler.find(it.item)
onOpenUrl?.invoke(url)
}
}
}
}
} else {
modifier
}
var imageUrl by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxWidth(),
) {
if (inlineImages || content.text != imageUrl) {
Text(
maxLines = maxLines ?: Int.MAX_VALUE,
text = content,
modifier = textModifier,
style = style,
inlineContent = mapOf(
TAG_IMAGE_URL to InlineTextContent(
if (inlineImages) {
Placeholder(
180.sp,
180.sp,
PlaceholderVerticalAlign.Bottom,
) // TODO: identify flexible scaling!
} else {
Placeholder(1.sp, 1.sp, PlaceholderVerticalAlign.Bottom)
}
) { link ->
if (inlineImages) {
CustomImage(
modifier = Modifier
.fillMaxWidth()
.onClick(
onClick = { onOpenImage?.invoke(imageUrl) },
),
url = link,
autoload = autoLoadImages,
quality = FilterQuality.Low,
contentScale = ContentScale.FillWidth,
onFailure = {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
text = LocalXmlStrings.current.messageImageLoadingError,
style = LocalMarkdownTypography.current.text
)
},
onLoading = { progress ->
val prog = if (progress != null) {
progress
} else {
val transition = rememberInfiniteTransition()
val res by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1000)
)
)
res
}
CircularProgressIndicator(
progress = prog,
color = MaterialTheme.colorScheme.primary,
)
},
)
} else {
imageUrl = link
}
},
),
color = LocalMarkdownColors.current.text,
onTextLayout = { layoutResult.value = it },
)
}
if (!inlineImages && imageUrl.isNotEmpty()) {
CustomImage(
modifier = modifier.fillMaxWidth()
// TODO: improve fixed values
.heightIn(min = 200.dp, max = Dp.Unspecified)
.clip(RoundedCornerShape(20.dp))
.onClick(
onClick = { onOpenImage?.invoke(imageUrl) },
onDoubleClick = {},
),
url = imageUrl,
autoload = autoLoadImages,
quality = FilterQuality.Low,
contentScale = ContentScale.FillWidth,
onFailure = {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
text = LocalXmlStrings.current.messageImageLoadingError,
style = LocalMarkdownTypography.current.text,
)
},
onLoading = { progress ->
val prog = if (progress != null) {
progress
} else {
val transition = rememberInfiniteTransition()
val res by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1000)
)
)
res
}
CircularProgressIndicator(
progress = prog,
color = MaterialTheme.colorScheme.primary,
)
},
)
}
}
}

View File

@ -1,154 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.MarkdownTokenTypes.Companion.TEXT
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
internal fun AnnotatedString.Builder.appendMarkdownLink(
content: String,
node: ASTNode,
linkColor: Color,
) {
val linkText = node.findChildOfType(MarkdownElementTypes.LINK_TEXT)?.children?.innerList()
if (linkText == null) {
append(node.getTextInNode(content).toString())
return
}
val destination =
node.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content)
?.toString()
val linkLabel =
node.findChildOfType(MarkdownElementTypes.LINK_LABEL)?.getTextInNode(content)?.toString()
val label = (destination ?: linkLabel)
if (label != null) {
pushStringAnnotation(TAG_URL, label)
}
pushStyle(
SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = linkColor,
)
)
buildMarkdownAnnotatedString(content, linkText, linkColor)
pop()
if (label != null) {
pop()
}
}
internal fun AnnotatedString.Builder.appendAutoLink(
content: String,
node: ASTNode,
linkColor: Color,
) {
val destination = node.getTextInNode(content).toString()
pushStringAnnotation(TAG_URL, (destination))
pushStyle(
SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
color = linkColor,
)
)
append(destination)
pop()
pop()
}
internal fun AnnotatedString.Builder.buildMarkdownAnnotatedString(
content: String,
node: ASTNode,
linkColor: Color,
) {
buildMarkdownAnnotatedString(content, node.children, linkColor)
}
internal fun AnnotatedString.Builder.buildMarkdownAnnotatedString(
content: String,
children: List<ASTNode>,
linkColor: Color,
) {
children.forEach { child ->
when (child.type) {
MarkdownElementTypes.PARAGRAPH -> buildMarkdownAnnotatedString(
content,
child,
linkColor
)
MarkdownElementTypes.IMAGE -> child.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)
?.let {
appendInlineContent(TAG_IMAGE_URL, it.getTextInNode(content).toString())
}
MarkdownElementTypes.EMPH -> {
pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
buildMarkdownAnnotatedString(content, child, linkColor)
pop()
}
MarkdownElementTypes.STRONG -> {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
buildMarkdownAnnotatedString(content, child, linkColor)
pop()
}
MarkdownElementTypes.CODE_SPAN -> {
pushStyle(SpanStyle(fontFamily = FontFamily.Monospace))
append(' ')
buildMarkdownAnnotatedString(content, child.children.innerList(), linkColor)
append(' ')
pop()
}
MarkdownElementTypes.AUTOLINK -> appendAutoLink(content, child, linkColor)
MarkdownElementTypes.INLINE_LINK -> appendMarkdownLink(content, child, linkColor)
MarkdownElementTypes.SHORT_REFERENCE_LINK -> appendMarkdownLink(
content,
child,
linkColor
)
MarkdownElementTypes.FULL_REFERENCE_LINK -> appendMarkdownLink(
content,
child,
linkColor
)
TEXT -> append(child.getTextInNode(content).toString())
GFMTokenTypes.GFM_AUTOLINK -> if (child.parent == MarkdownElementTypes.LINK_TEXT) {
append(child.getTextInNode(content).toString())
} else {
appendAutoLink(content, child, linkColor)
}
MarkdownTokenTypes.SINGLE_QUOTE -> append('\'')
MarkdownTokenTypes.DOUBLE_QUOTE -> append('\"')
MarkdownTokenTypes.LPAREN -> append('(')
MarkdownTokenTypes.RPAREN -> append(')')
MarkdownTokenTypes.LBRACKET -> append('[')
MarkdownTokenTypes.RBRACKET -> append(']')
MarkdownTokenTypes.LT -> append('<')
MarkdownTokenTypes.GT -> append('>')
MarkdownTokenTypes.COLON -> append(':')
MarkdownTokenTypes.EXCLAMATION_MARK -> append('!')
MarkdownTokenTypes.BACKTICK -> append('`')
MarkdownTokenTypes.HARD_LINE_BREAK -> append("\n\n")
MarkdownTokenTypes.EOL -> append('\n')
MarkdownTokenTypes.WHITE_SPACE -> append(' ')
}
}
}

View File

@ -1,47 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.core.markdown.utils
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
/**
* Tag used to indicate an url for inline content. Required for click handling.
*/
internal const val TAG_URL = "MARKDOWN_URL"
/**
* Tag used to indicate an image url for inline content. Required for rendering.
*/
internal const val TAG_IMAGE_URL = "MARKDOWN_IMAGE_URL"
/**
* Find a child node recursive
*/
internal fun ASTNode.findChildOfTypeRecursive(type: IElementType): ASTNode? {
children.forEach {
if (it.type == type) {
return it
} else {
val found = it.findChildOfTypeRecursive(type)
if (found != null) {
return found
}
}
}
return null
}
/**
* Helper function to drop the first and last element in the children list.
* E.g. we don't want to render the brackets of a link
*/
internal fun List<ASTNode>.innerList(): List<ASTNode> = this.subList(1, this.size - 1)
/**
* Helper function to filter out items within a list of nodes, not of interest for the bullet list.
*/
internal fun List<ASTNode>.filterNonListTypes(): List<ASTNode> = this.filter { n ->
n.type != MarkdownElementTypes.ORDERED_LIST && n.type != MarkdownElementTypes.UNORDERED_LIST && n.type != MarkdownTokenTypes.EOL
}

View File

@ -112,14 +112,11 @@ comment, remove post/comment, ban users and the ability to revert any of these a
## Credits
A saying from the original developer: «whenever in doubt, anguish or uncertainty, look at the code
A saying from the original developer: «Whenever in doubt, anguish or uncertainty, look at the code
of Jerboa for Lemmy». It is without any doubt that this project has a gratitude debt
towards [Jerboa](https://github.com/dessalines/jerboa), mainly in two crucial aspects of the app:
- markdown processing and rendering, where `MarkwonProvider` and its implementation are inspired by
Jerboa for Lemmy;
- comment processing to reconstruct the tree with missing nodes, where the memoized algorithm is,
too, inspired by Jerboa's.
towards [Jerboa](https://github.com/dessalines/jerboa), mainly in two crucial aspects of the app
such as comment processing to reconstruct the tree with missing nodes, where the memoized algorithm
is inspired by Jerboa's.
The UI is inspired (in principle, rather than in actual implementation) on the
great [Thunder](https://github.com/thunder-app/thunder) app.

View File

@ -24,9 +24,9 @@ Here is a description of the dependency flow:
by `:shared` (which does the binding between `:detailopener-api` and `:detailopener-impl`) and it
includes some unit modules but the fact of a unit module included by a core module in general
should never happen (instead, the reverse is perfectly ok);
- `:core` modules can sometimes include each other (but without cycles, e.g. `:core:md`
- `:core` modules can sometimes include each other (but without cycles, e.g. `:core:markdown`
includes `:core:commonui:components` / `:core:utils` because it is a mid-level module and
something similar happens with :core:persistence which
something similar happens with `:core:persistecnce` which
uses `:core:preferences` / `:core:appearance`) and nothing else; they are in turn used by all the
other types of modules.
@ -143,7 +143,7 @@ are called throughout the whole project. Here is a short description of them:
- `:modals`: definition of modal bottom sheets and dialogs that have no presentation logic. This
module was historically much bigger and over time components were migrated to separate units
modules;
- `:core:md` contains Markdown rendering components;
- `:core:markdown` contains Markdown rendering logic;
- `core:l10n` contains all the localization messages and the `L10nManager` interface which acts
as a wrapper around Lyricist to load the internationalized messages;
- `:core:navigation` contains the navigation manager used for stack navigation, bottom sheet

View File

@ -75,19 +75,14 @@ formerly <a href="https://github.com/sqlcipher/android-database-sqlcipher">Andro
<dt>Markdown rendering</dt>
<dd>
This was another part, like image loading, where KMP is still lacking and things are far more
complicated than it should be. The first approach that was used in the project, and which still survives
in the iOS platform (being it "no man's land" currently) involved using JetBrain's <a href="https://github.com/JetBrains/markdown">Markdown</a>
library for parsing in conjunction with custom Compose rendering inspired by
<a href="https://github.com/mikepenz/multiplatform-markdown-renderer">Multiplatform Markdown Renderer</a>.
This approach was promising in the beginning but it has proven to grow more and more difficult to
support custom Markdown features, such as Lemmy spoilers. For this reason, the Android counterpart
has been completely refactored and migrated to the <a href="https://github.com/noties/Markwon">Markwon</a>
library which is more flexible/extensible albeit more complicated to use, especially if called from
a multiplatform environment with <code>expect</code>/<code>actual</code> functions (and image opening/URL opening/custom links
like Lemmy URL references have to be managed). The big star here is <code>MarkwonProvider</code> and its implementation
<a href="https://github.com/diegoberaldin/RaccoonForLemmy/blob/master/core/md/src/androidMain/kotlin/com/github/diegoberaldin/raccoonforlemmy/core/markdown/provider/DefaultMarkwonProvider.kt">DefaultMarkwonProvider.kt</a>.
Parts of the Markwon configuration and usage is inspired by <a href="https://github.com/dessalines/jerboa">Jerboa for Lemmy</a>.
This was another part, like image loading, where KMP was at the beginning quite lacky. After having given
up for some time and used Markwon (Java + Views) on the Android part of the app, I decided to give a
second chance to <a href="https://github.com/mikepenz/multiplatform-markdown-renderer">Multiplatform Markdown Renderer</a>
which was initially user for the multiplatform source set. The project grew and matured over time and
it made it possible to add custom handlers (like modular plug-ins) which made possible to support
Lemmy's custom features like spoilers. The migration from multiplatform renderer to Markwon and back to
multiplatform renderer was not easy, but this project is about KMP so, as a consequence, a pure Kotlin and
pure Compose solution <em>had to</em> be preferred. Even if it implies to sacrifice some functionality.
</dd>
<dt>Video playback</dt>

View File

@ -20,8 +20,8 @@ ktor = "2.3.8"
ktorfit_gradle = "1.12.0"
ktorfit_lib = "1.12.0"
lyricist = "1.6.2-1.8.20"
markdown = "0.5.0"
markwon = "4.6.2"
markdown = "0.6.1"
multiplatform_markdown_renderer = "0.12.0"
materialKolor = "1.3.0"
multiplatform_settings = "1.1.1"
reorderable = "1.3.1"
@ -46,12 +46,7 @@ coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
android_gif_drawable = { module = "pl.droidsonroids.gif:android-gif-drawable", version.ref = "android.gif.drawable" }
markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
markwon_core = { module = "io.noties.markwon:core", version.ref = "markwon" }
markwon_strikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" }
markwon_tables = { module = "io.noties.markwon:ext-tables", version.ref = "markwon" }
markwon_html = { module = "io.noties.markwon:html", version.ref = "markwon" }
markwon_linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" }
markwon_image_coil = { module = "io.noties.markwon:image-coil", version.ref = "markwon" }
multiplatform_markdown_renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatform.markdown.renderer" }
multiplatform_settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform.settings" }

View File

@ -29,7 +29,7 @@ include(":core:commonui:detailopener-impl")
include(":core:commonui:lemmyui")
include(":core:commonui:modals")
include(":core:l10n")
include(":core:md")
include(":core:markdown")
include(":core:navigation")
include(":core:notifications")
include(":core:persistence")

View File

@ -49,7 +49,6 @@ kotlin {
implementation(projects.core.commonui.detailopenerImpl)
implementation(projects.core.commonui.lemmyui)
implementation(projects.core.l10n)
implementation(projects.core.md)
implementation(projects.core.navigation)
implementation(projects.core.notifications)
implementation(projects.core.persistence)

View File

@ -4,7 +4,6 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.di.coreApiModule
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.coreAppearanceModule
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.di.lemmyUiModule
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.di.coreL10nModule
import com.github.diegoberaldin.raccoonforlemmy.core.markdown.di.markwonModule
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.navigationModule
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.di.coreNotificationModule
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.di.corePersistenceModule
@ -54,7 +53,6 @@ val sharedHelperModule = module {
coreAppearanceModule,
corePreferencesModule,
coreApiModule,
markwonModule,
coreIdentityModule,
coreL10nModule,
coreNotificationModule,