| author | |
| committer | |
| log | 09d13de4381925fa2a6dea74eff8185fa301e905 |
| tree | ee52e1e7c05918e59cb8b1390fd47e93ae3d2e50 |
| parent | 5f878d2a85807618a4c8571cdef5daa3124672d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>10 files changed, 246 insertions(+), 23 deletions(-)
apps/mobile/modules/t3-markdown-text/android/build.gradle created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | apply plugin: 'com.android.library' | ||
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | ||
| 3 | |||
| 4 | group = 'com.t3tools.markdowntext' | ||
| 5 | version = '0.0.0' | ||
| 6 | |||
| 7 | android { | ||
| 8 | namespace 'expo.modules.t3markdowntext' | ||
| 9 | compileSdk rootProject.ext.compileSdkVersion | ||
| 10 | |||
| 11 | defaultConfig { | ||
| 12 | minSdkVersion rootProject.ext.minSdkVersion | ||
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | dependencies { | ||
| 18 | implementation project(':expo-modules-core') | ||
| 19 | implementation 'com.facebook.react:react-android' | ||
| 20 | } | ||
apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | package expo.modules.t3markdowntext | ||
| 2 | |||
| 3 | import android.content.ClipData | ||
| 4 | import android.content.ClipboardManager | ||
| 5 | import android.content.Context | ||
| 6 | import android.text.Spanned | ||
| 7 | import android.text.style.ReplacementSpan | ||
| 8 | import android.view.ActionMode | ||
| 9 | import android.view.Menu | ||
| 10 | import android.view.MenuItem | ||
| 11 | import android.widget.TextView | ||
| 12 | import com.facebook.react.bridge.ReactContext | ||
| 13 | import com.facebook.react.uimanager.UIManagerHelper | ||
| 14 | import expo.modules.kotlin.modules.Module | ||
| 15 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 16 | import kotlin.math.max | ||
| 17 | import kotlin.math.min | ||
| 18 | |||
| 19 | private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" | ||
| 20 | |||
| 21 | private fun copyTextWithoutInlineImages( | ||
| 22 | text: CharSequence, | ||
| 23 | start: Int, | ||
| 24 | end: Int | ||
| 25 | ): String { | ||
| 26 | if (text !is Spanned) return text.subSequence(start, end).toString() | ||
| 27 | |||
| 28 | return buildString { | ||
| 29 | for (index in start until end) { | ||
| 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]) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | private class SanitizingSelectionActionModeCallback( | ||
| 39 | private val textView: TextView, | ||
| 40 | private val delegate: ActionMode.Callback? | ||
| 41 | ) : ActionMode.Callback { | ||
| 42 | override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean = | ||
| 43 | delegate?.onCreateActionMode(mode, menu) ?: true | ||
| 44 | |||
| 45 | override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = | ||
| 46 | delegate?.onPrepareActionMode(mode, menu) ?: false | ||
| 47 | |||
| 48 | override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||
| 49 | if (item.itemId == android.R.id.copy) { | ||
| 50 | val start = min(textView.selectionStart, textView.selectionEnd) | ||
| 51 | val end = max(textView.selectionStart, textView.selectionEnd) | ||
| 52 | if (start >= 0 && end > start) { | ||
| 53 | val originalText = textView.text.subSequence(start, end).toString() | ||
| 54 | val selectedText = copyTextWithoutInlineImages(textView.text, start, end) | ||
| 55 | if (selectedText != originalText) { | ||
| 56 | val clipboard = | ||
| 57 | textView.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager | ||
| 58 | clipboard.setPrimaryClip(ClipData.newPlainText(null, selectedText)) | ||
| 59 | mode.finish() | ||
| 60 | return true | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | return delegate?.onActionItemClicked(mode, item) ?: false | ||
| 65 | } | ||
| 66 | |||
| 67 | override fun onDestroyActionMode(mode: ActionMode) { | ||
| 68 | delegate?.onDestroyActionMode(mode) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | class T3MarkdownTextSelectionModule : Module() { | ||
| 73 | override fun definition() = ModuleDefinition { | ||
| 74 | Name("T3MarkdownTextSelection") | ||
| 75 | |||
| 76 | Function("installCopySanitizer") { reactTag: Int -> | ||
| 77 | val reactContext = appContext.reactContext as? ReactContext ?: return@Function | ||
| 78 | reactContext.runOnUiQueueThread { | ||
| 79 | val textView = | ||
| 80 | runCatching { | ||
| 81 | UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)?.resolveView(reactTag) | ||
| 82 | } | ||
| 83 | .getOrNull() as? TextView ?: return@runOnUiQueueThread | ||
| 84 | val currentCallback = textView.customSelectionActionModeCallback | ||
| 85 | if (currentCallback is SanitizingSelectionActionModeCallback) { | ||
| 86 | return@runOnUiQueueThread | ||
| 87 | } | ||
| 88 | textView.customSelectionActionModeCallback = | ||
| 89 | SanitizingSelectionActionModeCallback(textView, currentCallback) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
apps/mobile/modules/t3-markdown-text/expo-module.config.json created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["android"], | ||
| 3 | "android": { | ||
| 4 | "modules": ["expo.modules.t3markdowntext.T3MarkdownTextSelectionModule"] | ||
| 5 | } | ||
| 6 | } | ||
apps/mobile/modules/t3-markdown-text/package.json+3| ... | @@ -4,7 +4,9 @@ | ... | @@ -4,7 +4,9 @@ |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "source": "./index.ts", | 5 | "source": "./index.ts", |
| 6 | "files": [ | 6 | "files": [ |
| 7 | "android", | ||
| 7 | "assets", | 8 | "assets", |
| 9 | "expo-module.config.json", | ||
| 8 | "ios", | 10 | "ios", |
| 9 | "src", | 11 | "src", |
| 10 | "index.ts", | 12 | "index.ts", |
| ... | @@ -28,6 +30,7 @@ | ... | @@ -28,6 +30,7 @@ |
| 28 | "peerDependencies": { | 30 | "peerDependencies": { |
| 29 | "@t3tools/client-runtime": "*", | 31 | "@t3tools/client-runtime": "*", |
| 30 | "@t3tools/shared": "*", | 32 | "@t3tools/shared": "*", |
| 33 | "expo": "*", | ||
| 31 | "expo-asset": "*", | 34 | "expo-asset": "*", |
| 32 | "expo-clipboard": "*", | 35 | "expo-clipboard": "*", |
| 33 | "expo-haptics": "*", | 36 | "expo-haptics": "*", |
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+12-5| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import React from "react"; | 1 | import React, { type Ref } from "react"; |
| 2 | import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; | 2 | import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; |
| 3 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; | 3 | import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; |
| 4 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; | 4 | import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; |
| ... | @@ -33,6 +33,7 @@ export type ContextMenuActionEvent = { | ... | @@ -33,6 +33,7 @@ export type ContextMenuActionEvent = { |
| 33 | * while the React Native Text fallback reports measured `TextLayoutLine`s. | 33 | * while the React Native Text fallback reports measured `TextLayoutLine`s. |
| 34 | */ | 34 | */ |
| 35 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { | 35 | export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { |
| 36 | nativeTextRef?: Ref<RNText>; | ||
| 36 | uiTextView?: boolean; | 37 | uiTextView?: boolean; |
| 37 | contextMenuConfig?: string; | 38 | contextMenuConfig?: string; |
| 38 | onContextMenuAction?: (event: ContextMenuActionEvent) => void; | 39 | onContextMenuAction?: (event: ContextMenuActionEvent) => void; |
| ... | @@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { | ... | @@ -45,7 +46,12 @@ export type MarkdownTextPrimitiveProps = Omit<TextProps, "onTextLayout"> & { |
| 45 | onSelectionChange?: (event: SelectionChangeEvent) => void; | 46 | onSelectionChange?: (event: SelectionChangeEvent) => void; |
| 46 | }; | 47 | }; |
| 47 | 48 | ||
| 48 | function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { | 49 | function MarkdownTextPrimitiveChild({ |
| 50 | style, | ||
| 51 | children, | ||
| 52 | nativeTextRef: _nativeTextRef, | ||
| 53 | ...rest | ||
| 54 | }: MarkdownTextPrimitiveProps) { | ||
| 49 | const [isAncestor, rootStyle] = useTextAncestorContext(); | 55 | const [isAncestor, rootStyle] = useTextAncestorContext(); |
| 50 | 56 | ||
| 51 | // Flatten the styles, and apply the root styles when needed | 57 | // Flatten the styles, and apply the root styles when needed |
| ... | @@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr | ... | @@ -97,21 +103,22 @@ function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPr |
| 97 | return <>{nativeChildren}</>; | 103 | return <>{nativeChildren}</>; |
| 98 | } | 104 | } |
| 99 | 105 | ||
| 100 | function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { | 106 | function MarkdownTextPrimitiveInner({ nativeTextRef, ...props }: MarkdownTextPrimitiveProps) { |
| 101 | const [isAncestor] = useTextAncestorContext(); | 107 | const [isAncestor] = useTextAncestorContext(); |
| 102 | 108 | ||
| 103 | // Even if the uiTextView prop is set, we can still default to using | 109 | // Even if the uiTextView prop is set, we can still default to using |
| 104 | // normal selection (i.e. base RN text) if the text doesn't need to be | 110 | // normal selection (i.e. base RN text) if the text doesn't need to be |
| 105 | // selectable | 111 | // selectable |
| 106 | if ((!props.selectable || !props.uiTextView) && !isAncestor) { | 112 | if ((!props.selectable || !props.uiTextView) && !isAncestor) { |
| 107 | return <RNText {...props} />; | 113 | return <RNText ref={nativeTextRef} {...props} />; |
| 108 | } | 114 | } |
| 109 | return <MarkdownTextPrimitiveChild {...props} />; | 115 | return <MarkdownTextPrimitiveChild {...props} />; |
| 110 | } | 116 | } |
| 111 | 117 | ||
| 112 | export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { | 118 | export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { |
| 113 | if (Platform.OS !== "ios") { | 119 | if (Platform.OS !== "ios") { |
| 114 | return <RNText {...props} />; | 120 | const { nativeTextRef, ...textProps } = props; |
| 121 | return <RNText ref={nativeTextRef} {...textProps} />; | ||
| 115 | } | 122 | } |
| 116 | return <MarkdownTextPrimitiveInner {...props} />; | 123 | return <MarkdownTextPrimitiveInner {...props} />; |
| 117 | } | 124 | } |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+17-6| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { createContext, useContext, useEffect, useState } from "react"; | 1 | import { createContext, useContext, useEffect, useState } from "react"; |
| 2 | import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; | 2 | import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native"; |
| 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | ||
| 5 | import { CopyTextButton } from "./CopyTextButton"; | 5 | import { CopyTextButton } from "./CopyTextButton"; |
| ... | @@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; | ... | @@ -22,6 +22,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; |
| 22 | const highlightedCodeCache = new Map<string, HighlightedCode>(); | 22 | const highlightedCodeCache = new Map<string, HighlightedCode>(); |
| 23 | const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>(); | 23 | const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>(); |
| 24 | const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; | 24 | const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; |
| 25 | const MONO_FONT_FAMILY = Platform.select({ | ||
| 26 | ios: "ui-monospace", | ||
| 27 | android: "monospace", | ||
| 28 | default: "monospace", | ||
| 29 | }); | ||
| 25 | 30 | ||
| 26 | function nodeKey(node: MarkdownNode, index: number): string { | 31 | function nodeKey(node: MarkdownNode, index: number): string { |
| 27 | return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; | 32 | return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; |
| ... | @@ -171,7 +176,7 @@ function HighlightedCodeText(props: { | ... | @@ -171,7 +176,7 @@ function HighlightedCodeText(props: { |
| 171 | selectable | 176 | selectable |
| 172 | style={{ | 177 | style={{ |
| 173 | color: props.textStyle.codeColor, | 178 | color: props.textStyle.codeColor, |
| 174 | fontFamily: "ui-monospace", | 179 | fontFamily: MONO_FONT_FAMILY, |
| 175 | fontSize: codeBlockFontSize(props.textStyle), | 180 | fontSize: codeBlockFontSize(props.textStyle), |
| 176 | lineHeight: codeBlockLineHeight(props.textStyle), | 181 | lineHeight: codeBlockLineHeight(props.textStyle), |
| 177 | }} | 182 | }} |
| ... | @@ -206,7 +211,7 @@ function HighlightedCodeText(props: { | ... | @@ -206,7 +211,7 @@ function HighlightedCodeText(props: { |
| 206 | selectable | 211 | selectable |
| 207 | style={{ | 212 | style={{ |
| 208 | color: props.textStyle.codeColor, | 213 | color: props.textStyle.codeColor, |
| 209 | fontFamily: "ui-monospace", | 214 | fontFamily: MONO_FONT_FAMILY, |
| 210 | fontSize: codeBlockFontSize(props.textStyle), | 215 | fontSize: codeBlockFontSize(props.textStyle), |
| 211 | lineHeight: codeBlockLineHeight(props.textStyle), | 216 | lineHeight: codeBlockLineHeight(props.textStyle), |
| 212 | }} | 217 | }} |
| ... | @@ -218,7 +223,7 @@ function HighlightedCodeText(props: { | ... | @@ -218,7 +223,7 @@ function HighlightedCodeText(props: { |
| 218 | key={key} | 223 | key={key} |
| 219 | style={{ | 224 | style={{ |
| 220 | color: token.color ?? props.textStyle.codeColor, | 225 | color: token.color ?? props.textStyle.codeColor, |
| 221 | fontFamily: "ui-monospace", | 226 | fontFamily: MONO_FONT_FAMILY, |
| 222 | fontStyle: | 227 | fontStyle: |
| 223 | token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", | 228 | token.fontStyle !== null && (token.fontStyle & 1) === 1 ? "italic" : "normal", |
| 224 | fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", | 229 | fontWeight: token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400", |
| ... | @@ -274,7 +279,7 @@ function NativeCodeBlock(props: { | ... | @@ -274,7 +279,7 @@ function NativeCodeBlock(props: { |
| 274 | style={{ | 279 | style={{ |
| 275 | flex: 1, | 280 | flex: 1, |
| 276 | color: props.textStyle.mutedColor, | 281 | color: props.textStyle.mutedColor, |
| 277 | fontFamily: "ui-monospace", | 282 | fontFamily: MONO_FONT_FAMILY, |
| 278 | fontSize: codeBlockFontSize(props.textStyle), | 283 | fontSize: codeBlockFontSize(props.textStyle), |
| 279 | }} | 284 | }} |
| 280 | > | 285 | > |
| ... | @@ -294,6 +299,7 @@ function NativeCodeBlock(props: { | ... | @@ -294,6 +299,7 @@ function NativeCodeBlock(props: { |
| 294 | <ScrollView | 299 | <ScrollView |
| 295 | horizontal | 300 | horizontal |
| 296 | bounces={false} | 301 | bounces={false} |
| 302 | nestedScrollEnabled={Platform.OS === "android"} | ||
| 297 | showsHorizontalScrollIndicator={false} | 303 | showsHorizontalScrollIndicator={false} |
| 298 | contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }} | 304 | contentContainerStyle={{ paddingHorizontal: 14, paddingVertical: 12 }} |
| 299 | > | 305 | > |
| ... | @@ -330,7 +336,12 @@ function NativeTable(props: { | ... | @@ -330,7 +336,12 @@ function NativeTable(props: { |
| 330 | }) { | 336 | }) { |
| 331 | const rows = collectTableRows(props.node); | 337 | const rows = collectTableRows(props.node); |
| 332 | return ( | 338 | return ( |
| 333 | <ScrollView horizontal bounces={false} showsHorizontalScrollIndicator={false}> | 339 | <ScrollView |
| 340 | horizontal | ||
| 341 | bounces={false} | ||
| 342 | nestedScrollEnabled={Platform.OS === "android"} | ||
| 343 | showsHorizontalScrollIndicator={false} | ||
| 344 | > | ||
| 334 | <View | 345 | <View |
| 335 | style={{ | 346 | style={{ |
| 336 | borderColor: props.textStyle.dividerColor, | 347 | borderColor: props.textStyle.dividerColor, |
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+55-10| ... | @@ -1,5 +1,14 @@ | ... | @@ -1,5 +1,14 @@ |
| 1 | import { createContext, useContext } from "react"; | 1 | import { createContext, useCallback, useContext } from "react"; |
| 2 | import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; | 2 | import { |
| 3 | findNodeHandle, | ||
| 4 | Image, | ||
| 5 | Linking, | ||
| 6 | Platform, | ||
| 7 | StyleSheet, | ||
| 8 | Text as RNText, | ||
| 9 | type TextStyle, | ||
| 10 | useColorScheme, | ||
| 11 | } from "react-native"; | ||
| 3 | 12 | ||
| 4 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; | 13 | import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; |
| 5 | import { markdownFileIconSource } from "./markdownFileIcons"; | 14 | import { markdownFileIconSource } from "./markdownFileIcons"; |
| ... | @@ -8,6 +17,7 @@ import type { | ... | @@ -8,6 +17,7 @@ import type { |
| 8 | MarkdownFileContextMenu, | 17 | MarkdownFileContextMenu, |
| 9 | NativeMarkdownTextStyle, | 18 | NativeMarkdownTextStyle, |
| 10 | } from "./SelectableMarkdownText.types"; | 19 | } from "./SelectableMarkdownText.types"; |
| 20 | import { installMarkdownCopySanitizer } from "./T3MarkdownTextSelectionModule"; | ||
| 11 | 21 | ||
| 12 | export interface MarkdownFileContextMenuHandlers { | 22 | export interface MarkdownFileContextMenuHandlers { |
| 13 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; | 23 | readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined; |
| ... | @@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ "; | ... | @@ -23,6 +33,19 @@ const EXTERNAL_LINK_PREFIX = "◉ "; |
| 23 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; | 33 | const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0"; |
| 24 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; | 34 | const SKILL_ICON_PLACEHOLDER = "\uFFFC"; |
| 25 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; | 35 | const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; |
| 36 | const MONO_FONT_FAMILY = Platform.select({ | ||
| 37 | ios: "ui-monospace", | ||
| 38 | android: "monospace", | ||
| 39 | default: "monospace", | ||
| 40 | }); | ||
| 41 | const styles = StyleSheet.create({ | ||
| 42 | inlineIcon: { | ||
| 43 | width: 14, | ||
| 44 | height: 14, | ||
| 45 | marginHorizontal: 3, | ||
| 46 | transform: [{ translateY: 2 }], | ||
| 47 | }, | ||
| 48 | }); | ||
| 26 | 49 | ||
| 27 | function runKeySignature(run: NativeMarkdownTextRun): string { | 50 | function runKeySignature(run: NativeMarkdownTextRun): string { |
| 28 | return [ | 51 | return [ |
| ... | @@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle | ... | @@ -102,7 +125,7 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle |
| 102 | isFile || isSkill | 125 | isFile || isSkill |
| 103 | ? textStyle.boldFontFamily | 126 | ? textStyle.boldFontFamily |
| 104 | : run.code || isCodeBlock | 127 | : run.code || isCodeBlock |
| 105 | ? "ui-monospace" | 128 | ? MONO_FONT_FAMILY |
| 106 | : isHeading | 129 | : isHeading |
| 107 | ? textStyle.headingFontFamily | 130 | ? textStyle.headingFontFamily |
| 108 | : run.bold | 131 | : run.bold |
| ... | @@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -154,6 +177,19 @@ export function NativeMarkdownSelectableText(props: { |
| 154 | }) { | 177 | }) { |
| 155 | const colorScheme = useColorScheme(); | 178 | const colorScheme = useColorScheme(); |
| 156 | const menu = useContext(MarkdownFileContextMenuContext); | 179 | const menu = useContext(MarkdownFileContextMenuContext); |
| 180 | const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); | ||
| 181 | const attachAndroidText = useCallback( | ||
| 182 | (textView: RNText | null) => { | ||
| 183 | if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { | ||
| 184 | return; | ||
| 185 | } | ||
| 186 | const reactTag = findNodeHandle(textView); | ||
| 187 | if (reactTag !== null) { | ||
| 188 | installMarkdownCopySanitizer(reactTag); | ||
| 189 | } | ||
| 190 | }, | ||
| 191 | [containsInlineFileIcon], | ||
| 192 | ); | ||
| 157 | const occurrences = new Map<string, number>(); | 193 | const occurrences = new Map<string, number>(); |
| 158 | const prefixedExternalLinks = new Set<string>(); | 194 | const prefixedExternalLinks = new Set<string>(); |
| 159 | const keyedRuns = props.runs.map((run) => { | 195 | const keyedRuns = props.runs.map((run) => { |
| ... | @@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -162,10 +198,13 @@ export function NativeMarkdownSelectableText(props: { |
| 162 | occurrences.set(signature, occurrence + 1); | 198 | occurrences.set(signature, occurrence + 1); |
| 163 | 199 | ||
| 164 | let text = run.text; | 200 | let text = run.text; |
| 165 | if (run.fileIcon) { | 201 | if (run.fileIcon && Platform.OS === "ios") { |
| 166 | text = `${INLINE_ATTACHMENT_PREFIX}${text}`; | 202 | text = `${INLINE_ATTACHMENT_PREFIX}${text}`; |
| 167 | } else if (run.skillName && run.skillLabel) { | 203 | } else if (run.skillName && run.skillLabel) { |
| 168 | text = `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}`; | 204 | text = |
| 205 | Platform.OS === "ios" | ||
| 206 | ? `${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}` | ||
| 207 | : `$${run.skillName}`; | ||
| 169 | } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { | 208 | } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { |
| 170 | prefixedExternalLinks.add(run.href); | 209 | prefixedExternalLinks.add(run.href); |
| 171 | text = `${EXTERNAL_LINK_PREFIX}${text}`; | 210 | text = `${EXTERNAL_LINK_PREFIX}${text}`; |
| ... | @@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -197,6 +236,7 @@ export function NativeMarkdownSelectableText(props: { |
| 197 | return ( | 236 | return ( |
| 198 | <MarkdownTextPrimitive | 237 | <MarkdownTextPrimitive |
| 199 | key={appearanceKey} | 238 | key={appearanceKey} |
| 239 | nativeTextRef={attachAndroidText} | ||
| 200 | uiTextView | 240 | uiTextView |
| 201 | selectable | 241 | selectable |
| 202 | style={{ | 242 | style={{ |
| ... | @@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -215,11 +255,13 @@ export function NativeMarkdownSelectableText(props: { |
| 215 | <MarkdownTextPrimitive | 255 | <MarkdownTextPrimitive |
| 216 | key={key} | 256 | key={key} |
| 217 | nativeID={ | 257 | nativeID={ |
| 218 | run.fileIcon | 258 | Platform.OS === "ios" |
| 219 | ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` | 259 | ? run.fileIcon |
| 220 | : run.skillName | 260 | ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` |
| 221 | ? "t3-skill:sf:cube" | 261 | : run.skillName |
| 222 | : undefined | 262 | ? "t3-skill:sf:cube" |
| 263 | : undefined | ||
| 264 | : undefined | ||
| 223 | } | 265 | } |
| 224 | contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} | 266 | contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} |
| 225 | style={runStyle(run, props.textStyle)} | 267 | style={runStyle(run, props.textStyle)} |
| ... | @@ -240,6 +282,9 @@ export function NativeMarkdownSelectableText(props: { | ... | @@ -240,6 +282,9 @@ export function NativeMarkdownSelectableText(props: { |
| 240 | : undefined | 282 | : undefined |
| 241 | } | 283 | } |
| 242 | > | 284 | > |
| 285 | {Platform.OS === "android" && run.fileIcon ? ( | ||
| 286 | <Image source={markdownFileIconSource(run.fileIcon)} style={styles.inlineIcon} /> | ||
| 287 | ) : null} | ||
| 243 | {text} | 288 | {text} |
| 244 | </MarkdownTextPrimitive> | 289 | </MarkdownTextPrimitive> |
| 245 | ); | 290 | ); |
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextSelectionModule.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { requireOptionalNativeModule } from "expo"; | ||
| 2 | |||
| 3 | interface T3MarkdownTextSelectionNativeModule { | ||
| 4 | readonly installCopySanitizer: (reactTag: number) => void; | ||
| 5 | } | ||
| 6 | |||
| 7 | const nativeModule = | ||
| 8 | requireOptionalNativeModule<T3MarkdownTextSelectionNativeModule>("T3MarkdownTextSelection"); | ||
| 9 | |||
| 10 | export function installMarkdownCopySanitizer(reactTag: number): void { | ||
| 11 | nativeModule?.installCopySanitizer(reactTag); | ||
| 12 | } | ||
apps/mobile/src/native/SelectableMarkdownText.android.tsx created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { | ||
| 2 | SelectableMarkdownText as T3SelectableMarkdownText, | ||
| 3 | type SelectableMarkdownTextProps, | ||
| 4 | } from "@t3tools/mobile-markdown-text/renderer"; | ||
| 5 | |||
| 6 | import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; | ||
| 7 | |||
| 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; | ||
| 9 | |||
| 10 | export type { | ||
| 11 | MarkdownImageRequest, | ||
| 12 | NativeMarkdownTextStyle, | ||
| 13 | SelectableMarkdownSkill, | ||
| 14 | } from "@t3tools/mobile-markdown-text/types"; | ||
| 15 | |||
| 16 | // The renderer falls back to React Native Text outside iOS, so Android can use | ||
| 17 | // the same Markdown chunking while retaining native text selection. | ||
| 18 | export function hasNativeSelectableMarkdownText(): boolean { | ||
| 19 | return true; | ||
| 20 | } | ||
| 21 | |||
| 22 | export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { | ||
| 23 | return <T3SelectableMarkdownText {...props} highlightCode={highlightCodeSnippet} />; | ||
| 24 | } | ||
pnpm-lock.yaml+4-2| ... | @@ -278,7 +278,7 @@ importers: | ... | @@ -278,7 +278,7 @@ importers: |
| 278 | version: link:../../packages/contracts | 278 | version: link:../../packages/contracts |
| 279 | '@t3tools/mobile-markdown-text': | 279 | '@t3tools/mobile-markdown-text': |
| 280 | specifier: file:./modules/t3-markdown-text | 280 | specifier: file:./modules/t3-markdown-text |
| 281 | version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919) | 281 | version: file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a) |
| 282 | '@t3tools/mobile-review-diff-native': | 282 | '@t3tools/mobile-review-diff-native': |
| 283 | specifier: file:./modules/t3-review-diff | 283 | specifier: file:./modules/t3-review-diff |
| 284 | version: file:apps/mobile/modules/t3-review-diff | 284 | version: file:apps/mobile/modules/t3-review-diff |
| ... | @@ -4513,6 +4513,7 @@ packages: | ... | @@ -4513,6 +4513,7 @@ packages: |
| 4513 | peerDependencies: | 4513 | peerDependencies: |
| 4514 | '@t3tools/client-runtime': '*' | 4514 | '@t3tools/client-runtime': '*' |
| 4515 | '@t3tools/shared': '*' | 4515 | '@t3tools/shared': '*' |
| 4516 | expo: '*' | ||
| 4516 | expo-asset: '*' | 4517 | expo-asset: '*' |
| 4517 | expo-clipboard: '*' | 4518 | expo-clipboard: '*' |
| 4518 | expo-haptics: '*' | 4519 | expo-haptics: '*' |
| ... | @@ -14372,10 +14373,11 @@ snapshots: | ... | @@ -14372,10 +14373,11 @@ snapshots: |
| 14372 | dependencies: | 14373 | dependencies: |
| 14373 | defer-to-connect: 2.0.1 | 14374 | defer-to-connect: 2.0.1 |
| 14374 | 14375 | ||
| 14375 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)': | 14376 | '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(eb96cd030e772ab91fe21f812240338a)': |
| 14376 | dependencies: | 14377 | dependencies: |
| 14377 | '@t3tools/client-runtime': link:packages/client-runtime | 14378 | '@t3tools/client-runtime': link:packages/client-runtime |
| 14378 | '@t3tools/shared': link:packages/shared | 14379 | '@t3tools/shared': link:packages/shared |
| 14380 | expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) | ||
| 14379 | expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | 14381 | expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 14380 | expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 14382 | expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14381 | expo-haptics: 57.0.2(expo@57.0.18) | 14383 | expo-haptics: 57.0.2(expo@57.0.18) |