@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
2222import androidx.compose.foundation.layout.padding
2323import androidx.compose.foundation.layout.size
2424import androidx.compose.foundation.rememberScrollState
25+import androidx.compose.foundation.text.BasicText
2526import androidx.compose.foundation.text.selection.SelectionContainer
2627import androidx.compose.material.icons.Icons
2728import androidx.compose.material.icons.automirrored.filled.WrapText
@@ -60,6 +61,7 @@ import androidx.compose.ui.platform.UriHandler
6061import androidx.compose.ui.res.pluralStringResource
6162import androidx.compose.ui.res.stringResource
6263import androidx.compose.ui.semantics.LiveRegionMode
64+import androidx.compose.ui.semantics.heading
6365import androidx.compose.ui.semantics.invisibleToUser
6466import androidx.compose.ui.semantics.liveRegion
6567import androidx.compose.ui.semantics.semantics
@@ -80,7 +82,6 @@ import com.mikepenz.markdown.compose.LocalMarkdownColors
8082import com.mikepenz.markdown.compose.LocalMarkdownTypography
8183import com.mikepenz.markdown.compose.components.MarkdownComponentModel
8284import com.mikepenz.markdown.compose.components.markdownComponents
83−import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
8485import com.mikepenz.markdown.m3.Markdown
8586import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
8687import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
@@ -94,7 +95,7 @@ import soy.iko.opencode.data.model.FilePart
9495import soy.iko.opencode.data.network.NetworkConfig
9596
9697/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
97− * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
98+ * annotator wrapper, so [MarkdownTextNoAnim]'s `pointerInput` can resolve a long-press
9899 * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
99100 * for link-tap hit-testing. */
100101private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
@@ -164,22 +165,30 @@ private fun MarkdownBody(
164165 // Without this the library's default image loader fetches with no auth and fails on
165166 // server-relative URLs. When no context is available, fall back to the library default
166167 // by not overriding the image component.
167− // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
168+ // The `paragraph` and `heading` components are overridden with [MarkdownTextNoAnim] so a long-press
168169 // on a single-backtick inline code span copies its text — matching the copy affordance the
169170 // fenced/indented code blocks already have via [CodeWithCopy].
170171 val components = if (imageContext != null) {
171172 // Remember on imageContext so a recomposition that doesn't change the context (the
172173 // common case during streaming/scroll) doesn't rebuild the 21-field components
173174 // container + 4 lambdas. The lambdas capture `imageContext` (for the image component)
174− // and the CodeWithCopy/ParagraphWithInlineCodeCopy factories (which only capture the
175+ // and the CodeWithCopy/MarkdownTextNoAnim factories (which only capture the
175176 // stable MarkdownComponentModel passed by the library at invoke time), so keying on
176177 // imageContext is sufficient for stability.
177178 remember(imageContext) {
178179 markdownComponents(
179180 codeFence = { CodeWithCopy(it) },
180181 codeBlock = { CodeWithCopy(it) },
181− paragraph = { ParagraphWithInlineCodeCopy(it) },
182+ paragraph = { MarkdownTextNoAnim(it, it.typography.paragraph) },
182183 image = { MarkdownImage(it, imageContext) },
184+ heading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
185+ heading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
186+ heading3 = { MarkdownTextNoAnim(it, it.typography.h3, Modifier.semantics { heading() }) },
187+ heading4 = { MarkdownTextNoAnim(it, it.typography.h4, Modifier.semantics { heading() }) },
188+ heading5 = { MarkdownTextNoAnim(it, it.typography.h5, Modifier.semantics { heading() }) },
189+ heading6 = { MarkdownTextNoAnim(it, it.typography.h6, Modifier.semantics { heading() }) },
190+ setextHeading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
191+ setextHeading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
183192 )
184193 }
185194 } else {
@@ -187,7 +196,15 @@ private fun MarkdownBody(
187196 markdownComponents(
188197 codeFence = { CodeWithCopy(it) },
189198 codeBlock = { CodeWithCopy(it) },
190− paragraph = { ParagraphWithInlineCodeCopy(it) },
199+ paragraph = { MarkdownTextNoAnim(it, it.typography.paragraph) },
200+ heading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
201+ heading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
202+ heading3 = { MarkdownTextNoAnim(it, it.typography.h3, Modifier.semantics { heading() }) },
203+ heading4 = { MarkdownTextNoAnim(it, it.typography.h4, Modifier.semantics { heading() }) },
204+ heading5 = { MarkdownTextNoAnim(it, it.typography.h5, Modifier.semantics { heading() }) },
205+ heading6 = { MarkdownTextNoAnim(it, it.typography.h6, Modifier.semantics { heading() }) },
206+ setextHeading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
207+ setextHeading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
191208 )
192209 }
193210 }
@@ -223,7 +240,7 @@ private fun MarkdownBody(
223240 val inlineCodeTypography = LocalMarkdownTypography.current
224241 val inlineCodeColors = LocalMarkdownColors.current
225242 // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
226− // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
243+ // CODE_SPAN range so [MarkdownTextNoAnim] can resolve a long-press offset to the
227244 // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
228245 // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
229246 // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
@@ -485,25 +502,45 @@ private fun LinkActionDialog(
485502}
486503
487504/**
488− * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
489− * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
490− * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
491− * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
492− * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
493− * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
494− * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
495− * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
496− * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
497− * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
498− * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
499− * wraps the text); when selection is active the user can still copy via the selection handles.
500− * Haptic feedback matches every other copy affordance.
505+ * Custom markdown `paragraph` / `heading` component that renders via [BasicText] directly,
506+ * bypassing the library's [com.mikepenz.markdown.compose.elements.MarkdownText].
507+ *
508+ * The library's `MarkdownText` wraps every text segment in
509+ * [animateContentSize][androidx.compose.animation.animateContentSize], which triggers draw-phase
510+ * remeasures through the nested SubcomposeLayout (BasicText-with-links uses SubcomposeLayout for
511+ * link hit-testing). During streaming and during the Loading→Success state transition, these
512+ * remeasures crash with an NPE in the innermost MeasurePolicy.measure (an R8-rewritten frame
513+ * throwing NullPointerException in ChildData.isTarget). [BasicText] handles link taps natively
514+ * (via LayoutWithLinksAndInlineContent), so the library's wrapper adds nothing but the
515+ * crash-prone animateContentSize.
516+ *
517+ * Also adds a long-press-to-copy affordance for inline code spans (single-backtick `` `code` ``).
518+ * The library renders CODE_SPAN inline as a styled run within the paragraph's [AnnotatedString];
519+ * there's no discrete `inlineCode` component to override. Instead, [MarkdownBody] installs an
520+ * annotator that stamps each CODE_SPAN range with a [MARKDOWN_TAG_INLINE_CODE] string annotation;
521+ * this component builds the [AnnotatedString] via the library's [buildMarkdownAnnotatedString]
522+ * (so the annotator runs and the tags land), then renders it with a `pointerInput` that hit-tests
523+ * a long-press against the tagged ranges and copies the span's text on hit — mirroring the copy
524+ * affordance fenced/indented code blocks get via [CodeWithCopy], for the common case of short
525+ * inline commands/identifiers. A long-press inside a [SelectionContainer] is consumed by the
526+ * selection machinery first, so the copy fires from non-selectable contexts (e.g. while streaming,
527+ * where no SelectionContainer wraps the text); when selection is active the user can still copy
528+ * via the selection handles. Haptic feedback matches every other copy affordance.
529+ *
530+ * The color resolution mirrors MarkdownBasicText's logic (style.color →
531+ * LocalMarkdownColors.current.text). The [modifier] is applied to the BasicText (used by headings
532+ * to add `heading()` semantics). Used for both the `paragraph` component (style =
533+ * typography.paragraph, no modifier) and the `heading1`–`heading6` / `setextHeading` components
534+ * (style = typography.h1–h6, `heading()` semantics modifier).
501535 */
502536@Composable
503−private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
537+private fun MarkdownTextNoAnim(
538+ model: MarkdownComponentModel,
539+ style: TextStyle,
540+ modifier: Modifier = Modifier,
541+) {
504542 val context = LocalContext.current
505543 val haptics = LocalHapticFeedback.current
506− val style = model.typography.paragraph
507544 val typography = LocalMarkdownTypography.current
508545 val annotator = LocalMarkdownAnnotator.current
509546 // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
@@ -531,10 +568,10 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
531568 // Capture the layout result so a long-press position can be mapped to a character offset,
532569 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
533570 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
534− LibraryMarkdownText(
535− content = styledText,
536− node = model.node,
537− modifier = Modifier.pointerInput(styledText) {
571+ val baseColor = LocalMarkdownColors.current.text
572+ BasicText(
573+ text = styledText,
574+ modifier = modifier.pointerInput(styledText) {
538575 detectTapGestures(
539576 onLongPress = { pos ->
540577 val result = layoutResult ?: return@detectTapGestures
@@ -549,7 +586,8 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
549586 )
550587 },
551588 style = style,
552− onTextLayout = { result, _ -> layoutResult = result },
589+ color = { baseColor },
590+ onTextLayout = { result -> layoutResult = result },
553591 )
554592}
555593