authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-10 13:33:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:03:09+05:30
log444fd8badd265ddde33babf2a21bf3f527334e98
treed75e2091e3ca686881678860f97c38405e65cf5a
parentd29c56a5c404cb0f58d3b2ac41762fa0d0ac28d4
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep Android markdown icons aligned with text (#11079)


9 files changed, 120 insertions(+), 22 deletions(-)

apps/mobile/modules/t3-markdown-text/android/build.gradle+12
......@@ -8,6 +8,10 @@ android {
88 namespace 'expo.modules.t3markdowntext'
99 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
1115 defaultConfig {
1216 minSdkVersion rootProject.ext.minSdkVersion
1317 targetSdkVersion rootProject.ext.targetSdkVersion
......@@ -17,4 +21,12 @@ android {
1721dependencies {
1822 implementation project(':expo-modules-core')
1923 implementation 'com.facebook.react:react-android'
24 testImplementation 'junit:junit:4.13.2'
25 testImplementation 'org.robolectric:robolectric:4.16.1'
26}
27
28tasks.withType(Test).configureEach {
29 javaLauncher = javaToolchains.launcherFor {
30 languageVersion = JavaLanguageVersion.of(21)
31 }
2032}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt+9-5
......@@ -18,19 +18,23 @@ import kotlin.math.min
1818
1919private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
2020
21private fun copyTextWithoutInlineImages(
21internal fun copyTextWithoutInlineImages(
2222 text: CharSequence,
2323 start: Int,
2424 end: Int
2525): String {
2626 if (text !is Spanned) return text.subSequence(start, end).toString()
2727
28 fun isInlineImage(index: Int): Boolean =
29 index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
30 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
31
2832 return buildString {
2933 for (index in start until end) {
30 val isInlineImage =
31 text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
32 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
33 if (!isInlineImage) append(text[index])
34 // The renderer inserts one NBSP after each image to keep its label on the same line.
35 // Inspect the original text even when selection starts after the image.
36 val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
37 if (!isInlineImage(index) && !isIconSpacer) append(text[index])
3438 }
3539 }
3640}
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt created+48
......@@ -0,0 +1,48 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.drawable.ColorDrawable
4import android.text.SpannableString
5import android.text.Spanned
6import android.text.style.ImageSpan
7import org.junit.Assert.assertEquals
8import org.junit.Test
9import org.junit.runner.RunWith
10import org.robolectric.RobolectricTestRunner
11import org.robolectric.annotation.Config
12
13@RunWith(RobolectricTestRunner::class)
14@Config(sdk = [36], manifest = Config.NONE)
15class MarkdownSelectionCopyTest {
16 private fun withIcon(value: String): SpannableString = SpannableString(value).apply {
17 val index = value.indexOf('\uFFFC')
18 setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
19 }
20
21 @Test
22 fun removesIconAndInjectedSpacer() {
23 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
24 assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length))
25 }
26
27 @Test
28 fun removesSpacerWhenSelectionStartsAfterIcon() {
29 val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.")
30 assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12))
31 }
32
33 @Test
34 fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() {
35 val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0")
36 assertEquals(
37 "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0",
38 copyTextWithoutInlineImages(text, 0, text.length)
39 )
40 }
41
42 @Test
43 fun preservesTextWithoutImageSpans() {
44 val text = "\uFFFC\u00A0main.go"
45 assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length))
46 assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length))
47 }
48}
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+25-10
......@@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
55import { CopyTextButton } from "./CopyTextButton";
66import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
7import {
8 nativeMarkdownBlockSpacing,
9 nativeMarkdownDocumentRuns,
10 nativeMarkdownListItemBlocks,
11} from "./nativeMarkdownText";
812import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
913import type {
1014 MarkdownCodeHighlighter,
......@@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: {
595599 switch (props.node.type) {
596600 case "document":
597601 return (
598 <View style={{ gap: 8 }}>
602 <View>
599603 {(props.node.children ?? []).map((child, index) => (
600 <NativeMarkdownBlock
604 <View
601605 key={nodeKey(child, index)}
602 node={child}
603 skills={props.skills}
604 textStyle={props.textStyle}
605 highlightCode={props.highlightCode}
606 onLinkPress={props.onLinkPress}
607 depth={depth}
608 />
606 style={{
607 paddingTop:
608 Platform.OS === "android"
609 ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child)
610 : index > 0
611 ? 8
612 : 0,
613 }}
614 >
615 <NativeMarkdownBlock
616 node={child}
617 skills={props.skills}
618 textStyle={props.textStyle}
619 highlightCode={props.highlightCode}
620 onLinkPress={props.onLinkPress}
621 depth={depth}
622 />
623 </View>
609624 ))}
610625 </View>
611626 );
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+4
......@@ -222,6 +222,10 @@ export function NativeMarkdownSelectableText(props: {
222222 }
223223 }
224224
225 if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
226 text = `\u00A0${text}`;
227 }
228
225229 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
226230 });
227231 // T3MarkdownText only rebuilds its attributed string during native layout. A
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+4-2
......@@ -1,5 +1,5 @@
11import { useMemo } from "react";
2import { View } from "react-native";
2import { Platform, View } from "react-native";
33import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
44
55import {
......@@ -84,8 +84,10 @@ export function SelectableMarkdownText({
8484 the resulting single-line width instead of reflowing it. */}
8585 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
8686 {chunks.map((chunk, index) => {
87 // Android inline images drift when one Text mixes paragraph and list
88 // spacer line heights. Keep those layouts in separate native blocks.
8789 const content =
88 chunk.kind === "rich" ? (
90 chunk.kind === "rich" || Platform.OS === "android" ? (
8991 <NativeMarkdownBlock
9092 node={chunk.node}
9193 skills={skills}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+9-5
......@@ -589,6 +589,14 @@ function appendTable(
589589 return runs;
590590}
591591
592export function nativeMarkdownBlockSpacing(
593 previous: MarkdownNode | undefined,
594 current: MarkdownNode,
595): number {
596 if (!previous) return 0;
597 return current.type === "heading" ? 20 : previous.type === "heading" ? 10 : 12;
598}
599
592600function appendDocumentBlock(
593601 runs: NativeMarkdownTextRun[],
594602 node: MarkdownNode,
......@@ -599,11 +607,7 @@ function appendDocumentBlock(
599607 const children = node.children ?? [];
600608 for (const [index, child] of children.entries()) {
601609 if (index > 0) {
602 const previous = children[index - 1];
603 appendSpacer(
604 runs,
605 child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
606 );
610 appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child));
607611 }
608612 appendDocumentBlock(runs, child, depth);
609613 }
apps/mobile/src/lib/nativeMarkdownText.test.ts+6
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22import type { MarkdownNode } from "react-native-nitro-markdown/headless";
33
44import {
5 nativeMarkdownBlockSpacing,
56 nativeMarkdownChunkSpacing,
67 nativeMarkdownDocumentChunks,
78 nativeMarkdownDocumentRuns,
......@@ -381,6 +382,11 @@ describe("nativeMarkdownDocumentRuns", () => {
381382 .filter((run) => run.role === "spacer")
382383 .map((run) => run.spacing),
383384 ).toEqual([20, 10, 12]);
385 expect(
386 node.children?.map((child, index) =>
387 nativeMarkdownBlockSpacing(node.children?.[index - 1], child),
388 ),
389 ).toEqual([0, 20, 10, 12]);
384390 });
385391
386392 it("renders tight list items whose inline nodes are direct children", () => {
docs/user/composer.md+3
......@@ -6,6 +6,9 @@ include a skill when the task needs more context.
66Messages can contain up to 120,000 characters. Longer drafts stay in the composer
77so you can shorten them or split them into several messages.
88
9On Android, long-press message text to select within a paragraph or list item.
10Use the message’s copy button to copy the whole message.
11
912## Attach files
1013
1114Attach up to eight files per message. Images can be up to 10 MB; other files can