diff --git a/apps/mobile/modules/t3-markdown-text/android/build.gradle b/apps/mobile/modules/t3-markdown-text/android/build.gradle
index 13584a00be426497491bad7651ad4e7181875a6c..5b7e372006f307cd554d8ca7260ea807762a4f68 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 af8675831f2d2753660b924f7b1840d36e574ab5..c68b8e641aab45426e22a3d3a69f30ab21cdc3aa 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 0000000000000000000000000000000000000000..da9012ee166581cf049515e2b6f5ad19600eb77d
--- /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 348a3c489a2c4458d04446ba8a078c6d1d88ef70..3aa7e1cf4e468868e883b3ca3ec774a0ec5e9a7c 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/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx
index a5c6cf540f1cde16d35d40ef8638dba8f60bb2fd..7e1eaf88f546200ac55d8aa4f7709b17dcda194f 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 2a231c6035840a458aa90ce714b5cf333e10f03f..b7768ffbae03b7852273b79be348b0a8dfb60f55 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" ? (
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 c3951c8d81f05d517cfac77637e19dbbcf3fde75..1091aeb65a6c1bd8ee1ceb4c2c1ebc69d6cc9109 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", () => {
diff --git a/docs/user/composer.md b/docs/user/composer.md
index 4a8df5333664e30aa347972256d365fd6b303b91..7e388d4eb34fb3c71cf8f3b11ba5fa30345d6fe8 100644
--- a/docs/user/composer.md
+++ b/docs/user/composer.md
@@ -6,6 +6,9 @@ include a skill when the task needs more context.
Messages can contain up to 120,000 characters. Longer drafts stay in the composer
so you can shorten them or split them into several messages.
+On Android, long-press message text to select within a paragraph or list item.
+Use the message’s copy button to copy the whole message.
+
## Attach files
Attach up to eight files per message. Images can be up to 10 MB; other files can