Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,6 @@ export type TextPropsAndroid = {
*/
selectionColor?: ?ColorValue,

/**
* Types of data converted to clickable URLs in the text element.
*
* @default `'none'`
* @platform android
*/
dataDetectorType?: ?('phoneNumber' | 'link' | 'email' | 'none' | 'all'),

/**
* Text break strategy on Android.
*
Expand All @@ -127,6 +119,13 @@ export type TextPropsAndroid = {
};

type TextBaseProps = Readonly<{
/**
* Types of data converted to clickable URLs in the text element.
*
* @default `'none'`
*/
dataDetectorType?: ?('phoneNumber' | 'link' | 'email' | 'none' | 'all'),

onAccessibilityAction?: ?(event: AccessibilityActionEvent) => unknown,

/**
Expand Down
42 changes: 42 additions & 0 deletions packages/react-native/Libraries/Text/__tests__/Text-itest.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,48 @@ describe('<Text>', () => {
});
});

describe('dataDetectorType', () => {
(['all', 'email', 'link', 'phoneNumber'] as const).forEach(propVal => {
it(`can be set to "${propVal}"`, () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<Text dataDetectorType={propVal}>{TEST_TEXT}</Text>);
});

expect(
root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(),
).toEqual(
<rn-paragraph dataDetectorType={propVal}>{TEST_TEXT}</rn-paragraph>,
);
});
});

it(`can be set to "none"`, () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<Text dataDetectorType="none">{TEST_TEXT}</Text>);
});

expect(
root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(),
).toEqual(<rn-paragraph>{TEST_TEXT}</rn-paragraph>);
});

it(`has "none" as default`, () => {
const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<Text>{TEST_TEXT}</Text>);
});

expect(
root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(),
).toEqual(<rn-paragraph>{TEST_TEXT}</rn-paragraph>);
});
});

describe('ellipsizeMode', () => {
it(`has 'tail' as default on JS side`, () => {
const root = Fantom.createRoot();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

@interface RCTParagraphComponentAccessibilityProvider : NSObject

/**
* The data that the provider adds link elements for. Set it before reading `accessibilityElements`.
*/
@property (nonatomic) RCTTextDataDetectorTypes dataDetectorTypes;

- (instancetype)initWithString:(facebook::react::AttributedString)attributedString
layoutManager:(RCTTextLayoutManager *)layoutManager
paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,15 @@ - (instancetype)initWithString:(facebook::react::AttributedString)attributedStri

[elements addObject:firstElement];

// The rects below are relative to the text, which starts after the padding.
const CGPoint textOrigin = _frame.origin;
const CGPoint textOriginOnScreen = CGPointMake(
firstElement.accessibilityFrame.origin.x + textOrigin.x, firstElement.accessibilityFrame.origin.y + textOrigin.y);

// add additional elements for those parts of text with embedded link so VoiceOver could specially recognize links
[_layoutManager getRectWithAttributedString:_attributedString
paragraphAttributes:_paragraphAttributes
dataDetectorTypes:_dataDetectorTypes
enumerateAttribute:RCTTextAttributesAccessibilityRoleAttributeName
frame:_frame
usingBlock:^(CGRect fragmentRect, NSString *_Nonnull fragmentText, NSString *value) {
Expand All @@ -86,6 +92,9 @@ - (instancetype)initWithString:(facebook::react::AttributedString)attributedStri
if ([fragmentText isEqualToString:firstElement.accessibilityLabel]) {
if ([value isEqualToString:@"link"]) {
firstElement.accessibilityTraits |= UIAccessibilityTraitLink;
firstElement.accessibilityActivationPoint = CGPointMake(
textOriginOnScreen.x + CGRectGetMidX(fragmentRect),
textOriginOnScreen.y + CGRectGetMidY(fragmentRect));
} else if ([value isEqualToString:@"button"]) {
firstElement.accessibilityTraits |= UIAccessibilityTraitButton;
}
Expand All @@ -110,7 +119,8 @@ - (instancetype)initWithString:(facebook::react::AttributedString)attributedStri
numberOfButtons++;
}
element.accessibilityLabel = fragmentText;
element.frameInContainerSpace = fragmentRect;
element.frameInContainerSpace =
CGRectOffset(fragmentRect, textOrigin.x, textOrigin.y);
[elements addObject:element];
}];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#import "RCTParagraphComponentAccessibilityProvider.h"

#import <MobileCoreServices/UTCoreTypes.h>
#import <React/RCTUtils.h>
#import <react/featureflags/ReactNativeFeatureFlags.h>
#import <react/renderer/components/text/ParagraphComponentDescriptor.h>
#import <react/renderer/components/text/ParagraphProps.h>
Expand Down Expand Up @@ -44,19 +45,36 @@ static BOOL RCTAttributedStringHasAttachments(const AttributedString &attributed
return NO;
}

static RCTTextDataDetectorTypes RCTTextDataDetectorTypesFromProp(DataDetectorType dataDetectorType)
{
switch (dataDetectorType) {
case DataDetectorType::All:
return RCTTextDataDetectorTypePhoneNumber | RCTTextDataDetectorTypeLink | RCTTextDataDetectorTypeEmail;
case DataDetectorType::Email:
return RCTTextDataDetectorTypeEmail;
case DataDetectorType::Link:
return RCTTextDataDetectorTypeLink;
case DataDetectorType::None:
return RCTTextDataDetectorTypeNone;
case DataDetectorType::PhoneNumber:
return RCTTextDataDetectorTypePhoneNumber;
}
}

// ParagraphTextView is an auxiliary view we set as contentView so the drawing
// can happen on top of the layers manipulated by RCTViewComponentView (the parent view)
@interface RCTParagraphTextView : UIView

@property (nonatomic) ParagraphShadowNode::ConcreteState::Shared state;
@property (nonatomic) ParagraphAttributes paragraphAttributes;
@property (nonatomic) RCTTextDataDetectorTypes dataDetectorTypes;
@property (nonatomic) LayoutMetrics layoutMetrics;
@property (nonatomic) CGRect drawingFrame;

@end

#if !TARGET_OS_TV
@interface RCTParagraphComponentView () <UIEditMenuInteractionDelegate>
@interface RCTParagraphComponentView () <UIEditMenuInteractionDelegate, UIGestureRecognizerDelegate>

@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0));

