refactor(markdown): decompose MarkdownText UI and styling
Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable, and MarkdownMediaResolver into ui/chat/markdown package.
This commit is contained in:
1 parent
f4904653d1
commit
5f79bdee54
5 files changed
+595
-498
No files matched your search
@@ -63,6 +63,10 @@ import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||
import com.m57.hermescontrol.ui.chat.markdown.FnNote
|
||||
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineText
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
||||
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
@@ -347,7 +351,7 @@ fun MarkdownText(
|
||||
}
|
||||
|
||||
is MdBlock.Video -> {
|
||||
val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) }
|
||||
val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) }
|
||||
var showVideoDialog by remember { mutableStateOf(false) }
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier =
|
||||
@@ -369,7 +373,7 @@ fun MarkdownText(
|
||||
}
|
||||
|
||||
is MdBlock.Image -> {
|
||||
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
||||
val model: Any = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) }
|
||||
val isGif =
|
||||
remember(block.uri) {
|
||||
block.uri.contains(".gif", ignoreCase = true) ||
|
||||
@@ -509,179 +513,6 @@ fun MarkdownText(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownInlineText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
latexMeasurer: LatexMeasurerState,
|
||||
style: TextStyle,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val resolvedStyle =
|
||||
style.copy(
|
||||
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
)
|
||||
val processedText =
|
||||
remember(text, isRtl) {
|
||||
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||
if (segments.none { it is InlineMathSegment.Math }) {
|
||||
Text(
|
||||
text =
|
||||
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||
parseInlineSource(
|
||||
text = processedText,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
},
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
return@CompositionLocalProvider
|
||||
}
|
||||
|
||||
val config =
|
||||
LatexConfig(
|
||||
fontSize = style.fontSize,
|
||||
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
||||
accessibilityEnabled = true,
|
||||
)
|
||||
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
||||
segments.forEachIndexed { index, segment ->
|
||||
if (segment is InlineMathSegment.Math) {
|
||||
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
||||
}
|
||||
}
|
||||
val annotated =
|
||||
buildAnnotatedString {
|
||||
segments.forEachIndexed { index, segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> {
|
||||
val id = "latex-$index"
|
||||
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
||||
}
|
||||
|
||||
is InlineMathSegment.Text -> {
|
||||
append(
|
||||
parseInlineSource(
|
||||
text = segment.value,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
inlineContent = inlineContent,
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownTable(
|
||||
block: MdBlock.Table,
|
||||
textColor: Color,
|
||||
) {
|
||||
val isRtl =
|
||||
remember(block) {
|
||||
block.header.any { BidiUtils.isRtlText(it) } ||
|
||||
block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } }
|
||||
}
|
||||
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val headerBg = textColor.copy(alpha = 0.08f)
|
||||
val alignments = block.alignments
|
||||
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
// Header row
|
||||
Row(modifier = Modifier.background(headerBg)) {
|
||||
block.header.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDirection = bidiTextDirection(cellRtl),
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
|
||||
// Body rows
|
||||
block.rows.forEach { row ->
|
||||
Row {
|
||||
row.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
textDirection = bidiTextDirection(cellRtl),
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableTextAlign(align: TableAlign?): TextAlign? =
|
||||
when (align) {
|
||||
TableAlign.CENTER -> TextAlign.Center
|
||||
TableAlign.RIGHT -> TextAlign.End
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***,
|
||||
* ~~strike~~, ==highlight==, ^sup^, ~sub~, <kbd>keys</kbd>, [^ref] footnotes, [text](url) and
|
||||
* bare URLs, plus search-query highlighting.
|
||||
*/
|
||||
internal fun parseInline(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
@@ -691,14 +522,8 @@ internal fun parseInline(
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString =
|
||||
parseInlineSource(
|
||||
text =
|
||||
splitInlineMath(text).joinToString(separator = "") { segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> segment.latex
|
||||
is InlineMathSegment.Text -> segment.value
|
||||
}
|
||||
},
|
||||
MarkdownInlineStyler.parseInline(
|
||||
text = text,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
@@ -706,318 +531,3 @@ internal fun parseInline(
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
|
||||
private fun parseInlineSource(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString {
|
||||
val searchHighlightColor =
|
||||
if (isCurrentMatch) {
|
||||
highlights.currentSearchBackground to highlights.currentSearchForeground
|
||||
} else {
|
||||
highlights.searchBackground to highlights.searchForeground
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
var i = 0
|
||||
val src = text
|
||||
while (i < src.length) {
|
||||
when {
|
||||
// ***bold italic***
|
||||
src.startsWith("***", i) -> {
|
||||
val end = src.indexOf("***", i + 3)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 3, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 3
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// **bold**
|
||||
src.startsWith("**", i) -> {
|
||||
val end = src.indexOf("**", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~~strike~~
|
||||
src.startsWith("~~", i) -> {
|
||||
val end = src.indexOf("~~", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// *italic*
|
||||
src.startsWith("*", i) -> {
|
||||
val end = src.indexOf('*', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ==highlight==
|
||||
src.startsWith("==", i) -> {
|
||||
val end = src.indexOf("==", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ^superscript^
|
||||
src.startsWith("^", i) -> {
|
||||
val end = src.indexOf('^', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~subscript~ (single tilde; ~~ handled above)
|
||||
src.startsWith("~", i) -> {
|
||||
val end = src.indexOf('~', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// <kbd>key</kbd>
|
||||
src.startsWith("<kbd>", i) -> {
|
||||
val end = src.indexOf("</kbd>", i)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 5, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = textColor.copy(alpha = 0.12f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 6
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [^ref] footnote marker -> superscript
|
||||
src.startsWith("[^", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1) {
|
||||
val id = src.substring(i + 2, close)
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
baselineShift = BaselineShift.Superscript,
|
||||
color = linkColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
) {
|
||||
append("[$id]")
|
||||
}
|
||||
i = close + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [text](url)
|
||||
src.startsWith("[", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
|
||||
val urlEnd = src.indexOf(')', close + 2)
|
||||
if (urlEnd != -1) {
|
||||
val label = src.substring(i + 1, close)
|
||||
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
|
||||
val url = src.substring(close + 2, urlEnd)
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(labelToAppend)
|
||||
}
|
||||
pop()
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// `inline code`
|
||||
src.startsWith("`", i) -> {
|
||||
val end = src.indexOf('`', i + 1)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
background = textColor.copy(alpha = 0.08f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// bare URL
|
||||
URL_PATTERN.matchAt(src, i) != null -> {
|
||||
val match = URL_PATTERN.matchAt(src, i)!!
|
||||
val url = match.value
|
||||
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(urlToAppend)
|
||||
}
|
||||
pop()
|
||||
i = match.range.last + 1
|
||||
}
|
||||
|
||||
// Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate
|
||||
// so an English word at the start/middle of an Arabic line doesn't flip the layout direction.
|
||||
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
|
||||
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||
var end = i + Character.charCount(src.codePointAt(i))
|
||||
while (end < src.length) {
|
||||
val cp = src.codePointAt(end)
|
||||
val dir = Character.getDirectionality(cp)
|
||||
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
|
||||
(
|
||||
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
|
||||
Character.getDirectionality(src.codePointAt(end + 1)) ==
|
||||
Character.DIRECTIONALITY_LEFT_TO_RIGHT
|
||||
)
|
||||
) {
|
||||
end += Character.charCount(cp)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
val ltrSnippet = src.substring(i, end)
|
||||
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
|
||||
i = end
|
||||
}
|
||||
|
||||
// search highlight
|
||||
searchQuery.isNotEmpty() &&
|
||||
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = searchHighlightColor.first,
|
||||
color = searchHighlightColor.second,
|
||||
),
|
||||
) {
|
||||
append(src.substring(i, i + searchQuery.length))
|
||||
}
|
||||
i += searchQuery.length
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveImageUrl(uri: String): String {
|
||||
val trimmed = uri.trim()
|
||||
if (trimmed.isBlank()) return uri
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) {
|
||||
return trimmed
|
||||
}
|
||||
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
|
||||
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
|
||||
val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
|
||||
if (mediaUrl != null) return mediaUrl
|
||||
|
||||
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
|
||||
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
||||
val sep = if (cleanPath.contains("?")) "&" else "?"
|
||||
return if (token.isNotBlank() && !cleanPath.contains("token=")) {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token"
|
||||
} else {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath"
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
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.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
object MarkdownInlineStyler {
|
||||
private val URL_PATTERN =
|
||||
Regex(
|
||||
"""https?://[^\s<>'"\)\],;]+(?<![.\),;])""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun parseInline(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString =
|
||||
parseInlineSource(
|
||||
text =
|
||||
splitInlineMath(text).joinToString(separator = "") { segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> segment.latex
|
||||
is InlineMathSegment.Text -> segment.value
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
|
||||
fun parseInlineSource(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||
): AnnotatedString {
|
||||
val searchHighlightColor =
|
||||
if (isCurrentMatch) {
|
||||
highlights.currentSearchBackground to highlights.currentSearchForeground
|
||||
} else {
|
||||
highlights.searchBackground to highlights.searchForeground
|
||||
}
|
||||
|
||||
return buildAnnotatedString {
|
||||
var i = 0
|
||||
val src = text
|
||||
while (i < src.length) {
|
||||
when {
|
||||
// ***bold italic***
|
||||
src.startsWith("***", i) -> {
|
||||
val end = src.indexOf("***", i + 3)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 3, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 3
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// **bold**
|
||||
src.startsWith("**", i) -> {
|
||||
val end = src.indexOf("**", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~~strike~~
|
||||
src.startsWith("~~", i) -> {
|
||||
val end = src.indexOf("~~", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// *italic*
|
||||
src.startsWith("*", i) -> {
|
||||
val end = src.indexOf('*', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ==highlight==
|
||||
src.startsWith("==", i) -> {
|
||||
val end = src.indexOf("==", i + 2)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 2, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(SpanStyle(background = highlights.markupBackground)) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ^superscript^
|
||||
src.startsWith("^", i) -> {
|
||||
val end = src.indexOf('^', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// ~subscript~ (single tilde; ~~ handled above)
|
||||
src.startsWith("~", i) -> {
|
||||
val end = src.indexOf('~', i + 1)
|
||||
if (end != -1 && end > i + 1) {
|
||||
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// <kbd>key</kbd>
|
||||
src.startsWith("<kbd>", i) -> {
|
||||
val end = src.indexOf("</kbd>", i)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 5, end)
|
||||
val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = textColor.copy(alpha = 0.12f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 6
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [^ref] footnote marker -> superscript
|
||||
src.startsWith("[^", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1) {
|
||||
val id = src.substring(i + 2, close)
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
baselineShift = BaselineShift.Superscript,
|
||||
color = linkColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
) {
|
||||
append("[$id]")
|
||||
}
|
||||
i = close + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// [text](url)
|
||||
src.startsWith("[", i) -> {
|
||||
val close = src.indexOf(']', i)
|
||||
if (close != -1 && close + 1 < src.length && src[close + 1] == '(') {
|
||||
val urlEnd = src.indexOf(')', close + 2)
|
||||
if (urlEnd != -1) {
|
||||
val label = src.substring(i + 1, close)
|
||||
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
|
||||
val url = src.substring(close + 2, urlEnd)
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(labelToAppend)
|
||||
}
|
||||
pop()
|
||||
i = urlEnd + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// `inline code`
|
||||
src.startsWith("`", i) -> {
|
||||
val end = src.indexOf('`', i + 1)
|
||||
if (end != -1) {
|
||||
val raw = src.substring(i + 1, end)
|
||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
background = textColor.copy(alpha = 0.08f),
|
||||
),
|
||||
) {
|
||||
append(toAppend)
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// bare URL
|
||||
URL_PATTERN.matchAt(src, i) != null -> {
|
||||
val match = URL_PATTERN.matchAt(src, i)!!
|
||||
val url = match.value
|
||||
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
|
||||
pushLink(LinkAnnotation.Url(url))
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(urlToAppend)
|
||||
}
|
||||
pop()
|
||||
i = match.range.last + 1
|
||||
}
|
||||
|
||||
// Plain text / words in RTL
|
||||
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
|
||||
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||
var end = i + Character.charCount(src.codePointAt(i))
|
||||
while (end < src.length) {
|
||||
val cp = src.codePointAt(end)
|
||||
val dir = Character.getDirectionality(cp)
|
||||
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
|
||||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
|
||||
(
|
||||
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
|
||||
Character.getDirectionality(src.codePointAt(end + 1)) ==
|
||||
Character.DIRECTIONALITY_LEFT_TO_RIGHT
|
||||
)
|
||||
) {
|
||||
end += Character.charCount(cp)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
val ltrSnippet = src.substring(i, end)
|
||||
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
|
||||
i = end
|
||||
}
|
||||
|
||||
// search highlight
|
||||
searchQuery.isNotEmpty() &&
|
||||
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = searchHighlightColor.first,
|
||||
color = searchHighlightColor.second,
|
||||
),
|
||||
) {
|
||||
append(src.substring(i, i + searchQuery.length))
|
||||
}
|
||||
i += searchQuery.length
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRtr(isRtl: Boolean): Boolean = isRtl
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.hrm.latex.renderer.measure.LatexMeasurerState
|
||||
import com.hrm.latex.renderer.model.LatexConfig
|
||||
import com.hrm.latex.renderer.model.LatexTheme
|
||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
@Composable
|
||||
fun MarkdownInlineText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
latexMeasurer: LatexMeasurerState,
|
||||
style: TextStyle,
|
||||
searchQuery: String,
|
||||
isCurrentMatch: Boolean,
|
||||
linkColor: Color,
|
||||
highlights: SearchHighlightColors,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val resolvedStyle =
|
||||
style.copy(
|
||||
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
)
|
||||
val processedText =
|
||||
remember(text, isRtl) {
|
||||
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||
if (segments.none { it is InlineMathSegment.Math }) {
|
||||
Text(
|
||||
text =
|
||||
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||
MarkdownInlineStyler.parseInlineSource(
|
||||
text = processedText,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
)
|
||||
},
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
return@CompositionLocalProvider
|
||||
}
|
||||
|
||||
val config =
|
||||
LatexConfig(
|
||||
fontSize = style.fontSize,
|
||||
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
||||
accessibilityEnabled = true,
|
||||
)
|
||||
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
||||
segments.forEachIndexed { index, segment ->
|
||||
if (segment is InlineMathSegment.Math) {
|
||||
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
||||
}
|
||||
}
|
||||
val annotated =
|
||||
buildAnnotatedString {
|
||||
segments.forEachIndexed { index, segment ->
|
||||
when (segment) {
|
||||
is InlineMathSegment.Math -> {
|
||||
val id = "latex-$index"
|
||||
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
||||
}
|
||||
|
||||
is InlineMathSegment.Text -> {
|
||||
append(
|
||||
MarkdownInlineStyler.parseInlineSource(
|
||||
text = segment.value,
|
||||
textColor = textColor,
|
||||
searchQuery = searchQuery,
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
linkColor = linkColor,
|
||||
highlights = highlights,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
inlineContent = inlineContent,
|
||||
color = textColor,
|
||||
style = resolvedStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
|
||||
object MarkdownMediaResolver {
|
||||
fun resolveImageUrl(uri: String): String {
|
||||
val trimmed = uri.trim()
|
||||
if (trimmed.isBlank()) return uri
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) {
|
||||
return trimmed
|
||||
}
|
||||
val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("")
|
||||
val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty()
|
||||
val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed)
|
||||
if (mediaUrl != null) return mediaUrl
|
||||
|
||||
if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) {
|
||||
val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
||||
val sep = if (cleanPath.contains("?")) "&" else "?"
|
||||
return if (token.isNotBlank() && !cleanPath.contains("token=")) {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token"
|
||||
} else {
|
||||
"${baseUrl.trimEnd('/')}$cleanPath"
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.ui.chat.markdown
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
|
||||
private val TABLE_COL_WIDTH = 140.dp
|
||||
|
||||
@Composable
|
||||
fun MarkdownTable(
|
||||
block: MdBlock.Table,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isRtl =
|
||||
remember(block) {
|
||||
block.header.any { BidiUtils.isRtlText(it) } ||
|
||||
block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } }
|
||||
}
|
||||
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
val headerBg = textColor.copy(alpha = 0.08f)
|
||||
val alignments = block.alignments
|
||||
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
// Header row
|
||||
Row(modifier = Modifier.background(headerBg)) {
|
||||
block.header.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
|
||||
// Body rows
|
||||
block.rows.forEach { row ->
|
||||
Row {
|
||||
row.forEachIndexed { idx, cell ->
|
||||
val cellRtl = BidiUtils.isRtlText(cell)
|
||||
Text(
|
||||
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
|
||||
textAlign = tableTextAlign(alignments.getOrNull(idx)),
|
||||
color = textColor,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.width(TABLE_COL_WIDTH)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableTextAlign(align: TableAlign?): TextAlign? =
|
||||
when (align) {
|
||||
TableAlign.CENTER -> TextAlign.Center
|
||||
TableAlign.RIGHT -> TextAlign.End
|
||||
else -> null
|
||||
}
|
||||
Reference in new issue
Block a user