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..aa44ee9edfb3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -7,6 +7,7 @@ #import "RCTParagraphComponentView.h" #import "RCTParagraphComponentAccessibilityProvider.h" +#import "RCTSelectableTextView+Internal.h" #import #import @@ -32,6 +33,10 @@ - (CGRect)drawingFrameForAttributedString:(const facebook::react::AttributedStri frame:(CGRect)frame containerFrame:(CGRect *)containerFrame; +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + @end static BOOL RCTAttributedStringHasAttachments(const AttributedString &attributedString) @@ -72,6 +77,15 @@ @implementation RCTParagraphComponentView { UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; CGRect _textLayoutFrame; +#if !TARGET_OS_TV + RCTSelectableTextView *_selectableTextView; + RCTTextLayoutManager *_selectionLayoutManager; + ParagraphShadowNode::ConcreteState::Shared _selectionRenderedState; + NSAttributedString *_selectionRenderedText; + CGSize _selectionRenderedSize; + ParagraphAttributes _selectionRenderedParagraphAttributes; + BOOL _selectableTextViewNeedsUpdate; +#endif } - (instancetype)initWithFrame:(CGRect)frame @@ -140,6 +154,16 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } } +#if !TARGET_OS_TV + // The selection text view lays the text out with these attributes. A change + // of them alone computes no new frame, so nothing else marks the view dirty. + if (newParagraphProps.isSelectable && + newParagraphProps.paragraphAttributes != oldParagraphProps.paragraphAttributes && + ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + _selectableTextViewNeedsUpdate = YES; + } +#endif + [super updateProps:props oldProps:oldProps]; } @@ -206,6 +230,15 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask if ((updateMask & (RNComponentViewUpdateMaskState | RNComponentViewUpdateMaskLayoutMetrics)) != 0) { [self _updateTextViewFrame]; } +#if !TARGET_OS_TV + // `updateProps:` runs before the state and the layout of the same mutation, + // and a change of `selectable` alone computes no frames. So the text view is + // updated here, once, after all of the mutation. A text view that does not + // exist yet waits for the first touch. + if (_selectableTextView != nil) { + [self updateSelectableTextViewIfNeeded]; + } +#endif } - (void)prepareForRecycle @@ -213,6 +246,14 @@ - (void)prepareForRecycle [super prepareForRecycle]; _textView.state = nullptr; _accessibilityProvider = nil; +#if !TARGET_OS_TV + // Recycling removes only the selection text view. The long press stays, + // because the view keeps its `_props`: when the next paragraph is selectable + // too, `updateProps:` sees no change and does not add the long press again. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self disableTextSelection]; + } +#endif } - (void)_updateTextViewFrame @@ -246,6 +287,12 @@ - (void)_updateTextViewFrame _textLayoutFrame = drawingFrame; _textView.frame = textViewFrame; _textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y); + +#if !TARGET_OS_TV + if ([self isPartialTextSelectionEnabled]) { + _selectableTextViewNeedsUpdate = YES; + } +#endif } #pragma mark - Accessibility @@ -350,6 +397,17 @@ - (UIAccessibilityTraits)accessibilityTraits - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point { +#if !TARGET_OS_TV + // A drag of a selection handle starts on the glyphs the handle sits on, and + // that is often a pressable . Adjusting a selection must not press it. + // Only a touch on the selected text or on its handles is held back: a tap in + // the padding still presses the paragraph, or the around it. + if (_selectableTextView != nil && + [_selectableTextView selectionOwnsTouchAtPoint:[self convertPoint:point toView:_selectableTextView]]) { + return nullptr; + } +#endif + const auto &state = _textView.state; if (!state) { return _eventEmitter; @@ -384,6 +442,11 @@ - (SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point #if !TARGET_OS_TV - (void)enableContextMenu { + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self enableTextSelection]; + return; + } + _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)]; @@ -396,6 +459,11 @@ - (void)enableContextMenu - (void)disableContextMenu { + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self disableTextSelection]; + return; + } + [self removeGestureRecognizer:_longPressGestureRecognizer]; if (@available(iOS 16.0, *)) { [self removeInteraction:_editMenuInteraction]; @@ -425,12 +493,24 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture - (BOOL)canBecomeFirstResponder { + // With partial selection, `_selectableTextView` is the responder that owns the selection. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + return NO; + } + const auto ¶graphProps = static_cast(*_props); return paragraphProps.isSelectable; } - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { + // With partial selection, `_selectableTextView` copies the selected range. + // This class implements `copy:` for the long-press menu, so the inherited + // answer for `copy:` is YES and would make the paragraph copy all its text. + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + return action != @selector(copy:) && [super canPerformAction:action withSender:sender]; + } + const auto ¶graphProps = static_cast(*_props); if (paragraphProps.isSelectable && action == @selector(copy:)) { @@ -459,6 +539,184 @@ - (void)copy:(id)sender UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; pasteboard.items = @[ item ]; } + +#pragma mark - Text Selection + +/* + * 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)enableTextSelection +{ + _selectableTextViewNeedsUpdate = YES; +} + +- (void)disableTextSelection +{ + _selectableTextViewNeedsUpdate = NO; + [self removeSelectableTextView]; + _selectionLayoutManager = nil; +} + +- (BOOL)isPartialTextSelectionEnabled +{ + const auto ¶graphProps = static_cast(*_props); + return paragraphProps.isSelectable && ReactNativeFeatureFlags::enableIOSPartialTextSelection(); +} + +/* + * The selection text view is built on the first touch that lands on the + * paragraph, and not on mount. Most selectable paragraphs are never selected, + * and each text view holds a TextKit stack of its own. + * + * The text view sits right below the glyphs, and so above the inline views of + * the paragraph. The inline views must still get their touches first, as they + * do without the text view. The text view then takes only the touches that + * the paragraph itself takes: none with `pointerEvents="box-none"`, and all on + * the text with `pointerEvents="box-only"`. + */ +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + UIView *hitView = [super hitTest:point withEvent:event]; + if (![self isPartialTextSelectionEnabled]) { + return hitView; + } + + if (hitView == self) { + [self updateSelectableTextViewIfNeeded]; + return [self hitTestSelectableTextView:point withEvent:event] ?: self; + } + + if (_selectableTextView != nil && [hitView isDescendantOfView:_selectableTextView]) { + UIView *inlineHitView = [self hitTestInlineViews:point withEvent:event]; + if (inlineHitView != nil) { + return inlineHitView; + } + return _props->pointerEvents == PointerEventsMode::BoxNone ? nil : hitView; + } + + return hitView; +} + +- (UIView *)hitTestSelectableTextView:(CGPoint)point withEvent:(UIEvent *)event +{ + if (_selectableTextView == nil) { + return nil; + } + return [_selectableTextView hitTest:[_selectableTextView convertPoint:point fromView:self] withEvent:event]; +} + +- (UIView *)hitTestInlineViews:(CGPoint)point withEvent:(UIEvent *)event +{ + for (UIView *subview in [_selectableTextView.superview.subviews reverseObjectEnumerator]) { + if (subview == _selectableTextView || subview == _textView) { + continue; + } + UIView *hitView = [subview hitTest:[subview convertPoint:point fromView:self] withEvent:event]; + if (hitView != nil) { + return hitView; + } + } + return nil; +} + +- (void)removeSelectableTextView +{ + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedState = nullptr; + _selectionRenderedText = nil; + _selectionRenderedSize = CGSizeZero; + _selectionRenderedParagraphAttributes = ParagraphAttributes{}; +} + +- (void)updateSelectableTextViewIfNeeded +{ + if (!_selectableTextViewNeedsUpdate) { + return; + } + _selectableTextViewNeedsUpdate = NO; + // `_textLayoutFrame` 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:_textLayoutFrame]; +} + +/* + * A `UITextView` binds its text container at initialisation, so the selectable + * text view is rebuilt only when the text, the available size or the paragraph + * attributes that configure the container change. + */ +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame +{ + const auto &state = _textView.state; + if (!state || CGRectIsEmpty(drawingFrame)) { + [self removeSelectableTextView]; + return; + } + + // A relayout of unchanged text is the most frequent update, and it needs no + // conversion of the text. The content comparison does not look at the layout + // metrics that size the attachments, so a paragraph with attachments always + // takes the full comparison. + const auto &attributedString = state->getData().attributedString; + if (_selectableTextView != nil && _selectionRenderedState && + CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize) && + _paragraphAttributes == _selectionRenderedParagraphAttributes && + !RCTAttributedStringHasAttachments(attributedString) && + attributedString.isContentEqual(_selectionRenderedState->getData().attributedString)) { + _selectableTextView.frame = drawingFrame; + return; + } + + NSAttributedString *attributedText = RCTNSAttributedStringFromAttributedString(attributedString); + if (attributedText.length == 0) { + [self removeSelectableTextView]; + return; + } + + // The layout string decides the rebuild, and the painted string does not. A + // press on a nested pressable paints a highlight, which changes the + // painted string. A rebuild in the middle of that touch destroys the text + // view before its long press starts, so the paragraph never selects. + NSAttributedString *layoutText = RCTUnpaintedAttributedString(attributedText); + + BOOL needsRebuild = _selectableTextView == nil || ![layoutText isEqualToAttributedString:_selectionRenderedText] || + !CGSizeEqualToSize(drawingFrame.size, _selectionRenderedSize) || + _paragraphAttributes != _selectionRenderedParagraphAttributes; + + if (needsRebuild) { + if (_selectionLayoutManager == nil) { + _selectionLayoutManager = [RCTTextLayoutManager new]; + } + NSTextStorage *textStorage = [_selectionLayoutManager textStorageForNSAttributedString:layoutText + 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]; + } + + _selectionRenderedText = [layoutText copy]; + _selectionRenderedSize = drawingFrame.size; + _selectionRenderedParagraphAttributes = _paragraphAttributes; + } + + _selectionRenderedState = state; + _selectableTextView.frame = drawingFrame; + _selectableTextView.sourceAttributedText = attributedText; +} #else - (void)enableContextMenu { diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h new file mode 100644 index 000000000000..bd372db9d7f3 --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h @@ -0,0 +1,68 @@ +/* + * 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 + +#if !TARGET_OS_TV + +NS_ASSUME_NONNULL_BEGIN + +/* + * 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. + */ +RCT_EXTERN NSAttributedString *RCTUnpaintedAttributedString(NSAttributedString *attributedString); + +/* + * 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 + +/* + * 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; + +/* + * YES while a selection shows and the touches on this view belong to it: a + * handle drag, or a tap on the selected text. The paragraph hands out no event + * emitter for such a touch. It turns on once the touches that made the + * selection are cancelled, and off when the selection clears. + */ +@property (nonatomic, readonly) BOOL selectionOwnsTouches; + +/* + * Whether a touch at `point`, in this view's coordinates, belongs to the + * selection: it lands on the text, or on one of the two selection handles. + * The handle knobs sit outside the bounds, above the first line and below the + * last, so a bounds check alone lets a handle drag through. + */ +- (BOOL)selectionOwnsTouchAtPoint:(CGPoint)point; + +@end + +NS_ASSUME_NONNULL_END + +#endif // !TARGET_OS_TV diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm new file mode 100644 index 000000000000..1191e1a4d0a8 --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm @@ -0,0 +1,284 @@ +/* + * 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 "RCTSelectableTextView+Internal.h" + +#if !TARGET_OS_TV + +#import +#import +#import +#import +#import + +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 removeAttribute:RCTAttributedStringIsHighlightedAttributeName range:range]; + [unpainted endEditing]; + + return unpainted; +} + +@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; + self.delegate = self; + } + return self; +} + +#pragma mark - Giving the touch to the selection + +/* + * The selection gesture and `RCTSurfaceTouchHandler` track the same touch, and + * neither cancels the other. `RCTSurfaceTouchHandler` resolves its event + * emitter when the finger lands, before any selection exists, so lifting the + * finger after a long press presses the under it. Selecting a link + * follows the link. + * + * Once a selection exists the touch belongs to the selection, so the handler + * has to let go of it, and only of it: a finger held on another control keeps + * its touch. The cancel reaches React Native as a cancelled touch and stops + * the press. + * + * `selectionOwnsTouches` turns on after the cancel, not before. The pointer + * handler resolves the emitter again for its `pointerCancel`, and the + * paragraph hands out none once the selection owns the touches. + */ +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + BOOL hasSelection = textView.selectedRange.length > 0; + if (hasSelection && !_selectionOwnsTouches) { + [self _cancelSurfaceTouchesInSelection]; + _selectionOwnsTouches = YES; + } else if (!hasSelection) { + _selectionOwnsTouches = NO; + } +} + +/* + * How far from a handle's anchor a finger still counts as being on the handle. + * The knob is about 10 points across and sits just outside the line, and + * UIKit accepts a drag that starts a little away from it. + */ +static const CGFloat RCTSelectionHandleGrabRadius = 24.0; + +- (BOOL)selectionOwnsTouchAtPoint:(CGPoint)point +{ + if (!_selectionOwnsTouches) { + return NO; + } + if ([self pointInside:point withEvent:nil]) { + return YES; + } + + UITextRange *selectedTextRange = self.selectedTextRange; + if (selectedTextRange == nil || selectedTextRange.isEmpty) { + return NO; + } + + CGRect startRect = CGRectNull; + CGRect endRect = CGRectNull; + for (UITextSelectionRect *selectionRect in [self selectionRectsForRange:selectedTextRange]) { + if (selectionRect.containsStart) { + startRect = selectionRect.rect; + } + if (selectionRect.containsEnd) { + endRect = selectionRect.rect; + } + } + if (CGRectIsNull(startRect) || CGRectIsNull(endRect)) { + return NO; + } + + // The start handle hangs off the top left of the first rect, and the end + // handle off the bottom right of the last one. + CGPoint startHandle = CGPointMake(CGRectGetMinX(startRect), CGRectGetMinY(startRect)); + CGPoint endHandle = CGPointMake(CGRectGetMaxX(endRect), CGRectGetMaxY(endRect)); + return hypot(point.x - startHandle.x, point.y - startHandle.y) <= RCTSelectionHandleGrabRadius || + hypot(point.x - endHandle.x, point.y - endHandle.y) <= RCTSelectionHandleGrabRadius; +} + +/* + * A touch that begins while a selection shows is a handle drag, or a tap on + * the selection. The paragraph hands out no emitter for where it lands, but + * the pointer handler resolves the target again for every event, at the + * finger, and a handle drag lifts away from the handle: iOS keeps the knob + * clear of the finger. Cancelling the touch here keeps it silent to the end. + * The handlers see the touch before this view does, so the cancel reaches a + * registered touch. + */ +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + [super touchesBegan:touches withEvent:event]; + if (_selectionOwnsTouches) { + [self _cancelSurfaceTouchesInSelection]; + } +} + +- (void)_cancelSurfaceTouchesInSelection +{ + // Every surface on the way up tracks the touch, with a touch handler and, + // for W3C pointer events, a pointer handler beside it. Every one lets go. + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) { + if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]]) { + [(RCTSurfaceTouchHandler *)recognizer cancelTouchesInView:self]; + } else if ([recognizer isKindOfClass:[RCTSurfacePointerHandler class]]) { + [(RCTSurfacePointerHandler *)recognizer cancelTouchesInView: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:)]; + _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 diff --git a/packages/react-native/React/Fabric/RCTSurfacePointerHandler+Internal.h b/packages/react-native/React/Fabric/RCTSurfacePointerHandler+Internal.h new file mode 100644 index 000000000000..ee7610b8ec1c --- /dev/null +++ b/packages/react-native/React/Fabric/RCTSurfacePointerHandler+Internal.h @@ -0,0 +1,29 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTSurfacePointerHandler (Internal) + +/* + * Cancels the active pointers that landed on `view` or on one of its + * descendants, and leaves every other pointer alone. + * + * The pointer counterpart of `-[RCTSurfaceTouchHandler cancelTouchesInView:]`: + * each pointer on `view` gets its `pointerCancel` now, and dispatches nothing + * more while UIKit keeps delivering the touch behind it. The two handlers sit + * on the same root view, and a caller that lets one go lets the other go too. + */ +- (void)cancelTouchesInView:(UIView *)view; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/RCTSurfacePointerHandler.mm b/packages/react-native/React/Fabric/RCTSurfacePointerHandler.mm index 18246804d43b..5aa1ddb7246d 100644 --- a/packages/react-native/React/Fabric/RCTSurfacePointerHandler.mm +++ b/packages/react-native/React/Fabric/RCTSurfacePointerHandler.mm @@ -6,6 +6,7 @@ */ #import "RCTSurfacePointerHandler.h" +#import "RCTSurfacePointerHandler+Internal.h" #import #import @@ -133,6 +134,12 @@ static BOOL AnyTouchesChanged(NSSet *touches) */ BOOL shouldLeaveWhenReleased; + /* + * `cancelTouchesInView:` cancelled the pointer. UIKit still delivers its + * touch, so it stays registered until it ends, but it dispatches nothing more. + */ + BOOL isCancelled = NO; + struct Hasher { size_t operator()(const ActivePointer &activePointer) const { @@ -564,7 +571,7 @@ - (void)_updateTouches:(NSSet *)touches withEvent:(UIEvent *)event for (UITouch *touch in touches) { auto iterator = _activePointers.find(touch); RCTAssert(iterator != _activePointers.end(), @"Inconsistency between local and UIKit touch registries"); - if (iterator == _activePointers.end()) { + if (iterator == _activePointers.end() || iterator->second.isCancelled) { continue; } UpdateActivePointerWithUITouch(iterator->second, touch, event, _rootComponentView); @@ -608,7 +615,7 @@ - (void)_unregisterTouches:(NSSet *)touches for (UITouch *touch in touches) { auto iterator = _activePointers.find(touch); RCTAssert(iterator != _activePointers.end(), @"Inconsistency between local and UIKit touch registries"); - if (iterator == _activePointers.end()) { + if (iterator == _activePointers.end() || iterator->second.isCancelled) { continue; } activePointers.push_back(iterator->second); @@ -717,6 +724,9 @@ - (void)reset activePointers.reserve(_activePointers.size()); for (const auto &pair : _activePointers) { + if (pair.second.isCancelled) { + continue; + } activePointers.push_back(pair.second); } @@ -733,6 +743,29 @@ - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)preventedGestureRecog return NO; } +#pragma mark - Internal Interface + +- (void)cancelTouchesInView:(UIView *)view +{ + std::vector cancelledPointers; + for (const auto &pair : _activePointers) { + if (!pair.second.isCancelled && [pair.first.view isDescendantOfView:view]) { + cancelledPointers.push_back(pair.second); + } + } + if (cancelledPointers.empty()) { + return; + } + + [self _dispatchActivePointers:cancelledPointers eventType:RCTPointerEventTypeCancel]; + + for (auto &pair : _activePointers) { + if ([pair.first.view isDescendantOfView:view]) { + pair.second.isCancelled = YES; + } + } +} + #pragma mark - Hover callbacks - (void)penHovering:(UIHoverGestureRecognizer *)recognizer API_AVAILABLE(ios(13.0)) diff --git a/packages/react-native/React/Fabric/RCTSurfaceTouchHandler+Internal.h b/packages/react-native/React/Fabric/RCTSurfaceTouchHandler+Internal.h new file mode 100644 index 000000000000..8f7d10c34ee6 --- /dev/null +++ b/packages/react-native/React/Fabric/RCTSurfaceTouchHandler+Internal.h @@ -0,0 +1,31 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +@interface RCTSurfaceTouchHandler (Internal) + +/* + * Cancels the active touches that landed on `view` or on one of its + * descendants, and leaves every other touch alone. + * + * A touch that a native control inside the surface takes over, such as the + * long press that selects text, has to stop reaching React Native, or lifting + * the finger presses the view under it. Disabling the handler would cancel + * every touch it tracks. This cancels only the touches on `view`: each one + * gets its `touchCancel` now, and dispatches nothing more while UIKit keeps + * delivering it. + */ +- (void)cancelTouchesInView:(UIView *)view; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Fabric/RCTSurfaceTouchHandler.mm b/packages/react-native/React/Fabric/RCTSurfaceTouchHandler.mm index 1bf565db2266..83ddbabffc35 100644 --- a/packages/react-native/React/Fabric/RCTSurfaceTouchHandler.mm +++ b/packages/react-native/React/Fabric/RCTSurfaceTouchHandler.mm @@ -14,6 +14,7 @@ #import "RCTConversions.h" #import "RCTSurfacePointerHandler.h" +#import "RCTSurfaceTouchHandler+Internal.h" #import "RCTTouchableComponentViewProtocol.h" #if !TARGET_OS_TV @@ -36,6 +37,12 @@ typedef NS_ENUM(NSInteger, RCTTouchEventType) { */ __strong UIView *componentView = nil; + /* + * `cancelTouchesInView:` cancelled the touch. UIKit still delivers it, so it + * stays registered until it ends, but it dispatches nothing more. + */ + bool isCancelled = false; + struct Hasher { size_t operator()(const ActiveTouch &activeTouch) const { @@ -241,6 +248,9 @@ - (void)_unregisterTouches:(NSSet *)touches RCTLogWarn(@"Inconsistency between local and UIKit touch registries"); continue; } + if (iterator->second.isCancelled) { + continue; + } activeTouches.push_back(iterator->second); } @@ -265,7 +275,7 @@ - (void)_dispatchActiveTouches:(std::vector)activeTouches eventType } for (const auto &pair : _activeTouches) { - if (!pair.second.eventEmitter) { + if (!pair.second.eventEmitter || pair.second.isCancelled) { continue; } @@ -280,7 +290,7 @@ - (void)_dispatchActiveTouches:(std::vector)activeTouches eventType event.targetTouches.clear(); for (const auto &pair : _activeTouches) { - if (pair.second.eventEmitter == eventEmitter) { + if (pair.second.eventEmitter == eventEmitter && !pair.second.isCancelled) { event.targetTouches.insert(pair.second.touch); } } @@ -367,6 +377,9 @@ - (void)reset activeTouches.reserve(_activeTouches.size()); for (const auto &pair : _activeTouches) { + if (pair.second.isCancelled) { + continue; + } activeTouches.push_back(pair.second); } @@ -417,6 +430,32 @@ - (void)_cancelTouches [self setEnabled:YES]; } +#pragma mark - Internal Interface + +- (void)cancelTouchesInView:(UIView *)view +{ + std::vector cancelledTouches; + for (const auto &pair : _activeTouches) { + if (!pair.second.isCancelled && [pair.first.view isDescendantOfView:view]) { + cancelledTouches.push_back(pair.second); + } + } + + if (cancelledTouches.empty()) { + return; + } + + // The cancel goes out while the touches still count as active, as it does + // from `touchesCancelled:withEvent:`. + [self _dispatchActiveTouches:cancelledTouches eventType:RCTTouchEventTypeTouchCancel]; + + for (auto &pair : _activeTouches) { + if ([pair.first.view isDescendantOfView:view]) { + pair.second.isCancelled = true; + } + } +} + @end #endif // !TARGET_OS_TV 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..6ed44948c7d5 --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -0,0 +1,1668 @@ +/* + * 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 +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +using namespace facebook::react; + +/* + * The paragraph view declares this in a private category of its own. It builds + * the text storage the paragraph draws with, and the one the selection lays + * out with, so the tests use it for the reference layout. + */ +@interface RCTTextLayoutManager (RCTParagraphSelectionTests) + +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + +@end + +/* + * A touch emitter whose events are recorded, by type and in order, as they are + * dispatched. The dispatcher's listener sees every event before it is queued, + * so no JavaScript runtime has to run. + */ +class RCTParagraphSelectionRecordingEmitter { + public: + RCTParagraphSelectionRecordingEmitter() + { + runtimeScheduler_ = std::make_unique([](std::function &&) {}); + auto eventBeat = std::make_unique(std::make_shared(), *runtimeScheduler_); + auto eventQueueProcessor = EventQueueProcessor( + [](facebook::jsi::Runtime &, + EventTarget *, + const std::string &, + ReactEventPriority, + const EventPayload &, + HighResTimeStamp) {}, + [](facebook::jsi::Runtime &) {}, + [](const StateUpdate &) {}, + {}); + eventDispatcher_ = std::make_shared( + eventQueueProcessor, std::move(eventBeat), [](const StateUpdate &) {}, std::weak_ptr{}); + listener_ = std::make_shared([this](const RawEvent &event) { + eventTypes_.push_back(event.type); + return true; + }); + eventDispatcher_->addListener(listener_); + emitter_ = std::make_shared(nullptr, eventDispatcher_); + } + + std::shared_ptr emitter() const + { + return emitter_; + } + + const std::vector &eventTypes() const + { + return eventTypes_; + } + + private: + std::unique_ptr runtimeScheduler_; + std::shared_ptr eventDispatcher_; + std::shared_ptr listener_; + std::shared_ptr emitter_; + std::vector eventTypes_; +}; + +class RCTParagraphSelectionFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + explicit RCTParagraphSelectionFeatureFlags(bool partialTextSelection) : partialTextSelection_(partialTextSelection) {} + + bool enableIOSPartialTextSelection() override + { + return partialTextSelection_; + } + + private: + bool partialTextSelection_; +}; + +@interface RCTParagraphComponentView (RCTParagraphSelectionTests) + +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame; + +@end + +/* + * A paragraph view that counts the updates of its selection text view. + */ +@interface RCTParagraphSelectionCountingView : RCTParagraphComponentView + +@property (nonatomic) NSUInteger selectionTextViewUpdateCount; + +@end + +@implementation RCTParagraphSelectionCountingView + +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame +{ + self.selectionTextViewUpdateCount++; + [super updateSelectableTextViewWithDrawingFrame:drawingFrame]; +} + +@end + +/* + * A native parent with an edit-menu action of its own. + */ +@interface RCTParagraphSelectionMenuParent : UIView + +- (void)customMenuAction:(id)sender; + +@end + +@implementation RCTParagraphSelectionMenuParent + +- (void)customMenuAction:(id)sender +{ +} + +@end + +/* + * A touch that the surface handlers can track outside UIKit's event delivery. + * It lands on one view, at one point in that view's coordinates, and reports + * no force, radius or tilt. + */ +@interface RCTParagraphSelectionTouch : UITouch + +@property (nonatomic) UITouchPhase touchPhase; + +/* + * Where the finger is now, in the coordinates of the view it landed on. + */ +@property (nonatomic) CGPoint point; + ++ (instancetype)touchOnView:(UIView *)view atPoint:(CGPoint)point; + +@end + +@implementation RCTParagraphSelectionTouch { + __weak UIView *_touchView; +} + ++ (instancetype)touchOnView:(UIView *)view atPoint:(CGPoint)point +{ + RCTParagraphSelectionTouch *touch = [self new]; + touch->_touchView = view; + touch->_point = point; + touch.touchPhase = UITouchPhaseBegan; + return touch; +} + +- (UIView *)view +{ + return _touchView; +} + +- (UIWindow *)window +{ + return _touchView.window; +} + +- (UITouchPhase)phase +{ + return _touchPhase; +} + +- (UITouchType)type +{ + return UITouchTypeDirect; +} + +- (NSUInteger)tapCount +{ + return 1; +} + +- (NSTimeInterval)timestamp +{ + return 0; +} + +- (CGFloat)force +{ + return 0; +} + +- (CGFloat)maximumPossibleForce +{ + return 0; +} + +- (CGFloat)majorRadius +{ + return 0; +} + +- (CGFloat)altitudeAngle +{ + return 0; +} + +- (CGFloat)azimuthAngleInView:(UIView *)view +{ + return 0; +} + +- (CGPoint)locationInView:(UIView *)view +{ + return [_touchView convertPoint:_point toView:view]; +} + +- (CGPoint)previousLocationInView:(UIView *)view +{ + return [self locationInView:view]; +} + +@end + +/* + * 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. + * + * Partial selection is behind `enableIOSPartialTextSelection`. `setUp` turns + * the flag on. The tests under "With the flag off" turn it off, and hold the + * paragraph to the long-press menu that copies the whole text. + */ +@interface RCTParagraphSelectionTests : XCTestCase +@end + +@implementation RCTParagraphSelectionTests { + std::shared_ptr _textLayoutManager; +} + +- (void)setUp +{ + [super setUp]; + [self setPartialTextSelectionEnabled:YES]; + _textLayoutManager = std::make_shared(std::make_shared()); +} + +- (void)tearDown +{ + ReactNativeFeatureFlags::dangerouslyReset(); + [super tearDown]; +} + +/* + * Call this before the test creates a paragraph view. The view reads the flag + * on every change of `selectable`, on recycling and on every frame update, so + * a view that sees both values is half in each mode. + */ +- (void)setPartialTextSelectionEnabled:(BOOL)enabled +{ + ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique(enabled)); +} + +#pragma mark - Fixtures + +/* + * A paragraph that paints everything UIKit cannot: a colour, a wavy underline, + * a strikethrough and a shadow. + */ +- (AttributedString)decoratedAttributedString +{ + return [self decoratedAttributedStringHighlighted:NO]; +} + +- (AttributedString)decoratedAttributedStringHighlighted:(BOOL)highlighted +{ + auto textAttributes = TextAttributes{}; + textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); + textAttributes.fontSize = 20; + textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; + textAttributes.textDecorationStyle = TextDecorationStyle::Wavy; + if (highlighted) { + textAttributes.isHighlighted = true; + } + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable wavy decoration"; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +/* + * A paragraph that paints with the attributes UIKit itself knows: a solid + * underline and strikethrough, a shadow and a background. `RCTAttributedTextUtils` + * maps these to the native attribute names, which the wavy fixture above never + * sets. + */ +- (AttributedString)nativelyPaintedAttributedString +{ + auto textAttributes = TextAttributes{}; + textAttributes.foregroundColor = colorFromRGBA(255, 0, 0, 255); + textAttributes.backgroundColor = colorFromRGBA(255, 255, 0, 255); + textAttributes.fontSize = 20; + textAttributes.textDecorationLineType = TextDecorationLineType::UnderlineStrikethrough; + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + textAttributes.textShadowOffset = facebook::react::Size{2, 2}; + textAttributes.textShadowRadius = 3; + textAttributes.textShadowColor = colorFromRGBA(0, 0, 255, 255); + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable solid decoration"; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +/* + * A paragraph whose single fragment carries a touch emitter, which is how a + * nested pressable reaches `touchEventEmitterAtPoint:`. + */ +- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr)eventEmitter +{ + return [self attributedStringWithEventEmitter:std::move(eventEmitter) text:"Selectable wavy decoration"]; +} + +/* + * Two paragraphs with the same text share one cached text storage, and with it + * one emitter. A test with two pressable paragraphs gives each its own text. + */ +- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr)eventEmitter + text:(std::string)text +{ + auto textAttributes = TextAttributes{}; + textAttributes.fontSize = 20; + + auto fragment = AttributedString::Fragment{}; + fragment.string = std::move(text); + fragment.textAttributes = textAttributes; + fragment.parentShadowView.eventEmitter = eventEmitter; + // The emitter only reaches the attributed string when the fragment names a + // component, which is what marks it as a nested rather than raw text. + fragment.parentShadowView.componentHandle = TextShadowNode::Handle(); + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +- (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr)eventEmitter +{ + return [self paragraphViewWithEventEmitter:std::move(eventEmitter) + text:"Selectable wavy decoration" + contentInsets:EdgeInsets{}]; +} + +/* + * A padded paragraph lays its text out inside the insets, so the selection text + * view covers the text and not the padding. + */ +- (RCTParagraphComponentView *)paragraphViewWithEventEmitter:(std::shared_ptr)eventEmitter + text:(std::string)text + contentInsets:(EdgeInsets)contentInsets +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:[self propsWithSelectable:YES] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithEventEmitter:eventEmitter + text:std::move(text)]] + oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + layoutMetrics.contentInsets = contentInsets; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; + + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + return view; +} + +/* + * Two paragraphs under one root view in a window, as a surface handler sees + * them: the first at the top, the second below it. The handlers convert touch + * points through the window's screen, so the root needs one. + */ +- (UIWindow *)windowWithRootView:(UIView *)rootView paragraphs:(NSArray *)paragraphs +{ + CGFloat top = 0; + for (RCTParagraphComponentView *paragraph in paragraphs) { + paragraph.frame = CGRectMake(0, top, 320, 100); + [rootView addSubview:paragraph]; + top += 200; + } + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 400)]; + [window addSubview:rootView]; + return window; +} + +- (AttributedString)attributedStringWithText:(std::string)text +{ + auto fragment = AttributedString::Fragment{}; + fragment.string = std::move(text); + 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; + // The paragraph view lays out the text in this size, not in its frame. The + // shadow node stores the content frame size here, and every test lays the + // paragraph out at 320 x 100 with no padding. + stateData.layoutSize = facebook::react::Size{320, 100}; + + 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; +} + +/* + * A selectable paragraph that truncates to one line. Changing the ellipsize + * mode changes only the props, and so computes no new frame. + */ +- (Props::Shared)selectablePropsWithEllipsizeMode:(EllipsizeMode)ellipsizeMode +{ + auto props = std::make_shared(); + props->isSelectable = true; + props->paragraphAttributes.maximumNumberOfLines = 1; + props->paragraphAttributes.ellipsizeMode = ellipsizeMode; + return props; +} + +- (Props::Shared)selectablePropsWithPointerEvents:(PointerEventsMode)pointerEvents +{ + auto props = std::make_shared(); + props->isSelectable = true; + props->pointerEvents = pointerEvents; + return props; +} + +- (Props::Shared)selectablePropsWithParagraphAttributes:(ParagraphAttributes)paragraphAttributes +{ + auto props = std::make_shared(); + props->isSelectable = true; + props->paragraphAttributes = std::move(paragraphAttributes); + return props; +} + +/* + * A paragraph long enough to wrap or to truncate at 320 points, at one font + * size, with an optional fixed line height. + */ +- (AttributedString)longAttributedStringWithFontSize:(Float)fontSize lineHeight:(Float)lineHeight +{ + auto textAttributes = TextAttributes{}; + textAttributes.fontSize = fontSize; + textAttributes.lineHeight = lineHeight; + + auto fragment = AttributedString::Fragment{}; + fragment.string = + "A paragraph that is long enough to wrap onto several lines at this width, " + "or to be cut short when it may take only one line."; + fragment.textAttributes = textAttributes; + + auto attributedString = AttributedString{}; + attributedString.appendFragment(std::move(fragment)); + return attributedString; +} + +/* + * The paragraph draws with a text storage that `RCTTextLayoutManager` builds + * from the painted string, at the drawing size. This builds the same storage, + * which is the layout the selection rects have to land on. + */ +- (NSLayoutManager *)drawingLayoutManagerFor:(RCTParagraphComponentView *)view + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + NSTextStorage *textStorage = [[RCTTextLayoutManager new] textStorageForNSAttributedString:view.attributedText + paragraphAttributes:paragraphAttributes + size:size]; + return textStorage.layoutManagers.firstObject; +} + +/* + * Every line fragment, as its used rect and its glyph range, in order. + */ +- (NSArray *)lineFragmentsOf:(NSLayoutManager *)layoutManager +{ + NSMutableArray *lineFragments = [NSMutableArray new]; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + [layoutManager enumerateLineFragmentsForGlyphRange:glyphRange + usingBlock:^( + CGRect rect, + CGRect usedRect, + NSTextContainer *container, + NSRange lineGlyphRange, + BOOL *stop) { + [lineFragments addObject:[NSValue valueWithCGRect:usedRect]]; + [lineFragments addObject:[NSValue valueWithRange:lineGlyphRange]]; + }]; + return lineFragments; +} + +/* + * Asserts that the selection text view places every line and every glyph where + * the drawing places it, and that the two use the same fonts, which is what + * `adjustsFontSizeToFit` changes. + */ +- (void)assertSelectionLayoutMatchesDrawingIn:(RCTParagraphComponentView *)view + paragraphAttributes:(ParagraphAttributes)paragraphAttributes +{ + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + NSLayoutManager *drawing = [self drawingLayoutManagerFor:view + paragraphAttributes:paragraphAttributes + size:selectionTextView.frame.size]; + NSLayoutManager *selection = selectionTextView.layoutManager; + + XCTAssertEqualObjects( + [self lineFragmentsOf:selection], + [self lineFragmentsOf:drawing], + @"The lines must sit where the drawing puts them."); + + NSRange drawnGlyphs = [drawing glyphRangeForTextContainer:drawing.textContainers.firstObject]; + NSRange selectedGlyphs = [selection glyphRangeForTextContainer:selection.textContainers.firstObject]; + XCTAssertTrue(NSEqualRanges(drawnGlyphs, selectedGlyphs), @"The selection must lay out the same glyphs."); + + for (NSUInteger glyphIndex = drawnGlyphs.location; glyphIndex < NSMaxRange(drawnGlyphs); glyphIndex++) { + CGPoint drawnLocation = [drawing locationForGlyphAtIndex:glyphIndex]; + CGPoint selectedLocation = [selection locationForGlyphAtIndex:glyphIndex]; + XCTAssertTrue( + CGPointEqualToPoint(drawnLocation, selectedLocation), + @"Glyph %lu must sit where the drawing puts it.", + (unsigned long)glyphIndex); + } + + NSTextStorage *drawnText = drawing.textStorage; + NSTextStorage *selectedText = selection.textStorage; + [drawnText enumerateAttribute:NSFontAttributeName + inRange:NSMakeRange(0, drawnText.length) + options:0 + usingBlock:^(UIFont *drawnFont, NSRange range, BOOL *stop) { + UIFont *selectedFont = [selectedText attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:nil]; + XCTAssertEqualObjects(selectedFont, drawnFont, @"The selection must use the drawn font."); + }]; +} + +/* + * Builds a paragraph view with the update calls that `RCTMountingManager` makes + * for an insert. The view gets no event emitter and no parent. The selection + * text view waits for the first touch; see `selectionTextViewAfterTouchIn:`. + */ +- (RCTParagraphComponentView *)paragraphViewSelectable:(BOOL)selectable +{ + return [self paragraphViewSelectable:selectable attributedString:[self decoratedAttributedString]]; +} + +- (RCTParagraphComponentView *)paragraphViewSelectable:(BOOL)selectable + attributedString:(AttributedString)attributedString +{ + return [self paragraphViewWithProps:[self propsWithSelectable:selectable] + attributedString:std::move(attributedString)]; +} + +- (RCTParagraphComponentView *)paragraphViewWithProps:(Props::Shared)props + attributedString:(AttributedString)attributedString +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:props oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:std::move(attributedString)] 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 finalizeUpdates:RNComponentViewUpdateMaskAll]; + return view; +} + +- (UITextView *)selectionTextViewIn:(UIView *)view +{ + for (UIView *subview in view.subviews) { + if ([subview isKindOfClass:[UITextView class]]) { + return (UITextView *)subview; + } + } + return nil; +} + +/* + * UIKit hit-tests the views under a finger before it delivers the touch, and + * the paragraph builds its selection text view in that hit test. + */ +- (UITextView *)selectionTextViewAfterTouchIn:(RCTParagraphComponentView *)view +{ + [view hitTest:CGPointMake(20, 10) withEvent:nil]; + return [self selectionTextViewIn:view]; +} + +/* + * An inline view, such as a nested in a , inside the text frame. + * The paragraph mounts it below the drawn glyphs, as it mounts every child. + */ +- (UIView *)mountInlineViewIn:(RCTParagraphComponentView *)view +{ + RCTViewComponentView *inlineView = [RCTViewComponentView new]; + inlineView.frame = CGRectMake(200, 40, 50, 30); + [view mountChildComponentView:inlineView index:0]; + return inlineView; +} + +- (UILongPressGestureRecognizer *)longPressRecognizerIn:(UIView *)view +{ + for (UIGestureRecognizer *recognizer in view.gestureRecognizers) { + if ([recognizer isKindOfClass:[UILongPressGestureRecognizer class]]) { + return (UILongPressGestureRecognizer *)recognizer; + } + } + return nil; +} + +- (UIEditMenuInteraction *)editMenuInteractionIn:(UIView *)view API_AVAILABLE(ios(16.0)) +{ + for (id interaction in view.interactions) { + if ([interaction isKindOfClass:[UIEditMenuInteraction class]]) { + return (UIEditMenuInteraction *)interaction; + } + } + return nil; +} + +/* + * Mounts a recycled view again with the update calls of an insert. The view + * keeps its `_props`, and `prepareForRecycle` empties its layout metrics. + */ +- (void)mountRecycledView:(RCTParagraphComponentView *)view selectable:(BOOL)selectable text:(std::string)text +{ + [view updateProps:[self propsWithSelectable:selectable] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:std::move(text)]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; +} + +#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 selectionTextViewAfterTouchIn: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 selectionTextViewAfterTouchIn: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."); + }]; +} + +/* + * The wavy fixture never sets the native underline, strikethrough and shadow + * attributes, so a nil assertion on them proves nothing. This fixture sets all + * of them, and the test first checks that the painted string holds them. + */ +- (void)testSelectionTextViewStripsTheNativePaintingAttributes +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES + attributedString:[self nativelyPaintedAttributedString]]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + NSAttributedString *painted = view.attributedText; + NSAttributedString *laidOut = selectionTextView.textStorage; + XCTAssertGreaterThan(painted.length, 0u); + XCTAssertEqual(laidOut.length, painted.length); + + NSRange range = NSMakeRange(0, painted.length); + [painted + enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attributes, NSRange r, BOOL *stop) { + XCTAssertNotEqualObjects(attributes[NSForegroundColorAttributeName], UIColor.clearColor); + XCTAssertNotEqualObjects(attributes[NSBackgroundColorAttributeName], UIColor.clearColor); + XCTAssertNotNil(attributes[NSBackgroundColorAttributeName]); + XCTAssertEqualObjects(attributes[NSUnderlineStyleAttributeName], @(NSUnderlineStyleSingle)); + XCTAssertEqualObjects(attributes[NSStrikethroughStyleAttributeName], @(NSUnderlineStyleSingle)); + NSShadow *shadow = attributes[NSShadowAttributeName]; + XCTAssertNotNil(shadow, @"The fixture must paint a shadow, or the strip is not tested."); + XCTAssertEqual(shadow.shadowBlurRadius, 3); + }]; + + UIFont *paintedFont = [painted attribute:NSFontAttributeName atIndex:0 effectiveRange:nil]; + [laidOut enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attributes, NSRange r, BOOL *stop) { + XCTAssertEqualObjects(attributes[NSForegroundColorAttributeName], UIColor.clearColor); + XCTAssertEqualObjects(attributes[NSBackgroundColorAttributeName], UIColor.clearColor); + XCTAssertNil(attributes[NSUnderlineStyleAttributeName]); + XCTAssertNil(attributes[NSStrikethroughStyleAttributeName]); + XCTAssertNil(attributes[NSShadowAttributeName]); + XCTAssertEqualObjects( + attributes[NSFontAttributeName], paintedFont, @"The font must survive the strip."); + }]; +} + +#pragma mark - The selection lays out as the drawing does + +/* + * Stripping the paint must not move a glyph. The default fixtures lay out at + * one size with default paragraph attributes, which proves little. These tests + * take the layouts that bend the text, build the drawing's layout with the + * same method the paragraph draws with, and compare line by line and glyph by + * glyph. Each one first checks that its layout really bent the text. + */ +- (void)testSelectionLayoutMatchesTheDrawingWithAdjustsFontSizeToFit +{ + auto paragraphAttributes = ParagraphAttributes{}; + paragraphAttributes.adjustsFontSizeToFit = true; + paragraphAttributes.minimumFontScale = 0.5; + // The scaling sees an overflow only as a truncated line, so it needs a line + // limit and a truncating break mode, as `` gives it: JavaScript defaults `ellipsizeMode` to + // tail, and a clipping container never reports an overflow. + paragraphAttributes.maximumNumberOfLines = 1; + paragraphAttributes.ellipsizeMode = EllipsizeMode::Tail; + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithParagraphAttributes:paragraphAttributes] + attributedString:[self longAttributedStringWithFontSize:40 lineHeight:NAN]]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + NSLayoutManager *drawing = [self drawingLayoutManagerFor:view + paragraphAttributes:paragraphAttributes + size:selectionTextView.frame.size]; + UIFont *drawnFont = [drawing.textStorage attribute:NSFontAttributeName atIndex:0 effectiveRange:nil]; + XCTAssertLessThan(drawnFont.pointSize, 40, @"The drawing must shrink the font, or the fixture tests nothing."); + + [self assertSelectionLayoutMatchesDrawingIn:view paragraphAttributes:paragraphAttributes]; +} + +- (void)testSelectionLayoutMatchesTheDrawingWithTruncation +{ + auto paragraphAttributes = ParagraphAttributes{}; + paragraphAttributes.maximumNumberOfLines = 1; + paragraphAttributes.ellipsizeMode = EllipsizeMode::Tail; + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithParagraphAttributes:paragraphAttributes] + attributedString:[self longAttributedStringWithFontSize:20 lineHeight:NAN]]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + NSLayoutManager *drawing = [self drawingLayoutManagerFor:view + paragraphAttributes:paragraphAttributes + size:selectionTextView.frame.size]; + NSRange drawnGlyphs = [drawing glyphRangeForTextContainer:drawing.textContainers.firstObject]; + XCTAssertNotEqual( + [drawing truncatedGlyphRangeInLineFragmentForGlyphAtIndex:drawnGlyphs.location].location, + NSNotFound, + @"The drawing must truncate the line, or the fixture tests nothing."); + + [self assertSelectionLayoutMatchesDrawingIn:view paragraphAttributes:paragraphAttributes]; +} + +- (void)testSelectionLayoutMatchesTheDrawingWithACompressedLineHeight +{ + auto paragraphAttributes = ParagraphAttributes{}; + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithParagraphAttributes:paragraphAttributes] + attributedString:[self longAttributedStringWithFontSize:20 lineHeight:12]]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + NSLayoutManager *drawing = [self drawingLayoutManagerFor:view + paragraphAttributes:paragraphAttributes + size:selectionTextView.frame.size]; + NSArray *drawnLines = [self lineFragmentsOf:drawing]; + XCTAssertGreaterThan(drawnLines.count, 2u, @"The text must wrap, or the line height is not tested."); + XCTAssertEqual(drawnLines[0].CGRectValue.size.height, 12, @"The drawing must compress the lines."); + + [self assertSelectionLayoutMatchesDrawingIn:view paragraphAttributes:paragraphAttributes]; +} + +/* + * `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 selectionTextViewAfterTouchIn: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 selectionTextViewAfterTouchIn: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 selectionTextViewAfterTouchIn: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 selectionTextViewAfterTouchIn:view]); + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNil([self selectionTextViewAfterTouchIn:view], @"Turning selection off must remove the text view."); + XCTAssertFalse(view.contentView.hidden, @"The paragraph must still draw itself."); +} + +/* + * A change of `selectable` alone is a props update, and `finalizeUpdates:` + * computes no frames for it. The text view must still come back on the next + * touch without a later change of the text or the layout. + */ +- (void)testTurningSelectableBackOnRestoresTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewAfterTouchIn:view]); + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + XCTAssertNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:NO]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNotNil( + [self selectionTextViewAfterTouchIn:view], @"Turning selection on again must build the text view on a touch."); +} + +/* + * One mutation can turn `selectable` on and change the text. `updateProps:` + * runs before `updateState:`, so a text view built there would hold the old + * text. + */ +- (void)testTurningSelectableOnWithNewTextBuildsFromTheNewText +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:NO]; + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:NO]]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"The new paragraph"]] oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps | RNComponentViewUpdateMaskState]; + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + XCTAssertEqual( + selectionTextView.textStorage.length, + [@"The new paragraph" length], + @"The text view must hold the text of the same mutation."); +} + +/* + * The text container is configured from the paragraph attributes: the line + * limit, the ellipsize mode and the font scaling. A change of them alone is a + * props-only update, which computes no new frame, so the rebuild check must + * look at the attributes and `updateProps:` must schedule the update. + */ +- (void)testChangingEllipsizeModeRebuildsTheSelectionTextView +{ + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Head] + attributedString:[self decoratedAttributedString]]; + UITextView *before = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(before); + XCTAssertEqual(before.textContainer.lineBreakMode, NSLineBreakByTruncatingHead); + + [view updateProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Tail] + oldProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Head]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + UITextView *after = [self selectionTextViewIn:view]; + XCTAssertNotNil(after); + XCTAssertNotEqual(after, before, @"A new ellipsize mode needs a new text container."); + XCTAssertEqual(after.textContainer.lineBreakMode, NSLineBreakByTruncatingTail); +} + +- (void)testPropsUpdateWithTheSameAttributesKeepsTheSelectionTextView +{ + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Tail] + attributedString:[self decoratedAttributedString]]; + UITextView *before = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(before); + + [view updateProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Tail] + oldProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Tail]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertEqual([self selectionTextViewIn:view], before, @"Unchanged attributes must not rebuild the text view."); +} + +#pragma mark - The text view waits for a touch + +/* + * Most selectable paragraphs are never selected. The text view and its TextKit + * stack must cost nothing until a finger lands on the paragraph. + */ +- (void)testSelectableParagraphBuildsNoSelectionTextViewBeforeATouch +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNil([self selectionTextViewIn:view], @"Mounting must not build the selection text view."); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + XCTAssertNil([self selectionTextViewIn:view], @"An update before the first touch must not build it either."); + + UIView *hitView = [view hitTest:CGPointMake(20, 10) withEvent:nil]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView, @"The first touch must build the selection text view."); + XCTAssertTrue([hitView isDescendantOfView:selectionTextView], @"The touch that builds the text view must go to it."); + XCTAssertEqual(selectionTextView.textStorage.length, [@"A different paragraph" length]); +} + +/* + * One mutation can change the props, the state and the layout of a paragraph + * together. The text view must be updated once for the whole mutation, and not + * once for each part of it. + */ +- (void)testOneMutationUpdatesTheSelectionTextViewOnce +{ + RCTParagraphSelectionCountingView *view = [RCTParagraphSelectionCountingView new]; + view.frame = CGRectMake(0, 0, 320, 100); + [view updateProps:[self propsWithSelectable:YES] 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 finalizeUpdates:RNComponentViewUpdateMaskAll]; + XCTAssertEqual(view.selectionTextViewUpdateCount, 0u, @"Mounting must not update a text view that does not exist."); + + XCTAssertNotNil([self selectionTextViewAfterTouchIn:view]); + [view hitTest:CGPointMake(20, 10) withEvent:nil]; + XCTAssertEqual(view.selectionTextViewUpdateCount, 1u, @"A second touch must not update the text view again."); + + [view updateProps:[self selectablePropsWithEllipsizeMode:EllipsizeMode::Tail] + oldProps:[self propsWithSelectable:YES]]; + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + auto movedLayoutMetrics = layoutMetrics; + movedLayoutMetrics.frame.origin = facebook::react::Point{0, 20}; + [view updateLayoutMetrics:movedLayoutMetrics oldLayoutMetrics:layoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + + XCTAssertEqual(view.selectionTextViewUpdateCount, 2u, @"One mutation must update the text view once."); +} + +/* + * A relayout of unchanged text is the most frequent update of a paragraph. It + * must keep the text view and must not convert the text again. + */ +- (void)testRelayoutOfUnchangedTextConvertsNoText +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *before = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(before); + id convertedText = [before valueForKey:@"sourceAttributedText"]; + XCTAssertNotNil(convertedText); + + [view updateState:[self stateWithAttributedString:[self decoratedAttributedString]] oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + + XCTAssertEqual([self selectionTextViewIn:view], before); + XCTAssertEqual( + [before valueForKey:@"sourceAttributedText"], convertedText, @"Unchanged text must not be converted again."); +} + +#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 selectionTextViewAfterTouchIn:view]); + + [view prepareForRecycle]; + + XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); +} + +/* + * A recycled view keeps the props of its last paragraph. When the next + * paragraph is selectable too, `updateProps:` sees no change and re-enables + * nothing, so the selection text view must be able to build itself from + * scratch after `prepareForRecycle`. + */ +- (void)testRecycledParagraphBuildsAWorkingSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [view prepareForRecycle]; + [self mountRecycledView:view selectable:YES text:"The next paragraph"]; + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView, @"A recycled selectable paragraph must get a selection text view again."); + XCTAssertEqual( + selectionTextView.textStorage.length, + [@"The next paragraph" length], + @"The selection text view of a recycled paragraph must hold the new text."); +} + +#pragma mark - A press must not rebuild the selection + +/* + * Pressing a nested pressable paints a highlight, which changes the + * painted string but not the layout. Rebuilding the text view in the middle of + * that touch destroys the long press that UIKit has already started, and the + * paragraph never selects. Only a layout change may rebuild it. + */ +- (void)testHighlightOnlyUpdateKeepsTheSameSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *before = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(before); + + [view updateState:[self stateWithAttributedString:[self decoratedAttributedStringHighlighted:YES]] oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + + XCTAssertEqual( + [self selectionTextViewIn:view], before, @"A pressed highlight must not rebuild the selection text view."); +} + +/* + * The cache must not go too far the other way: different text lays out + * differently, so it has to produce a new text view. + */ +- (void)testTextChangeRebuildsTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *before = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(before); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + + UITextView *after = [self selectionTextViewIn:view]; + XCTAssertNotNil(after, @"A text view that exists must follow the text without a new touch."); + XCTAssertNotEqual(after, before, @"New text must rebuild the selection text view."); +} + +#pragma mark - A showing selection owns the touch + +/* + * A drag of a selection handle starts on the glyphs the handle sits on, which + * is often a pressable . Delivering that touch presses the link, so + * adjusting a selection follows it. While a selection shows, the paragraph + * must hand out no event emitter for a touch on the text. + */ +- (void)testShowingSelectionGivesNoEventEmitter +{ + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + selectionTextView.selectedRange = NSMakeRange(0, 5); + + XCTAssertTrue( + [(id)view touchEventEmitterAtPoint:CGPointMake(100, 10)] == nullptr, + @"A touch that lands while a selection shows must not press the text under it."); +} + +/* + * The guard stops at the edge of the text. A padded paragraph inside a + * still presses from its padding while a selection shows, through + * the paragraph's own emitter, as it does with nothing selected. + * + * The point sits in the left padding and below the single line of text: the + * layout manager reads the point in text container coordinates, and a point + * past the last glyph is what falls back to the paragraph's own emitter. + */ +- (void)testATouchInThePaddingStillPressesWhileASelectionShows +{ + auto fragmentEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + auto paragraphEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:fragmentEmitter + text:"Selectable wavy decoration" + contentInsets:EdgeInsets{10, 10, 10, 10}]; + [view updateEventEmitter:paragraphEmitter]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + XCTAssertEqual( + selectionTextView.frame.origin.x, 10, @"The selection text view must cover the text, not the padding."); + CGPoint inPadding = CGPointMake(5, 95); + XCTAssertFalse( + [selectionTextView pointInside:[view convertPoint:inPadding toView:selectionTextView] withEvent:nil], + @"The point must be outside the selection text view."); + + selectionTextView.selectedRange = NSMakeRange(0, 5); + + XCTAssertEqual( + (const void *)[(id)view touchEventEmitterAtPoint:inPadding].get(), + (const void *)paragraphEmitter.get(), + @"A touch in the padding must still press the paragraph while a selection shows."); + XCTAssertTrue( + [(id)view touchEventEmitterAtPoint:CGPointMake(100, 50)] == nullptr, + @"A touch on the text must still hand out no emitter."); +} + +/* + * A handle drag starts on the knob, which hangs outside the text view's + * bounds: above the first line of the selection, or below its last. In a + * padded paragraph that knob sits in the padding, and dragging it must not + * press. Farther from the handle, the padding presses as usual. + */ +- (void)testATouchOnASelectionHandleDoesNotPress +{ + auto fragmentEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + auto paragraphEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:fragmentEmitter + text:"Selectable wavy decoration" + contentInsets:EdgeInsets{40, 40, 40, 40}]; + [view updateEventEmitter:paragraphEmitter]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + selectionTextView.selectedRange = NSMakeRange(3, 5); + NSArray *selectionRects = + [selectionTextView selectionRectsForRange:selectionTextView.selectedTextRange]; + XCTAssertGreaterThan(selectionRects.count, 0u, @"The selection must have rects, or there is no handle to test."); + + CGRect firstRect = selectionRects.firstObject.rect; + CGPoint onTheStartKnob = [view convertPoint:CGPointMake(CGRectGetMinX(firstRect), CGRectGetMinY(firstRect) - 8) + fromView:selectionTextView]; + XCTAssertFalse( + [selectionTextView pointInside:[view convertPoint:onTheStartKnob toView:selectionTextView] withEvent:nil], + @"The knob must sit outside the text view, or the bounds check alone covers it."); + XCTAssertTrue( + [(id)view touchEventEmitterAtPoint:onTheStartKnob] == nullptr, + @"A drag that starts on the handle must not press."); + + // In the left margin, past both handles, and below the line in text + // container coordinates: the layout manager maps a point in those, so only + // a point past the text falls back to the paragraph's own emitter. + CGPoint farFromTheKnob = CGPointMake(10, 95); + XCTAssertFalse( + [selectionTextView pointInside:[view convertPoint:farFromTheKnob toView:selectionTextView] withEvent:nil], + @"The control point must sit outside the text view."); + XCTAssertEqual( + (const void *)[(id)view touchEventEmitterAtPoint:farFromTheKnob].get(), + (const void *)paragraphEmitter.get(), + @"The padding away from the handle must still press."); +} + +/* + * The long press that selects is one touch of possibly several. Only that + * touch leaves React Native: a finger held on another paragraph keeps its + * touch, and ends it as usual. The cancelled touch dispatches nothing more + * while UIKit keeps delivering it. + */ +- (void)testSelectingWithOneFingerCancelsOnlyThatFingersTouch +{ + RCTParagraphSelectionRecordingEmitter selected; + RCTParagraphSelectionRecordingEmitter held; + RCTParagraphComponentView *selectedParagraph = [self paragraphViewWithEventEmitter:selected.emitter()]; + RCTParagraphComponentView *heldParagraph = [self paragraphViewWithEventEmitter:held.emitter() + text:"Held pressable paragraph" + contentInsets:EdgeInsets{}]; + UIView *rootView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 400)]; + UIWindow *window = [self windowWithRootView:rootView paragraphs:@[ selectedParagraph, heldParagraph ]]; + RCTSurfaceTouchHandler *touchHandler = [RCTSurfaceTouchHandler new]; + [touchHandler attachToView:rootView]; + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:selectedParagraph]; + RCTParagraphSelectionTouch *selectingTouch = [RCTParagraphSelectionTouch touchOnView:selectionTextView + atPoint:CGPointMake(100, 10)]; + RCTParagraphSelectionTouch *heldTouch = [RCTParagraphSelectionTouch touchOnView:heldParagraph + atPoint:CGPointMake(100, 10)]; + [touchHandler touchesBegan:[NSSet setWithObject:selectingTouch] withEvent:nil]; + [touchHandler touchesBegan:[NSSet setWithObject:heldTouch] withEvent:nil]; + + selectionTextView.selectedRange = NSMakeRange(0, 5); + + XCTAssertTrue( + selected.eventTypes() == (std::vector{"topTouchStart", "topTouchCancel"}), + @"The touch that made the selection must be cancelled."); + XCTAssertTrue( + held.eventTypes() == (std::vector{"topTouchStart"}), + @"A finger held on another paragraph must keep its touch."); + + heldTouch.touchPhase = UITouchPhaseEnded; + [touchHandler touchesEnded:[NSSet setWithObject:heldTouch] withEvent:nil]; + selectingTouch.touchPhase = UITouchPhaseMoved; + [touchHandler touchesMoved:[NSSet setWithObject:selectingTouch] withEvent:nil]; + selectingTouch.touchPhase = UITouchPhaseEnded; + [touchHandler touchesEnded:[NSSet setWithObject:selectingTouch] withEvent:nil]; + + XCTAssertTrue( + held.eventTypes() == (std::vector{"topTouchStart", "topTouchEnd"}), + @"The held touch must end as usual."); + XCTAssertTrue( + selected.eventTypes() == (std::vector{"topTouchStart", "topTouchCancel"}), + @"A cancelled touch must dispatch nothing more."); + + [touchHandler detachFromView:rootView]; + window.hidden = YES; +} + +/* + * A handle drag begins on the selection and lifts away from it: iOS keeps the + * knob clear of the finger. The pointer handler resolves the target at the + * finger for every event, so without a cancel the lift would reach the + * paragraph as a `pointerUp` with no `pointerDown` before it. The selection + * view sees the touch begin after the handlers do, and cancels it there. + */ +- (void)testAHandleDragThatLiftsInThePaddingStaysSilent +{ + RCTParagraphSelectionRecordingEmitter paragraph; + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:paragraph.emitter() + text:"Selectable wavy decoration" + contentInsets:EdgeInsets{40, 40, 40, 40}]; + UIView *rootView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 400)]; + UIWindow *window = [self windowWithRootView:rootView paragraphs:@[ view ]]; + RCTSurfacePointerHandler *pointerHandler = [RCTSurfacePointerHandler new]; + [pointerHandler attachToView:rootView]; + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + selectionTextView.selectedRange = NSMakeRange(3, 5); + + // The drag begins on the selected text, which the handlers see first, and + // the responder chain then hands it to the selection view. + RCTParagraphSelectionTouch *drag = [RCTParagraphSelectionTouch touchOnView:selectionTextView + atPoint:CGPointMake(50, 10)]; + [pointerHandler touchesBegan:[NSSet setWithObject:drag] withEvent:nil]; + [selectionTextView touchesBegan:[NSSet setWithObject:drag] withEvent:nil]; + + // The finger lifts in the padding below the text, past both handles. + drag.point = CGPointMake(50, 150); + drag.touchPhase = UITouchPhaseMoved; + [pointerHandler touchesMoved:[NSSet setWithObject:drag] withEvent:nil]; + drag.touchPhase = UITouchPhaseEnded; + [pointerHandler touchesEnded:[NSSet setWithObject:drag] withEvent:nil]; + + XCTAssertTrue(paragraph.eventTypes().empty(), @"A handle drag must reach the paragraph as no pointer event at all."); + + [pointerHandler detachFromView:rootView]; + window.hidden = YES; +} + +/* + * W3C pointer events resolve their emitter again on every dispatch. The + * `pointerCancel` of the pointer that made the selection must still find the + * emitter, so the paragraph holds touches back only after the cancel. The + * other pointer stays, and lifts with a `pointerUp`. + */ +- (void)testSelectingCancelsThePointerThatMadeItAndKeepsTheOther +{ + RCTParagraphSelectionRecordingEmitter selected; + RCTParagraphSelectionRecordingEmitter held; + RCTParagraphComponentView *selectedParagraph = [self paragraphViewWithEventEmitter:selected.emitter()]; + RCTParagraphComponentView *heldParagraph = [self paragraphViewWithEventEmitter:held.emitter() + text:"Held pressable paragraph" + contentInsets:EdgeInsets{}]; + UIView *rootView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 400)]; + UIWindow *window = [self windowWithRootView:rootView paragraphs:@[ selectedParagraph, heldParagraph ]]; + RCTSurfacePointerHandler *pointerHandler = [RCTSurfacePointerHandler new]; + [pointerHandler attachToView:rootView]; + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:selectedParagraph]; + RCTParagraphSelectionTouch *selectingTouch = [RCTParagraphSelectionTouch touchOnView:selectionTextView + atPoint:CGPointMake(100, 10)]; + RCTParagraphSelectionTouch *heldTouch = [RCTParagraphSelectionTouch touchOnView:heldParagraph + atPoint:CGPointMake(100, 10)]; + [pointerHandler touchesBegan:[NSSet setWithObject:selectingTouch] withEvent:nil]; + [pointerHandler touchesBegan:[NSSet setWithObject:heldTouch] withEvent:nil]; + + selectionTextView.selectedRange = NSMakeRange(0, 5); + + XCTAssertTrue( + selected.eventTypes() == (std::vector{"topPointerDown", "topPointerCancel"}), + @"The pointer that made the selection must get its pointerCancel."); + XCTAssertTrue( + held.eventTypes() == (std::vector{"topPointerDown"}), + @"A pointer held on another paragraph must keep going."); + + heldTouch.touchPhase = UITouchPhaseEnded; + [pointerHandler touchesEnded:[NSSet setWithObject:heldTouch] withEvent:nil]; + selectingTouch.touchPhase = UITouchPhaseMoved; + [pointerHandler touchesMoved:[NSSet setWithObject:selectingTouch] withEvent:nil]; + selectingTouch.touchPhase = UITouchPhaseEnded; + [pointerHandler touchesEnded:[NSSet setWithObject:selectingTouch] withEvent:nil]; + + XCTAssertTrue( + held.eventTypes() == (std::vector{"topPointerDown", "topPointerUp"}), + @"The held pointer must lift as usual."); + XCTAssertTrue( + selected.eventTypes() == (std::vector{"topPointerDown", "topPointerCancel"}), + @"A cancelled pointer must dispatch nothing more."); + + [pointerHandler detachFromView:rootView]; + window.hidden = YES; +} + +/* + * The guard must not reach an ordinary tap. With nothing selected the pressable + * fragment still gets its press. + */ +- (void)testWithoutASelectionThePressStillGoesThrough +{ + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + + selectionTextView.selectedRange = NSMakeRange(0, 0); + + XCTAssertEqual( + (const void *)[(id)view touchEventEmitterAtPoint:CGPointMake(100, 10)].get(), + (const void *)eventEmitter.get(), + @"With nothing selected, a press must still reach the pressable text."); +} + +#pragma mark - Inline views and pointerEvents + +/* + * The selection text view sits right below the glyphs, and so above the inline + * views. A touch on an inline view must still reach it: before the text view + * exists, after it exists, and after it is built again. + */ +- (void)testATouchOnAnInlineViewGoesToTheInlineView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UIView *inlineView = [self mountInlineViewIn:view]; + CGPoint onInlineView = CGPointMake(225, 55); + + XCTAssertEqual([view hitTest:onInlineView withEvent:nil], inlineView); + + UITextView *selectionTextView = [self selectionTextViewAfterTouchIn:view]; + XCTAssertNotNil(selectionTextView); + XCTAssertTrue( + CGRectContainsPoint(selectionTextView.frame, onInlineView), + @"The text view must cover the inline view, or this test proves nothing."); + XCTAssertGreaterThan([view.subviews indexOfObject:selectionTextView], [view.subviews indexOfObject:inlineView]); + XCTAssertEqual([view hitTest:onInlineView withEvent:nil], inlineView, @"The text view must not take the touch."); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + XCTAssertNotEqual([self selectionTextViewIn:view], selectionTextView); + XCTAssertEqual( + [view hitTest:onInlineView withEvent:nil], inlineView, @"A rebuilt text view must not take the touch either."); +} + +/* + * With `pointerEvents="box-none"` the paragraph takes no touches, and only its + * inline views do. The selection text view is a part of the paragraph. + */ +- (void)testBoxNoneParagraphTakesNoTouchOnTheText +{ + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithPointerEvents:PointerEventsMode::BoxNone] + attributedString:[self decoratedAttributedString]]; + UIView *inlineView = [self mountInlineViewIn:view]; + + XCTAssertNil([view hitTest:CGPointMake(20, 10) withEvent:nil]); + XCTAssertNil([self selectionTextViewIn:view], @"A paragraph that takes no touch must not build the text view."); + XCTAssertEqual([view hitTest:CGPointMake(225, 55) withEvent:nil], inlineView); +} + +- (void)testSwitchingToBoxNoneTakesTheTouchesFromTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UIView *inlineView = [self mountInlineViewIn:view]; + XCTAssertNotNil([self selectionTextViewAfterTouchIn:view]); + + [view updateProps:[self selectablePropsWithPointerEvents:PointerEventsMode::BoxNone] + oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNil([view hitTest:CGPointMake(20, 10) withEvent:nil], @"The existing text view must not take the touch."); + XCTAssertEqual([view hitTest:CGPointMake(225, 55) withEvent:nil], inlineView); +} + +/* + * With `pointerEvents="box-only"` the paragraph takes every touch, and its + * inline views take none. Without the flag, the long press sits on the + * paragraph itself, so the text can be selected in this mode. It still can. + */ +- (void)testBoxOnlyParagraphStillSelects +{ + RCTParagraphComponentView *view = + [self paragraphViewWithProps:[self selectablePropsWithPointerEvents:PointerEventsMode::BoxOnly] + attributedString:[self decoratedAttributedString]]; + UIView *inlineView = [self mountInlineViewIn:view]; + + UIView *hitView = [view hitTest:CGPointMake(20, 10) withEvent:nil]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + XCTAssertTrue([hitView isDescendantOfView:selectionTextView], @"The text must still be selectable."); + + UIView *inlineHitView = [view hitTest:CGPointMake(225, 55) withEvent:nil]; + XCTAssertNotNil(inlineHitView); + XCTAssertFalse([inlineHitView isDescendantOfView:inlineView], @"An inline view must take no touch."); +} + +#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 selectionTextViewAfterTouchIn:view]; + + XCTAssertNotNil(selectionTextView); + XCTAssertTrue(selectionTextView.accessibilityElementsHidden, @"The text view must not be read by VoiceOver."); +} + +#pragma mark - The selection text view owns the edit menu + +/* + * The paragraph implements `copy:` for the long-press menu of the flag-off + * path. With the flag on it must refuse `copy:`, or a copy that reaches it + * copies the whole text instead of the selected range. + */ +- (void)testSelectableParagraphLeavesCopyToTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertFalse(view.canBecomeFirstResponder, @"The selection text view owns the selection."); + XCTAssertFalse([view canPerformAction:@selector(copy:) withSender:nil], @"Only the selection text view may copy."); + XCTAssertNil([self longPressRecognizerIn:view], @"The long-press menu of the flag-off path must not exist."); +} + +/* + * UIKit looks for the target of an edit-menu action up the responder chain, + * from the selection text view. A paragraph that answers for its parent stops + * that walk and then gets an action that it cannot perform. + */ +- (void)testSelectableParagraphLeavesOtherActionsToItsParent +{ + RCTParagraphSelectionMenuParent *parent = [RCTParagraphSelectionMenuParent new]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [parent addSubview:view]; + + XCTAssertTrue([parent canPerformAction:@selector(customMenuAction:) withSender:nil]); + XCTAssertFalse( + [view canPerformAction:@selector(customMenuAction:) withSender:nil], + @"The paragraph must not answer for an action that only its parent performs."); +} + +#pragma mark - With the flag off + +/* + * Without the flag, a selectable paragraph is what it was before partial + * selection: no selection text view, and a long press that shows a menu to + * copy the whole text. + */ +- (void)testFlagOffSelectableParagraphKeepsTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + XCTAssertNil( + [self selectionTextViewAfterTouchIn:view], @"Without the flag, a paragraph must gain no selection text view."); + XCTAssertNotNil([self longPressRecognizerIn:view], @"Without the flag, a long press must show the menu."); + if (@available(iOS 16.0, *)) { + XCTAssertNotNil([self editMenuInteractionIn:view]); + } + XCTAssertTrue(view.canBecomeFirstResponder); + XCTAssertTrue([view canPerformAction:@selector(copy:) withSender:nil], @"The menu copies the whole text."); +} + +- (void)testFlagOffTurningSelectableOffRemovesTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNil([self longPressRecognizerIn:view]); + if (@available(iOS 16.0, *)) { + XCTAssertNil([self editMenuInteractionIn:view]); + } + XCTAssertFalse(view.canBecomeFirstResponder); +} + +/* + * A recycled view keeps its props. When the next paragraph is selectable too, + * `updateProps:` sees no change and does not add the long press again, so + * recycling must not remove it. + */ +- (void)testFlagOffRecycledParagraphKeepsTheLongPressMenu +{ + [self setPartialTextSelectionEnabled:NO]; + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + [view prepareForRecycle]; + [self mountRecycledView:view selectable:YES text:"The next paragraph"]; + + XCTAssertNotNil([self longPressRecognizerIn:view], @"A recycled selectable paragraph must keep its long press."); + XCTAssertNil([self selectionTextViewAfterTouchIn:view]); +} + +@end diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 52a38cf443ad..0d2108f36f50 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<<6b457b1b41a17fa2065720edc82939e3>> + * @generated SignedSource<<3e8e0db62761e8883d79917c14b0c482>> */ /** @@ -216,6 +216,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enableIOSCompressedTextFrameAdjustment(): Boolean = accessor.enableIOSCompressedTextFrameAdjustment() + /** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ + @JvmStatic + public fun enableIOSPartialTextSelection(): Boolean = accessor.enableIOSPartialTextSelection() + /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 680122cc12ac..8c12b1f31626 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -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<<0fdae97a0b469d01bad69d0d05c79121>> + * @generated SignedSource<<4b837ccd48f2927cfa374508dd6426c0>> */ /** @@ -51,6 +51,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFollowUpMountItemDispatchAndroidCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null + private var enableIOSPartialTextSelectionCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null @@ -398,6 +399,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enableIOSPartialTextSelection(): Boolean { + var cached = enableIOSPartialTextSelectionCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enableIOSPartialTextSelection() + enableIOSPartialTextSelectionCache = cached + } + return cached + } + override fun enableIOSTextBaselineOffsetPerLine(): Boolean { var cached = enableIOSTextBaselineOffsetPerLineCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index dcb7a89ce632..e3a0dfd70ac0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -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<> */ /** @@ -90,6 +90,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enableIOSCompressedTextFrameAdjustment(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSPartialTextSelection(): Boolean + @DoNotStrip @JvmStatic public external fun enableIOSTextBaselineOffsetPerLine(): Boolean @DoNotStrip @JvmStatic public external fun enableIOSViewClipToPaddingBox(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index cec4ef39804d..7da3f5fb3f93 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<92e293070f5407d0096ab7f3fd208d45>> + * @generated SignedSource<<56ba99a4f3aa061df29dd2e707086524>> */ /** @@ -85,6 +85,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableIOSCompressedTextFrameAdjustment(): Boolean = false + override fun enableIOSPartialTextSelection(): Boolean = false + override fun enableIOSTextBaselineOffsetPerLine(): Boolean = false override fun enableIOSViewClipToPaddingBox(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 8f5be62abb3a..14fcb2a38ea4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -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<<17a4a2a29b0480e06ed3e8ed71fd908f>> + * @generated SignedSource<> */ /** @@ -55,6 +55,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFollowUpMountItemDispatchAndroidCache: Boolean? = null private var enableIOSCompressedTextFrameAdjustmentCache: Boolean? = null + private var enableIOSPartialTextSelectionCache: Boolean? = null private var enableIOSTextBaselineOffsetPerLineCache: Boolean? = null private var enableIOSViewClipToPaddingBoxCache: Boolean? = null private var enableImagePrefetchingAndroidCache: Boolean? = null @@ -433,6 +434,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enableIOSPartialTextSelection(): Boolean { + var cached = enableIOSPartialTextSelectionCache + if (cached == null) { + cached = currentProvider.enableIOSPartialTextSelection() + accessedFeatureFlags.add("enableIOSPartialTextSelection") + enableIOSPartialTextSelectionCache = cached + } + return cached + } + override fun enableIOSTextBaselineOffsetPerLine(): Boolean { var cached = enableIOSTextBaselineOffsetPerLineCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 9cd4b79042de..0aa068036dad 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -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<<3c3e22fe0cffa5498750e91ad05df9a8>> + * @generated SignedSource<> */ /** @@ -85,6 +85,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enableIOSCompressedTextFrameAdjustment(): Boolean + @DoNotStrip public fun enableIOSPartialTextSelection(): Boolean + @DoNotStrip public fun enableIOSTextBaselineOffsetPerLine(): Boolean @DoNotStrip public fun enableIOSViewClipToPaddingBox(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 763c9053a62d..6bf1b26d3ead 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -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<<9ebb2ad1ff4665b8b5068798946ad281>> */ /** @@ -225,6 +225,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enableIOSPartialTextSelection() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSPartialTextSelection"); + return method(javaProvider_); + } + bool enableIOSTextBaselineOffsetPerLine() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableIOSTextBaselineOffsetPerLine"); @@ -786,6 +792,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableIOSCompressedTextFrameAdjustment( return ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment(); } +bool JReactNativeFeatureFlagsCxxInterop::enableIOSPartialTextSelection( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enableIOSPartialTextSelection(); +} + bool JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); @@ -1245,6 +1256,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enableIOSCompressedTextFrameAdjustment", JReactNativeFeatureFlagsCxxInterop::enableIOSCompressedTextFrameAdjustment), + makeNativeMethod( + "enableIOSPartialTextSelection", + JReactNativeFeatureFlagsCxxInterop::enableIOSPartialTextSelection), makeNativeMethod( "enableIOSTextBaselineOffsetPerLine", JReactNativeFeatureFlagsCxxInterop::enableIOSTextBaselineOffsetPerLine), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index a83506cc480c..b26c8e2073ec 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<<8d25ec35055628073cf8cd61f8cc838e>> + * @generated SignedSource<> */ /** @@ -123,6 +123,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enableIOSCompressedTextFrameAdjustment( facebook::jni::alias_ref); + static bool enableIOSPartialTextSelection( + facebook::jni::alias_ref); + static bool enableIOSTextBaselineOffsetPerLine( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index ebab1f56c12a..def49fa848ef 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<<831fb9840955085ec29c74490214d4ca>> */ /** @@ -150,6 +150,10 @@ bool ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment() { return getAccessor().enableIOSCompressedTextFrameAdjustment(); } +bool ReactNativeFeatureFlags::enableIOSPartialTextSelection() { + return getAccessor().enableIOSPartialTextSelection(); +} + bool ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine() { return getAccessor().enableIOSTextBaselineOffsetPerLine(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index ae4726251a4c..9cb33f74d37b 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<8991088f237ee5347385d79227b701a9>> */ /** @@ -196,6 +196,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enableIOSCompressedTextFrameAdjustment(); + /** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ + RN_EXPORT static bool enableIOSPartialTextSelection(); + /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index c50fa0882e59..a9045b2c9345 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<> */ /** @@ -587,6 +587,24 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSCompressedTextFrameAdjustment() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enableIOSPartialTextSelection() { + auto flagValue = enableIOSPartialTextSelection_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(31, "enableIOSPartialTextSelection"); + + flagValue = currentProvider_->enableIOSPartialTextSelection(); + enableIOSPartialTextSelection_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { auto flagValue = enableIOSTextBaselineOffsetPerLine_.load(); @@ -596,7 +614,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(31, "enableIOSTextBaselineOffsetPerLine"); + markFlagAsAccessed(32, "enableIOSTextBaselineOffsetPerLine"); flagValue = currentProvider_->enableIOSTextBaselineOffsetPerLine(); enableIOSTextBaselineOffsetPerLine_ = flagValue; @@ -614,7 +632,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIOSViewClipToPaddingBox() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(32, "enableIOSViewClipToPaddingBox"); + markFlagAsAccessed(33, "enableIOSViewClipToPaddingBox"); flagValue = currentProvider_->enableIOSViewClipToPaddingBox(); enableIOSViewClipToPaddingBox_ = flagValue; @@ -632,7 +650,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImagePrefetchingAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(33, "enableImagePrefetchingAndroid"); + markFlagAsAccessed(34, "enableImagePrefetchingAndroid"); flagValue = currentProvider_->enableImagePrefetchingAndroid(); enableImagePrefetchingAndroid_ = flagValue; @@ -650,7 +668,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImageTransparentTintColor() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(34, "enableImageTransparentTintColor"); + markFlagAsAccessed(35, "enableImageTransparentTintColor"); flagValue = currentProvider_->enableImageTransparentTintColor(); enableImageTransparentTintColor_ = flagValue; @@ -668,7 +686,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImmediateUpdateModeForContentOffsetC // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(35, "enableImmediateUpdateModeForContentOffsetChanges"); + markFlagAsAccessed(36, "enableImmediateUpdateModeForContentOffsetChanges"); flagValue = currentProvider_->enableImmediateUpdateModeForContentOffsetChanges(); enableImmediateUpdateModeForContentOffsetChanges_ = flagValue; @@ -686,7 +704,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(36, "enableImperativeEvents"); + markFlagAsAccessed(37, "enableImperativeEvents"); flagValue = currentProvider_->enableImperativeEvents(); enableImperativeEvents_ = flagValue; @@ -704,7 +722,7 @@ bool ReactNativeFeatureFlagsAccessor::enableImperativeFocus() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(37, "enableImperativeFocus"); + markFlagAsAccessed(38, "enableImperativeFocus"); flagValue = currentProvider_->enableImperativeFocus(); enableImperativeFocus_ = flagValue; @@ -722,7 +740,7 @@ bool ReactNativeFeatureFlagsAccessor::enableInteractableResponderViews() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(38, "enableInteractableResponderViews"); + markFlagAsAccessed(39, "enableInteractableResponderViews"); flagValue = currentProvider_->enableInteractableResponderViews(); enableInteractableResponderViews_ = flagValue; @@ -740,7 +758,7 @@ bool ReactNativeFeatureFlagsAccessor::enableInteropViewManagerClassLookUpOptimiz // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(39, "enableInteropViewManagerClassLookUpOptimizationIOS"); + markFlagAsAccessed(40, "enableInteropViewManagerClassLookUpOptimizationIOS"); flagValue = currentProvider_->enableInteropViewManagerClassLookUpOptimizationIOS(); enableInteropViewManagerClassLookUpOptimizationIOS_ = flagValue; @@ -758,7 +776,7 @@ bool ReactNativeFeatureFlagsAccessor::enableIntersectionObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(40, "enableIntersectionObserverByDefault"); + markFlagAsAccessed(41, "enableIntersectionObserverByDefault"); flagValue = currentProvider_->enableIntersectionObserverByDefault(); enableIntersectionObserverByDefault_ = flagValue; @@ -776,7 +794,7 @@ bool ReactNativeFeatureFlagsAccessor::enableKeyEvents() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(41, "enableKeyEvents"); + markFlagAsAccessed(42, "enableKeyEvents"); flagValue = currentProvider_->enableKeyEvents(); enableKeyEvents_ = flagValue; @@ -794,7 +812,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(42, "enableLayoutAnimationsOnAndroid"); + markFlagAsAccessed(43, "enableLayoutAnimationsOnAndroid"); flagValue = currentProvider_->enableLayoutAnimationsOnAndroid(); enableLayoutAnimationsOnAndroid_ = flagValue; @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableLayoutAnimationsOnIOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableLayoutAnimationsOnIOS"); + markFlagAsAccessed(44, "enableLayoutAnimationsOnIOS"); flagValue = currentProvider_->enableLayoutAnimationsOnIOS(); enableLayoutAnimationsOnIOS_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableModuleArgumentNSNullConversionIOS() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableModuleArgumentNSNullConversionIOS"); + markFlagAsAccessed(45, "enableModuleArgumentNSNullConversionIOS"); flagValue = currentProvider_->enableModuleArgumentNSNullConversionIOS(); enableModuleArgumentNSNullConversionIOS_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMountingCoordinatorPullModelAndroid( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enableMountingCoordinatorPullModelAndroid"); + markFlagAsAccessed(46, "enableMountingCoordinatorPullModelAndroid"); flagValue = currentProvider_->enableMountingCoordinatorPullModelAndroid(); enableMountingCoordinatorPullModelAndroid_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enableMutationObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enableMutationObserverByDefault"); + markFlagAsAccessed(47, "enableMutationObserverByDefault"); flagValue = currentProvider_->enableMutationObserverByDefault(); enableMutationObserverByDefault_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enableNativeCSSParsing() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enableNativeCSSParsing"); + markFlagAsAccessed(48, "enableNativeCSSParsing"); flagValue = currentProvider_->enableNativeCSSParsing(); enableNativeCSSParsing_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreallocatedPropsDiffOnInsertAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enablePreallocatedPropsDiffOnInsertAndroid"); + markFlagAsAccessed(49, "enablePreallocatedPropsDiffOnInsertAndroid"); flagValue = currentProvider_->enablePreallocatedPropsDiffOnInsertAndroid(); enablePreallocatedPropsDiffOnInsertAndroid_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enablePreparedTextLayout"); + markFlagAsAccessed(50, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(51, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableResizeObserverByDefault"); + markFlagAsAccessed(52, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(53, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "enableViewCulling"); + markFlagAsAccessed(54, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "enableViewRecycling"); + markFlagAsAccessed(55, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "enableViewRecyclingForImage"); + markFlagAsAccessed(56, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(57, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "enableViewRecyclingForText"); + markFlagAsAccessed(58, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "enableViewRecyclingForView"); + markFlagAsAccessed(59, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(60, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fixBorderlessRippleAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(61, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(62, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::fixTextLayoutOnPixelDensityChange() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "fixTextLayoutOnPixelDensityChange"); + markFlagAsAccessed(63, "fixTextLayoutOnPixelDensityChange"); flagValue = currentProvider_->fixTextLayoutOnPixelDensityChange(); fixTextLayoutOnPixelDensityChange_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(64, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(65, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "fuseboxEnabledRelease"); + markFlagAsAccessed(66, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(67, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(68, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(69, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::keepTimersRunningWhileHostPausedAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "keepTimersRunningWhileHostPausedAndroid"); + markFlagAsAccessed(70, "keepTimersRunningWhileHostPausedAndroid"); flagValue = currentProvider_->keepTimersRunningWhileHostPausedAndroid(); keepTimersRunningWhileHostPausedAndroid_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(71, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(72, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "perfIssuesEnabled"); + markFlagAsAccessed(73, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "perfMonitorV2Enabled"); + markFlagAsAccessed(74, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1370,7 +1388,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "preparedTextCacheSize"); + markFlagAsAccessed(75, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(76, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "redBoxV2Android"); + markFlagAsAccessed(77, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "redBoxV2IOS"); + markFlagAsAccessed(78, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(79, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(80, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(81, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::skipUnchangedChildrenInDifferentiator() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "skipUnchangedChildrenInDifferentiator"); + markFlagAsAccessed(82, "skipUnchangedChildrenInDifferentiator"); flagValue = currentProvider_->skipUnchangedChildrenInDifferentiator(); skipUnchangedChildrenInDifferentiator_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(83, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(84, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(85, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(86, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(87, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1604,7 +1622,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "useFabricInterop"); + markFlagAsAccessed(88, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(89, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "useNestedScrollViewAndroid"); + markFlagAsAccessed(90, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1658,7 +1676,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "useSharedAnimatedBackend"); + markFlagAsAccessed(91, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1676,7 +1694,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(91, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(92, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1694,7 +1712,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(92, "useTurboModuleInterop"); + markFlagAsAccessed(93, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1712,7 +1730,7 @@ bool ReactNativeFeatureFlagsAccessor::useWindowDensityForDisplayMetricsAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(93, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(94, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1730,7 +1748,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(94, "viewCullingOutsetRatio"); + markFlagAsAccessed(95, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1748,7 +1766,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(95, "viewTransitionEnabled"); + markFlagAsAccessed(96, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1766,7 +1784,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(96, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(97, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1784,7 +1802,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(97, "virtualViewPrerenderRatio"); + markFlagAsAccessed(98, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 56e79170dc8a..2ebbf93fddab 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<<3c072a5cbda73530a3c24e16cd978efc>> + * @generated SignedSource<<9e37968cee8acc4c28ba737f77cd5cf9>> */ /** @@ -65,6 +65,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableFlexboxAutoMinSizeInStrictMode(); bool enableFollowUpMountItemDispatchAndroid(); bool enableIOSCompressedTextFrameAdjustment(); + bool enableIOSPartialTextSelection(); bool enableIOSTextBaselineOffsetPerLine(); bool enableIOSViewClipToPaddingBox(); bool enableImagePrefetchingAndroid(); @@ -143,7 +144,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 98> accessedFeatureFlags_; + std::array, 99> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -176,6 +177,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableFlexboxAutoMinSizeInStrictMode_; std::atomic> enableFollowUpMountItemDispatchAndroid_; std::atomic> enableIOSCompressedTextFrameAdjustment_; + std::atomic> enableIOSPartialTextSelection_; std::atomic> enableIOSTextBaselineOffsetPerLine_; std::atomic> enableIOSViewClipToPaddingBox_; std::atomic> enableImagePrefetchingAndroid_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index f513d02bd888..280f8ad1ca9b 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<6f34d9978d5cd0ec5ecb7360c2f4720f>> + * @generated SignedSource<<89ab9e63f9f36a3a7144d2798fb0ee29>> */ /** @@ -153,6 +153,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enableIOSPartialTextSelection() override { + return false; + } + bool enableIOSTextBaselineOffsetPerLine() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 62a50c9d4adc..1365b46d152c 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<<8c22809fda00ab42d807816b7606e291>> + * @generated SignedSource<<481e389d251f72f9e76d1ffee8c749e6>> */ /** @@ -326,6 +326,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enableIOSCompressedTextFrameAdjustment(); } + bool enableIOSPartialTextSelection() override { + auto value = values_["enableIOSPartialTextSelection"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enableIOSPartialTextSelection(); + } + bool enableIOSTextBaselineOffsetPerLine() override { auto value = values_["enableIOSTextBaselineOffsetPerLine"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index dcb3e03fbec7..43cfc60b5145 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<<06ca90611bd46976af67067995edf2fd>> + * @generated SignedSource<<2835823a0015e62d7fa337b3ea16613f>> */ /** @@ -58,6 +58,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableFlexboxAutoMinSizeInStrictMode() = 0; virtual bool enableFollowUpMountItemDispatchAndroid() = 0; virtual bool enableIOSCompressedTextFrameAdjustment() = 0; + virtual bool enableIOSPartialTextSelection() = 0; virtual bool enableIOSTextBaselineOffsetPerLine() = 0; virtual bool enableIOSViewClipToPaddingBox() = 0; virtual bool enableImagePrefetchingAndroid() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 35f22a9efccb..7cf32dc2047a 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<> */ /** @@ -199,6 +199,11 @@ bool NativeReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment( return ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment(); } +bool NativeReactNativeFeatureFlags::enableIOSPartialTextSelection( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enableIOSPartialTextSelection(); +} + bool NativeReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enableIOSTextBaselineOffsetPerLine(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index c1ba42cbbc63..571d3a7589eb 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<<481fe2c32710b3898c8aed30ec8e135d>> + * @generated SignedSource<<8c23655c467069d0eea1ad83e77111ff>> */ /** @@ -100,6 +100,8 @@ class NativeReactNativeFeatureFlags bool enableIOSCompressedTextFrameAdjustment(jsi::Runtime& runtime); + bool enableIOSPartialTextSelection(jsi::Runtime& runtime); + bool enableIOSTextBaselineOffsetPerLine(jsi::Runtime& runtime); bool enableIOSViewClipToPaddingBox(jsi::Runtime& runtime); 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 994f46d2dd45..03036a0fec84 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 @@ -427,6 +427,16 @@ - (LinesMeasurements)getLinesForAttributedString:(const facebook::react::Attribu return paragraphLines; } +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + return + [self _textStorageAndLayoutManagerWithAttributesString:attributedString + paragraphAttributes:paragraphAttributes + size:RCTTextLayoutSizeForFrameSize(size, paragraphAttributes)]; +} + - (CGFloat)_maximumFontSizeInAttributedString:(NSAttributedString *)attributedString { __block CGFloat maximumFontSize = 0.0; diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index e1ff5983f392..d18caec22d03 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -385,6 +385,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableIOSPartialTextSelection: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-10', + description: + 'Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableIOSTextBaselineOffsetPerLine: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 2a5609d67807..836488be47a2 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<> * @flow strict * @noformat */ @@ -80,6 +80,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableFlexboxAutoMinSizeInStrictMode: Getter, enableFollowUpMountItemDispatchAndroid: Getter, enableIOSCompressedTextFrameAdjustment: Getter, + enableIOSPartialTextSelection: Getter, enableIOSTextBaselineOffsetPerLine: Getter, enableIOSViewClipToPaddingBox: Getter, enableImagePrefetchingAndroid: Getter, @@ -347,6 +348,10 @@ export const enableFollowUpMountItemDispatchAndroid: Getter = createNat * Adjusts iOS Text drawing frames for compressed explicit line heights. */ export const enableIOSCompressedTextFrameAdjustment: Getter = createNativeFlagGetter('enableIOSCompressedTextFrameAdjustment', false); +/** + * Enables partial text selection for selectable Text on iOS. When disabled, a long press shows a menu that copies the whole paragraph. + */ +export const enableIOSPartialTextSelection: Getter = createNativeFlagGetter('enableIOSPartialTextSelection', false); /** * Applies base offset for each line of text separately on iOS. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 02805c4b8a2c..aef54f3057d5 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<<9a8a9d5bb580291412397dc811f3660b>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -56,6 +56,7 @@ export interface Spec extends TurboModule { readonly enableFlexboxAutoMinSizeInStrictMode?: () => boolean; readonly enableFollowUpMountItemDispatchAndroid?: () => boolean; readonly enableIOSCompressedTextFrameAdjustment?: () => boolean; + readonly enableIOSPartialTextSelection?: () => boolean; readonly enableIOSTextBaselineOffsetPerLine?: () => boolean; readonly enableIOSViewClipToPaddingBox?: () => boolean; readonly enableImagePrefetchingAndroid?: () => boolean;