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..98e82e2f55ad 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 @@ -46,22 +50,215 @@ @interface RCTParagraphTextView : UIView @end #if !TARGET_OS_TV -@interface RCTParagraphComponentView () +/* + * 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, and it never paints the text. + */ +@interface RCTSelectableTextView : UITextView -@property (nonatomic, nullable) UIEditMenuInteraction *editMenuInteraction API_AVAILABLE(ios(16.0)); +/* + * 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 -#else + +@implementation RCTSelectableTextView { + UITapGestureRecognizer *_dismissSelectionRecognizer; +} + +- (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; +} + +#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]; +} + +#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 + @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 +334,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 +368,9 @@ - (void)prepareForRecycle [super prepareForRecycle]; _textView.state = nullptr; _accessibilityProvider = nil; +#if !TARGET_OS_TV + [self disableContextMenu]; +#endif } - (void)layoutSubviews @@ -196,6 +399,16 @@ - (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) { + // `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 } #pragma mark - Accessibility @@ -332,83 +545,88 @@ - (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; + [self removeSelectableTextView]; + // Nothing else uses it while the paragraph is not selectable. + _selectionLayoutManager = nil; } -- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture +- (void)removeSelectableTextView { - 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]; - } + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _selectionRenderedSize = CGSizeZero; } -- (BOOL)canBecomeFirstResponder +/* + * 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)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame { - const auto ¶graphProps = static_cast(*_props); - return paragraphProps.isSelectable; -} + NSAttributedString *attributedText = self.attributedText; + if (attributedText.length == 0 || CGRectIsEmpty(drawingFrame)) { + [self removeSelectableTextView]; + return; + } -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender -{ - const auto ¶graphProps = static_cast(*_props); + BOOL needsRebuild = _selectableTextView == nil || + ![attributedText isEqualToAttributedString:_selectionRenderedText] || + !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize); + + if (needsRebuild) { + NSTextStorage *textStorage = + [_selectionLayoutManager textStorageForNSAttributedString:RCTUnpaintedAttributedString(attributedText) + paragraphAttributes:_paragraphAttributes + size:drawingFrame.size]; + NSTextContainer *textContainer = textStorage.layoutManagers.firstObject.textContainers.firstObject; + + [_selectableTextView removeFromSuperview]; + _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]; + } - if (paragraphProps.isSelectable && action == @selector(copy:)) { - return YES; + _selectionRenderedText = [attributedText copy]; + _selectionRenderedSize = drawingFrame.size; } - return [self.nextResponder canPerformAction:action withSender:sender]; + _selectableTextView.frame = drawingFrame; + // Copy reads the range from the painted string, not from the stripped copy + // the text view lays out. + _selectableTextView.sourceAttributedText = attributedText; } -- (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/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 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