Expand All @@ -68,8 +86,12 @@ @interface RCTParagraphComponentView ()

@implementation RCTParagraphComponentView {
ParagraphAttributes _paragraphAttributes;
RCTTextDataDetectorTypes _dataDetectorTypes;
RCTParagraphComponentAccessibilityProvider *_accessibilityProvider;
UILongPressGestureRecognizer *_longPressGestureRecognizer;
#if !TARGET_OS_TV
UITapGestureRecognizer *_detectedURLTapGestureRecognizer;
#endif
RCTParagraphTextView *_textView;
CGRect _textLayoutFrame;
}
Expand Down Expand Up @@ -132,6 +154,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
_paragraphAttributes = newParagraphProps.paragraphAttributes;
_textView.paragraphAttributes = _paragraphAttributes;

RCTTextDataDetectorTypes dataDetectorTypes = RCTTextDataDetectorTypesFromProp(newParagraphProps.dataDetectorType);
if (dataDetectorTypes != _dataDetectorTypes) {
_dataDetectorTypes = dataDetectorTypes;
_textView.dataDetectorTypes = dataDetectorTypes;
_accessibilityProvider = nil;
[_textView setNeedsDisplay];
[self _updateDetectedURLTapGestureRecognizer];
}

if (newParagraphProps.isSelectable != oldParagraphProps.isSelectable) {
if (newParagraphProps.isSelectable) {
[self enableContextMenu];
Expand Down Expand Up @@ -297,6 +328,7 @@ - (NSArray *)accessibilityElements
paragraphAttributes:data.paragraphAttributes
frame:frame
view:self];
_accessibilityProvider.dataDetectorTypes = _dataDetectorTypes;
}
}

Expand Down Expand Up @@ -379,6 +411,72 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
return std::static_pointer_cast<const TouchEventEmitter>(eventEmitter);
}

#pragma mark - Detected URLs

#if !TARGET_OS_TV
- (void)_updateDetectedURLTapGestureRecognizer
{
BOOL needsRecognizer = _dataDetectorTypes != RCTTextDataDetectorTypeNone;
if (needsRecognizer && _detectedURLTapGestureRecognizer == nil) {
_detectedURLTapGestureRecognizer =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleDetectedURLTap:)];
_detectedURLTapGestureRecognizer.cancelsTouchesInView = NO;
_detectedURLTapGestureRecognizer.delegate = self;
[self addGestureRecognizer:_detectedURLTapGestureRecognizer];
} else if (!needsRecognizer && _detectedURLTapGestureRecognizer != nil) {
[self removeGestureRecognizer:_detectedURLTapGestureRecognizer];
_detectedURLTapGestureRecognizer = nil;
}
}

