mirror of
https://github.com/LiveFastEatTrashRaccoon/RaccoonForLemmy.git
synced 2025-02-08 20:08:56 +01:00
parent
787cfc94a8
commit
edddbe113f
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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"))
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
|
@ -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("&", "&")
|
||||
.replace(" ", " ")
|
||||
.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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)?
|
||||
}
|
@ -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")
|
||||
}
|
@ -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,
|
||||
)
|
@ -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
|
||||
}
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
@ -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("&", "&")
|
||||
.replace(" ", " ")
|
||||
|
||||
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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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(' ')
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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" }
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user