fix(app): native iOS text selection in assistant messages via UITextView
Fixes #238 Fixes #648 Refs #21
This commit is contained in:
Generated
+13
@@ -30907,6 +30907,18 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-unistyles": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
|
"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-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.14.0",
|
"react-native-svg": "^15.14.0",
|
||||||
|
"react-native-uitextview": "^2.2.0",
|
||||||
"react-native-unistyles": "^3.2.4",
|
"react-native-unistyles": "^3.2.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.14.0",
|
"react-native-svg": "^15.14.0",
|
||||||
|
"react-native-uitextview": "^2.2.0",
|
||||||
"react-native-unistyles": "^3.2.4",
|
"react-native-unistyles": "^3.2.4",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { Check, Copy } from "lucide-react-native";
|
import { Check, Copy } from "lucide-react-native";
|
||||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
@@ -119,7 +120,7 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
|||||||
onPointerLeave={handlePointerLeave}
|
onPointerLeave={handlePointerLeave}
|
||||||
>
|
>
|
||||||
{keyedLines ? (
|
{keyedLines ? (
|
||||||
<Text style={innerTextStyle}>
|
<MarkdownTextSpan style={innerTextStyle}>
|
||||||
{keyedLines.map((line, lineIndex) => (
|
{keyedLines.map((line, lineIndex) => (
|
||||||
<React.Fragment key={line.key}>
|
<React.Fragment key={line.key}>
|
||||||
{lineIndex > 0 ? "\n" : null}
|
{lineIndex > 0 ? "\n" : null}
|
||||||
@@ -128,9 +129,9 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
|||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Text>
|
</MarkdownTextSpan>
|
||||||
) : (
|
) : (
|
||||||
<Text style={innerTextStyle}>{code}</Text>
|
<MarkdownTextSpan style={innerTextStyle}>{code}</MarkdownTextSpan>
|
||||||
)}
|
)}
|
||||||
<CopyButton getCode={getCode} visible={controlsVisible} />
|
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||||
</View>
|
</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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ViewStyle,
|
ViewStyle,
|
||||||
type TextStyle,
|
type TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
@@ -1527,7 +1528,7 @@ function MarkdownInheritedText({
|
|||||||
() => [inheritedStyles, textStyle, overrideStyle],
|
() => [inheritedStyles, textStyle, overrideStyle],
|
||||||
[inheritedStyles, textStyle, overrideStyle],
|
[inheritedStyles, textStyle, overrideStyle],
|
||||||
);
|
);
|
||||||
return <Text style={style}>{children}</Text>;
|
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkdownListItemContentProps {
|
interface MarkdownListItemContentProps {
|
||||||
@@ -1542,18 +1543,6 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon
|
|||||||
return <View style={style}>{children}</View>;
|
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 {
|
interface MarkdownListViewProps {
|
||||||
baseStyle: ViewStyle;
|
baseStyle: ViewStyle;
|
||||||
spacing: { marginTop: number; marginBottom: number };
|
spacing: { marginTop: number; marginBottom: number };
|
||||||
|
|||||||
Reference in New Issue
Block a user