From face14d7a5f8bc4b8ae910b729f288e7e219adf6 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:36:25 +0530 Subject: [PATCH 1/4] feat: implement selectable text view and update text layout manager for consistent paragraph rendering --- .../Text/RCTParagraphComponentView.mm | 167 +++++++++++------- .../textlayoutmanager/RCTTextLayoutManager.h | 10 ++ .../textlayoutmanager/RCTTextLayoutManager.mm | 9 + 3 files changed, 124 insertions(+), 62 deletions(-) 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 228ec0d2a28f..e36b288fe792 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -46,22 +46,60 @@ @interface RCTParagraphTextView : UIView @end #if !TARGET_OS_TV -@interface RCTParagraphComponentView () +/* + * A non-editable `UITextView` used to render a selectable paragraph. + * + * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` + * measured the paragraph with, so its layout matches the measurement by + * construction rather than by coincidence. UIKit performs the selection; it + * never performs the layout. + */ +@interface RCTSelectableTextView : UITextView +@end -@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0)); +@implementation RCTSelectableTextView + +- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer +{ + if (self = [super initWithFrame:frame textContainer:textContainer]) { + self.backgroundColor = UIColor.clearColor; + self.editable = NO; + self.selectable = YES; + self.scrollEnabled = NO; + self.contentInset = UIEdgeInsetsZero; + self.textContainerInset = UIEdgeInsetsZero; + self.adjustsFontForContentSizeCategory = NO; + // `RCTTextLayoutManager` already applies the padding it wants. + self.textContainer.lineFragmentPadding = 0.0; + // The paragraph owns its layout; the text view must never reflow it. + self.textContainer.widthTracksTextView = NO; + self.textContainer.heightTracksTextView = NO; + // publishes its own accessibility elements, one per link, through + // `RCTParagraphComponentAccessibilityProvider`. Keeping the text view out of + // the accessibility tree leaves that contract exactly as it was. + self.accessibilityElementsHidden = YES; + } + return self; +} @end -#else +#endif // !TARGET_OS_TV + @interface RCTParagraphComponentView () @end -#endif @implementation RCTParagraphComponentView { ParagraphAttributes _paragraphAttributes; RCTParagraphComponentAccessibilityProvider *_accessibilityProvider; - UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; CGRect _textLayoutFrame; +#if !TARGET_OS_TV + // Selection state. `_selectableTextView` is non-nil only while `selectable` is set. + RCTSelectableTextView *_selectableTextView; + RCTTextLayoutManager *_selectionLayoutManager; + NSAttributedString *_selectionRenderedText; + CGSize _selectionRenderedSize; +#endif } - (instancetype)initWithFrame:(CGRect)frame @@ -137,6 +175,9 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & { _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; +#if !TARGET_OS_TV + _selectionRenderedText = nil; +#endif [self setNeedsLayout]; // If the attributed string has changed, we need to notify the accessibility system that something changed, @@ -168,6 +209,9 @@ - (void)prepareForRecycle [super prepareForRecycle]; _textView.state = nullptr; _accessibilityProvider = nil; +#if !TARGET_OS_TV + [self disableContextMenu]; +#endif } - (void)layoutSubviews @@ -196,6 +240,13 @@ - (void)layoutSubviews _textLayoutFrame = drawingFrame; _textView.frame = textViewFrame; _textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y); + +#if !TARGET_OS_TV + const auto ¶graphProps = static_cast(*_props); + if (paragraphProps.isSelectable) { + [self updateSelectableTextViewWithFrame:RCTCGRectFromRect(_layoutMetrics.getContentFrame())]; + } +#endif } #pragma mark - Accessibility @@ -332,83 +383,75 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point #pragma mark - Context Menu #if !TARGET_OS_TV +/* + * Selection is provided by a `UITextView` laid out with the paragraph's own + * TextKit stack, which gives the platform behaviour users expect: long press to + * select a word, drag handles to extend the range and an edit menu that copies + * only what is selected. + */ - (void)enableContextMenu { - _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self - action:@selector(handleLongPress:)]; - - if (@available(iOS 16.0, *)) { - _editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self]; - [self addInteraction:_editMenuInteraction]; + if (_selectionLayoutManager == nil) { + _selectionLayoutManager = [RCTTextLayoutManager new]; } - [self addGestureRecognizer:_longPressGestureRecognizer]; + _selectionRenderedText = nil; + [self setNeedsLayout]; } - (void)disableContextMenu { - [self removeGestureRecognizer:_longPressGestureRecognizer]; - if (@available(iOS 16.0, *)) { - [self removeInteraction:_editMenuInteraction]; - _editMenuInteraction = nil; - } - _longPressGestureRecognizer = nil; + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _selectionRenderedSize = CGSizeZero; + _textView.hidden = NO; } -- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture +/* + * Builds or repositions the selectable text view. A `UITextView` binds its text + * container at initialisation, so it is rebuilt only when the text or the + * available size actually changes. + */ +- (void)updateSelectableTextViewWithFrame:(CGRect)contentFrame { - if (@available(iOS 16.0, macCatalyst 16.0, *)) { - CGPoint location = [gesture locationInView:self]; - UIEditMenuConfiguration *config = [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:location]; - if (_editMenuInteraction) { - [_editMenuInteraction presentEditMenuWithConfiguration:config]; - } - } else { - UIMenuController *menuController = [UIMenuController sharedMenuController]; - - if (menuController.isMenuVisible) { - return; - } - - [menuController showMenuFromView:self rect:self.bounds]; + NSAttributedString *attributedText = self.attributedText; + if (attributedText == nil || CGRectIsEmpty(contentFrame)) { + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _textView.hidden = NO; + return; } -} -- (BOOL)canBecomeFirstResponder -{ - const auto ¶graphProps = static_cast(*_props); - return paragraphProps.isSelectable; -} + BOOL needsRebuild = _selectableTextView == nil || + ![attributedText isEqualToAttributedString:_selectionRenderedText] || + !CGSizeEqualToSize(contentFrame.size, _selectionRenderedSize); -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender -{ - const auto ¶graphProps = static_cast(*_props); + if (needsRebuild) { + NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:attributedText + paragraphAttributes:_paragraphAttributes + size:contentFrame.size]; + NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; - if (paragraphProps.isSelectable && action == @selector(copy:)) { - return YES; + [_selectableTextView removeFromSuperview]; + _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:contentFrame textContainer:textContainer]; + [self addSubview:_selectableTextView]; + + _selectionRenderedText = [attributedText copy]; + _selectionRenderedSize = contentFrame.size; } - return [self.nextResponder canPerformAction:action withSender:sender]; + _selectableTextView.frame = contentFrame; + // The text view renders the paragraph, so the drawn copy must stay hidden. + _textView.hidden = YES; } -- (void)copy:(id)sender +- (BOOL)canBecomeFirstResponder { - NSAttributedString *attributedText = self.attributedText; - - NSMutableDictionary *item = [NSMutableDictionary new]; - - NSData *rtf = [attributedText dataFromRange:NSMakeRange(0, attributedText.length) - documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} - error:nil]; - - if (rtf) { - [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; - } - - [item setObject:attributedText.string forKey:(id)kUTTypeUTF8PlainText]; - - UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; - pasteboard.items = @[ item ]; + // While selectable, `_selectableTextView` is the responder that owns the selection. + return NO; } + #else - (void)enableContextMenu { 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 a4b9be72c453..9c0136e6a0cb 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 @@ -53,6 +53,16 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame atPoint:(CGPoint)point; +/** + * Returns a TextKit stack (an `NSTextStorage` with an attached `NSLayoutManager` + * and `NSTextContainer`) configured exactly as the one used to measure and draw + * the paragraph. Handing the returned container to a `UITextView` makes that + * text view lay the text out identically to `drawAttributedString:`. + */ +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + - (void)getRectWithAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes enumerateAttribute:(NSString *)enumerateAttribute 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 5f9847a398fa..5724744c7486 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 @@ -415,6 +415,15 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr return paragraphLines; } +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + return [self _textStorageAndLayoutManagerWithAttributesString:attributedString + paragraphAttributes:paragraphAttributes + size:size]; +} + - (NSTextStorage *)_textStorageAndLayoutManagerWithAttributesString:(NSAttributedString *)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size From bfb22e789388df6926823b1815bb9e4f5ecf0779 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:08:23 +0530 Subject: [PATCH 2/4] fix: add textStorageForNSAttributedString method to RCTTextLayoutManager --- .../ComponentViews/Text/RCTParagraphComponentView.mm | 4 ++++ .../renderer/textlayoutmanager/RCTTextLayoutManager.h | 10 ---------- 2 files changed, 4 insertions(+), 10 deletions(-) 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 e36b288fe792..4afc025358bc 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -32,6 +32,10 @@ - (CGRect)drawingFrameForAttributedString:(facebook::react::AttributedString)att frame:(CGRect)frame containerFrame:(CGRect *)containerFrame; +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + @end // ParagraphTextView is an auxiliary view we set as contentView so the drawing 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 9c0136e6a0cb..a4b9be72c453 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 @@ -53,16 +53,6 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame atPoint:(CGPoint)point; -/** - * Returns a TextKit stack (an `NSTextStorage` with an attached `NSLayoutManager` - * and `NSTextContainer`) configured exactly as the one used to measure and draw - * the paragraph. Handing the returned container to a `UITextView` makes that - * text view lay the text out identically to `drawAttributedString:`. - */ -- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString - paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes - size:(CGSize)size; - - (void)getRectWithAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes enumerateAttribute:(NSString *)enumerateAttribute From d185a90d61edf989bcc22731619ac9dd31cf0c2d Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:20:27 +0530 Subject: [PATCH 3/4] fix: enhance RCTSelectableTextView for improved text selection handling --- .../Text/RCTParagraphComponentView.mm | 163 +++++++++++++++--- 1 file changed, 142 insertions(+), 21 deletions(-) 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 4afc025358bc..595d6821ecd9 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -51,17 +51,46 @@ @interface RCTParagraphTextView : UIView #if !TARGET_OS_TV /* - * A non-editable `UITextView` used to render a selectable paragraph. + * Strips every attribute that paints, and keeps every attribute that lays out. + * + * `RCTTextLayoutManager` draws the paragraph itself, and it draws effects UIKit + * knows nothing about: wavy, dotted and dashed decorations, and the pressed + * highlight of a nested pressable . The selection text view must lay the + * same glyphs out, because that is what places the selection rects, but it must + * not paint them. So the font, the kerning, the paragraph style and the + * attachments stay, and the colors, the decorations and the shadow go. + */ +static NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString) +{ + NSMutableAttributedString *unpainted = [attributedString mutableCopy]; + NSRange range = NSMakeRange(0, unpainted.length); + + [unpainted beginEditing]; + [unpainted addAttribute:NSForegroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted addAttribute:NSBackgroundColorAttributeName value:UIColor.clearColor range:range]; + [unpainted removeAttribute:NSUnderlineStyleAttributeName range:range]; + [unpainted removeAttribute:NSStrikethroughStyleAttributeName range:range]; + [unpainted removeAttribute:NSShadowAttributeName range:range]; + [unpainted endEditing]; + + return unpainted; +} + +/* + * A non-editable `UITextView` that provides selection for a paragraph, and + * nothing else. * * It is created with the very `NSTextContainer` that `RCTTextLayoutManager` * measured the paragraph with, so its layout matches the measurement by * construction rather than by coincidence. UIKit performs the selection; it - * never performs the layout. + * never performs the layout, and it never paints the text. */ @interface RCTSelectableTextView : UITextView @end -@implementation RCTSelectableTextView +@implementation RCTSelectableTextView { + UITapGestureRecognizer *_dismissSelectionRecognizer; +} - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer { @@ -86,6 +115,87 @@ - (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)tex return self; } +#pragma mark - Dismissing the selection + +/* + * A tap outside the text clears the selection, which is what Android does and + * what a user expects. Nothing else in React Native takes first responder on a + * tap, so without this the selection stays on screen forever. + */ +- (BOOL)becomeFirstResponder +{ + BOOL didBecomeFirstResponder = [super becomeFirstResponder]; + if (didBecomeFirstResponder) { + [self _addDismissSelectionRecognizer]; + } + return didBecomeFirstResponder; +} + +- (BOOL)resignFirstResponder +{ + BOOL didResignFirstResponder = [super resignFirstResponder]; + if (didResignFirstResponder) { + [self _removeDismissSelectionRecognizer]; + self.selectedRange = NSMakeRange(0, 0); + } + return didResignFirstResponder; +} + +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + if (newWindow == nil) { + // The recognizer holds this view, so it has to go when the view does. + [self _removeDismissSelectionRecognizer]; + } +} + +- (void)_addDismissSelectionRecognizer +{ + if (_dismissSelectionRecognizer != nil) { + return; + } + + // The recognizer belongs on the topmost React Native view, and not on the + // window: `RCTSurfaceTouchHandler` gives way to a recognizer that sits + // outside the surface, so a recognizer on the window would make every touch + // in the application wait for this one. + UIView *rootView = nil; + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + if ([ancestor isKindOfClass:[RCTViewComponentView class]]) { + rootView = ancestor; + } + } + if (rootView == nil) { + return; + } + + _dismissSelectionRecognizer = + [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapToDismissSelection:)]; + // The tap still reaches the component the user tapped. + _dismissSelectionRecognizer.cancelsTouchesInView = NO; + _dismissSelectionRecognizer.delaysTouchesBegan = NO; + _dismissSelectionRecognizer.delaysTouchesEnded = NO; + [rootView addGestureRecognizer:_dismissSelectionRecognizer]; +} + +- (void)_removeDismissSelectionRecognizer +{ + [_dismissSelectionRecognizer.view removeGestureRecognizer:_dismissSelectionRecognizer]; + _dismissSelectionRecognizer = nil; +} + +- (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer +{ + // A tap on the text itself belongs to the text view, which moves or clears + // the selection on its own. + if ([self pointInside:[recognizer locationInView:self] withEvent:nil]) { + return; + } + + [self resignFirstResponder]; +} + @end #endif // !TARGET_OS_TV @@ -248,7 +358,10 @@ - (void)layoutSubviews #if !TARGET_OS_TV const auto ¶graphProps = static_cast(*_props); if (paragraphProps.isSelectable) { - [self updateSelectableTextViewWithFrame:RCTCGRectFromRect(_layoutMetrics.getContentFrame())]; + // `drawingFrame` is the frame `RCTParagraphTextView` draws the glyphs into, + // compression adjustment included. The selection must use the same frame, + // or the selection rects sit away from the glyphs they select. + [self updateSelectableTextViewWithDrawingFrame:drawingFrame]; } #endif } @@ -403,12 +516,16 @@ - (void)enableContextMenu } - (void)disableContextMenu +{ + [self removeSelectableTextView]; +} + +- (void)removeSelectableTextView { [_selectableTextView removeFromSuperview]; _selectableTextView = nil; _selectionRenderedText = nil; _selectionRenderedSize = CGSizeZero; - _textView.hidden = NO; } /* @@ -416,38 +533,42 @@ - (void)disableContextMenu * container at initialisation, so it is rebuilt only when the text or the * available size actually changes. */ -- (void)updateSelectableTextViewWithFrame:(CGRect)contentFrame +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame { NSAttributedString *attributedText = self.attributedText; - if (attributedText == nil || CGRectIsEmpty(contentFrame)) { - [_selectableTextView removeFromSuperview]; - _selectableTextView = nil; - _selectionRenderedText = nil; - _textView.hidden = NO; + if (attributedText.length == 0 || CGRectIsEmpty(drawingFrame)) { + [self removeSelectableTextView]; return; } BOOL needsRebuild = _selectableTextView == nil || ![attributedText isEqualToAttributedString:_selectionRenderedText] || - !CGSizeEqualToSize(contentFrame.size, _selectionRenderedSize); + !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize); if (needsRebuild) { - NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:attributedText - paragraphAttributes:_paragraphAttributes - size:contentFrame.size]; + NSTextStorage *textStorage = + [_selectionLayoutManager textStorageForNSAttributedString:RCTUnpaintedAttributedString(attributedText) + paragraphAttributes:_paragraphAttributes + size:drawingFrame.size]; NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; [_selectableTextView removeFromSuperview]; - _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:contentFrame textContainer:textContainer]; - [self addSubview:_selectableTextView]; + _selectableTextView = [[RCTSelectableTextView alloc] initWithFrame:drawingFrame textContainer:textContainer]; + // Under the drawn paragraph, which is how a native text view stacks the two: + // UIKit paints the selection, and the glyphs go on top of it. The drawn + // paragraph passes touches through, so the text view still gets them. + UIView *container = _textView.superview; + if (container != nil) { + [container insertSubview:_selectableTextView belowSubview:_textView]; + } else { + [self addSubview:_selectableTextView]; + } _selectionRenderedText = [attributedText copy]; - _selectionRenderedSize = contentFrame.size; + _selectionRenderedSize = drawingFrame.size; } - _selectableTextView.frame = contentFrame; - // The text view renders the paragraph, so the drawn copy must stay hidden. - _textView.hidden = YES; + _selectableTextView.frame = drawingFrame; } - (BOOL)canBecomeFirstResponder From 2dbe5b784aec8ae8b9fa5be2ebef6c9990ce3248 Mon Sep 17 00:00:00 2001 From: Rajendran Nadar <17236768+raajnadar@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:11:29 +0530 Subject: [PATCH 4/4] fix: add sourceAttributedText property and update copy method in RCTSelectableTextView for improved text selection handling --- .../Text/RCTParagraphComponentView.mm | 50 +++ .../Tests/Text/RCTParagraphSelectionTests.mm | 310 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm 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 595d6821ecd9..98e82e2f55ad 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -86,6 +86,16 @@ @interface RCTParagraphTextView : UIView * never performs the layout, and it never paints the text. */ @interface RCTSelectableTextView : UITextView + +/* + * The paragraph as it is painted, before `RCTUnpaintedAttributedString` strips + * it. The text view lays the stripped copy out, so `copy:` must read the range + * from this string instead, or the pasteboard receives clear text with no + * decorations. Both strings hold the same characters, so the range maps + * directly from one to the other. + */ +@property (nonatomic, copy, nullable) NSAttributedString *sourceAttributedText; + @end @implementation RCTSelectableTextView { @@ -196,6 +206,41 @@ - (void)_handleTapToDismissSelection:(UITapGestureRecognizer *)recognizer [self resignFirstResponder]; } +#pragma mark - Copying + +/* + * Writes the selected range to the pasteboard as rich text and as plain text, + * which is what `RCTParagraphComponentView` did for the whole paragraph before + * selection existed. `UITextView` would otherwise copy from its own storage, + * and that storage carries no colour and no decorations. + */ +- (void)copy:(id)sender +{ + NSRange selectedRange = self.selectedRange; + NSAttributedString *sourceAttributedText = _sourceAttributedText; + + if (sourceAttributedText == nil || selectedRange.length == 0 || + NSMaxRange(selectedRange) > sourceAttributedText.length) { + [super copy:sender]; + return; + } + + NSAttributedString *selectedText = [sourceAttributedText attributedSubstringFromRange:selectedRange]; + NSMutableDictionary *item = [NSMutableDictionary new]; + + NSData *rtf = [selectedText dataFromRange:NSMakeRange(0, selectedText.length) + documentAttributes:@{NSDocumentTypeDocumentAttribute : NSRTFDTextDocumentType} + error:nil]; + + if (rtf) { + [item setObject:rtf forKey:(id)kUTTypeFlatRTFD]; + } + + [item setObject:selectedText.string forKey:(id)kUTTypeUTF8PlainText]; + + UIPasteboard.generalPasteboard.items = @[ item ]; +} + @end #endif // !TARGET_OS_TV @@ -518,6 +563,8 @@ - (void)enableContextMenu - (void)disableContextMenu { [self removeSelectableTextView]; + // Nothing else uses it while the paragraph is not selectable. + _selectionLayoutManager = nil; } - (void)removeSelectableTextView @@ -569,6 +616,9 @@ - (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame } _selectableTextView.frame = drawingFrame; + // Copy reads the range from the painted string, not from the stripped copy + // the text view lays out. + _selectableTextView.sourceAttributedText = attributedText; } - (BOOL)canBecomeFirstResponder diff --git a/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm new file mode 100644 index 000000000000..9e6c6a0013ee --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -0,0 +1,310 @@ +/* + * 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 +#import +#import +#import +#import +#import + +using namespace facebook::react; + +/* + * Covers `` on iOS. Selection is provided by a `UITextView` + * that lays the paragraph out but never paints it, while + * `RCTParagraphComponentView` keeps drawing the glyphs. These tests hold that + * split in place, and hold the paragraph unchanged when it is not selectable. + */ +@interface RCTParagraphSelectionTests : XCTestCase +@end + +@implementation RCTParagraphSelectionTests { + std::shared_ptr _textLayoutManager; +} + +- (void)setUp +{ + [super setUp]; + _textLayoutManager = std::make_shared(std::make_shared()); +} + +#pragma mark - Fixtures + +/* + * A paragraph that paints everything UIKit cannot: a colour, a wavy underline, + * a strikethrough and a shadow. + */ +- (AttributedString)decoratedAttributedString +{ + auto textAttributes = TextAttributes{}; + textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); + textAttributes.fontSize = 20; + textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; + textAttributes.textDecorationStyle = TextDecorationStyle::Wavy; + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable wavy decoration"; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +- (ParagraphShadowNode::ConcreteState::Shared)stateWithAttributedString:(AttributedString)attributedString +{ + auto stateData = ParagraphState{}; + stateData.attributedString = std::move(attributedString); + stateData.paragraphAttributes = ParagraphAttributes{}; + stateData.layoutManager = _textLayoutManager; + + return std::make_shared( + std::make_shared(std::move(stateData)), ShadowNodeFamily::Weak{}); +} + +- (Props::Shared)propsWithSelectable:(BOOL)selectable +{ + auto props = std::make_shared(); + props->isSelectable = selectable; + return props; +} + +/* + * Builds a laid-out paragraph view. `layoutIfNeeded` is what creates or removes + * the selection text view, so every test needs it. + */ +- (RCTParagraphComponentView *)paragraphViewSelectable:(BOOL)selectable +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:[self propsWithSelectable:selectable] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self decoratedAttributedString]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; + + [view layoutIfNeeded]; + return view; +} + +- (UITextView *)selectionTextViewIn:(UIView *)view +{ + for (UIView *subview in view.subviews) { + if ([subview isKindOfClass:[UITextView class]]) { + return (UITextView *)subview; + } + } + return nil; +} + +#pragma mark - The paragraph keeps drawing itself + +/* + * The first version of selection hid the drawn paragraph and let the text view + * paint instead. That lost wavy decorations, compressed line heights and the + * pressed highlight of a nested pressable . + */ +- (void)testDrawnParagraphStaysVisibleWhileSelectable +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertNotNil(view.contentView, @"The paragraph must keep its drawing view."); + XCTAssertFalse(view.contentView.hidden, @"The drawn paragraph must stay visible, or its effects are lost."); +} + +- (void)testSelectionTextViewSitsBelowTheDrawnParagraph +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView, @"A selectable paragraph must have a selection text view."); + + NSUInteger selectionIndex = [view.subviews indexOfObject:selectionTextView]; + NSUInteger drawnIndex = [view.subviews indexOfObject:view.contentView]; + + XCTAssertNotEqual(selectionIndex, NSNotFound); + XCTAssertNotEqual(drawnIndex, NSNotFound); + XCTAssertLessThan(selectionIndex, drawnIndex, @"UIKit paints the selection, and the glyphs must go on top of it."); +} + +/* + * The text view must lay the glyphs out, because that is what places the + * selection rects, and must paint none of them. + */ +- (void)testSelectionTextViewLaysOutButDoesNotPaint +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + NSAttributedString *laidOut = selectionTextView.textStorage; + XCTAssertGreaterThan(laidOut.length, 0u); + + NSRange range = NSMakeRange(0, laidOut.length); + [laidOut enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attributes, NSRange r, BOOL *stop) { + // Painting attributes are gone. + UIColor *foreground = attributes[NSForegroundColorAttributeName]; + XCTAssertEqualObjects(foreground, UIColor.clearColor, @"Text must not be painted twice."); + XCTAssertNil(attributes[NSUnderlineStyleAttributeName]); + XCTAssertNil(attributes[NSStrikethroughStyleAttributeName]); + XCTAssertNil(attributes[NSShadowAttributeName]); + + // Layout attributes stay, or the selection rects move. + XCTAssertNotNil(attributes[NSFontAttributeName], @"The font places the glyphs."); + }]; +} + +/* + * `copy:` maps the selected range onto the painted string. That only works if + * stripping the paint leaves the characters alone. + */ +- (void)testStrippedTextKeepsTheSameCharacters +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + XCTAssertEqualObjects( + selectionTextView.textStorage.string, + view.attributedText.string, + @"Stripping the paint must not change a single character."); +} + +#pragma mark - Copying + +/* + * Before selection existed, `copy:` wrote the whole paragraph as rich text and + * as plain text. It must still write rich text, and the rich text must carry + * the colour the user sees rather than the clear colour used for layout. + */ +- (void)testCopyWritesPaintedRichTextForTheSelectedRange +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + UIPasteboard *pasteboard = UIPasteboard.generalPasteboard; + pasteboard.items = @[]; + + NSRange selectedRange = NSMakeRange(0, 10); // "Selectable" + selectionTextView.selectedRange = selectedRange; + [selectionTextView copy:nil]; + + NSString *expected = [view.attributedText.string substringWithRange:selectedRange]; + XCTAssertEqualObjects(pasteboard.string, expected, @"Copy must copy the selected range, not the whole paragraph."); + + NSData *rtf = [pasteboard dataForPasteboardType:(id)kUTTypeFlatRTFD]; + XCTAssertNotNil(rtf, @"Copy must still put rich text on the pasteboard."); + + NSAttributedString *pasted = [[NSAttributedString alloc] initWithData:rtf + options:@{} + documentAttributes:nil + error:nil]; + XCTAssertEqualObjects(pasted.string, expected); + + // The decisive check. The text view lays out a copy with the foreground set to + // the clear colour, so a paste that came from the text view's own storage + // would be invisible. The pasted colour must be the painted one. + NSAttributedString *paintedSelection = [view.attributedText attributedSubstringFromRange:selectedRange]; + UIColor *paintedColor = [paintedSelection attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + UIColor *pastedColor = [pasted attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + XCTAssertNotNil(paintedColor); + XCTAssertNotNil(pastedColor); + + CGFloat paintedRed = 0, paintedAlpha = 0, pastedRed = 0, pastedAlpha = 0; + [paintedColor getRed:&paintedRed green:NULL blue:NULL alpha:&paintedAlpha]; + [pastedColor getRed:&pastedRed green:NULL blue:NULL alpha:&pastedAlpha]; + + XCTAssertEqualWithAccuracy(pastedRed, paintedRed, 0.01, @"Copied text must carry the colour the reader sees."); + XCTAssertEqualWithAccuracy(pastedAlpha, paintedAlpha, 0.01, @"Copied text must not be the clear layout copy."); + XCTAssertGreaterThan(pastedAlpha, 0.0, @"Copied text must be visible when pasted."); +} + +#pragma mark - Paragraphs that are not selectable + +/* + * Everything in this change sits behind `isSelectable`. A paragraph without it + * must be exactly what it was. + */ +- (void)testNonSelectableParagraphHasNoSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:NO]; + + XCTAssertNil([self selectionTextViewIn:view], @"A paragraph that is not selectable must gain no extra view."); + XCTAssertFalse(view.contentView.hidden); +} + +- (void)testTurningSelectableOffRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view layoutIfNeeded]; + + XCTAssertNil([self selectionTextViewIn:view], @"Turning selection off must remove the text view."); + XCTAssertFalse(view.contentView.hidden, @"The paragraph must still draw itself."); +} + +- (void)testTurningSelectableBackOnRestoresTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view layoutIfNeeded]; + XCTAssertNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:NO]]; + [view layoutIfNeeded]; + + XCTAssertNotNil([self selectionTextViewIn:view], @"Turning selection on again must rebuild the text view."); +} + +#pragma mark - Recycling + +/* + * Views are pooled and reused. A recycled paragraph must not carry the previous + * paragraph's selection into its next life. + */ +- (void)testRecyclingRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view prepareForRecycle]; + + XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); +} + +#pragma mark - Accessibility + +/* + * publishes one accessibility element per link through + * `RCTParagraphComponentAccessibilityProvider`. The selection text view must + * stay out of that tree, or VoiceOver reads the paragraph twice. + */ +- (void)testSelectionTextViewIsHiddenFromAccessibility +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView); + XCTAssertTrue(selectionTextView.accessibilityElementsHidden, @"The text view must not be read by VoiceOver."); +} + +@end