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 e169755fe8e2..a7d5cf344307 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:(facebook::react::AttributedString)att frame:(CGRect)frame containerFrame:(CGRect *)containerFrame; +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes + size:(CGSize)size; + @end // ParagraphTextView is an auxiliary view we set as contentView so the drawing @@ -62,6 +67,13 @@ @implementation RCTParagraphComponentView { UILongPressGestureRecognizer *_longPressGestureRecognizer; RCTParagraphTextView *_textView; CGRect _textLayoutFrame; +#if !TARGET_OS_TV + RCTSelectableTextView *_selectableTextView; + RCTTextLayoutManager *_selectionLayoutManager; + NSAttributedString *_selectionRenderedText; + CGSize _selectionRenderedSize; + BOOL _selectableTextViewNeedsUpdate; +#endif } - (instancetype)initWithFrame:(CGRect)frame @@ -166,6 +178,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 + // built here, after all of the mutation, from the drawing frame computed last. + if (_selectableTextViewNeedsUpdate) { + _selectableTextViewNeedsUpdate = NO; + [self updateSelectableTextViewWithDrawingFrame:_textLayoutFrame]; + } +#endif } - (void)prepareForRecycle @@ -173,6 +194,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 @@ -199,6 +228,16 @@ - (void)_updateTextViewFrame _textLayoutFrame = drawingFrame; _textView.frame = textViewFrame; _textView.drawingFrame = CGRectOffset(drawingFrame, -textViewFrame.origin.x, -textViewFrame.origin.y); + +#if !TARGET_OS_TV + const auto ¶graphProps = static_cast(*_props); + if (paragraphProps.isSelectable && ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + // `drawingFrame` is the frame `RCTParagraphTextView` draws the glyphs into, + // compression adjustment included. The selection must use the same frame, + // or the selection rects sit away from the glyphs they select. + [self updateSelectableTextViewWithDrawingFrame:drawingFrame]; + } +#endif } #pragma mark - Accessibility @@ -303,6 +342,16 @@ - (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. + // `resignFirstResponder` empties the range, so an empty range means no + // selection is on screen and an ordinary press goes through. + if (_selectableTextView.selectedRange.length > 0) { + return nullptr; + } +#endif + const auto &state = _textView.state; if (!state) { return _eventEmitter; @@ -337,6 +386,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:)]; @@ -349,6 +403,11 @@ - (void)enableContextMenu - (void)disableContextMenu { + if (ReactNativeFeatureFlags::enableIOSPartialTextSelection()) { + [self disableTextSelection]; + return; + } + [self removeGestureRecognizer:_longPressGestureRecognizer]; if (@available(iOS 16.0, *)) { [self removeInteraction:_editMenuInteraction]; @@ -378,12 +437,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:)) { @@ -412,6 +483,84 @@ - (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; +} + +- (void)removeSelectableTextView +{ + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + _selectionRenderedText = nil; + _selectionRenderedSize = CGSizeZero; +} + +/* + * A `UITextView` binds its text container at initialisation, so the selectable + * text view is rebuilt only when the text or the available size changes. + */ +- (void)updateSelectableTextViewWithDrawingFrame:(CGRect)drawingFrame +{ + NSAttributedString *attributedText = self.attributedText; + if (attributedText.length == 0 || CGRectIsEmpty(drawingFrame)) { + [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); + + 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; + } + + _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..62189cd2c48e --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView+Internal.h @@ -0,0 +1,52 @@ +/* + * 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; + +@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..5f510b584739 --- /dev/null +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTSelectableTextView.mm @@ -0,0 +1,216 @@ +/* + * 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 + +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; + BOOL _didCancelTouchesForSelection; +} + +- (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 other + * recognizer has to let go. Disabling a recognizer cancels what it tracks, + * which reaches React Native as a cancelled touch and stops the press. + */ +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + BOOL hasSelection = textView.selectedRange.length > 0; + if (hasSelection && !_didCancelTouchesForSelection) { + _didCancelTouchesForSelection = YES; + [self _cancelSurfaceTouches]; + } else if (!hasSelection) { + _didCancelTouchesForSelection = NO; + } +} + +- (void)_cancelSurfaceTouches +{ + for (UIView *ancestor = self.superview; ancestor != nil; ancestor = ancestor.superview) { + for (UIGestureRecognizer *recognizer in ancestor.gestureRecognizers) { + if ([recognizer isKindOfClass:[RCTSurfaceTouchHandler class]] && recognizer.isEnabled) { + recognizer.enabled = NO; + recognizer.enabled = YES; + } + } + } +} + +#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/Tests/Text/RCTParagraphSelectionTests.mm b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm new file mode 100644 index 000000000000..032e891394fe --- /dev/null +++ b/packages/react-native/React/Tests/Text/RCTParagraphSelectionTests.mm @@ -0,0 +1,672 @@ +/* + * 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 + +using namespace facebook::react; + +class RCTParagraphSelectionFeatureFlags : public ReactNativeFeatureFlagsDefaults { + public: + explicit RCTParagraphSelectionFeatureFlags(bool partialTextSelection) : partialTextSelection_(partialTextSelection) {} + + bool enableIOSPartialTextSelection() override + { + return partialTextSelection_; + } + + private: + bool partialTextSelection_; +}; + +/* + * 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 + +/* + * 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 whose single fragment carries a touch emitter, which is how a + * nested pressable reaches `touchEventEmitterAtPoint:`. + */ +- (AttributedString)attributedStringWithEventEmitter:(std::shared_ptr)eventEmitter +{ + auto textAttributes = TextAttributes{}; + textAttributes.fontSize = 20; + + auto fragment = AttributedString::Fragment{}; + fragment.string = "Selectable wavy decoration"; + 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 +{ + 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]] 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; +} + +- (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; + + return std::make_shared( + std::make_shared(std::move(stateData)), ShadowNodeFamily::Weak{}); +} + +- (Props::Shared)propsWithSelectable:(BOOL)selectable +{ + auto props = std::make_shared(); + props->isSelectable = selectable; + return props; +} + +/* + * Builds a paragraph view with the update calls that `RCTMountingManager` makes + * for an insert. The view gets no event emitter and no parent. + * `finalizeUpdates:` creates the selection text view, so every test needs it. + */ +- (RCTParagraphComponentView *)paragraphViewSelectable:(BOOL)selectable +{ + RCTParagraphComponentView *view = [RCTParagraphComponentView new]; + view.frame = CGRectMake(0, 0, 320, 100); + + [view updateProps:[self propsWithSelectable:selectable] oldProps:nullptr]; + [view updateState:[self stateWithAttributedString:[self decoratedAttributedString]] oldState:nil]; + + auto layoutMetrics = LayoutMetrics{}; + layoutMetrics.frame = facebook::react::Rect{facebook::react::Point{0, 0}, facebook::react::Size{320, 100}}; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:{}]; + + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + return view; +} + +- (UITextView *)selectionTextViewIn:(UIView *)view +{ + for (UIView *subview in view.subviews) { + if ([subview isKindOfClass:[UITextView class]]) { + return (UITextView *)subview; + } + } + return nil; +} + +- (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 selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView, @"A selectable paragraph must have a selection text view."); + + NSUInteger selectionIndex = [view.subviews indexOfObject:selectionTextView]; + NSUInteger drawnIndex = [view.subviews indexOfObject:view.contentView]; + + XCTAssertNotEqual(selectionIndex, NSNotFound); + XCTAssertNotEqual(drawnIndex, NSNotFound); + XCTAssertLessThan(selectionIndex, drawnIndex, @"UIKit paints the selection, and the glyphs must go on top of it."); +} + +/* + * The text view must lay the glyphs out, because that is what places the + * selection rects, and must paint none of them. + */ +- (void)testSelectionTextViewLaysOutButDoesNotPaint +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + NSAttributedString *laidOut = selectionTextView.textStorage; + XCTAssertGreaterThan(laidOut.length, 0u); + + NSRange range = NSMakeRange(0, laidOut.length); + [laidOut enumerateAttributesInRange:range + options:0 + usingBlock:^(NSDictionary *attributes, NSRange r, BOOL *stop) { + // Painting attributes are gone. + UIColor *foreground = attributes[NSForegroundColorAttributeName]; + XCTAssertEqualObjects(foreground, UIColor.clearColor, @"Text must not be painted twice."); + XCTAssertNil(attributes[NSUnderlineStyleAttributeName]); + XCTAssertNil(attributes[NSStrikethroughStyleAttributeName]); + XCTAssertNil(attributes[NSShadowAttributeName]); + + // Layout attributes stay, or the selection rects move. + XCTAssertNotNil(attributes[NSFontAttributeName], @"The font places the glyphs."); + }]; +} + +/* + * `copy:` maps the selected range onto the painted string. That only works if + * stripping the paint leaves the characters alone. + */ +- (void)testStrippedTextKeepsTheSameCharacters +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + XCTAssertEqualObjects( + selectionTextView.textStorage.string, + view.attributedText.string, + @"Stripping the paint must not change a single character."); +} + +#pragma mark - Copying + +/* + * Before selection existed, `copy:` wrote the whole paragraph as rich text and + * as plain text. It must still write rich text, and the rich text must carry + * the colour the user sees rather than the clear colour used for layout. + */ +- (void)testCopyWritesPaintedRichTextForTheSelectedRange +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + + UIPasteboard *pasteboard = UIPasteboard.generalPasteboard; + pasteboard.items = @[]; + + NSRange selectedRange = NSMakeRange(0, 10); // "Selectable" + selectionTextView.selectedRange = selectedRange; + [selectionTextView copy:nil]; + + NSString *expected = [view.attributedText.string substringWithRange:selectedRange]; + XCTAssertEqualObjects(pasteboard.string, expected, @"Copy must copy the selected range, not the whole paragraph."); + + NSData *rtf = [pasteboard dataForPasteboardType:(id)kUTTypeFlatRTFD]; + XCTAssertNotNil(rtf, @"Copy must still put rich text on the pasteboard."); + + NSAttributedString *pasted = [[NSAttributedString alloc] initWithData:rtf + options:@{} + documentAttributes:nil + error:nil]; + XCTAssertEqualObjects(pasted.string, expected); + + // The decisive check. The text view lays out a copy with the foreground set to + // the clear colour, so a paste that came from the text view's own storage + // would be invisible. The pasted colour must be the painted one. + NSAttributedString *paintedSelection = [view.attributedText attributedSubstringFromRange:selectedRange]; + UIColor *paintedColor = [paintedSelection attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + UIColor *pastedColor = [pasted attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL]; + XCTAssertNotNil(paintedColor); + XCTAssertNotNil(pastedColor); + + CGFloat paintedRed = 0, paintedAlpha = 0, pastedRed = 0, pastedAlpha = 0; + [paintedColor getRed:&paintedRed green:NULL blue:NULL alpha:&paintedAlpha]; + [pastedColor getRed:&pastedRed green:NULL blue:NULL alpha:&pastedAlpha]; + + XCTAssertEqualWithAccuracy(pastedRed, paintedRed, 0.01, @"Copied text must carry the colour the reader sees."); + XCTAssertEqualWithAccuracy(pastedAlpha, paintedAlpha, 0.01, @"Copied text must not be the clear layout copy."); + XCTAssertGreaterThan(pastedAlpha, 0.0, @"Copied text must be visible when pasted."); +} + +#pragma mark - Paragraphs that are not selectable + +/* + * Everything in this change sits behind `isSelectable`. A paragraph without it + * must be exactly what it was. + */ +- (void)testNonSelectableParagraphHasNoSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:NO]; + + XCTAssertNil([self selectionTextViewIn:view], @"A paragraph that is not selectable must gain no extra view."); + XCTAssertFalse(view.contentView.hidden); +} + +- (void)testTurningSelectableOffRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view updateProps:[self propsWithSelectable:NO] oldProps:[self propsWithSelectable:YES]]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + XCTAssertNil([self selectionTextViewIn: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 without a + * later change of the text or the layout. + */ +- (void)testTurningSelectableBackOnRestoresTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + + [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 selectionTextViewIn:view], @"Turning selection on again must rebuild the text view."); +} + +/* + * One mutation can turn `selectable` on and change the text. `updateProps:` + * runs before `updateState:`, so a text view built there holds the old text + * and is built again a moment later. + */ +- (void)testTurningSelectableOnWithNewTextBuildsOnceFromTheNewText +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:NO]; + + [view updateProps:[self propsWithSelectable:YES] oldProps:[self propsWithSelectable:NO]]; + XCTAssertNil([self selectionTextViewIn:view], @"The text view must wait for the rest of the mutation."); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"The new paragraph"]] oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps | RNComponentViewUpdateMaskState]; + + UITextView *selectionTextView = [self selectionTextViewIn:view]; + XCTAssertNotNil(selectionTextView); + XCTAssertEqual( + selectionTextView.textStorage.length, + [@"The new paragraph" length], + @"The text view must hold the text of the same mutation."); +} + +#pragma mark - Recycling + +/* + * Views are pooled and reused. A recycled paragraph must not carry the previous + * paragraph's selection into its next life. + */ +- (void)testRecyclingRemovesTheSelectionTextView +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + XCTAssertNotNil([self selectionTextViewIn:view]); + + [view prepareForRecycle]; + + XCTAssertNil([self selectionTextViewIn:view], @"A recycled paragraph must not keep a selection text view."); +} + +/* + * 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 selectionTextViewIn: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 selectionTextViewIn: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 selectionTextViewIn:view]; + XCTAssertNotNil(before); + + [view updateState:[self stateWithAttributedString:[self attributedStringWithText:"A different paragraph"]] + oldState:nil]; + [view finalizeUpdates:RNComponentViewUpdateMaskState]; + + XCTAssertNotEqual([self selectionTextViewIn:view], 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 at all. + */ +- (void)testShowingSelectionGivesNoEventEmitter +{ + auto eventEmitter = std::make_shared(nullptr, EventDispatcher::Weak{}); + RCTParagraphComponentView *view = [self paragraphViewWithEventEmitter:eventEmitter]; + UITextView *selectionTextView = [self selectionTextViewIn: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 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 selectionTextViewIn: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 - Accessibility + +/* + * publishes one accessibility element per link through + * `RCTParagraphComponentAccessibilityProvider`. The selection text view must + * stay out of that tree, or VoiceOver reads the paragraph twice. + */ +- (void)testSelectionTextViewIsHiddenFromAccessibility +{ + RCTParagraphComponentView *view = [self paragraphViewSelectable:YES]; + UITextView *selectionTextView = [self selectionTextViewIn:view]; + + XCTAssertNotNil(selectionTextView); + XCTAssertTrue(selectionTextView.accessibilityElementsHidden, @"The text view must not be read by VoiceOver."); +} + +#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 selectionTextViewIn: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 selectionTextViewIn: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 07ef963a6612..cd8f9f4c6dbe 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<<72ad53973a7de132705b9ec1a72aac08>> + * @generated SignedSource<<6d1d028d27437df483886b87151f6cd6>> */ /** @@ -204,6 +204,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 31815488c19a..33ad87d9dd61 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<<60051ebbc387ff06db855ed661340217>> + * @generated SignedSource<> */ /** @@ -49,6 +49,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFontScaleChangesUpdatingLayoutCache: 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 @@ -373,6 +374,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 88728162c941..f63e45449d89 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<<9ed55c4c14d640c0fb2bd6c8fb7ef557>> + * @generated SignedSource<<60ced4c5136d47b2929752516b6ae2ea>> */ /** @@ -86,6 +86,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 b80430013c55..5f2e0ef375b9 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<<2170527111c8b3c34c213c24a9948931>> + * @generated SignedSource<<1d8c2e54c4ff3c57f2e8190811dc4399>> */ /** @@ -81,6 +81,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 ddef18a5642f..8872dd673c7b 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<> + * @generated SignedSource<> */ /** @@ -53,6 +53,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableFlexboxAutoMinSizeInStrictModeCache: Boolean? = null private var enableFontScaleChangesUpdatingLayoutCache: 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 @@ -406,6 +407,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 399cbb4f0f91..3ea2508543c7 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<<256b97731c59551616a8e0cbc777f3fa>> + * @generated SignedSource<<99236a6ac70d6efaf668a07185787560>> */ /** @@ -81,6 +81,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 1c7c222ebe99..83de406c3da3 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<<2b4f896552dd54c3ec858ca788ab4254>> + * @generated SignedSource<> */ /** @@ -213,6 +213,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"); @@ -734,6 +740,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(); @@ -1162,6 +1173,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 1cc1567097c5..2433c3835bc6 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<<228fff4f0ef0e530bf50203050adb9b0>> + * @generated SignedSource<> */ /** @@ -117,6 +117,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 a3c89045c15e..b71e715b3c97 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<<2c4c3259015438d07cc137e9288db04a>> + * @generated SignedSource<> */ /** @@ -142,6 +142,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 937527653893..f48641672e94 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<<73ae8b45c0961bc60eeb804d4d40bd07>> + * @generated SignedSource<<722a0f944588de1699cf96fe7c822679>> */ /** @@ -186,6 +186,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 0f3cf823a5a1..c7e5dbe1d095 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<<46fc995d3bb226c417f9c6f8f8089360>> + * @generated SignedSource<<96517a6218c8a941f0d4ade165844e7c>> */ /** @@ -551,6 +551,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(29, "enableIOSPartialTextSelection"); + + flagValue = currentProvider_->enableIOSPartialTextSelection(); + enableIOSPartialTextSelection_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableIOSTextBaselineOffsetPerLine() { auto flagValue = enableIOSTextBaselineOffsetPerLine_.load(); @@ -560,7 +578,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(29, "enableIOSTextBaselineOffsetPerLine"); + markFlagAsAccessed(30, "enableIOSTextBaselineOffsetPerLine"); flagValue = currentProvider_->enableIOSTextBaselineOffsetPerLine(); enableIOSTextBaselineOffsetPerLine_ = flagValue; @@ -578,7 +596,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(30, "enableIOSViewClipToPaddingBox"); + markFlagAsAccessed(31, "enableIOSViewClipToPaddingBox"); flagValue = currentProvider_->enableIOSViewClipToPaddingBox(); enableIOSViewClipToPaddingBox_ = flagValue; @@ -596,7 +614,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(31, "enableImagePrefetchingAndroid"); + markFlagAsAccessed(32, "enableImagePrefetchingAndroid"); flagValue = currentProvider_->enableImagePrefetchingAndroid(); enableImagePrefetchingAndroid_ = flagValue; @@ -614,7 +632,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(32, "enableImageTransparentTintColor"); + markFlagAsAccessed(33, "enableImageTransparentTintColor"); flagValue = currentProvider_->enableImageTransparentTintColor(); enableImageTransparentTintColor_ = flagValue; @@ -632,7 +650,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(33, "enableImmediateUpdateModeForContentOffsetChanges"); + markFlagAsAccessed(34, "enableImmediateUpdateModeForContentOffsetChanges"); flagValue = currentProvider_->enableImmediateUpdateModeForContentOffsetChanges(); enableImmediateUpdateModeForContentOffsetChanges_ = flagValue; @@ -650,7 +668,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(34, "enableImperativeEvents"); + markFlagAsAccessed(35, "enableImperativeEvents"); flagValue = currentProvider_->enableImperativeEvents(); enableImperativeEvents_ = flagValue; @@ -668,7 +686,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(35, "enableImperativeFocus"); + markFlagAsAccessed(36, "enableImperativeFocus"); flagValue = currentProvider_->enableImperativeFocus(); enableImperativeFocus_ = flagValue; @@ -686,7 +704,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(36, "enableInteropViewManagerClassLookUpOptimizationIOS"); + markFlagAsAccessed(37, "enableInteropViewManagerClassLookUpOptimizationIOS"); flagValue = currentProvider_->enableInteropViewManagerClassLookUpOptimizationIOS(); enableInteropViewManagerClassLookUpOptimizationIOS_ = flagValue; @@ -704,7 +722,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(37, "enableIntersectionObserverByDefault"); + markFlagAsAccessed(38, "enableIntersectionObserverByDefault"); flagValue = currentProvider_->enableIntersectionObserverByDefault(); enableIntersectionObserverByDefault_ = flagValue; @@ -722,7 +740,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(38, "enableKeyEvents"); + markFlagAsAccessed(39, "enableKeyEvents"); flagValue = currentProvider_->enableKeyEvents(); enableKeyEvents_ = flagValue; @@ -740,7 +758,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(39, "enableLayoutAnimationsOnAndroid"); + markFlagAsAccessed(40, "enableLayoutAnimationsOnAndroid"); flagValue = currentProvider_->enableLayoutAnimationsOnAndroid(); enableLayoutAnimationsOnAndroid_ = flagValue; @@ -758,7 +776,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(40, "enableLayoutAnimationsOnIOS"); + markFlagAsAccessed(41, "enableLayoutAnimationsOnIOS"); flagValue = currentProvider_->enableLayoutAnimationsOnIOS(); enableLayoutAnimationsOnIOS_ = flagValue; @@ -776,7 +794,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(41, "enableModuleArgumentNSNullConversionIOS"); + markFlagAsAccessed(42, "enableModuleArgumentNSNullConversionIOS"); flagValue = currentProvider_->enableModuleArgumentNSNullConversionIOS(); enableModuleArgumentNSNullConversionIOS_ = flagValue; @@ -794,7 +812,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(42, "enableMountingCoordinatorPullModelAndroid"); + markFlagAsAccessed(43, "enableMountingCoordinatorPullModelAndroid"); flagValue = currentProvider_->enableMountingCoordinatorPullModelAndroid(); enableMountingCoordinatorPullModelAndroid_ = flagValue; @@ -812,7 +830,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(43, "enableMutationObserverByDefault"); + markFlagAsAccessed(44, "enableMutationObserverByDefault"); flagValue = currentProvider_->enableMutationObserverByDefault(); enableMutationObserverByDefault_ = flagValue; @@ -830,7 +848,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(44, "enableNativeCSSParsing"); + markFlagAsAccessed(45, "enableNativeCSSParsing"); flagValue = currentProvider_->enableNativeCSSParsing(); enableNativeCSSParsing_ = flagValue; @@ -848,7 +866,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(45, "enablePreallocatedPropsDiffOnInsertAndroid"); + markFlagAsAccessed(46, "enablePreallocatedPropsDiffOnInsertAndroid"); flagValue = currentProvider_->enablePreallocatedPropsDiffOnInsertAndroid(); enablePreallocatedPropsDiffOnInsertAndroid_ = flagValue; @@ -866,7 +884,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(46, "enablePreparedTextLayout"); + markFlagAsAccessed(47, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -884,7 +902,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(47, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(48, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -902,7 +920,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(48, "enableResizeObserverByDefault"); + markFlagAsAccessed(49, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -920,7 +938,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(49, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(50, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -938,7 +956,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(50, "enableViewCulling"); + markFlagAsAccessed(51, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -956,7 +974,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(51, "enableViewRecycling"); + markFlagAsAccessed(52, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -974,7 +992,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(52, "enableViewRecyclingForImage"); + markFlagAsAccessed(53, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -992,7 +1010,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(53, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(54, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1010,7 +1028,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(54, "enableViewRecyclingForText"); + markFlagAsAccessed(55, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1028,7 +1046,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(55, "enableViewRecyclingForView"); + markFlagAsAccessed(56, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1046,7 +1064,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(56, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(57, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1064,7 +1082,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(57, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(58, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1082,7 +1100,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(58, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(59, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1100,7 +1118,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(59, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(60, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1118,7 +1136,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(60, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(61, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1136,7 +1154,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(61, "fuseboxEnabledRelease"); + markFlagAsAccessed(62, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1154,7 +1172,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(62, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(63, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1172,7 +1190,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(63, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(64, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1190,7 +1208,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(64, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(65, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1208,7 +1226,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(65, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(66, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1226,7 +1244,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(66, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(67, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1244,7 +1262,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(67, "perfIssuesEnabled"); + markFlagAsAccessed(68, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1262,7 +1280,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(68, "perfMonitorV2Enabled"); + markFlagAsAccessed(69, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1280,7 +1298,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(69, "preparedTextCacheSize"); + markFlagAsAccessed(70, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1298,7 +1316,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(70, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(71, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1316,7 +1334,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(71, "redBoxV2Android"); + markFlagAsAccessed(72, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1334,7 +1352,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(72, "redBoxV2IOS"); + markFlagAsAccessed(73, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1352,7 +1370,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(73, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(74, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1370,7 +1388,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(74, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(75, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1388,7 +1406,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(75, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(76, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1406,7 +1424,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(76, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(77, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1424,7 +1442,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(77, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(78, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1442,7 +1460,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(78, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(79, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1460,7 +1478,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(79, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(80, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1478,7 +1496,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(80, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(81, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1496,7 +1514,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(81, "useFabricInterop"); + markFlagAsAccessed(82, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1514,7 +1532,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(82, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(83, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1532,7 +1550,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(83, "useNestedScrollViewAndroid"); + markFlagAsAccessed(84, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1550,7 +1568,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(84, "useSharedAnimatedBackend"); + markFlagAsAccessed(85, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1568,7 +1586,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(85, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(86, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1586,7 +1604,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(86, "useTurboModuleInterop"); + markFlagAsAccessed(87, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1604,7 +1622,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(87, "viewCullingOutsetRatio"); + markFlagAsAccessed(88, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1622,7 +1640,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(88, "viewTransitionEnabled"); + markFlagAsAccessed(89, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1640,7 +1658,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(89, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(90, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1658,7 +1676,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(90, "virtualViewPrerenderRatio"); + markFlagAsAccessed(91, "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 5233e208552e..3cdc931e6944 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<> + * @generated SignedSource<<2dd1249cd98bf8d1e8d4763ef7b54b55>> */ /** @@ -63,6 +63,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableFlexboxAutoMinSizeInStrictMode(); bool enableFontScaleChangesUpdatingLayout(); bool enableIOSCompressedTextFrameAdjustment(); + bool enableIOSPartialTextSelection(); bool enableIOSTextBaselineOffsetPerLine(); bool enableIOSViewClipToPaddingBox(); bool enableImagePrefetchingAndroid(); @@ -136,7 +137,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 91> accessedFeatureFlags_; + std::array, 92> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -167,6 +168,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableFlexboxAutoMinSizeInStrictMode_; std::atomic> enableFontScaleChangesUpdatingLayout_; 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 18e5f25979bc..1aa02a1fd2fa 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<<9218c0c441b0b937737d2fae33fd1e35>> + * @generated SignedSource<<9b10ad868b2158e959632ca2193654b0>> */ /** @@ -145,6 +145,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 9113ffb35fdc..6523f4dd2bd1 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<<419516aab11d7e44485bb29e696a75dd>> + * @generated SignedSource<> */ /** @@ -308,6 +308,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 a29aacbef32e..b1d16274c3a9 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<> + * @generated SignedSource<<34ce2ac04886ca44d37f774f72086477>> */ /** @@ -56,6 +56,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableFlexboxAutoMinSizeInStrictMode() = 0; virtual bool enableFontScaleChangesUpdatingLayout() = 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 65dad77ae6f4..9e284b1c6636 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<<7a923d3dd77e45aa82eab0c242aa6138>> */ /** @@ -189,6 +189,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 9e1273933741..369eea8c5aa8 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<> + * @generated SignedSource<<73194e9fd319e55f509f4d7441d2c123>> */ /** @@ -96,6 +96,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 33306db54922..3b1b36235960 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm @@ -415,6 +415,15 @@ - (LinesMeasurements)getLinesForAttributedString:(facebook::react::AttributedStr return paragraphLines; } +- (NSTextStorage *)textStorageForNSAttributedString:(NSAttributedString *)attributedString + paragraphAttributes:(ParagraphAttributes)paragraphAttributes + size:(CGSize)size +{ + return [self _textStorageAndLayoutManagerWithAttributesString:attributedString + paragraphAttributes:paragraphAttributes + size:size]; +} + - (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 592bc1dc87ec..250b1fdbce26 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -363,6 +363,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 958225bf8e88..113f096eacd1 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 */ @@ -79,6 +79,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableFlexboxAutoMinSizeInStrictMode: Getter, enableFontScaleChangesUpdatingLayout: Getter, enableIOSCompressedTextFrameAdjustment: Getter, + enableIOSPartialTextSelection: Getter, enableIOSTextBaselineOffsetPerLine: Getter, enableIOSViewClipToPaddingBox: Getter, enableImagePrefetchingAndroid: Getter, @@ -338,6 +339,10 @@ export const enableFontScaleChangesUpdatingLayout: Getter = createNativ * 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 b5585a64a217..884dd8c0a8c4 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<<26f30821669e81ddb8c654ea71bbaca6>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -54,6 +54,7 @@ export interface Spec extends TurboModule { readonly enableFlexboxAutoMinSizeInStrictMode?: () => boolean; readonly enableFontScaleChangesUpdatingLayout?: () => boolean; readonly enableIOSCompressedTextFrameAdjustment?: () => boolean; + readonly enableIOSPartialTextSelection?: () => boolean; readonly enableIOSTextBaselineOffsetPerLine?: () => boolean; readonly enableIOSViewClipToPaddingBox?: () => boolean; readonly enableImagePrefetchingAndroid?: () => boolean;