feat: inline and blocklevel katex in markdown
This commit is contained in:
parent
39a025fcaa
commit
a5b7a51fa8
|
|
@ -283,6 +283,8 @@ dependencies {
|
|||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
implementation(libs.ratex.android)
|
||||
|
||||
androidTestImplementation(libs.android.test.core)
|
||||
androidTestImplementation(libs.android.test.rules)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import androidx.browser.customtabs.CustomTabsIntent
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -18,9 +20,12 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -37,6 +42,7 @@ import androidx.compose.ui.text.TextLinkStyles
|
|||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.net.toUri
|
||||
import chat.stoat.R
|
||||
|
|
@ -70,12 +76,54 @@ import com.mikepenz.markdown.model.State
|
|||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.markdownInlineContent
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import io.ratex.RaTeXEngine
|
||||
import io.ratex.RaTeXFontLoader
|
||||
import io.ratex.RaTeXRenderer
|
||||
import io.ratex.RaTeXView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.getTextInNode
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes
|
||||
import org.intellij.markdown.parser.MarkdownParser
|
||||
|
||||
private data class MathEntry(val key: String, val latex: String, val displayMode: Boolean)
|
||||
|
||||
private val mathSizeCache = ConcurrentHashMap<Triple<String, Boolean, Float>, Size>()
|
||||
|
||||
private fun collectMathEntries(node: ASTNode, content: String): List<MathEntry> {
|
||||
val entries = mutableListOf<MathEntry>()
|
||||
node.children.forEach { child ->
|
||||
when (child.type) {
|
||||
GFMElementTypes.INLINE_MATH -> {
|
||||
val latex = child.getTextInNode(content).toString().removeSurrounding("$")
|
||||
entries += MathEntry("math:i:$latex", latex, false)
|
||||
}
|
||||
GFMElementTypes.BLOCK_MATH -> {
|
||||
val latex = child.getTextInNode(content).toString().removeSurrounding("$$").trim()
|
||||
entries += MathEntry("math:b:$latex", latex, true)
|
||||
}
|
||||
else -> entries += collectMathEntries(child, content)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun collectEmoteUlids(node: ASTNode, content: String): List<String> {
|
||||
val ulids = mutableListOf<String>()
|
||||
node.children.forEach { child ->
|
||||
when (child.type) {
|
||||
CUSTOM_EMOTE_ELEMENT_TYPE -> ulids += child.getTextInNode(content).toString().removeSurrounding(":")
|
||||
else -> ulids += collectEmoteUlids(child, content)
|
||||
}
|
||||
}
|
||||
return ulids
|
||||
}
|
||||
|
||||
// Converts single \n to hard line breaks so chat messages render line by line, while leaving fenced
|
||||
// code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks
|
||||
internal fun easyLineBreaks(content: String): String {
|
||||
|
|
@ -144,9 +192,49 @@ fun ChatMarkdown(
|
|||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val fontSizePx = with(density) { fontSize.toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val onSurfaceArgb = MaterialTheme.colorScheme.onSurface.toArgb()
|
||||
val mathEntries = remember(state) {
|
||||
(state as? State.Success)?.let {
|
||||
collectMathEntries(it.node, it.content).distinctBy { e -> e.key }
|
||||
} ?: emptyList()
|
||||
}
|
||||
val emoteUlids = remember(state) {
|
||||
(state as? State.Success)?.let {
|
||||
collectEmoteUlids(it.node, it.content).distinct()
|
||||
} ?: emptyList()
|
||||
}
|
||||
var mathSizes by remember(mathEntries, fontSizePx) {
|
||||
mutableStateOf(buildMap {
|
||||
mathEntries.forEach { entry ->
|
||||
mathSizeCache[Triple(entry.latex, entry.displayMode, fontSizePx)]?.let { put(entry.key, it) }
|
||||
}
|
||||
})
|
||||
}
|
||||
val uncachedEntries = mathEntries.filter { mathSizeCache[Triple(it.latex, it.displayMode, fontSizePx)] == null }
|
||||
LaunchedEffect(mathEntries, fontSizePx) {
|
||||
if (uncachedEntries.isEmpty()) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) { RaTeXFontLoader.ensureLoaded(context) }
|
||||
val newSizes = withContext(Dispatchers.Default) {
|
||||
buildMap {
|
||||
uncachedEntries.forEach { entry ->
|
||||
try {
|
||||
val dl = RaTeXEngine.parseBlocking(entry.latex, entry.displayMode, onSurfaceArgb)
|
||||
val r = RaTeXRenderer(dl, fontSizePx) { RaTeXFontLoader.getTypeface(it) }
|
||||
val size = Size(r.widthPx, r.totalHeightPx)
|
||||
mathSizeCache[Triple(entry.latex, entry.displayMode, fontSizePx)] = size
|
||||
put(entry.key, size)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newSizes.isNotEmpty()) mathSizes = mathSizes + newSizes
|
||||
}
|
||||
val mentionLinkStyle = remember(primaryColor) {
|
||||
TextLinkStyles(
|
||||
SpanStyle(
|
||||
|
|
@ -160,7 +248,20 @@ fun ChatMarkdown(
|
|||
when (child.type) {
|
||||
CUSTOM_EMOTE_ELEMENT_TYPE -> {
|
||||
val ulid = child.getTextInNode(content).toString().removeSurrounding(":")
|
||||
appendInlineContent("custom_emote", ulid)
|
||||
val name = StoatAPI.emojiCache[ulid]?.name ?: ":$ulid:"
|
||||
appendInlineContent("emote:$ulid", name)
|
||||
true
|
||||
}
|
||||
|
||||
GFMElementTypes.INLINE_MATH -> {
|
||||
val latex = child.getTextInNode(content).toString().removeSurrounding("$")
|
||||
appendInlineContent("math:i:$latex", latex)
|
||||
true
|
||||
}
|
||||
|
||||
GFMElementTypes.BLOCK_MATH -> {
|
||||
val latex = child.getTextInNode(content).toString().removeSurrounding("$$").trim()
|
||||
appendInlineContent("math:b:$latex", latex)
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +350,6 @@ fun ChatMarkdown(
|
|||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||
val uriHandler = remember(scope, serverId, toolbarColor) {
|
||||
|
|
@ -347,42 +447,73 @@ fun ChatMarkdown(
|
|||
),
|
||||
),
|
||||
inlineContent = markdownInlineContent(
|
||||
mapOf(
|
||||
"custom_emote" to InlineTextContent(
|
||||
Placeholder(
|
||||
width = fontSize * 1.5f,
|
||||
height = fontSize * 1.5f,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
)
|
||||
) { ulid ->
|
||||
val emote = StoatAPI.emojiCache[ulid]
|
||||
if (emote == null) {
|
||||
LaunchedEffect(ulid) {
|
||||
try {
|
||||
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid)
|
||||
} catch (_: Exception) {
|
||||
buildMap {
|
||||
mathEntries.forEach { entry ->
|
||||
val measured = mathSizes[entry.key]
|
||||
val (widthSp, heightSp) = if (measured != null) {
|
||||
with(density) { measured.width.toSp() to measured.height.toSp() }
|
||||
} else if (entry.displayMode) {
|
||||
fontSize * 16f to fontSize * 3f
|
||||
} else {
|
||||
fontSize * 4f to fontSize * 1.5f
|
||||
}
|
||||
put(entry.key, InlineTextContent(
|
||||
Placeholder(
|
||||
width = widthSp,
|
||||
height = heightSp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
)
|
||||
) { latex ->
|
||||
val foreground = LocalContentColor.current
|
||||
AndroidView(
|
||||
factory = { ctx -> RaTeXView(ctx) },
|
||||
update = { view ->
|
||||
view.displayMode = entry.displayMode
|
||||
view.latex = latex
|
||||
view.fontSize = with(density) { fontSize.toPx().toDp().value }
|
||||
view.color = foreground.toArgb()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
})
|
||||
}
|
||||
emoteUlids.forEach { ulid ->
|
||||
put("emote:$ulid", InlineTextContent(
|
||||
Placeholder(
|
||||
width = fontSize * 1.5f,
|
||||
height = fontSize * 1.5f,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
)
|
||||
) { _ ->
|
||||
val emote = StoatAPI.emojiCache[ulid]
|
||||
if (emote == null) {
|
||||
LaunchedEffect(ulid) {
|
||||
try {
|
||||
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
with(LocalDensity.current) {
|
||||
RemoteImage(
|
||||
url = "$STOAT_FILES/emojis/$ulid",
|
||||
description = emote.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width((fontSize * 1.5f).toDp())
|
||||
.height((fontSize * 1.5f).toDp())
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
with(LocalDensity.current) {
|
||||
RemoteImage(
|
||||
url = "$STOAT_FILES/emojis/$ulid",
|
||||
description = emote.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width((fontSize * 1.5f).toDp())
|
||||
.height((fontSize * 1.5f).toDp())
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
components = markdownComponents(
|
||||
image = {},
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-r
|
|||
multiplatform-markdown-code = { module = "com.mikepenz:multiplatform-markdown-renderer-code", version.ref = "multiplatform-markdown" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||
ratex-android = { module = "io.github.erweixin:ratex-android", version = "0.1.11" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Reference in New Issue