| 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,6 +8,10 @@ android { |
| 8 | namespace 'expo.modules.t3markdowntext' | 8 | namespace 'expo.modules.t3markdowntext' |
| 9 | compileSdk rootProject.ext.compileSdkVersion | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | ||
| 11 | testOptions { | ||
| 12 | unitTests.includeAndroidResources = true | ||
| 13 | } | ||
| 14 | |||
| 11 | defaultConfig { | 15 | defaultConfig { |
| 12 | minSdkVersion rootProject.ext.minSdkVersion | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | @@ -17,4 +21,12 @@ android { | ... | @@ -17,4 +21,12 @@ android { |
| 17 | dependencies { | 21 | dependencies { |
| 18 | implementation project(':expo-modules-core') | 22 | implementation project(':expo-modules-core') |
| 19 | implementation 'com.facebook.react:react-android' | 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,19 +28,23 @@ private object MarkdownSpannableFactory : Spannable.Factory() { |
| 28 | SpannableStringBuilder(source) | 28 | SpannableStringBuilder(source) |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | private fun copyTextWithoutInlineImages( | 31 | internal fun copyTextWithoutInlineImages( |
| 32 | text: CharSequence, | 32 | text: CharSequence, |
| 33 | start: Int, | 33 | start: Int, |
| 34 | end: Int | 34 | end: Int |
| 35 | ): String { | 35 | ): String { |
| 36 | if (text !is Spanned) return text.subSequence(start, end).toString() | 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 | return buildString { | 42 | return buildString { |
| 39 | for (index in start until end) { | 43 | for (index in start until end) { |
| 40 | val isInlineImage = | 44 | // The renderer inserts one NBSP after each image to keep its label on the same line. |
| 41 | text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && | 45 | // Inspect the original text even when selection starts after the image. |
| 42 | text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() | 46 | val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) |
| 43 | if (!isInlineImage) append(text[index]) | 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,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 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; | 231 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; |
| 226 | }); | 232 | }); |
| 227 | // T3MarkdownText only rebuilds its attributed string during native layout. A | 233 | // T3MarkdownText only rebuilds its attributed string during native layout. A |