fix(app): native iOS text selection in assistant messages via UITextView

Fixes #238
Fixes #648
Refs #21
This commit is contained in:
Li Mu Zhi
2026-05-27 04:55:46 +00:00
committed by GitHub
parent 1908ab8765
commit 8aa1530be1
8 changed files with 134 additions and 16 deletions
+13
View File
@@ -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",
+1
View File
@@ -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",
@@ -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 ? (
<Text style={innerTextStyle}>
<MarkdownTextSpan style={innerTextStyle}>
{keyedLines.map((line, lineIndex) => (
<React.Fragment key={line.key}>
{lineIndex > 0 ? "\n" : null}
@@ -128,9 +129,9 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
))}
</React.Fragment>
))}
</Text>
</MarkdownTextSpan>
) : (
<Text style={innerTextStyle}>{code}</Text>
<MarkdownTextSpan style={innerTextStyle}>{code}</MarkdownTextSpan>
)}
<CopyButton getCode={getCode} visible={controlsVisible} />
</View>
@@ -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<TextStyle>;
children: ReactNode;
}
// Android's <Text selectable> 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 (
<Text selectable style={style}>
{children}
</Text>
);
}
interface MarkdownParagraphViewProps {
paragraphStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
// Paragraph stays a <View>, not a <Text>, 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.
// <View> 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 <View style={style}>{children}</View>;
}
+1
View File
@@ -0,0 +1 @@
export * from "./markdown-text.ios";
@@ -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<TextStyle>;
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 (
<UITextView uiTextView selectable style={style}>
{children}
</UITextView>
);
}
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<TextStyle>,
[paragraphStyle],
);
return (
<UITextView uiTextView selectable style={style}>
{children}
</UITextView>
);
}
@@ -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<TextStyle>;
children: ReactNode;
}
// react-native-web renders Text as <span>/<div> 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 <Text style={style}>{children}</Text>;
}
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 <View style={style}>{children}</View>;
}
+2 -13
View File
@@ -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 <Text style={style}>{children}</Text>;
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1542,18 +1543,6 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon
return <View style={style}>{children}</View>;
}
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 <View style={style}>{children}</View>;
}
interface MarkdownListViewProps {
baseStyle: ViewStyle;
spacing: { marginTop: number; marginBottom: number };