From 20b4b61fb9937203a9a2227adab115e4597396e0 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 1 Nov 2026 20:41:06 +0100 Subject: [PATCH] Support dataDetectorType on iOS Text `dataDetectorType` only existed on Android. On iOS the prop was ignored, so apps had to find links in the text themselves and split it into nested Text. The prop lives in the iOS paragraph props, the same way Android keeps its own in its platform folder. Android code doesn't change. Detection runs when the text is drawn and hit tested, not when it is measured, because the attributes it adds don't change the layout. A tap on detected data opens its URL, and VoiceOver gets an element for each link. Only http, https, mailto and tel URLs open. NSDataDetector also matches other schemes, which must not turn text in the app into deep links. Co-Authored-By: Claude Opus 5.5 --- .../react-native/Libraries/Text/TextProps.js | 15 +- .../Libraries/Text/__tests__/Text-itest.js | 42 +++++ ...TParagraphComponentAccessibilityProvider.h | 5 + ...ParagraphComponentAccessibilityProvider.mm | 12 +- .../Text/RCTParagraphComponentView.mm | 101 +++++++++++- .../text/HostPlatformParagraphProps.cpp | 54 +++++++ .../text/HostPlatformParagraphProps.h | 30 +++- .../renderer/components/text/conversions.h | 70 +++++++++ .../renderer/components/text/primitives.h | 22 +++ .../textlayoutmanager/RCTTextLayoutManager.h | 32 ++++ .../textlayoutmanager/RCTTextLayoutManager.mm | 144 +++++++++++++++++- packages/react-native/ReactNativeApi.d.ts | 10 +- .../types_DEPRECATED/Libraries/Text/Text.d.ts | 14 +- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTTextDataDetectorTests.mm | 138 +++++++++++++++++ .../js/examples/Text/TextExample.ios.js | 21 +++ .../api-snapshots/ReactAppleDebugCxx.api | 1 + .../api-snapshots/ReactAppleFrameworksCxx.api | 4 + .../api-snapshots/ReactAppleNewarchCxx.api | 1 + .../api-snapshots/ReactAppleReleaseCxx.api | 1 + 20 files changed, 697 insertions(+), 24 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/conversions.h create mode 100644 packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/primitives.h create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTTextDataDetectorTests.mm diff --git a/packages/react-native/Libraries/Text/TextProps.js b/packages/react-native/Libraries/Text/TextProps.js index 6fb8b163595a..aa0c76ea3ff2 100644 --- a/packages/react-native/Libraries/Text/TextProps.js +++ b/packages/react-native/Libraries/Text/TextProps.js @@ -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. * @@ -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, /** diff --git a/packages/react-native/Libraries/Text/__tests__/Text-itest.js b/packages/react-native/Libraries/Text/__tests__/Text-itest.js index f2126b41af5d..d548e51f7667 100644 --- a/packages/react-native/Libraries/Text/__tests__/Text-itest.js +++ b/packages/react-native/Libraries/Text/__tests__/Text-itest.js @@ -150,6 +150,48 @@ describe('', () => { }); }); + describe('dataDetectorType', () => { + (['all', 'email', 'link', 'phoneNumber'] as const).forEach(propVal => { + it(`can be set to "${propVal}"`, () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + + expect( + root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(), + ).toEqual( + {TEST_TEXT}, + ); + }); + }); + + it(`can be set to "none"`, () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + + expect( + root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(), + ).toEqual({TEST_TEXT}); + }); + + it(`has "none" as default`, () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render({TEST_TEXT}); + }); + + expect( + root.getRenderedOutput({props: ['dataDetectorType']}).toJSX(), + ).toEqual({TEST_TEXT}); + }); + }); + describe('ellipsizeMode', () => { it(`has 'tail' as default on JS side`, () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.h index 3fb0da7c2a5f..f6391cb25ca3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.h @@ -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 diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.mm index c2cc3884264f..f467228812ca 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentAccessibilityProvider.mm @@ -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) { @@ -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; } @@ -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]; }]; diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index 4d7c72408486..c918bed73784 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -9,6 +9,7 @@ #import "RCTParagraphComponentAccessibilityProvider.h" #import +#import #import #import #import @@ -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 () +@interface RCTParagraphComponentView () @property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0)); @@ -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; } @@ -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]; @@ -297,6 +328,7 @@ - (NSArray *)accessibilityElements paragraphAttributes:data.paragraphAttributes frame:frame view:self]; + _accessibilityProvider.dataDetectorTypes = _dataDetectorTypes; } } @@ -379,6 +411,72 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point return std::static_pointer_cast(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 @@ -504,6 +602,7 @@ - (void)drawRect:(CGRect)rect [nativeTextLayoutManager drawAttributedString:stateData.attributedString paragraphAttributes:_paragraphAttributes + dataDetectorTypes:_dataDetectorTypes frame:frame drawHighlightPath:^(UIBezierPath *highlightPath) { if (highlightPath) { diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.cpp new file mode 100644 index 000000000000..c4f218550747 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.cpp @@ -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 +#include +#include + +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 diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.h b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.h index 3d88cc6439ce..a622efaa6a87 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/HostPlatformParagraphProps.h @@ -10,7 +10,35 @@ #include #include +#include namespace facebook::react { -using HostPlatformParagraphProps = BaseParagraphProps; + +/* + * Props of 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 diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/conversions.h new file mode 100644 index 000000000000..28a71e0f8aed --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/conversions.h @@ -0,0 +1,70 @@ +/* + * 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. + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +inline void fromRawValue(const PropsParserContext & /*context*/, const RawValue &value, DataDetectorType &result) +{ + react_native_expect(value.hasType()); + if (value.hasType()) { + auto string = (std::string)value; + if (string == "all") { + result = DataDetectorType::All; + } else if (string == "email") { + result = DataDetectorType::Email; + } else if (string == "link") { + result = DataDetectorType::Link; + } else if (string == "none") { + result = DataDetectorType::None; + } else if (string == "phoneNumber") { + result = DataDetectorType::PhoneNumber; + } else { + // sane default + LOG(ERROR) << "Unsupported DataDetectorType value: " << string; + react_native_expect(false); + result = DataDetectorType::None; + } + return; + } + + LOG(ERROR) << "Unsupported DataDetectorType type"; + react_native_expect(false); + result = DataDetectorType::None; +} + +inline std::string toString(const DataDetectorType &value) +{ + switch (value) { + case DataDetectorType::All: + return "all"; + case DataDetectorType::Email: + return "email"; + case DataDetectorType::Link: + return "link"; + case DataDetectorType::None: + return "none"; + case DataDetectorType::PhoneNumber: + return "phoneNumber"; + } + + LOG(ERROR) << "Unsupported DataDetectorType value"; + react_native_expect(false); + return "none"; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/primitives.h b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/primitives.h new file mode 100644 index 000000000000..22241c7b7f36 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/primitives.h @@ -0,0 +1,22 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace facebook::react { + +enum class DataDetectorType { + All, + Email, + Link, + None, + PhoneNumber, +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index 2cd7f1bce247..187afc128f80 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -24,6 +24,16 @@ NS_ASSUME_NONNULL_BEGIN using RCTTextLayoutFragmentEnumerationBlock = void (^)(CGRect fragmentRect, NSString *_Nonnull fragmentText, NSString *value); +/** + * The kinds of data to make tappable, as chosen by the `dataDetectorType` prop of `Text`. + */ +typedef NS_OPTIONS(NSUInteger, RCTTextDataDetectorTypes) { + RCTTextDataDetectorTypeNone = 0, + RCTTextDataDetectorTypePhoneNumber = 1 << 0, + RCTTextDataDetectorTypeLink = 1 << 1, + RCTTextDataDetectorTypeEmail = 1 << 2, +}; + /** * iOS-specific TextLayoutManager */ @@ -44,6 +54,12 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block; +- (void)drawAttributedString:(const facebook::react::AttributedString &)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes + frame:(CGRect)frame + drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block; + - (facebook::react::LinesMeasurements) getLinesForAttributedString:(const facebook::react::AttributedString &)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes @@ -55,8 +71,24 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame atPoint:(CGPoint)point; +/** + * Returns the URL of the data detected at a point in the coordinates of `frame`'s parent, if any. + */ +- (nullable NSURL *)detectedURLWithAttributedString:(const facebook::react::AttributedString &)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes + frame:(CGRect)frame + atPoint:(CGPoint)point; + +- (void)getRectWithAttributedString:(const facebook::react::AttributedString &)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + enumerateAttribute:(NSString *)enumerateAttribute + frame:(CGRect)frame + usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; + - (void)getRectWithAttributedString:(const facebook::react::AttributedString &)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes enumerateAttribute:(NSString *)enumerateAttribute frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index 3e93b60e64be..33fafb4dc77b 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -21,6 +21,85 @@ using namespace facebook::react; +static NSString *const RCTDetectedURLAttributeName = @"RCTDetectedURL"; + +static NSURL *_Nullable RCTPhoneURLFromNumber(NSString *number) +{ + // NSDataDetector appends an extension after `;` or `,`. Dial the number itself. + NSRange extension = [number rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@";,"]]; + if (extension.location != NSNotFound) { + number = [number substringToIndex:extension.location]; + } + NSCharacterSet *removed = [[NSCharacterSet characterSetWithCharactersInString:@"+0123456789"] invertedSet]; + number = [[number componentsSeparatedByCharactersInSet:removed] componentsJoinedByString:@""]; + return number.length > 0 ? [NSURL URLWithString:[@"tel:" stringByAppendingString:number]] : nil; +} + +static NSURL *_Nullable RCTURLFromTextCheckingResult(NSTextCheckingResult *result, RCTTextDataDetectorTypes types) +{ + if (result.resultType == NSTextCheckingTypePhoneNumber) { + return (types & RCTTextDataDetectorTypePhoneNumber) != 0 ? RCTPhoneURLFromNumber(result.phoneNumber) : nil; + } + // NSDataDetector reports email addresses as mailto: links. It also matches any other scheme, which must not make + // text in the app open arbitrary deep links. + NSString *scheme = result.URL.scheme.lowercaseString; + if ([scheme isEqualToString:@"mailto"]) { + return (types & RCTTextDataDetectorTypeEmail) != 0 ? result.URL : nil; + } + if ([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]) { + return (types & RCTTextDataDetectorTypeLink) != 0 ? result.URL : nil; + } + return nil; +} + +// Runs on the text that is displayed, after `textTransform`. The attributes don't change the layout, so measuring +// doesn't need them. +static void RCTApplyDataDetector(NSTextStorage *textStorage, RCTTextDataDetectorTypes types) +{ + if (types == RCTTextDataDetectorTypeNone || textStorage.length == 0) { + return; + } + + static NSDataDetector *detector; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink | NSTextCheckingTypePhoneNumber error:NULL]; + }); + + [textStorage beginEditing]; + [detector enumerateMatchesInString:textStorage.string + options:0 + range:NSMakeRange(0, textStorage.length) + usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) { + NSURL *url = RCTURLFromTextCheckingResult(result, types); + if (url == nil) { + return; + } + [textStorage addAttributes:@{ + RCTDetectedURLAttributeName : url, + NSForegroundColorAttributeName : UIColor.linkColor, + NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle), + RCTTextAttributesAccessibilityRoleAttributeName : @"link", + } + range:result.range]; + }]; + [textStorage endEditing]; +} + +// Glyphs that a trailing ellipsis replaces are still laid out, so leave them out. +static NSRange RCTVisibleGlyphRange(NSLayoutManager *layoutManager, NSTextContainer *textContainer) +{ + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + if (glyphRange.length == 0) { + return glyphRange; + } + NSRange truncatedRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:NSMaxRange(glyphRange) - 1]; + if (truncatedRange.location != NSNotFound) { + glyphRange.length = truncatedRange.location - glyphRange.location; + } + return glyphRange; +} + @implementation RCTTextLayoutManager { SimpleThreadSafeCache, 256> _cache; } @@ -132,11 +211,25 @@ - (void)drawAttributedString:(const AttributedString &)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes frame:(CGRect)frame drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block +{ + [self drawAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:RCTTextDataDetectorTypeNone + frame:frame + drawHighlightPath:block]; +} + +- (void)drawAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes + frame:(CGRect)frame + drawHighlightPath:(void (^_Nullable)(UIBezierPath *highlightPath))block { NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes size:frame.size]; + RCTApplyDataDetector(textStorage, dataDetectorTypes); NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; NSTextContainer *textContainer = layoutManager.textContainers.firstObject; @@ -497,22 +590,71 @@ - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttribute return nil; } +- (NSURL *)detectedURLWithAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes + frame:(CGRect)frame + atPoint:(CGPoint)point +{ + NSTextStorage *textStorage = [self + _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] + paragraphAttributes:paragraphAttributes + size:frame.size]; + if (textStorage.length == 0) { + return nil; + } + RCTApplyDataDetector(textStorage, dataDetectorTypes); + + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + CGPoint pointInContainer = CGPointMake(point.x - frame.origin.x, point.y - frame.origin.y); + + // The glyph index is that of the nearest glyph, so check that the point is really on it. + NSUInteger glyphIndex = [layoutManager glyphIndexForPoint:pointInContainer inTextContainer:textContainer]; + CGRect glyphRect = [layoutManager boundingRectForGlyphRange:NSMakeRange(glyphIndex, 1) inTextContainer:textContainer]; + if (!CGRectContainsPoint(glyphRect, pointInContainer) || + !NSLocationInRange(glyphIndex, RCTVisibleGlyphRange(layoutManager, textContainer))) { + return nil; + } + return [textStorage attribute:RCTDetectedURLAttributeName + atIndex:[layoutManager characterIndexForGlyphAtIndex:glyphIndex] + effectiveRange:NULL]; +} + - (void)getRectWithAttributedString:(const AttributedString &)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes enumerateAttribute:(NSString *)enumerateAttribute frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block +{ + [self getRectWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:RCTTextDataDetectorTypeNone + enumerateAttribute:enumerateAttribute + frame:frame + usingBlock:block]; +} + +- (void)getRectWithAttributedString:(const AttributedString &)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + dataDetectorTypes:(RCTTextDataDetectorTypes)dataDetectorTypes + enumerateAttribute:(NSString *)enumerateAttribute + frame:(CGRect)frame + usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block { NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:[self _nsAttributedStringFromAttributedString:attributedString] paragraphAttributes:paragraphAttributes size:frame.size]; + RCTApplyDataDetector(textStorage, dataDetectorTypes); NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; NSTextContainer *textContainer = layoutManager.textContainers.firstObject; [layoutManager ensureLayoutForTextContainer:textContainer]; - NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSRange glyphRange = dataDetectorTypes != RCTTextDataDetectorTypeNone + ? RCTVisibleGlyphRange(layoutManager, textContainer) + : [layoutManager glyphRangeForTextContainer:textContainer]; NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL]; [textStorage enumerateAttribute:enumerateAttribute diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..730a2ad03459 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<41528fad88b43dfd16d9d71cf8685fc1>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -4867,6 +4867,7 @@ declare type TextBaseProps = { readonly allowFontScaling?: boolean readonly android_hyphenationFrequency?: "full" | "none" | "normal" readonly children?: React.ReactNode + readonly dataDetectorType?: "all" | "email" | "link" | "none" | "phoneNumber" readonly ellipsizeMode?: "clip" | "head" | "middle" | "tail" readonly id?: string readonly lineBreak?: "auto" | "loose" | "normal" | "strict" @@ -5191,7 +5192,6 @@ declare interface TextProps extends Readonly< > {} declare type TextPropsAndroid = { adjustsFontSizeToFit?: boolean - dataDetectorType?: "all" | "email" | "link" | "none" | "phoneNumber" disabled?: boolean minimumFontScale?: number selectionColor?: ColorValue @@ -5791,7 +5791,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 33fe7464 + Animated, // 9623d6d3 AppConfig, // 35c0ca70 AppRegistry, // edec2a30 AppState, // 12012be5 @@ -6025,7 +6025,7 @@ export { TVViewPropsIOS, // 1c612e4e TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // 935ec305 + Text, // 54f0f649 TextContentType, // 239b3ecc TextInput, // cf6467dc TextInputAndroidProps, // 9ebbc103 @@ -6042,7 +6042,7 @@ export { TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 TextLayoutEvent, // 1a21e24a - TextProps, // b3707dfc + TextProps, // 3d8670c8 TextStyle, // 898adfd1 ToastAndroid, // 88a8969a TouchableHighlight, // a015710f diff --git a/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts index e793c03bc6d5..a75a10277fd2 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Text/Text.d.ts @@ -77,13 +77,6 @@ export interface TextPropsAndroid { */ textBreakStrategy?: 'simple' | 'highQuality' | 'balanced' | undefined; - /** - * Determines the types of data converted to clickable URLs in the text element. - * By default no data types are detected. - */ - dataDetectorType?: - null | 'phoneNumber' | 'link' | 'email' | 'none' | 'all' | undefined; - /** * Hyphenation strategy */ @@ -106,6 +99,13 @@ export interface TextPropsAndroid { // https://reactnative.dev/docs/text#props export interface TextProps extends TextPropsIOS, TextPropsAndroid, AccessibilityProps { + /** + * Determines the types of data converted to clickable URLs in the text element. + * By default no data types are detected. + */ + dataDetectorType?: + null | 'phoneNumber' | 'link' | 'email' | 'none' | 'all' | undefined; + /** * Specifies whether fonts should scale to respect Text Size accessibility settings. * The default is `true`. diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 39dceb0ee86c..7710b45b8965 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */; }; E62F11832A5C6580000BF1C8 /* FlexibleSizeExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.mm */; }; E62F11842A5C6584000BF1C8 /* UpdatePropertiesExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.mm */; }; + D1A7C0DE00000000000000A1 /* RCTTextDataDetectorTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = D1A7C0DE00000000000000A2 /* RCTTextDataDetectorTests.mm */; }; E7DB20D122B2BAA6005AC45F /* RCTBundleURLProviderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20A922B2BAA3005AC45F /* RCTBundleURLProviderTests.m */; }; E7DB20D322B2BAA6005AC45F /* RCTBlobManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20AB22B2BAA3005AC45F /* RCTBlobManagerTests.m */; }; E7DB20D522B2BAA6005AC45F /* RCTPerformanceLoggerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */; }; @@ -100,6 +101,7 @@ E771AEEA22B44E3100EA1189 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNTester/Info.plist; sourceTree = ""; }; E7DB209F22B2BA84005AC45F /* RNTesterUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNTesterUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E7DB20A322B2BA84005AC45F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D1A7C0DE00000000000000A2 /* RCTTextDataDetectorTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTTextDataDetectorTests.mm; sourceTree = ""; }; E7DB20A922B2BAA3005AC45F /* RCTBundleURLProviderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBundleURLProviderTests.m; sourceTree = ""; }; E7DB20AB22B2BAA3005AC45F /* RCTBlobManagerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBlobManagerTests.m; sourceTree = ""; }; E7DB20AD22B2BAA3005AC45F /* RCTPerformanceLoggerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTPerformanceLoggerTests.m; sourceTree = ""; }; @@ -292,6 +294,7 @@ E7DB20C622B2BAA5005AC45F /* RCTAllocationTests.m */, E7DB20B222B2BAA4005AC45F /* RCTAnimationUtilsTests.m */, E7DB20AB22B2BAA3005AC45F /* RCTBlobManagerTests.m */, + D1A7C0DE00000000000000A2 /* RCTTextDataDetectorTests.mm */, E7DB20A922B2BAA3005AC45F /* RCTBundleURLProviderTests.m */, E7DB20CA22B2BAA5005AC45F /* RCTConvert_NSURLTests.m */, E7DB20CE22B2BAA5005AC45F /* RCTConvert_YGValueTests.m */, @@ -611,6 +614,7 @@ E7DB20DF22B2BAA6005AC45F /* RCTImageLoaderTests.m in Sources */, E7DB20D522B2BAA6005AC45F /* RCTPerformanceLoggerTests.m in Sources */, E7DB20D922B2BAA6005AC45F /* RCTAnimationUtilsTests.m in Sources */, + D1A7C0DE00000000000000A1 /* RCTTextDataDetectorTests.mm in Sources */, E7DB20D122B2BAA6005AC45F /* RCTBundleURLProviderTests.m in Sources */, E7DB20EC22B2BAA6005AC45F /* RCTMultipartStreamReaderTests.m in Sources */, E7DB20E022B2BAA6005AC45F /* RCTMethodArgumentTests.m in Sources */, diff --git a/packages/rn-tester/RNTesterUnitTests/RCTTextDataDetectorTests.mm b/packages/rn-tester/RNTesterUnitTests/RCTTextDataDetectorTests.mm new file mode 100644 index 000000000000..9a47e0521e20 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTTextDataDetectorTests.mm @@ -0,0 +1,138 @@ +/* + * 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. + */ + +#import + +#import +#import +#import +#import + +using namespace facebook::react; + +static const RCTTextDataDetectorTypes kAllTypes = + RCTTextDataDetectorTypePhoneNumber | RCTTextDataDetectorTypeLink | RCTTextDataDetectorTypeEmail; + +static AttributedString RCTAttributedStringWithText(const std::string &text) +{ + auto fragment = AttributedString::Fragment{}; + fragment.string = text; + fragment.textAttributes.fontSize = 14; + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +@interface RCTTextDataDetectorTests : XCTestCase +@end + +@implementation RCTTextDataDetectorTests { + RCTTextLayoutManager *_layoutManager; +} + +- (void)setUp +{ + [super setUp]; + _layoutManager = [RCTTextLayoutManager new]; +} + +// Returns the URL that each detected link opens when tapped in the middle. +- (NSArray *)_URLsInText:(const std::string &)text + types:(RCTTextDataDetectorTypes)types + frame:(CGRect)frame + paragraphAttributes:(ParagraphAttributes)paragraphAttributes +{ + auto attributedString = RCTAttributedStringWithText(text); + NSMutableArray *urls = [NSMutableArray new]; + [_layoutManager getRectWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:types + enumerateAttribute:RCTTextAttributesAccessibilityRoleAttributeName + frame:frame + usingBlock:^(CGRect fragmentRect, NSString *fragmentText, NSString *value) { + CGPoint point = CGPointMake( + frame.origin.x + CGRectGetMidX(fragmentRect), + frame.origin.y + CGRectGetMidY(fragmentRect)); + NSURL *url = + [self->_layoutManager detectedURLWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:types + frame:frame + atPoint:point]; + [urls addObject:url.absoluteString ?: @"nil"]; + }]; + return urls; +} + +- (NSArray *)_URLsInText:(const std::string &)text types:(RCTTextDataDetectorTypes)types +{ + return [self _URLsInText:text types:types frame:CGRectMake(0, 0, 1000, 200) paragraphAttributes:{}]; +} + +- (void)testDetectsOnlyTheRequestedTypes +{ + std::string text = "Call 123-123-1234 or see https://example.com or mail a@example.com"; + XCTAssertEqualObjects([self _URLsInText:text types:RCTTextDataDetectorTypeNone], @[]); + XCTAssertEqualObjects([self _URLsInText:text types:RCTTextDataDetectorTypePhoneNumber], @[ @"tel:1231231234" ]); + XCTAssertEqualObjects([self _URLsInText:text types:RCTTextDataDetectorTypeLink], @[ @"https://example.com" ]); + XCTAssertEqualObjects([self _URLsInText:text types:RCTTextDataDetectorTypeEmail], @[ @"mailto:a@example.com" ]); + NSArray *expected = @[ @"tel:1231231234", @"https://example.com", @"mailto:a@example.com" ]; + XCTAssertEqualObjects([self _URLsInText:text types:kAllTypes], expected); +} + +- (void)testPhoneExtensionIsNotDialedAsPartOfTheNumber +{ + XCTAssertEqualObjects( + [self _URLsInText:"Call 123-123-1234 ext. 55 today" types:RCTTextDataDetectorTypePhoneNumber], + @[ @"tel:1231231234" ]); +} + +- (void)testOtherSchemesAreNotLinks +{ + std::string text = "myapp://path file:///etc/hosts ssh://user@example.com"; + XCTAssertEqualObjects([self _URLsInText:text types:RCTTextDataDetectorTypeLink], @[]); + XCTAssertEqualObjects([self _URLsInText:text types:kAllTypes], @[]); +} + +- (void)testPointsAreRelativeToTheFrame +{ + auto attributedString = RCTAttributedStringWithText("https://example.com"); + ParagraphAttributes paragraphAttributes; + CGRect frame = CGRectMake(40, 40, 300, 100); + XCTAssertNotNil([_layoutManager detectedURLWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:RCTTextDataDetectorTypeLink + frame:frame + atPoint:CGPointMake(50, 45)]); + // Inside the text if the frame were at the origin, but in the padding here. + XCTAssertNil([_layoutManager detectedURLWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:RCTTextDataDetectorTypeLink + frame:frame + atPoint:CGPointMake(10, 5)]); + // Past the end of the text. + XCTAssertNil([_layoutManager detectedURLWithAttributedString:attributedString + paragraphAttributes:paragraphAttributes + dataDetectorTypes:RCTTextDataDetectorTypeLink + frame:frame + atPoint:CGPointMake(330, 45)]); +} + +- (void)testLinksHiddenByTruncationAreNotReported +{ + ParagraphAttributes paragraphAttributes; + paragraphAttributes.maximumNumberOfLines = 1; + paragraphAttributes.ellipsizeMode = EllipsizeMode::Tail; + std::string text = "https://first.example.com and a long run of words that will not fit https://hidden.example.com"; + NSArray *urls = [self _URLsInText:text + types:RCTTextDataDetectorTypeLink + frame:CGRectMake(0, 0, 300, 20) + paragraphAttributes:paragraphAttributes]; + XCTAssertEqualObjects(urls, @[ @"https://first.example.com" ]); +} + +@end diff --git a/packages/rn-tester/js/examples/Text/TextExample.ios.js b/packages/rn-tester/js/examples/Text/TextExample.ios.js index ff5ff29a097f..9a359bf0a284 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.ios.js +++ b/packages/rn-tester/js/examples/Text/TextExample.ios.js @@ -860,6 +860,27 @@ const examples = [ ); }, }, + { + title: 'Data detectors', + name: 'dataDetectorType', + render: function (): React.Node { + return ( + + Phone number: 123-123-1234 + Link: https://www.facebook.com + Email: employee@facebook.com + + Phone number: 123-123-1234 Link: https://www.facebook.com Email: + employee@facebook.com + + + Phone number: 123-123-1234 Link: https://www.facebook.com Email: + employee@facebook.com + + + ); + }, + }, { title: 'Text Decoration', name: 'textDecoration', diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 0dd275db8b98..e68daa755ddd 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1430,6 +1430,7 @@ interface RCTObjectAnimatedNode : public RCTAnimatedNode { } interface RCTParagraphComponentAccessibilityProvider : public NSObject { + public @property (assign) RCTTextDataDetectorTypes dataDetectorTypes; public virtual BOOL isUpToDate:(facebook::react::AttributedString currentAttributedString); public virtual NSArray* accessibilityElements(); public virtual instancetype initWithString:layoutManager:paragraphAttributes:frame:view:(facebook::react::AttributedString attributedString, RCTTextLayoutManager* layoutManager, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, UIView* view); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index af03ebeb5a9c..e4faf50292fa 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1473,6 +1473,7 @@ interface RCTObjectAnimatedNode : public RCTAnimatedNode { } interface RCTParagraphComponentAccessibilityProvider : public NSObject { + public @property (assign) RCTTextDataDetectorTypes dataDetectorTypes; public virtual BOOL isUpToDate:(facebook::react::AttributedString currentAttributedString); public virtual NSArray* accessibilityElements(); public virtual instancetype initWithString:layoutManager:paragraphAttributes:frame:view:(facebook::react::AttributedString attributedString, RCTTextLayoutManager* layoutManager, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, UIView* view); @@ -1872,11 +1873,14 @@ interface RCTTextInputComponentView : public RCTViewComponentView { } interface RCTTextLayoutManager : public NSObject { + public virtual _Nullable NSURL* detectedURLWithAttributedString:paragraphAttributes:dataDetectorTypes:frame:atPoint:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, RCTTextDataDetectorTypes dataDetectorTypes, CGRect frame, CGPoint point); public virtual facebook::react::LinesMeasurements getLinesForAttributedString:paragraphAttributes:size:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGSize size); public virtual facebook::react::TextMeasurement measureAttributedString:paragraphAttributes:layoutContext:layoutConstraints:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, facebook::react::TextLayoutContext layoutContext, facebook::react::LayoutConstraints layoutConstraints); public virtual facebook::react::TextMeasurement measureNSAttributedString:paragraphAttributes:layoutContext:layoutConstraints:(NSAttributedString* attributedString, facebook::react::ParagraphAttributes paragraphAttributes, facebook::react::TextLayoutContext layoutContext, facebook::react::LayoutConstraints layoutConstraints); public virtual std::shared_ptr getEventEmitterWithAttributeString:paragraphAttributes:frame:atPoint:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, CGPoint point); + public virtual void drawAttributedString:paragraphAttributes:dataDetectorTypes:frame:drawHighlightPath:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, RCTTextDataDetectorTypes dataDetectorTypes, CGRect frame, void(^)(UIBezierPath* highlightPath) block); public virtual void drawAttributedString:paragraphAttributes:frame:drawHighlightPath:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, void(^)(UIBezierPath* highlightPath) block); + public virtual void getRectWithAttributedString:paragraphAttributes:dataDetectorTypes:enumerateAttribute:frame:usingBlock:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, RCTTextDataDetectorTypes dataDetectorTypes, NSString* enumerateAttribute, CGRect frame, RCTTextLayoutFragmentEnumerationBlock block); public virtual void getRectWithAttributedString:paragraphAttributes:enumerateAttribute:frame:usingBlock:(const facebook::react::AttributedString& attributedString, facebook::react::ParagraphAttributes paragraphAttributes, NSString* enumerateAttribute, CGRect frame, RCTTextLayoutFragmentEnumerationBlock block); } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 6de636e0f56d..5df7bdbf625a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1430,6 +1430,7 @@ interface RCTObjectAnimatedNode : public RCTAnimatedNode { } interface RCTParagraphComponentAccessibilityProvider : public NSObject { + public @property (assign) RCTTextDataDetectorTypes dataDetectorTypes; public virtual BOOL isUpToDate:(facebook::react::AttributedString currentAttributedString); public virtual NSArray* accessibilityElements(); public virtual instancetype initWithString:layoutManager:paragraphAttributes:frame:view:(facebook::react::AttributedString attributedString, RCTTextLayoutManager* layoutManager, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, UIView* view); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 7ab648c8073e..34640dd547de 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1430,6 +1430,7 @@ interface RCTObjectAnimatedNode : public RCTAnimatedNode { } interface RCTParagraphComponentAccessibilityProvider : public NSObject { + public @property (assign) RCTTextDataDetectorTypes dataDetectorTypes; public virtual BOOL isUpToDate:(facebook::react::AttributedString currentAttributedString); public virtual NSArray* accessibilityElements(); public virtual instancetype initWithString:layoutManager:paragraphAttributes:frame:view:(facebook::react::AttributedString attributedString, RCTTextLayoutManager* layoutManager, facebook::react::ParagraphAttributes paragraphAttributes, CGRect frame, UIView* view);