| 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,10 +8,6 @@ android { |
| 8 | namespace 'expo.modules.t3markdowntext' | 8 | namespace 'expo.modules.t3markdowntext' |
| 9 | compileSdk rootProject.ext.compileSdkVersion | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | ||
| 11 | testOptions { | ||
| 12 | unitTests.includeAndroidResources = true | ||
| 13 | } | ||
| 14 | |||
| 15 | defaultConfig { | 11 | defaultConfig { |
| 16 | minSdkVersion rootProject.ext.minSdkVersion | 12 | minSdkVersion rootProject.ext.minSdkVersion |
| 17 | targetSdkVersion rootProject.ext.targetSdkVersion | 13 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | @@ -21,12 +17,4 @@ android { | ... | @@ -21,12 +17,4 @@ android { |
| 21 | dependencies { | 17 | dependencies { |
| 22 | implementation project(':expo-modules-core') | 18 | implementation project(':expo-modules-core') |
| 23 | implementation 'com.facebook.react:react-android' | 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,23 +18,19 @@ import kotlin.math.min |
| 18 | 18 | ||
| 19 | private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" | 19 | private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" |
| 20 | 20 | ||
| 21 | internal fun copyTextWithoutInlineImages( | 21 | private fun copyTextWithoutInlineImages( |
| 22 | text: CharSequence, | 22 | text: CharSequence, |
| 23 | start: Int, | 23 | start: Int, |
| 24 | end: Int | 24 | end: Int |
| 25 | ): String { | 25 | ): String { |
| 26 | if (text !is Spanned) return text.subSequence(start, end).toString() | 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 | return buildString { | 28 | return buildString { |
| 33 | for (index in start until end) { | 29 | for (index in start until end) { |
| 34 | // The renderer inserts one NBSP after each image to keep its label on the same line. | 30 | val isInlineImage = |
| 35 | // Inspect the original text even when selection starts after the image. | 31 | text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && |
| 36 | val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) | 32 | text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() |
| 37 | if (!isInlineImage(index) && !isIconSpacer) append(text[index]) | 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,11 +4,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | ||
| 5 | import { CopyTextButton } from "./CopyTextButton"; | 5 | import { CopyTextButton } from "./CopyTextButton"; |
| 6 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | 6 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 7 | import { | 7 | import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; |
| 8 | nativeMarkdownBlockSpacing, | ||
| 9 | nativeMarkdownDocumentRuns, | ||
| 10 | nativeMarkdownListItemBlocks, | ||
| 11 | } from "./nativeMarkdownText"; | ||
| 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; | 8 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; |
| 13 | import type { | 9 | import type { |
| 14 | MarkdownCodeHighlighter, | 10 | MarkdownCodeHighlighter, |
| ... | @@ -599,28 +595,17 @@ export function NativeMarkdownBlock(props: { | ... | @@ -599,28 +595,17 @@ export function NativeMarkdownBlock(props: { |
| 599 | switch (props.node.type) { | 595 | switch (props.node.type) { |
| 600 | case "document": | 596 | case "document": |
| 601 | return ( | 597 | return ( |
| 602 | <View> | 598 | <View style={{ gap: 8 }}> |
| 603 | {(props.node.children ?? []).map((child, index) => ( | 599 | {(props.node.children ?? []).map((child, index) => ( |
| 604 | <View | 600 | <NativeMarkdownBlock |
| 605 | key={nodeKey(child, index)} | 601 | key={nodeKey(child, index)} |
| 606 | style={{ | 602 | node={child} |
| 607 | paddingTop: | 603 | skills={props.skills} |
| 608 | Platform.OS === "android" | 604 | textStyle={props.textStyle} |
| 609 | ? nativeMarkdownBlockSpacing(props.node.children?.[index - 1], child) | 605 | highlightCode={props.highlightCode} |
| 610 | : index > 0 | 606 | onLinkPress={props.onLinkPress} |
| 611 | ? 8 | 607 | depth={depth} |
| 612 | : 0, | 608 | /> |
| 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> | ||
| 624 | ))} | 609 | ))} |
| 625 | </View> | 610 | </View> |
| 626 | ); | 611 | ); |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx-4| ... | @@ -222,10 +222,6 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -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 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; | 225 | return { key: `${signature}:${occurrence}`, run, text, linkIcon }; |
| 230 | }); | 226 | }); |
| 231 | // T3MarkdownText only rebuilds its attributed string during native layout. A | 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,5 +1,5 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { Platform, View } from "react-native"; | 2 | import { View } from "react-native"; |
| 3 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; | 3 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| ... | @@ -84,10 +84,8 @@ export function SelectableMarkdownText({ | ... | @@ -84,10 +84,8 @@ export function SelectableMarkdownText({ |
| 84 | the resulting single-line width instead of reflowing it. */} | 84 | the resulting single-line width instead of reflowing it. */} |
| 85 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | 85 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> |
| 86 | {chunks.map((chunk, index) => { | 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 | const content = | 87 | const content = |
| 90 | chunk.kind === "rich" || Platform.OS === "android" ? ( | 88 | chunk.kind === "rich" ? ( |
| 91 | <NativeMarkdownBlock | 89 | <NativeMarkdownBlock |
| 92 | node={chunk.node} | 90 | node={chunk.node} |
| 93 | skills={skills} | 91 | skills={skills} |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+5-9| ... | @@ -589,14 +589,6 @@ function appendTable( | ... | @@ -589,14 +589,6 @@ function appendTable( |
| 589 | return runs; | 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 | function appendDocumentBlock( | 592 | function appendDocumentBlock( |
| 601 | runs: NativeMarkdownTextRun[], | 593 | runs: NativeMarkdownTextRun[], |
| 602 | node: MarkdownNode, | 594 | node: MarkdownNode, |
| ... | @@ -607,7 +599,11 @@ function appendDocumentBlock( | ... | @@ -607,7 +599,11 @@ function appendDocumentBlock( |
| 607 | const children = node.children ?? []; | 599 | const children = node.children ?? []; |
| 608 | for (const [index, child] of children.entries()) { | 600 | for (const [index, child] of children.entries()) { |
| 609 | if (index > 0) { | 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 | appendDocumentBlock(runs, child, depth); | 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,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | nativeMarkdownBlockSpacing, | ||
| 6 | nativeMarkdownChunkSpacing, | 5 | nativeMarkdownChunkSpacing, |
| 7 | nativeMarkdownDocumentChunks, | 6 | nativeMarkdownDocumentChunks, |
| 8 | nativeMarkdownDocumentRuns, | 7 | nativeMarkdownDocumentRuns, |
| ... | @@ -382,11 +381,6 @@ describe("nativeMarkdownDocumentRuns", () => { | ... | @@ -382,11 +381,6 @@ describe("nativeMarkdownDocumentRuns", () => { |
| 382 | .filter((run) => run.role === "spacer") | 381 | .filter((run) => run.role === "spacer") |
| 383 | .map((run) => run.spacing), | 382 | .map((run) => run.spacing), |
| 384 | ).toEqual([20, 10, 12]); | 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 | it("renders tight list items whose inline nodes are direct children", () => { | 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,9 +6,6 @@ include a skill when the task needs more context. |
| 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer | 6 | Messages can contain up to 120,000 characters. Longer drafts stay in the composer |
| 7 | so you can shorten them or split them into several messages. | 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 | ## Attach files | 9 | ## Attach files |
| 13 | 10 | ||
| 14 | Attach up to eight files per message. Images can be up to 10 MB; other files can | 11 | Attach up to eight files per message. Images can be up to 10 MB; other files can |