From 788603c871ca700f1c654a06bf0cbd62ca81f1a4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:41:00 -0400 Subject: [PATCH] fix(mobile): keep Android file icons on the line with wrapped filenames Android renders file and link icons as an inline Image before the label. The regular space between them is a line-break opportunity, so a long filename could wrap while its icon stayed on the previous line. Prefix the label with a non-breaking space on Android so the icon and its label wrap together. The native copy sanitizer strips that spacer alongside the image placeholder so selected text matches what was authored. Co-Authored-By: Claude Fable 5.1 --- .../t3-markdown-text/android/build.gradle | 12 +++++ .../T3MarkdownTextSelectionModule.kt | 14 ++++-- .../MarkdownSelectionCopyTest.kt | 48 +++++++++++++++++++ .../src/NativeMarkdownSelectableText.tsx | 6 +++ 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle index 13584a00be42..5b7e372006f3 100644 --- a/apps/mobile/modules/t3-markdown-text/android/build.gradle +++ b/apps/mobile/modules/t3-markdown-text/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3markdowntext' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -17,4 +21,12 @@ android { dependencies { implementation project(':expo-modules-core') implementation 'com.facebook.react:react-android' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 8e9fa3894aa9..63a4d59f93fb 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -28,19 +28,23 @@ private object MarkdownSpannableFactory : Spannable.Factory() { SpannableStringBuilder(source) } -private fun copyTextWithoutInlineImages( +internal fun copyTextWithoutInlineImages( text: CharSequence, start: Int, end: Int ): String { if (text !is Spanned) return text.subSequence(start, end).toString() + fun isInlineImage(index: Int): Boolean = + index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && + text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() + return buildString { for (index in start until end) { - val isInlineImage = - text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && - text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() - if (!isInlineImage) append(text[index]) + // The renderer inserts one NBSP after each image to keep its label on the same line. + // Inspect the original text even when selection starts after the image. + val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) + if (!isInlineImage(index) && !isIconSpacer) append(text[index]) } } } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt new file mode 100644 index 000000000000..da9012ee1665 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt @@ -0,0 +1,48 @@ +package expo.modules.t3markdowntext + +import android.graphics.drawable.ColorDrawable +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ImageSpan +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +class MarkdownSelectionCopyTest { + private fun withIcon(value: String): SpannableString = SpannableString(value).apply { + val index = value.indexOf('\uFFFC') + setSpan(ImageSpan(ColorDrawable()), index, index + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + @Test + fun removesIconAndInjectedSpacer() { + val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") + assertEquals("main.go:12 starts the server.", copyTextWithoutInlineImages(text, 0, text.length)) + } + + @Test + fun removesSpacerWhenSelectionStartsAfterIcon() { + val text = withIcon("\uFFFC\u00A0main.go:12 starts the server.") + assertEquals("main.go:12", copyTextWithoutInlineImages(text, 1, 12)) + } + + @Test + fun preservesAuthoredWhitespaceAndLiteralObjectCharacters() { + val text = withIcon("before\u00A0 \uFFFC\u00A0\u00A0 main.go after\u00A0\uFFFC\u00A0") + assertEquals( + "before\u00A0 \u00A0 main.go after\u00A0\uFFFC\u00A0", + copyTextWithoutInlineImages(text, 0, text.length) + ) + } + + @Test + fun preservesTextWithoutImageSpans() { + val text = "\uFFFC\u00A0main.go" + assertEquals(text, copyTextWithoutInlineImages(text, 0, text.length)) + assertEquals(text, copyTextWithoutInlineImages(SpannableString(text), 0, text.length)) + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index a5c6cf540f1c..f0686bc574dc 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -222,6 +222,12 @@ export function NativeMarkdownSelectableText(props: { } } + // Android renders the icon as an inline Image before the text. A regular space + // lets the line break between them, stranding the icon on the previous line. + if (Platform.OS === "android" && (run.fileIcon || linkIcon)) { + text = `\u00A0${text}`; + } + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A