From 8aa1530be1db0b0f4c96062d353095ca627e6f1f Mon Sep 17 00:00:00 2001 From: Li Mu Zhi Date: Wed, 27 May 2026 12:55:46 +0800 Subject: [PATCH] fix(app): native iOS text selection in assistant messages via UITextView Fixes #238 Fixes #648 Refs #21 --- package-lock.json | 13 ++++++ packages/app/package.json | 1 + .../src/components/highlighted-code-block.tsx | 7 +-- .../src/components/markdown-text.android.tsx | 37 +++++++++++++++ .../app/src/components/markdown-text.d.ts | 1 + .../app/src/components/markdown-text.ios.tsx | 45 +++++++++++++++++++ .../app/src/components/markdown-text.web.tsx | 31 +++++++++++++ packages/app/src/components/message.tsx | 15 +------ 8 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/components/markdown-text.android.tsx create mode 100644 packages/app/src/components/markdown-text.d.ts create mode 100644 packages/app/src/components/markdown-text.ios.tsx create mode 100644 packages/app/src/components/markdown-text.web.tsx diff --git a/package-lock.json b/package-lock.json index 1c77e3afa..b08039262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30907,6 +30907,18 @@ "react-native": "*" } }, + "node_modules/react-native-uitextview": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-native-uitextview/-/react-native-uitextview-2.2.0.tgz", + "integrity": "sha512-Vbv3cTAuyfkYrfsR2YKFsOd9OYgfys+IX5yvvYY7Wd6TrOd6FSGrX93xtQHjeLXV1ds6fDJcFJsuLi3S4Ypr8A==", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-unistyles": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz", @@ -36389,6 +36401,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.14.0", + "react-native-uitextview": "^2.2.0", "react-native-unistyles": "^3.2.4", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", diff --git a/packages/app/package.json b/packages/app/package.json index 2eac34e94..818ad060d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -93,6 +93,7 @@ "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.14.0", + "react-native-uitextview": "^2.2.0", "react-native-unistyles": "^3.2.4", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", diff --git a/packages/app/src/components/highlighted-code-block.tsx b/packages/app/src/components/highlighted-code-block.tsx index b3e1ce3b5..0822f884e 100644 --- a/packages/app/src/components/highlighted-code-block.tsx +++ b/packages/app/src/components/highlighted-code-block.tsx @@ -8,6 +8,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { MarkdownTextSpan } from "@/components/markdown-text"; import * as Clipboard from "expo-clipboard"; import { Check, Copy } from "lucide-react-native"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; @@ -119,7 +120,7 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ onPointerLeave={handlePointerLeave} > {keyedLines ? ( - + {keyedLines.map((line, lineIndex) => ( {lineIndex > 0 ? "\n" : null} @@ -128,9 +129,9 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ ))} ))} - + ) : ( - {code} + {code} )} diff --git a/packages/app/src/components/markdown-text.android.tsx b/packages/app/src/components/markdown-text.android.tsx new file mode 100644 index 000000000..d43d10ed8 --- /dev/null +++ b/packages/app/src/components/markdown-text.android.tsx @@ -0,0 +1,37 @@ +import { useMemo, type ReactNode } from "react"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; + +interface MarkdownTextSpanProps { + style?: StyleProp; + children: ReactNode; +} + +// Android's enables per-text-node selection natively. Each +// sibling Text is its own selection scope — drag can't span across siblings +// (that requires a single UITextView ancestor and is iOS-only). +export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) { + return ( + + {children} + + ); +} + +interface MarkdownParagraphViewProps { + paragraphStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; + +// Paragraph stays a , not a , for layout fidelity. RN Android's +// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan +// in ReactBaseTextShadowNode), so this isn't a crash-avoidance choice — but +// inline-placeholder spans collapse block-level children (e.g. paragraph +// images) into one-character placeholders, which destroys image row layout. +// preserves the original block layout; the trade-off is no cross-span +// selection on Android (a UITextView-style trick has no Android equivalent). +export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) { + const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]); + return {children}; +} diff --git a/packages/app/src/components/markdown-text.d.ts b/packages/app/src/components/markdown-text.d.ts new file mode 100644 index 000000000..00194ac31 --- /dev/null +++ b/packages/app/src/components/markdown-text.d.ts @@ -0,0 +1 @@ +export * from "./markdown-text.ios"; diff --git a/packages/app/src/components/markdown-text.ios.tsx b/packages/app/src/components/markdown-text.ios.tsx new file mode 100644 index 000000000..21b5c3c08 --- /dev/null +++ b/packages/app/src/components/markdown-text.ios.tsx @@ -0,0 +1,45 @@ +import { useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { UITextView } from "react-native-uitextview"; + +interface MarkdownTextSpanProps { + style?: StyleProp; + children: ReactNode; +} + +// Inline span backed by UITextView so iOS gets native word-selection handles. +// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the +// library's TextAncestorContext hoists these into UITextViewChild nodes so +// selection drags can cross sibling spans (e.g. plain text → **bold** → code). +export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) { + return ( + + {children} + + ); +} + +interface MarkdownParagraphViewProps { + paragraphStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; + +// iOS-only: paragraph wraps in UITextView so the entire paragraph is one +// native text view. That's what unlocks cross-inline drag selection — handles +// can span every MarkdownTextSpan child inside this paragraph. +// ViewStyle is structurally compatible with the layout props paragraphs use +// (margin, padding, alignment); the cast lets the existing paragraphStyle +// flow through unchanged. +export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) { + const style = useMemo( + () => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp, + [paragraphStyle], + ); + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/markdown-text.web.tsx b/packages/app/src/components/markdown-text.web.tsx new file mode 100644 index 000000000..146276e3d --- /dev/null +++ b/packages/app/src/components/markdown-text.web.tsx @@ -0,0 +1,31 @@ +import { useMemo, type ReactNode } from "react"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; + +interface MarkdownTextSpanProps { + style?: StyleProp; + children: ReactNode; +} + +// react-native-web renders Text as /
with `user-select: text` +// already applied via markdownStyleMapping. The web bundle must not import +// react-native-uitextview: its transitive import of codegenNativeComponent +// pulls in setUpReactDevTools, which doesn't resolve under Metro's web +// target in dev mode. +export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) { + return {children}; +} + +interface MarkdownParagraphViewProps { + paragraphStyle: ViewStyle; + children: ReactNode; +} + +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; + +// Same shape as Android — paragraph is a View so block-level children (images) +// keep their natural layout. Web text selection already spans nested inline +// elements via CSS user-select, so no UITextView equivalent is needed. +export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) { + const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]); + return {children}; +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 753bb9948..0f0c3a3f4 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -10,6 +10,7 @@ import { ViewStyle, type TextStyle, } from "react-native"; +import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import * as React from "react"; import { useState, @@ -1527,7 +1528,7 @@ function MarkdownInheritedText({ () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); - return {children}; + return {children}; } interface MarkdownListItemContentProps { @@ -1542,18 +1543,6 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon return {children}; } -interface MarkdownParagraphViewProps { - paragraphStyle: ViewStyle; - children: ReactNode; -} - -const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; - -function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) { - const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]); - return {children}; -} - interface MarkdownListViewProps { baseStyle: ViewStyle; spacing: { marginTop: number; marginBottom: number };