refactor(markdown): decompose MarkdownText UI and styling
Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable, and MarkdownMediaResolver into ui/chat/markdown package.
This commit is contained in:
@@ -63,6 +63,10 @@ import com.m57.hermescontrol.theme.SearchHighlightColors
|
|||||||
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.FnNote
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
|
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.MdBlock
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||||
@@ -347,7 +351,7 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Video -> {
|
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) }
|
var showVideoDialog by remember { mutableStateOf(false) }
|
||||||
androidx.compose.foundation.layout.Box(
|
androidx.compose.foundation.layout.Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -369,7 +373,7 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Image -> {
|
is MdBlock.Image -> {
|
||||||
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
val model: Any = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) }
|
||||||
val isGif =
|
val isGif =
|
||||||
remember(block.uri) {
|
remember(block.uri) {
|
||||||
block.uri.contains(".gif", ignoreCase = true) ||
|
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(
|
internal fun parseInline(
|
||||||
text: String,
|
text: String,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
@@ -691,14 +522,8 @@ internal fun parseInline(
|
|||||||
highlights: SearchHighlightColors,
|
highlights: SearchHighlightColors,
|
||||||
isRtl: Boolean = BidiUtils.isRtlText(text),
|
isRtl: Boolean = BidiUtils.isRtlText(text),
|
||||||
): AnnotatedString =
|
): AnnotatedString =
|
||||||
parseInlineSource(
|
MarkdownInlineStyler.parseInline(
|
||||||
text =
|
text = text,
|
||||||
splitInlineMath(text).joinToString(separator = "") { segment ->
|
|
||||||
when (segment) {
|
|
||||||
is InlineMathSegment.Math -> segment.latex
|
|
||||||
is InlineMathSegment.Text -> segment.value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
@@ -706,318 +531,3 @@ internal fun parseInline(
|
|||||||
highlights = highlights,
|
highlights = highlights,
|
||||||
isRtl = isRtl,
|
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