From 8e520d38f46d94bd55ab5b3998400aa65613f8fa Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:13:11 -0400 Subject: [PATCH 1/2] fix(mobile): keep Android markdown icons aligned with text --- .../src/NativeMarkdownSelectableText.ios.tsx | 4 ++++ .../t3-markdown-text/src/SelectableMarkdownText.ios.tsx | 6 ++++-- docs/user/composer.md | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index a5c6cf540f1c..7e1eaf88f546 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -222,6 +222,10 @@ export function NativeMarkdownSelectableText(props: { } } + 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 diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 2a231c603584..b7768ffbae03 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; import { @@ -84,8 +84,10 @@ export function SelectableMarkdownText({ the resulting single-line width instead of reflowing it. */} {chunks.map((chunk, index) => { + // Android inline images drift when one Text mixes paragraph and list + // spacer line heights. Keep those layouts in separate native blocks. const content = - chunk.kind === "rich" ? ( + chunk.kind === "rich" || Platform.OS === "android" ? ( Date: Thu, 10 Sep 2026 12:17:36 -0400 Subject: [PATCH 2/2] fix(mobile): preserve Android markdown spacing and clean selection copies --- .../t3-markdown-text/android/build.gradle | 12 +++++ .../T3MarkdownTextSelectionModule.kt | 14 ++++-- .../MarkdownSelectionCopyTest.kt | 48 +++++++++++++++++++ .../src/NativeMarkdownBlock.ios.tsx | 35 ++++++++++---- .../src/nativeMarkdownText.ts | 14 ++++-- .../mobile/src/lib/nativeMarkdownText.test.ts | 6 +++ 6 files changed, 109 insertions(+), 20 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 af8675831f2d..c68b8e641aab 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 @@ -18,19 +18,23 @@ import kotlin.math.min private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" -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/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 348a3c489a2c..3aa7e1cf4e46 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; +import { + nativeMarkdownBlockSpacing, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, +} from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, @@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: { switch (props.node.type) { case "document": return ( - + {(props.node.children ?? []).map((child, index) => ( - + style={{ + paddingTop: + Platform.OS === "android" + ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child) + : index > 0 + ? 8 + : 0, + }} + > + + ))} ); diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 4c8a6c4d7cd5..aa22dc85291c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -589,6 +589,14 @@ function appendTable( return runs; } +export function nativeMarkdownBlockSpacing( + previous: MarkdownNode | undefined, + current: MarkdownNode, +): number { + if (!previous) return 0; + return current.type === "heading" ? 20 : previous.type === "heading" ? 10 : 12; +} + function appendDocumentBlock( runs: NativeMarkdownTextRun[], node: MarkdownNode, @@ -599,11 +607,7 @@ function appendDocumentBlock( const children = node.children ?? []; for (const [index, child] of children.entries()) { if (index > 0) { - const previous = children[index - 1]; - appendSpacer( - runs, - child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, - ); + appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child)); } appendDocumentBlock(runs, child, depth); } diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index c3951c8d81f0..1091aeb65a6c 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { + nativeMarkdownBlockSpacing, nativeMarkdownChunkSpacing, nativeMarkdownDocumentChunks, nativeMarkdownDocumentRuns, @@ -381,6 +382,11 @@ describe("nativeMarkdownDocumentRuns", () => { .filter((run) => run.role === "spacer") .map((run) => run.spacing), ).toEqual([20, 10, 12]); + expect( + node.children?.map((child, index) => + nativeMarkdownBlockSpacing(node.children?.[index - 1], child), + ), + ).toEqual([0, 20, 10, 12]); }); it("renders tight list items whose inline nodes are direct children", () => {