merge main in
what are you doing there? go away fix recognizer to clear selected text on tap remove jank/hacks update readme remove android stuff (?) don't remove clipped subview on android for selection enable selection of alt text add numberOfLines properly apply container styles handle both selection and expand press events in alt text far better implementation revert link changes revert lightbox changes for now fix file name
This commit is contained in:
@@ -35,11 +35,7 @@ podspec inside of this directory.
|
||||
## Technical
|
||||
|
||||
Whenever we use the SelectableText component, we then loop over each of the children (or just the text if the child
|
||||
is typeof Text). We create "segments" of text based on this and encode them to JSON* to pass to the native side.
|
||||
|
||||
/* Expo Modules doesn't currently support passing an array over the bridge, but even if it did, it would be parsing
|
||||
sending JSON over the bridge anyway, so this is not an issue. I want to make sure this is the case (on expo support) but
|
||||
regardless there's no real perf concern.
|
||||
is typeof Text). We create "segments" of text based on this.
|
||||
|
||||
The native side renders a *single* UITextView and adds each of the text segments to the view. Using NSAttributedStrings,
|
||||
we can add the styles for each segment individually (falling back to the root styles).
|
||||
@@ -54,7 +50,3 @@ As such, we create a gesture recognizer for the entire UITextView. The recognize
|
||||
the press, determines which segment it is, and sends an `onTextPress` event to the JS side along with the index of the
|
||||
segment. On the JS side, we have our array of segments (that included the `onPress` event from the `Text`/`SelectableText`)
|
||||
that we can now call based on the index from the native event.
|
||||
|
||||
We can't reliably modify the size of the view's container on the native side (*don't take my word for this, I might be -
|
||||
and likely am - wrong about this). Therefore, we send an `onTextLayout` event to the JS thread that we can use to resize
|
||||
the container's height based on the required height to display the text. This only gets called once per text update.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoSelectableTextModule"]
|
||||
"modules": ["ExpoUITextViewModule", "ExpoUITextViewChildModule"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ExpoSelectableTextView from './src/ExpoSelectableTextView'
|
||||
import {ExpoProTextViewProps} from './src/ExpoSelectableText.types'
|
||||
import ExpoSelectableTextView from './src/ExpoUITextView'
|
||||
import {ExpoProTextViewProps} from './src/ExpoUITextView.types'
|
||||
|
||||
export {ExpoSelectableTextView as SelectableText, type ExpoProTextViewProps}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoSelectableTextModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoSelectableText")
|
||||
|
||||
View(ExpoSelectableTextView.self) {
|
||||
Events("onTextPress", "onTextLongPress", "onTextLayout")
|
||||
|
||||
Prop("segments") { (view: ExpoSelectableTextView, prop: String) in
|
||||
// Convert the JSON to segments
|
||||
if let data = prop.data(using: .utf8) {
|
||||
if let segments = try? JSONDecoder().decode(TextSegments.self, from: data) {
|
||||
view.segments = segments.segments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Prop("rootStyle") { (view: ExpoSelectableTextView, prop: String) in
|
||||
if let data = prop.data(using: .utf8) {
|
||||
if let style = try? JSONDecoder().decode(TextStyle.self, from: data) {
|
||||
view.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Prop("selectable") { (view: ExpoSelectableTextView, prop: Bool) in
|
||||
view.textView.isSelectable = prop
|
||||
}
|
||||
|
||||
Prop("children") { (view: ExpoSelectableTextView, prop: JavaScriptValue) in
|
||||
print(prop)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
class ExpoSelectableTextView: ExpoView {
|
||||
var textView: UITextView
|
||||
var segments: Array<TextSegment> = [] {
|
||||
didSet {
|
||||
// We don't want to set the text if the root style has not been set yet
|
||||
self.setText()
|
||||
}
|
||||
}
|
||||
var style: TextStyle? {
|
||||
didSet {
|
||||
// If the text has not been set and there are segments, set the text
|
||||
self.setText()
|
||||
}
|
||||
}
|
||||
|
||||
let onTextPress = EventDispatcher()
|
||||
let onTextLongPress = EventDispatcher()
|
||||
let onTextLayout = EventDispatcher()
|
||||
|
||||
public required init(appContext: AppContext? = nil) {
|
||||
if #available(iOS 16.0, *) {
|
||||
textView = UITextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
textView = UITextView()
|
||||
}
|
||||
|
||||
super.init(appContext: appContext)
|
||||
|
||||
// Configure default appearance
|
||||
textView.scrollsToTop = false
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.backgroundColor = .clear
|
||||
|
||||
// Remove all of the padding from the view
|
||||
textView.textContainerInset = UIEdgeInsets.zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
|
||||
// Add the text view to the root view
|
||||
self.addSubview(textView)
|
||||
|
||||
// Configure the press recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
textView.addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
override func layoutSubviews() -> Void {
|
||||
// Set the textView's frame on layout
|
||||
self.setSize()
|
||||
}
|
||||
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||
if let segment = getPressedSegment(sender), segment.handlePress {
|
||||
if textView.selectedTextRange == nil {
|
||||
onTextPress([
|
||||
"index": segment.index
|
||||
])
|
||||
} else {
|
||||
// Clear the selected text range if we are not pressing on a link
|
||||
textView.selectedTextRange = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setSize() -> Void {
|
||||
// Figure out the height of our text and create a CGRect
|
||||
let maxWidth = bounds.width
|
||||
let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT)))
|
||||
let size = CGSize(width: maxWidth, height: sizeThatFits.height)
|
||||
textView.frame.size = size
|
||||
|
||||
self.onTextLayout([
|
||||
"height": sizeThatFits.height,
|
||||
"width": maxWidth
|
||||
])
|
||||
}
|
||||
|
||||
func setText() -> Void {
|
||||
let finalAttributedString = NSMutableAttributedString()
|
||||
|
||||
self.segments.forEach { segment in
|
||||
// Set some generic attributes that don't need ranges
|
||||
let attributes: [NSAttributedString.Key:Any] = [
|
||||
.font: UIFont.systemFont(ofSize: segment.style?.fontSize ?? self.style?.fontSize ?? 12.0, weight: segment.style?.fontWeight?.toFontWeight() ?? self.style?.fontWeight?.toFontWeight() ?? .regular),
|
||||
.foregroundColor: ExpoSelectableTextUtil.hexToUIColor(hex: segment.style?.color),
|
||||
]
|
||||
|
||||
// Create the attributed string with the generic attributes
|
||||
let string = NSMutableAttributedString(string: segment.text, attributes: attributes)
|
||||
|
||||
// Set the paragraph style attributes if necessary
|
||||
if let lineHeight = segment.style?.lineHeight {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.minimumLineHeight = lineHeight
|
||||
paragraphStyle.maximumLineHeight = lineHeight
|
||||
string.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, string.length))
|
||||
}
|
||||
|
||||
if let textDecorationLine = segment.style?.textDecorationLine {
|
||||
if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough {
|
||||
string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length))
|
||||
}
|
||||
|
||||
if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough {
|
||||
string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length))
|
||||
}
|
||||
}
|
||||
|
||||
finalAttributedString.append(string)
|
||||
}
|
||||
|
||||
textView.attributedText = finalAttributedString
|
||||
textView.selectedTextRange = nil
|
||||
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
|
||||
func getPressedSegment(_ sender: UITapGestureRecognizer) -> TextSegment? {
|
||||
let layoutManager = textView.layoutManager
|
||||
var location = sender.location(in: textView)
|
||||
|
||||
// Remove the padding
|
||||
location.x -= textView.textContainerInset.left
|
||||
location.y -= textView.textContainerInset.top
|
||||
|
||||
// Get the index of the char
|
||||
let charIndex = layoutManager.characterIndex(for: location, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
|
||||
|
||||
let text = textView.attributedText.string
|
||||
var foundSegment: TextSegment?
|
||||
|
||||
// Check each segment
|
||||
self.segments.forEach { segment in
|
||||
let range = text.range(of: segment.text)
|
||||
// Figure out the bounds
|
||||
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
|
||||
if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) {
|
||||
foundSegment = segment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundSegment
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoSelectableText'
|
||||
s.name = 'ExpoUITextView'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Simple wrapper for RN Text to use UITextView instead of UILabel'
|
||||
s.description = 'Simple wrapper for RN Text to use UITextView instead of UILabel'
|
||||
s.summary = 'Drop in replacement for RN Text that uses UITextView'
|
||||
s.description = 'Drop in replacement for RN Text that uses UITextView'
|
||||
s.author = ''
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app/modules/expo-selectable-text'
|
||||
s.platform = :ios, '13.0'
|
||||
@@ -0,0 +1,192 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
class ExpoUITextView: ExpoView {
|
||||
var textView: UITextView
|
||||
var textChildren: [ExpoUITextViewChild] = []
|
||||
|
||||
var tapGestureRecognizer: UITapGestureRecognizer?
|
||||
|
||||
public required init(appContext: AppContext? = nil) {
|
||||
if #available(iOS 16.0, *) {
|
||||
textView = UITextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
textView = UITextView()
|
||||
}
|
||||
|
||||
// Configure default appearance
|
||||
textView.scrollsToTop = false
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
||||
textView.isSelectable = true
|
||||
|
||||
// Remove all of the padding from the view
|
||||
textView.textContainerInset = UIEdgeInsets.zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
|
||||
super.init(appContext: appContext)
|
||||
|
||||
addSubview(textView)
|
||||
|
||||
// Configure the tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tapGestureRecognizer.isEnabled = true
|
||||
self.tapGestureRecognizer = tapGestureRecognizer
|
||||
textView.addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// Update children whenever new react subviews are added
|
||||
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
|
||||
if subview.isKind(of: ExpoUITextViewChild.self) {
|
||||
insertSubview(subview, at: atIndex)
|
||||
self.getTextChildren()
|
||||
}
|
||||
}
|
||||
|
||||
// Do the same whenever subviews are removed
|
||||
override func removeReactSubview(_ subview: UIView!) {
|
||||
if subview.isKind(of: ExpoUITextViewChild.self) {
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func reactSubviews() -> [UIView]! {
|
||||
return subviews
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
// Get the width from the bounds
|
||||
let maxWidth = bounds.width
|
||||
// Calculate the size of the text
|
||||
let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT)))
|
||||
let size = CGSize(width: maxWidth, height: sizeThatFits.height)
|
||||
|
||||
// Set the textview's frame
|
||||
textView.frame.size = size
|
||||
self.appContext?.reactBridge?.uiManager.setSize(size, for: self)
|
||||
|
||||
}
|
||||
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
|
||||
// If we find a child, then call onPress
|
||||
if let child = getPressed(sender) {
|
||||
if textView.selectedTextRange == nil {
|
||||
child.onTextPress()
|
||||
} else {
|
||||
// Clear the selected text range if we are not pressing on a link
|
||||
textView.selectedTextRange = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get the pressed segment
|
||||
func getPressed(_ sender: UITapGestureRecognizer) -> ExpoUITextViewChild? {
|
||||
let layoutManager = textView.layoutManager
|
||||
var location = sender.location(in: textView)
|
||||
|
||||
// Remove the padding
|
||||
location.x -= textView.textContainerInset.left
|
||||
location.y -= textView.textContainerInset.top
|
||||
|
||||
// Get the index of the char
|
||||
let charIndex = layoutManager.characterIndex(
|
||||
for: location,
|
||||
in: textView.textContainer,
|
||||
fractionOfDistanceBetweenInsertionPoints: nil
|
||||
)
|
||||
|
||||
let text = textView.attributedText.string
|
||||
var foundChild: ExpoUITextViewChild?
|
||||
|
||||
// Check each segment
|
||||
self.textChildren.forEach { child in
|
||||
let range = text.range(of: child.text ?? "")
|
||||
// Figure out the bounds
|
||||
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
|
||||
if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) {
|
||||
foundChild = child
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return foundChild
|
||||
}
|
||||
|
||||
// Get the children. Always use getTextChildren() so that we ensure the correct order of views
|
||||
func getTextChildren() -> Void {
|
||||
var children: [ExpoUITextViewChild] = []
|
||||
|
||||
self.reactSubviews().forEach { view in
|
||||
if view.isKind(of: ExpoUITextViewChild.self) {
|
||||
children.append(view as! ExpoUITextViewChild)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the children for our onPress handler
|
||||
self.textChildren = children
|
||||
// Update the UITextView with the styled text
|
||||
self.setText()
|
||||
}
|
||||
|
||||
func setText() -> Void {
|
||||
// Create an attributed string to store each of the segments
|
||||
let finalAttributedString = NSMutableAttributedString()
|
||||
|
||||
self.textChildren.forEach { child in
|
||||
// If we don't have any text in this child, move to the next one
|
||||
guard let text = child.text else {
|
||||
return
|
||||
}
|
||||
|
||||
// Set some generic attributes that don't need ranges
|
||||
let attributes: [NSAttributedString.Key:Any] = [
|
||||
.font: UIFont.systemFont(
|
||||
ofSize: child.style?.fontSize ?? 12.0,
|
||||
weight: child.style?.fontWeight?.toFontWeight() ?? .regular
|
||||
),
|
||||
.foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color),
|
||||
]
|
||||
|
||||
// Create the attributed string with the generic attributes
|
||||
let string = NSMutableAttributedString(string: text, attributes: attributes)
|
||||
|
||||
// Set the paragraph style attributes if necessary
|
||||
if let lineHeight = child.style?.lineHeight {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.minimumLineHeight = lineHeight
|
||||
paragraphStyle.maximumLineHeight = lineHeight
|
||||
string.addAttribute(
|
||||
NSAttributedString.Key.paragraphStyle,
|
||||
value: paragraphStyle,
|
||||
range: NSMakeRange(0, string.length)
|
||||
)
|
||||
}
|
||||
|
||||
if let textDecorationLine = child.style?.textDecorationLine {
|
||||
if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough {
|
||||
string.addAttribute(
|
||||
NSAttributedString.Key.underlineStyle,
|
||||
value: NSUnderlineStyle.single.rawValue,
|
||||
range: NSMakeRange(0, string.length)
|
||||
)
|
||||
}
|
||||
|
||||
if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough {
|
||||
string.addAttribute(
|
||||
NSAttributedString.Key.strikethroughStyle,
|
||||
value: NSUnderlineStyle.single.rawValue,
|
||||
range: NSMakeRange(0, string.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
finalAttributedString.append(string)
|
||||
}
|
||||
|
||||
textView.attributedText = finalAttributedString
|
||||
textView.selectedTextRange = nil
|
||||
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
class ExpoUITextViewChild: ExpoView {
|
||||
var text: String?
|
||||
var style: TextStyle?
|
||||
let onTextPress = EventDispatcher()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoUITextViewModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoSelectableText")
|
||||
|
||||
View(ExpoUITextView.self) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Children should have parity with React Native text props. The difference is that we will use the text prop for the text rather than getting it from children.
|
||||
*/
|
||||
public class ExpoUITextViewChildModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoTextChild")
|
||||
|
||||
View(ExpoUITextViewChild.self) {
|
||||
Events("onTextPress")
|
||||
|
||||
Prop("text") { (view: ExpoUITextViewChild, prop: String) in
|
||||
view.text = prop
|
||||
}
|
||||
|
||||
Prop("textStyle") { (view: ExpoUITextViewChild, prop: TextStyle) in
|
||||
view.style = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
struct TextSegments: Decodable {
|
||||
let segments: Array<TextSegment>
|
||||
struct TextSegments: Record {
|
||||
@Field
|
||||
var segments: Array<TextSegment>
|
||||
}
|
||||
|
||||
struct TextSegment: Decodable {
|
||||
let index: Int
|
||||
let text: String
|
||||
let style: TextStyle?
|
||||
let handlePress: Bool
|
||||
let handleLongPress: Bool
|
||||
struct TextSegment: Record {
|
||||
@Field
|
||||
var index: Int
|
||||
@Field
|
||||
var text: String
|
||||
@Field
|
||||
var style: TextStyle?
|
||||
}
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
struct TextStyle: Decodable {
|
||||
import ExpoModulesCore
|
||||
|
||||
struct TextStyle: Record {
|
||||
@Field
|
||||
var color: String? = "black"
|
||||
@Field
|
||||
var fontSize: CGFloat? = 12
|
||||
@Field
|
||||
var fontStyle: String? = "normal"
|
||||
@Field
|
||||
var fontWeight: FontWeight?
|
||||
@Field
|
||||
var letterSpacing: Double?
|
||||
@Field
|
||||
var textAlign: String? = "auto"
|
||||
@Field
|
||||
var lineHeight: Double?
|
||||
@Field
|
||||
var textDecorationLine: TextDecorationLine?
|
||||
@Field
|
||||
var flex: Int?
|
||||
@Field
|
||||
var pointerEvents: String?
|
||||
}
|
||||
|
||||
enum FontWeight: String, Decodable {
|
||||
enum FontWeight: String, Enumerable {
|
||||
case bold
|
||||
case normal
|
||||
case one = "100"
|
||||
@@ -51,10 +64,9 @@ enum FontWeight: String, Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
enum TextDecorationLine: String, Decodable {
|
||||
enum TextDecorationLine: String, Enumerable {
|
||||
case underline
|
||||
case lineThrough = "line-through"
|
||||
case underlineLineThrough = "underline line-through"
|
||||
|
||||
case normal
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
public class ExpoSelectableTextUtil {
|
||||
public class TextUtil {
|
||||
public static func hexToUIColor(hex: String?) -> UIColor {
|
||||
guard let hex else {
|
||||
return UIColor.black
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
import {TextStyle, ViewStyle} from 'react-native'
|
||||
|
||||
interface ExpoProTextViewCommonProps {
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
export interface ExpoProTextViewProps extends ExpoProTextViewCommonProps {
|
||||
children: React.ReactNode
|
||||
style?: TextStyle
|
||||
onPress?: () => void
|
||||
onLongPress?: () => void
|
||||
}
|
||||
|
||||
export interface ExpoProTextNativeViewProps extends ExpoProTextViewCommonProps {
|
||||
segments: string
|
||||
textStyle?: TextStyle
|
||||
onTextPress?: (event: ExpoProTextPressEvent) => void
|
||||
onTextLongPress?: (event: ExpoProTextPressEvent) => void
|
||||
onTextLayout?: (event: ExpoProTextLayoutEvent) => void
|
||||
disableLongPress: boolean
|
||||
style: ViewStyle
|
||||
rootStyle?: string
|
||||
}
|
||||
|
||||
export interface ExpoProTextLayoutEvent {
|
||||
nativeEvent: {
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExpoProTextPressEvent {
|
||||
nativeEvent: {
|
||||
index: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExpoProTextSegment {
|
||||
index: number
|
||||
text: string
|
||||
style?: TextStyle
|
||||
handlePress?: boolean
|
||||
handleLongPress?: boolean
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
ExpoProTextLayoutEvent,
|
||||
ExpoProTextNativeViewProps,
|
||||
ExpoProTextPressEvent,
|
||||
ExpoProTextSegment,
|
||||
ExpoProTextViewProps,
|
||||
} from './ExpoSelectableText.types'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {onTextLinkPress, TextLink} from 'view/com/util/Link'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useModalControls} from 'state/modals'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoProTextNativeViewProps> =
|
||||
requireNativeViewManager('ExpoSelectableText')
|
||||
|
||||
export default function ExpoSelectableTextView({
|
||||
style,
|
||||
children,
|
||||
selectable = true,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}: ExpoProTextViewProps) {
|
||||
// Dimensions based on the native view's text height
|
||||
const [dims, setDims] = React.useState({height: 0})
|
||||
|
||||
// Needed for navigation on link presses
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
|
||||
// Store the callbacks for onPress and onLongPress events
|
||||
const segmentPressCallbacks = React.useRef<
|
||||
Array<{index: number; onPress: () => void}>
|
||||
>([])
|
||||
const segmentLongPressCallbacks = React.useRef<
|
||||
Array<{index: number; onLongPress: () => void}>
|
||||
>([])
|
||||
|
||||
// The root style, stringified
|
||||
const rootStyle = React.useMemo(() => {
|
||||
return style ? JSON.stringify(style) : undefined
|
||||
}, [style])
|
||||
|
||||
// The text segments, stringified
|
||||
const textSegments = React.useMemo(() => {
|
||||
const segments: ExpoProTextSegment[] = []
|
||||
|
||||
for (const [index, child] of React.Children.toArray(children).entries()) {
|
||||
// Most of our children will be strings. Simply add them to the segments array.
|
||||
if (typeof child === 'string') {
|
||||
segments.push({
|
||||
index,
|
||||
text: child,
|
||||
style: style,
|
||||
handlePress: onPress !== undefined,
|
||||
handleLongPress: onLongPress !== undefined,
|
||||
})
|
||||
} else if (React.isValidElement(child)) {
|
||||
// If it is a child, it is either a nested <Text> or a <TextLink>. Check if the child is a string or a <TextLink>
|
||||
// If it's a <TextLink> we need to create on the onPress handler (it won't be created in the component since the
|
||||
// component never actually gets rendered)
|
||||
|
||||
const {
|
||||
children: innerChildren,
|
||||
onLongPress: innerOnLongPress,
|
||||
style: innerStyle,
|
||||
text: innerText,
|
||||
href,
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
} = child.props
|
||||
let innerOnPress = child.props.onPress
|
||||
|
||||
const type = (child as React.ReactElement<any>).type
|
||||
|
||||
if (typeof innerChildren === 'string' || type === TextLink) {
|
||||
if (type === TextLink) {
|
||||
// Set the onPress handler
|
||||
innerOnPress = () => {
|
||||
onTextLinkPress({
|
||||
openModal,
|
||||
closeModal,
|
||||
text: innerText,
|
||||
navigation,
|
||||
href,
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add the segment to the array
|
||||
segments.push({
|
||||
index,
|
||||
text: innerText ?? innerChildren,
|
||||
style: StyleSheet.flatten(innerStyle),
|
||||
handlePress: innerOnPress !== undefined,
|
||||
handleLongPress: innerOnLongPress !== undefined,
|
||||
})
|
||||
|
||||
// If we have press events, push them in
|
||||
if (innerOnPress !== undefined) {
|
||||
segmentPressCallbacks.current.push({
|
||||
index,
|
||||
onPress: innerOnPress,
|
||||
})
|
||||
}
|
||||
if (onLongPress !== undefined) {
|
||||
segmentLongPressCallbacks.current.push({
|
||||
index,
|
||||
onLongPress: innerOnLongPress,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}, [children, closeModal, navigation, onLongPress, onPress, openModal, style])
|
||||
|
||||
const segmentsJson = React.useMemo(() => {
|
||||
return JSON.stringify({segments: textSegments})
|
||||
}, [textSegments])
|
||||
|
||||
const onTextLayout = React.useCallback((e: ExpoProTextLayoutEvent) => {
|
||||
setDims({
|
||||
height: e.nativeEvent.height,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onTextPress = React.useCallback(
|
||||
(e: ExpoProTextPressEvent) => {
|
||||
const onPressSegment =
|
||||
segmentPressCallbacks.current.find(s => s.index === e.nativeEvent.index)
|
||||
?.onPress ?? onPress
|
||||
|
||||
onPressSegment?.()
|
||||
},
|
||||
[onPress],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[dims, {width: '100%'}]}>
|
||||
<NativeView
|
||||
segments={segmentsJson}
|
||||
selectable={selectable}
|
||||
onTextPress={onTextPress}
|
||||
onTextLongPress={onLongPress}
|
||||
onTextLayout={onTextLayout}
|
||||
disableLongPress={onLongPress !== undefined}
|
||||
style={{flex: 1}}
|
||||
rootStyle={rootStyle}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
import {StyleSheet, ViewStyle} from 'react-native'
|
||||
import {
|
||||
ExpoUITextViewChildNativeProps,
|
||||
ExpoUITextViewNativeProps,
|
||||
ExpoUITextViewProps,
|
||||
} from './ExpoUITextView.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoUITextViewNativeProps> =
|
||||
requireNativeViewManager('ExpoSelectableText')
|
||||
|
||||
const NativeViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
|
||||
requireNativeViewManager('ExpoTextChild')
|
||||
|
||||
const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
|
||||
false,
|
||||
StyleSheet.create({}),
|
||||
])
|
||||
const useTextAncestorContext = () => React.useContext(TextAncestorContext)
|
||||
|
||||
export default function ExpoUITextView({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
...rest
|
||||
}: ExpoUITextViewProps) {
|
||||
const [isAncestor, rootStyle] = useTextAncestorContext()
|
||||
|
||||
// Flatten the styles, and apply the root styles when needed
|
||||
const flattenedStyle = React.useMemo(
|
||||
() => StyleSheet.flatten([rootStyle, style]),
|
||||
[rootStyle, style],
|
||||
)
|
||||
|
||||
if (!isAncestor) {
|
||||
return (
|
||||
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
|
||||
<NativeView style={{flex: 1}}>
|
||||
{React.Children.toArray(children).map((c, index) => {
|
||||
if (React.isValidElement(c)) {
|
||||
return c
|
||||
} else if (typeof c === 'string') {
|
||||
return (
|
||||
<NativeViewChild
|
||||
key={index}
|
||||
textStyle={flattenedStyle}
|
||||
text={c}
|
||||
onTextPress={onPress}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</NativeView>
|
||||
</TextAncestorContext.Provider>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
{React.Children.toArray(children).map((c, index) => {
|
||||
if (React.isValidElement(c)) {
|
||||
return c
|
||||
} else if (typeof c === 'string') {
|
||||
return (
|
||||
<NativeViewChild
|
||||
key={index}
|
||||
textStyle={flattenedStyle}
|
||||
text={c}
|
||||
onTextPress={onPress}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import {TextProps, TextStyle, ViewStyle} from 'react-native'
|
||||
|
||||
export interface ExpoUITextViewProps extends TextProps {}
|
||||
|
||||
export interface ExpoUITextViewNativeProps {
|
||||
children: React.ReactNode
|
||||
style: ViewStyle
|
||||
}
|
||||
|
||||
export interface ExpoUITextViewChildNativeProps extends ExpoUITextViewProps {
|
||||
text: string
|
||||
textStyle: TextStyle
|
||||
onTextPress?: (...args: any[]) => void
|
||||
onTextLongPress?: (...args: any[]) => void
|
||||
}
|
||||
@@ -110,7 +110,8 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
|
||||
accessibilityRole="button">
|
||||
<Text
|
||||
style={[s.gray3, styles.footerText]}
|
||||
numberOfLines={isAltExpanded ? undefined : 3}>
|
||||
numberOfLines={isAltExpanded ? undefined : 3}
|
||||
selectable>
|
||||
{altText}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
|
||||
@@ -358,6 +358,7 @@ function PostThreadLoaded({
|
||||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Link
|
||||
<View
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
noFeedback
|
||||
accessible={false}>
|
||||
<PostSandboxWarning />
|
||||
<View style={styles.layout}>
|
||||
@@ -446,7 +445,7 @@ let PostThreadItemLoaded = ({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
</View>
|
||||
<WhoCanReply post={post} />
|
||||
</>
|
||||
)
|
||||
|
||||
+33
-57
@@ -70,14 +70,14 @@ export const Link = memo(function Link({
|
||||
const onPress = React.useCallback(
|
||||
(e?: Event) => {
|
||||
if (typeof href === 'string') {
|
||||
return onTextLinkPress({
|
||||
return onPressInner(
|
||||
closeModal,
|
||||
navigation,
|
||||
href: sanitizeUrl(href),
|
||||
sanitizeUrl(href),
|
||||
navigationAction,
|
||||
openLink,
|
||||
e,
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
[closeModal, navigation, navigationAction, href, openLink],
|
||||
@@ -182,28 +182,40 @@ export const TextLink = memo(function TextLink({
|
||||
|
||||
props.onPress = React.useCallback(
|
||||
(e?: Event) => {
|
||||
onTextLinkPress({
|
||||
onPress,
|
||||
e,
|
||||
openModal,
|
||||
const requiresWarning =
|
||||
warnOnMismatchingLabel &&
|
||||
linkRequiresWarning(href, typeof text === 'string' ? text : '')
|
||||
if (requiresWarning) {
|
||||
e?.preventDefault?.()
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: typeof text === 'string' ? text : '',
|
||||
href,
|
||||
})
|
||||
}
|
||||
if (onPress) {
|
||||
e?.preventDefault?.()
|
||||
// @ts-ignore function signature differs by platform -prf
|
||||
return onPress()
|
||||
}
|
||||
return onPressInner(
|
||||
closeModal,
|
||||
navigation,
|
||||
href,
|
||||
text,
|
||||
sanitizeUrl(href),
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
openLink,
|
||||
})
|
||||
e,
|
||||
)
|
||||
},
|
||||
[
|
||||
onPress,
|
||||
openModal,
|
||||
closeModal,
|
||||
openModal,
|
||||
navigation,
|
||||
href,
|
||||
text,
|
||||
navigationAction,
|
||||
warnOnMismatchingLabel,
|
||||
navigationAction,
|
||||
openLink,
|
||||
],
|
||||
)
|
||||
@@ -294,9 +306,6 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
|
||||
)
|
||||
})
|
||||
|
||||
// Moving all of this logic into a separate function. Becuase we need to be able to use this function from
|
||||
// SelectableText, it's easier to move it outside the component instead of duplicating logic
|
||||
|
||||
// NOTE
|
||||
// we can't use the onPress given by useLinkProps because it will
|
||||
// match most paths to the HomeTab routes while we actually want to
|
||||
@@ -308,47 +317,14 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
|
||||
// this method copies from the onPress implementation but adds our
|
||||
// needed customizations
|
||||
// -prf
|
||||
export const onTextLinkPress = ({
|
||||
onPress,
|
||||
e,
|
||||
openModal,
|
||||
closeModal,
|
||||
navigation,
|
||||
href,
|
||||
text,
|
||||
navigationAction = 'push',
|
||||
warnOnMismatchingLabel,
|
||||
openLink,
|
||||
}: {
|
||||
onPress?: (e: GestureResponderEvent) => void
|
||||
e?: Event
|
||||
openModal?: any
|
||||
closeModal: any
|
||||
navigation: NavigationProp
|
||||
href: string
|
||||
text?: any
|
||||
navigationAction?: 'push' | 'replace' | 'navigate'
|
||||
warnOnMismatchingLabel?: boolean
|
||||
openLink: (href: string) => void
|
||||
}) => {
|
||||
const requiresWarning =
|
||||
warnOnMismatchingLabel &&
|
||||
linkRequiresWarning(href, typeof text === 'string' ? text : '')
|
||||
if (requiresWarning) {
|
||||
e?.preventDefault?.()
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: typeof text === 'string' ? text : '',
|
||||
href,
|
||||
})
|
||||
}
|
||||
if (onPress) {
|
||||
e?.preventDefault?.()
|
||||
// @ts-ignore function signature differs by platform -prf
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
|
||||
function onPressInner(
|
||||
closeModal = () => {},
|
||||
navigation: NavigationProp,
|
||||
href: string,
|
||||
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
||||
openLink: (href: string) => void,
|
||||
e?: Event,
|
||||
) {
|
||||
let shouldHandle = false
|
||||
const isLeftClick =
|
||||
// @ts-ignore Web only -prf
|
||||
|
||||
@@ -44,7 +44,11 @@ export function RichText({
|
||||
}
|
||||
return (
|
||||
// @ts-ignore web only -prf
|
||||
<Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}>
|
||||
<Text
|
||||
testID={testID}
|
||||
style={[style, pal.text]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}>
|
||||
{text}
|
||||
</Text>
|
||||
)
|
||||
@@ -82,6 +86,7 @@ export function RichText({
|
||||
href={`/profile/${mention.did}`}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
@@ -94,6 +99,7 @@ export function RichText({
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
warnOnMismatchingLabel
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, Text as RNText, TextProps} from 'react-native'
|
||||
import {Text as RNText, TextProps} from 'react-native'
|
||||
import {s, lh} from 'lib/styles'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {SelectableText} from '../../../../../modules/expo-selectable-text'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import ExpoSelectableTextView from '../../../../../modules/expo-selectable-text/src/ExpoUITextView'
|
||||
|
||||
export type CustomTextProps = TextProps & {
|
||||
type?: TypographyVariant
|
||||
@@ -27,20 +27,13 @@ export function Text({
|
||||
const typography = theme.typography[type]
|
||||
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
|
||||
|
||||
// if (false) {
|
||||
// TODO remove
|
||||
if (selectable && isIOS) {
|
||||
return (
|
||||
<SelectableText
|
||||
selectable
|
||||
style={StyleSheet.flatten([
|
||||
s.black,
|
||||
typography,
|
||||
lineHeightStyle,
|
||||
style,
|
||||
])}>
|
||||
<ExpoSelectableTextView
|
||||
style={[s.black, typography, lineHeightStyle, style]}
|
||||
{...props}>
|
||||
{children}
|
||||
</SelectableText>
|
||||
</ExpoSelectableTextView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +42,7 @@ export function Text({
|
||||
style={[s.black, typography, lineHeightStyle, style]}
|
||||
// @ts-ignore web only -esb
|
||||
dataSet={Object.assign({tooltip: title}, dataSet || {})}
|
||||
selectable={selectable}
|
||||
{...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
|
||||
Reference in New Issue
Block a user