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.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
||||||
|
implementation(libs.ratex.android)
|
||||||
|
|
||||||
androidTestImplementation(libs.android.test.core)
|
androidTestImplementation(libs.android.test.core)
|
||||||
androidTestImplementation(libs.android.test.rules)
|
androidTestImplementation(libs.android.test.rules)
|
||||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import androidx.browser.customtabs.CustomTabsIntent
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -18,9 +20,12 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withLink
|
import androidx.compose.ui.text.withLink
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.graphics.toColorInt
|
import androidx.core.graphics.toColorInt
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import chat.stoat.R
|
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.markdownAnnotator
|
||||||
import com.mikepenz.markdown.model.markdownInlineContent
|
import com.mikepenz.markdown.model.markdownInlineContent
|
||||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import dev.snipme.highlights.Highlights
|
import dev.snipme.highlights.Highlights
|
||||||
import dev.snipme.highlights.model.SyntaxThemes
|
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.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.intellij.markdown.ast.ASTNode
|
||||||
import org.intellij.markdown.ast.getTextInNode
|
import org.intellij.markdown.ast.getTextInNode
|
||||||
|
import org.intellij.markdown.flavours.gfm.GFMElementTypes
|
||||||
import org.intellij.markdown.parser.MarkdownParser
|
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
|
// 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
|
// code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks
|
||||||
internal fun easyLineBreaks(content: String): String {
|
internal fun easyLineBreaks(content: String): String {
|
||||||
|
|
@ -144,9 +192,49 @@ fun ChatMarkdown(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
|
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
|
||||||
|
val context = LocalContext.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val fontSizePx = with(density) { fontSize.toPx() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val primaryColor = MaterialTheme.colorScheme.primary
|
val primaryColor = MaterialTheme.colorScheme.primary
|
||||||
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
|
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) {
|
val mentionLinkStyle = remember(primaryColor) {
|
||||||
TextLinkStyles(
|
TextLinkStyles(
|
||||||
SpanStyle(
|
SpanStyle(
|
||||||
|
|
@ -160,7 +248,20 @@ fun ChatMarkdown(
|
||||||
when (child.type) {
|
when (child.type) {
|
||||||
CUSTOM_EMOTE_ELEMENT_TYPE -> {
|
CUSTOM_EMOTE_ELEMENT_TYPE -> {
|
||||||
val ulid = child.getTextInNode(content).toString().removeSurrounding(":")
|
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
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +350,6 @@ fun ChatMarkdown(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val context = LocalContext.current
|
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||||
val uriHandler = remember(scope, serverId, toolbarColor) {
|
val uriHandler = remember(scope, serverId, toolbarColor) {
|
||||||
|
|
@ -347,42 +447,73 @@ fun ChatMarkdown(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
inlineContent = markdownInlineContent(
|
inlineContent = markdownInlineContent(
|
||||||
mapOf(
|
buildMap {
|
||||||
"custom_emote" to InlineTextContent(
|
mathEntries.forEach { entry ->
|
||||||
Placeholder(
|
val measured = mathSizes[entry.key]
|
||||||
width = fontSize * 1.5f,
|
val (widthSp, heightSp) = if (measured != null) {
|
||||||
height = fontSize * 1.5f,
|
with(density) { measured.width.toSp() to measured.height.toSp() }
|
||||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
} else if (entry.displayMode) {
|
||||||
)
|
fontSize * 16f to fontSize * 3f
|
||||||
) { ulid ->
|
} else {
|
||||||
val emote = StoatAPI.emojiCache[ulid]
|
fontSize * 4f to fontSize * 1.5f
|
||||||
if (emote == null) {
|
}
|
||||||
LaunchedEffect(ulid) {
|
put(entry.key, InlineTextContent(
|
||||||
try {
|
Placeholder(
|
||||||
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid)
|
width = widthSp,
|
||||||
} catch (_: Exception) {
|
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(
|
components = markdownComponents(
|
||||||
image = {},
|
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" }
|
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-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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue