diff --git a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt index 282c24d7..b83bd882 100644 --- a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt +++ b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt @@ -13,8 +13,10 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.text.LinkAnnotation import com.mikepenz.markdown.m3.Markdown import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue /** @@ -48,6 +50,42 @@ class MarkdownA11yTest { ) } + /** + * Issue #569 fix (#570): the paragraph carrying inline links must be a screen-reader + * merge boundary so TalkBack reads it as one unit instead of skipping it. In Compose + * `mergeDescendants` maps to the ANI `isScreenReaderFocusable` flag, so we assert the + * flag directly on the unmerged tree — it is `false` without the fix and `true` with it. + * + * Simultaneously guards #487: the two inline links must survive as addressable + * `LinkAnnotation`s on that same text node (merging must not prune them). + * + * Unlike a pixel snapshot, this distinguishes the patched from the unpatched renderer: + * the merge flag flips while the rendered output stays byte-identical. + */ + @Test + fun link_paragraph_merges_yet_keeps_both_links() = runComposeUiTest { + setContent { Wrap { Markdown(docLinks) } } + val root = onRoot(useUnmergedTree = true).fetchSemanticsNode() + val paragraph = findNodeWhoseTextContains(root, "Anthropic") + ?: error("Could not locate paragraph text node containing the link copy") + + // #569: paragraph is a screen-reader merge boundary (false on develop, true on #570). + assertTrue( + paragraph.config.isMergingSemanticsOfDescendants, + "Paragraph with inline links must merge descendants so TalkBack reads it — regression of #569" + ) + + // #487: both inline links remain individually addressable after the merge. + val annotatedString = paragraph.config[SemanticsProperties.Text].first() + val urls = annotatedString.getLinkAnnotations(0, annotatedString.length) + .mapNotNull { (it.item as? LinkAnnotation.Url)?.url } + .toSet() + assertEquals( + setOf("https://anthropic.com", "https://claude.ai"), urls, + "Both inline links must survive merging — regression of #487" + ) + } + @Test fun headings_expose_heading_property() = runComposeUiTest { setContent { Wrap { Markdown("# Title\n\nBody") } } diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt index 43f6a127..8cea35d1 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt @@ -1,6 +1,7 @@ package com.mikepenz.markdown.compose.elements import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -196,9 +197,15 @@ fun MarkdownText( val segmentDrawModifier = if (extendedSpans != null) { segmentModifier.drawBehind(extendedSpans) } else segmentModifier + val hasSegmentLinks = segment.getLinkAnnotations(0, segment.length).isNotEmpty() + val finalModifier = if (hasSegmentLinks) { + segmentDrawModifier.semantics(mergeDescendants = true) { } + } else { + segmentDrawModifier + } MarkdownBasicText( text = extended, - modifier = segmentDrawModifier.let { animations.animateTextSize(it) }, + modifier = finalModifier.let { animations.animateTextSize(it) }, style = style, inlineContent = resolvedInlineContent, onTextLayout = { result -> @@ -210,7 +217,18 @@ fun MarkdownText( } if (blockImageRanges.isEmpty()) { - textSegment(content, containerModifier(modifier)) + val hasLinks = content.getLinkAnnotations(0, content.length).isNotEmpty() + if (hasLinks) { + Box(modifier = containerModifier(modifier)) { + textSegment(content, Modifier) + } + } else { + textSegment(content, modifier.onPlaced { + it.parentLayoutCoordinates?.also { coordinates -> + containerSize.value = coordinates.size.toSize() + } + }) + } } else { val components = LocalMarkdownComponents.current val typography = LocalMarkdownTypography.current