authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-11 19:22:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 20:22:07-03:00
logcd64ad384e40988f57e4caa5a3f74f25ca5ef450
tree4a334540dda3eb4198d11e91a3144d3b588a6dc4
parent50791a0530a9c4eed7924dbfedf2fcf430d4ed98
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep Android file icons on the line with wrapped filenames (#11234)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 75 insertions(+), 5 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
......@@ -28,19 +28,23 @@ private object MarkdownSpannableFactory : Spannable.Factory() {
2828 SpannableStringBuilder(source)
2929}
3030
31private fun copyTextWithoutInlineImages(
31internal fun copyTextWithoutInlineImages(
3232 text: CharSequence,
3333 start: Int,
3434 end: Int
3535): String {
3636 if (text !is Spanned) return text.subSequence(start, end).toString()
3737
38 fun isInlineImage(index: Int): Boolean =
39 index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
40 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
41
3842 return buildString {
3943 for (index in start until end) {
40 val isInlineImage =
41 text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
42 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
43 if (!isInlineImage) append(text[index])
44 // The renderer inserts one NBSP after each image to keep its label on the same line.
45 // Inspect the original text even when selection starts after the image.
46 val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
47 if (!isInlineImage(index) && !isIconSpacer) append(text[index])
4448 }
4549 }
4650}
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/NativeMarkdownSelectableText.tsx+6
......@@ -222,6 +222,12 @@ export function NativeMarkdownSelectableText(props: {
222222 }
223223 }
224224
225 // Android renders the icon as an inline Image before the text. A regular space
226 // lets the line break between them, stranding the icon on the previous line.
227 if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
228 text = `\u00A0${text}`;
229 }
230
225231 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
226232 });
227233 // T3MarkdownText only rebuilds its attributed string during native layout. A