authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 10:35:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 10:35:34-07:00
log3836890e4484406813997259efc69065b25ce698
tree6056ee76648e1cb353a46a1cfd0230f63e45325c
parent444fd8badd265ddde33babf2a21bf3f527334e98
signature Signed by PGP key B5690EEEBB952194

Revert "fix(mobile): keep Android markdown icons aligned with text" (#11098)


9 files changed, 22 insertions(+), 120 deletions(-)

apps/mobile/modules/t3-markdown-text/android/build.gradle-12
......@@ -8,10 +8,6 @@ android {
88 namespace 'expo.modules.t3markdowntext'
99 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
1511 defaultConfig {
1612 minSdkVersion rootProject.ext.minSdkVersion
1713 targetSdkVersion rootProject.ext.targetSdkVersion
......@@ -21,12 +17,4 @@ android {
2117dependencies {
2218 implementation project(':expo-modules-core')
2319 implementation 'com.facebook.react:react-android'
24 testImplementation 'junit:junit:4.13.2'
25 testImplementation 'org.robolectric:robolectric:4.16.1'
26}
27
28tasks.withType(Test).configureEach {
29 javaLauncher = javaToolchains.launcherFor {
30 languageVersion = JavaLanguageVersion.of(21)
31 }
3220}
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt+5-9
......@@ -18,23 +18,19 @@ import kotlin.math.min
1818
1919private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"
2020
21internal fun copyTextWithoutInlineImages(
21private fun copyTextWithoutInlineImages(
2222 text: CharSequence,
2323 start: Int,
2424 end: Int
2525): String {
2626 if (text !is Spanned) return text.subSequence(start, end).toString()
2727
28 fun isInlineImage(index: Int): Boolean =
29 index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
30 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
31
3228 return buildString {
3329 for (index in start until end) {
34 // The renderer inserts one NBSP after each image to keep its label on the same line.
35 // Inspect the original text even when selection starts after the image.
36 val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1)
37 if (!isInlineImage(index) && !isIconSpacer) append(text[index])
30 val isInlineImage =
31 text[index].toString() == OBJECT_REPLACEMENT_CHARACTER &&
32 text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty()
33 if (!isInlineImage) append(text[index])
3834 }
3935 }
4036}
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted-48
......@@ -1,48 +0,0 @@
1package expo.modules.t3markdowntext
2
3import android.graphics.drawable.ColorDrawable
4import android.text.SpannableString
5import android.text.Spanned
6import android.text.style.ImageSpan
7import org.junit.Assert.assertEquals
8import org.junit.Test
9import org.junit.runner.RunWith
10import org.robolectric.RobolectricTestRunner
11import org.robolectric.annotation.Config
12
13@RunWith(RobolectricTestRunner::class)
14@Config(sdk = [36], manifest = Config.NONE)
15class 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/NativeMarkdownBlock.ios.tsx+10-25
......@@ -4,11 +4,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
44
55import { CopyTextButton } from "./CopyTextButton";
66import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
7import {
8 nativeMarkdownBlockSpacing,
9 nativeMarkdownDocumentRuns,
10 nativeMarkdownListItemBlocks,
11} from "./nativeMarkdownText";
7import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
128import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
139import type {
1410 MarkdownCodeHighlighter,
......@@ -599,28 +595,17 @@ export function NativeMarkdownBlock(props: {
599595 switch (props.node.type) {
600596 case "document":
601597 return (
602 <View>
598 <View style={{ gap: 8 }}>
603599 {(props.node.children ?? []).map((child, index) => (
604 <View
600 <NativeMarkdownBlock
605601 key={nodeKey(child, index)}
606 style={{
607 paddingTop:
608 Platform.OS === "android"
609 ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child)
610 : index > 0
611 ? 8
612 : 0,
613 }}
614 >
615 <NativeMarkdownBlock
616 node={child}
617 skills={props.skills}
618 textStyle={props.textStyle}
619 highlightCode={props.highlightCode}
620 onLinkPress={props.onLinkPress}
621 depth={depth}
622 />
623 </View>
602 node={child}
603 skills={props.skills}
604 textStyle={props.textStyle}
605 highlightCode={props.highlightCode}
606 onLinkPress={props.onLinkPress}
607 depth={depth}
608 />
624609 ))}
625610 </View>
626611 );
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx-4
......@@ -222,10 +222,6 @@ export function NativeMarkdownSelectableText(props: {
222222 }
223223 }
224224
225 if (Platform.OS === "android" && (run.fileIcon || linkIcon)) {
226 text = `\u00A0${text}`;
227 }
228
229225 return { key: `${signature}:${occurrence}`, run, text, linkIcon };
230226 });
231227 // T3MarkdownText only rebuilds its attributed string during native layout. A
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+2-4
......@@ -1,5 +1,5 @@
11import { useMemo } from "react";
2import { Platform, View } from "react-native";
2import { View } from "react-native";
33import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
44
55import {
......@@ -84,10 +84,8 @@ export function SelectableMarkdownText({
8484 the resulting single-line width instead of reflowing it. */}
8585 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
8686 {chunks.map((chunk, index) => {
87 // Android inline images drift when one Text mixes paragraph and list
88 // spacer line heights. Keep those layouts in separate native blocks.
8987 const content =
90 chunk.kind === "rich" || Platform.OS === "android" ? (
88 chunk.kind === "rich" ? (
9189 <NativeMarkdownBlock
9290 node={chunk.node}
9391 skills={skills}
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+5-9
......@@ -589,14 +589,6 @@ function appendTable(
589589 return runs;
590590}
591591
592export function nativeMarkdownBlockSpacing(
593 previous: MarkdownNode | undefined,
594 current: MarkdownNode,
595): number {
596 if (!previous) return 0;
597 return current.type === "heading" ? 20 : previous.type === "heading" ? 10 : 12;
598}
599
600592function appendDocumentBlock(
601593 runs: NativeMarkdownTextRun[],
602594 node: MarkdownNode,
......@@ -607,7 +599,11 @@ function appendDocumentBlock(
607599 const children = node.children ?? [];
608600 for (const [index, child] of children.entries()) {
609601 if (index > 0) {
610 appendSpacer(runs, nativeMarkdownBlockSpacing(children[index - 1], child));
602 const previous = children[index - 1];
603 appendSpacer(
604 runs,
605 child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
606 );
611607 }
612608 appendDocumentBlock(runs, child, depth);
613609 }
apps/mobile/src/lib/nativeMarkdownText.test.ts-6
......@@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test";
22import type { MarkdownNode } from "react-native-nitro-markdown/headless";
33
44import {
5 nativeMarkdownBlockSpacing,
65 nativeMarkdownChunkSpacing,
76 nativeMarkdownDocumentChunks,
87 nativeMarkdownDocumentRuns,
......@@ -382,11 +381,6 @@ describe("nativeMarkdownDocumentRuns", () => {
382381 .filter((run) => run.role === "spacer")
383382 .map((run) => run.spacing),
384383 ).toEqual([20, 10, 12]);
385 expect(
386 node.children?.map((child, index) =>
387 nativeMarkdownBlockSpacing(node.children?.[index - 1], child),
388 ),
389 ).toEqual([0, 20, 10, 12]);
390384 });
391385
392386 it("renders tight list items whose inline nodes are direct children", () => {
docs/user/composer.md-3
......@@ -6,9 +6,6 @@ include a skill when the task needs more context.
66Messages can contain up to 120,000 characters. Longer drafts stay in the composer
77so you can shorten them or split them into several messages.
88
9On Android, long-press message text to select within a paragraph or list item.
10Use the message’s copy button to copy the whole message.
11
129## Attach files
1310
1411Attach up to eight files per message. Images can be up to 10 MB; other files can