| author | |
| committer | |
| log | 3836890e4484406813997259efc69065b25ce698 |
| tree | 6056ee76648e1cb353a46a1cfd0230f63e45325c |
| parent | 444fd8badd265ddde33babf2a21bf3f527334e98 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 22 insertions(+), 120 deletions(-)
apps/mobile/modules/t3-markdown-text/android/build.gradle-12| ... | ... | @@ -8,10 +8,6 @@ android { |
| 8 | 8 | namespace 'expo.modules.t3markdowntext' |
| 9 | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | |
| 11 | testOptions { | |
| 12 | unitTests.includeAndroidResources = true | |
| 13 | } | |
| 14 | ||
| 15 | 11 | defaultConfig { |
| 16 | 12 | minSdkVersion rootProject.ext.minSdkVersion |
| 17 | 13 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | ... | @@ -21,12 +17,4 @@ android { |
| 21 | 17 | dependencies { |
| 22 | 18 | implementation project(':expo-modules-core') |
| 23 | 19 | 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 | } | |
| 32 | 20 | } |
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 |
| 18 | 18 | |
| 19 | 19 | private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" |
| 20 | 20 | |
| 21 | internal fun copyTextWithoutInlineImages( | |
| 21 | private fun copyTextWithoutInlineImages( | |
| 22 | 22 | text: CharSequence, |
| 23 | 23 | start: Int, |
| 24 | 24 | end: Int |
| 25 | 25 | ): String { |
| 26 | 26 | if (text !is Spanned) return text.subSequence(start, end).toString() |
| 27 | 27 | |
| 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 | ||
| 32 | 28 | return buildString { |
| 33 | 29 | 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]) | |
| 38 | 34 | } |
| 39 | 35 | } |
| 40 | 36 | } |
apps/mobile/modules/t3-markdown-text/android/src/test/java/expo/modules/t3markdowntext/MarkdownSelectionCopyTest.kt deleted-48| ... | ... | @@ -1,48 +0,0 @@ |
| 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/NativeMarkdownBlock.ios.tsx+10-25| ... | ... | @@ -4,11 +4,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | |
| 5 | 5 | import { CopyTextButton } from "./CopyTextButton"; |
| 6 | 6 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 7 | import { | |
| 8 | nativeMarkdownBlockSpacing, | |
| 9 | nativeMarkdownDocumentRuns, | |
| 10 | nativeMarkdownListItemBlocks, | |
| 11 | } from "./nativeMarkdownText"; | |
| 7 | import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; | |
| 12 | 8 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; |
| 13 | 9 | import type { |
| 14 | 10 | MarkdownCodeHighlighter, |
| ... | ... | @@ -599,28 +595,17 @@ export function NativeMarkdownBlock(props: { |
| 599 | 595 | switch (props.node.type) { |
| 600 | 596 | case "document": |
| 601 | 597 | return ( |
| 602 | <View> | |
| 598 | <View style={{ gap: 8 }}> | |
| 603 | 599 | {(props.node.children ?? []).map((child, index) => ( |
| 604 | <View | |
| 600 | <NativeMarkdownBlock | |
| 605 | 601 | 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 | /> | |
| 624 | 609 | ))} |
| 625 | 610 | </View> |
| 626 | 611 | ); |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx-4| ... | ... | @@ -222,10 +222,6 @@ export function NativeMarkdownSelectableText(props: { |
| 222 | 222 | } |
| 223 | 223 | } |
| 224 | 224 | |
| 225 | if (Platform.OS === "android" && (run.fileIcon || linkIcon)) { | |
| 226 | text = `\u00A0${text}`; | |
| 227 | } | |
| 228 | ||
| 229 | 225 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; |
| 230 | 226 | }); |
| 231 | 227 | // 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 @@ |
| 1 | 1 | import { useMemo } from "react"; |
| 2 | import { Platform, View } from "react-native"; | |
| 2 | import { View } from "react-native"; | |
| 3 | 3 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | |
| 5 | 5 | import { |
| ... | ... | @@ -84,10 +84,8 @@ export function SelectableMarkdownText({ |
| 84 | 84 | the resulting single-line width instead of reflowing it. */} |
| 85 | 85 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> |
| 86 | 86 | {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. | |
| 89 | 87 | const content = |
| 90 | chunk.kind === "rich" || Platform.OS === "android" ? ( | |
| 88 | chunk.kind === "rich" ? ( | |
| 91 | 89 | <NativeMarkdownBlock |
| 92 | 90 | node={chunk.node} |
| 93 | 91 | skills={skills} |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+5-9| ... | ... | @@ -589,14 +589,6 @@ function appendTable( |
| 589 | 589 | return runs; |
| 590 | 590 | } |
| 591 | 591 | |
| 592 | export 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 | ||
| 600 | 592 | function appendDocumentBlock( |
| 601 | 593 | runs: NativeMarkdownTextRun[], |
| 602 | 594 | node: MarkdownNode, |
| ... | ... | @@ -607,7 +599,11 @@ function appendDocumentBlock( |
| 607 | 599 | const children = node.children ?? []; |
| 608 | 600 | for (const [index, child] of children.entries()) { |
| 609 | 601 | 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 | ); | |
| 611 | 607 | } |
| 612 | 608 | appendDocumentBlock(runs, child, depth); |
| 613 | 609 | } |
apps/mobile/src/lib/nativeMarkdownText.test.ts-6| ... | ... | @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | nativeMarkdownBlockSpacing, | |
| 6 | 5 | nativeMarkdownChunkSpacing, |
| 7 | 6 | nativeMarkdownDocumentChunks, |
| 8 | 7 | nativeMarkdownDocumentRuns, |
| ... | ... | @@ -382,11 +381,6 @@ describe("nativeMarkdownDocumentRuns", () => { |
| 382 | 381 | .filter((run) => run.role === "spacer") |
| 383 | 382 | .map((run) => run.spacing), |
| 384 | 383 | ).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]); | |
| 390 | 384 | }); |
| 391 | 385 | |
| 392 | 386 | 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. |
| 6 | 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer |
| 7 | 7 | so you can shorten them or split them into several messages. |
| 8 | 8 | |
| 9 | On Android, long-press message text to select within a paragraph or list item. | |
| 10 | Use the message’s copy button to copy the whole message. | |
| 11 | ||
| 12 | 9 | ## Attach files |
| 13 | 10 | |
| 14 | 11 | Attach up to eight files per message. Images can be up to 10 MB; other files can |