Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/mobile/modules/t3-markdown-text/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -595,17 +599,28 @@ export function NativeMarkdownBlock(props: {
switch (props.node.type) {
case "document":
return (
<View style={{ gap: 8 }}>
<View>
{(props.node.children ?? []).map((child, index) => (
<NativeMarkdownBlock
<View
key={nodeKey(child, index)}
node={child}
skills={props.skills}
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
depth={depth}
/>
style={{
paddingTop:
Platform.OS === "android"
? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child)
: index > 0
? 8
: 0,
}}
>
<NativeMarkdownBlock
node={child}
skills={props.skills}
textStyle={props.textStyle}
highlightCode={props.highlightCode}
onLinkPress={props.onLinkPress}
depth={depth}
/>
</View>
))}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -84,8 +84,10 @@ export function SelectableMarkdownText({
the resulting single-line width instead of reflowing it. */}
<View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
{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" ? (
Comment thread
SunkenInTime marked this conversation as resolved.
<NativeMarkdownBlock
node={chunk.node}
skills={skills}
Expand Down
14 changes: 9 additions & 5 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading