| author | |
| committer | |
| log | cd64ad384e40988f57e4caa5a3f74f25ca5ef450 |
| tree | 4a334540dda3eb4198d11e91a3144d3b588a6dc4 |
| parent | 50791a0530a9c4eed7924dbfedf2fcf430d4ed98 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 8 | 8 | namespace 'expo.modules.t3markdowntext' |
| 9 | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | |
| 11 | testOptions { | |
| 12 | unitTests.includeAndroidResources = true | |
| 13 | } | |
| 14 | ||
| 11 | 15 | defaultConfig { |
| 12 | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | ... | @@ -17,4 +21,12 @@ android { |
| 17 | 21 | dependencies { |
| 18 | 22 | implementation project(':expo-modules-core') |
| 19 | 23 | implementation 'com.facebook.react:react-android' |
| 24 | testImplementation 'junit:junit:4.13.2' | |
| 25 | testImplementation 'org.robolectric:robolectric:4.16.1' | |
| 26 | } | |
| 27 | ||
| 28 | tasks.withType(Test).configureEach { | |
| 29 | javaLauncher = javaToolchains.launcherFor { | |
| 30 | languageVersion = JavaLanguageVersion.of(21) | |
| 31 | } | |
| 20 | 32 | } |
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() { |
| 28 | 28 | SpannableStringBuilder(source) |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | private fun copyTextWithoutInlineImages( | |
| 31 | internal fun copyTextWithoutInlineImages( | |
| 32 | 32 | text: CharSequence, |
| 33 | 33 | start: Int, |
| 34 | 34 | end: Int |
| 35 | 35 | ): String { |
| 36 | 36 | if (text !is Spanned) return text.subSequence(start, end).toString() |
| 37 | 37 | |
| 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 | ||
| 38 | 42 | return buildString { |
| 39 | 43 | 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]) | |
| 44 | 48 | } |
| 45 | 49 | } |
| 46 | 50 | } |
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | package expo.modules.t3markdowntext | |
| 2 | ||
| 3 | import android.graphics.drawable.ColorDrawable | |
| 4 | import android.text.SpannableString | |
| 5 | import android.text.Spanned | |
| 6 | import android.text.style.ImageSpan | |
| 7 | import org.junit.Assert.assertEquals | |
| 8 | import org.junit.Test | |
| 9 | import org.junit.runner.RunWith | |
| 10 | import org.robolectric.RobolectricTestRunner | |
| 11 | import org.robolectric.annotation.Config | |
| 12 | ||
| 13 | @RunWith(RobolectricTestRunner::class) | |
| 14 | @Config(sdk = [36], manifest = Config.NONE) | |
| 15 | class 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: { |
| 222 | 222 | } |
| 223 | 223 | } |
| 224 | 224 | |
| 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 | ||
| 225 | 231 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; |
| 226 | 232 | }); |
| 227 | 233 | // T3MarkdownText only rebuilds its attributed string during native layout. A |