- (nullable NSURL *)_detectedURLAtPoint:(CGPoint)point
{
if (!_textView.state) {
return nil;
}
const auto &stateData = _textView.state->getData();
auto textLayoutManager = stateData.layoutManager.lock();
if (!textLayoutManager) {
return nil;
}
RCTTextLayoutManager *nativeTextLayoutManager =
(RCTTextLayoutManager *)unwrapManagedObject(textLayoutManager->getNativeTextLayoutManager());
return [nativeTextLayoutManager detectedURLWithAttributedString:stateData.attributedString
paragraphAttributes:_paragraphAttributes
dataDetectorTypes:_dataDetectorTypes
frame:_textLayoutFrame
atPoint:point];
}

- (void)_handleDetectedURLTap:(UITapGestureRecognizer *)gesture
{
NSURL *url = [self _detectedURLAtPoint:[gesture locationInView:self]];
if (url != nil) {
[RCTSharedApplication() openURL:url options:@{} completionHandler:nil];
}
}

// Called when the tap is about to be recognized, so a touch that starts a scroll doesn't pay for this.
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
if (gestureRecognizer != _detectedURLTapGestureRecognizer) {
return [super gestureRecognizerShouldBegin:gestureRecognizer];
}
return [self _detectedURLAtPoint:[gestureRecognizer locationInView:self]] != nil;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
// A long press on selectable text shows the context menu instead of opening the URL.
return otherGestureRecognizer == _longPressGestureRecognizer;
}
#else
- (void)_updateDetectedURLTapGestureRecognizer
{
}
#endif

#pragma mark - Context Menu

#if !TARGET_OS_TV
Expand Down Expand Up @@ -504,6 +602,7 @@ - (void)drawRect:(CGRect)rect

[nativeTextLayoutManager drawAttributedString:stateData.attributedString
paragraphAttributes:_paragraphAttributes
dataDetectorTypes:_dataDetectorTypes
frame:frame
drawHighlightPath:^(UIBezierPath *highlightPath) {
if (highlightPath) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#include "HostPlatformParagraphProps.h"

#include <react/renderer/components/text/conversions.h>
#include <react/renderer/core/propsConversions.h>
#include <react/renderer/debug/debugStringConvertibleUtils.h>

namespace facebook::react {

HostPlatformParagraphProps::HostPlatformParagraphProps(
const PropsParserContext& context,
const HostPlatformParagraphProps& sourceProps,
const RawProps& rawProps)
: BaseParagraphProps(context, sourceProps, rawProps),
dataDetectorType(convertRawProp(
context,
rawProps,
"dataDetectorType",
sourceProps.dataDetectorType,
DataDetectorType::None)) {}

void HostPlatformParagraphProps::setProp(
const PropsParserContext& context,
RawPropsPropNameHash hash,
const char* propName,
const RawValue& value) {
// All Props structs setProp methods must always, unconditionally,
// call all super::setProp methods, since multiple structs may
// reuse the same values.
BaseParagraphProps::setProp(context, hash, propName, value);

static auto defaults = HostPlatformParagraphProps{};

switch (hash) { RAW_SET_PROP_SWITCH_CASE_BASIC(dataDetectorType); }
}

#pragma mark - DebugStringConvertible

#if RN_DEBUG_STRING_CONVERTIBLE
SharedDebugStringConvertibleList HostPlatformParagraphProps::getDebugProps()
const {
return BaseParagraphProps::getDebugProps() +
SharedDebugStringConvertibleList{debugStringConvertibleItem(
"dataDetectorType", dataDetectorType, DataDetectorType::None)};
}
#endif

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,35 @@
#include <react/cxxstableapi/FrameworksGuard.h>

#include <react/renderer/components/text/BaseParagraphProps.h>
#include <react/renderer/components/text/primitives.h>

namespace facebook::react {
using HostPlatformParagraphProps = BaseParagraphProps;

/*
* Props of <Paragraph> component.
* Most of the props are directly stored in composed `ParagraphAttributes`
* object.
*/
class HostPlatformParagraphProps : public BaseParagraphProps {
public:
HostPlatformParagraphProps() = default;
HostPlatformParagraphProps(
const PropsParserContext &context,
const HostPlatformParagraphProps &sourceProps,
const RawProps &rawProps);

void
setProp(const PropsParserContext &context, RawPropsPropNameHash hash, const char *propName, const RawValue &value);

#pragma mark - Props

DataDetectorType dataDetectorType{DataDetectorType::None};

#pragma mark - DebugStringConvertible

#if RN_DEBUG_STRING_CONVERTIBLE
SharedDebugStringConvertibleList getDebugProps() const override;
#endif
};

} // namespace facebook::react
Loading
Loading