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 b1beb6c931f..6c2b6a113b9 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -29,8 +29,33 @@ #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" +#if TARGET_OS_OSX // [macOS +#import +#endif // macOS] + using namespace facebook::react; +#if TARGET_OS_OSX // [macOS +// Cancel React Native's touch handling by finding the RCTSurfaceTouchHandler +// gesture recognizer in the view hierarchy and toggling its enabled state. +// This matches what RCTSurfaceTouchHandler._cancelTouches does internally. +static void RCTCancelTouchesForView(RCTPlatformView *view) +{ + while (view) { + for (id gestureHandler in view.gestureRecognizers) { + if ([gestureHandler isKindOfClass:[RCTSurfaceTouchHandler class]]) { + [gestureHandler setEnabled:NO]; + [gestureHandler setEnabled:YES]; + return; + } + } + view = view.superview; + } +} +#endif // macOS] + +#pragma mark - RCTParagraphTextView (default, non-selectable) // [macOS] + // ParagraphTextView is an auxiliary view we set as contentView so the drawing // can happen on top of the layers manipulated by RCTViewComponentView (the parent view) @interface RCTParagraphTextView : RCTUIView // [macOS] @@ -41,6 +66,22 @@ @interface RCTParagraphTextView : RCTUIView // [macOS] @end +// [macOS Platform-native text view used when selectable={true}. +// Handles both text rendering and native text selection. +#if TARGET_OS_OSX // [macOS +@interface RCTParagraphSelectableTextView : NSTextView + +- (void)setNeedsDisplay; + +#else +@interface RCTParagraphSelectableTextView : UITextView +#endif + +@end +// macOS] + +#pragma mark - RCTParagraphComponentView // [macOS] + #if !TARGET_OS_OSX // [macOS] @interface RCTParagraphComponentView () @@ -48,9 +89,9 @@ @interface RCTParagraphComponentView () @end #else // [macOS -@interface RCTParagraphComponentView () +@interface RCTParagraphComponentView () @end -#endif // [macOS] +#endif // macOS] @implementation RCTParagraphComponentView { ParagraphAttributes _paragraphAttributes; @@ -59,6 +100,7 @@ @implementation RCTParagraphComponentView { UILongPressGestureRecognizer *_longPressGestureRecognizer; #endif // [macOS] RCTParagraphTextView *_textView; + RCTParagraphSelectableTextView *_selectableTextView; // [macOS] } - (instancetype)initWithFrame:(CGRect)frame @@ -121,13 +163,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _textView.paragraphAttributes = _paragraphAttributes; if (newParagraphProps.isSelectable != oldParagraphProps.isSelectable) { -#if !TARGET_OS_OSX // [macOS] + // [macOS Replaced enableContextMenu/disableContextMenu with _enableSelection/_disableSelection + // to swap in a native text view that supports text selection. if (newParagraphProps.isSelectable) { - [self enableContextMenu]; + [self _enableSelection]; } else { - [self disableContextMenu]; + [self _disableSelection]; } -#endif // [macOS] + // macOS] } [super updateProps:props oldProps:oldProps]; @@ -138,6 +181,10 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & _textView.state = std::static_pointer_cast(state); [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + [self updateSelectableTextStorage]; + } } - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics @@ -149,11 +196,18 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics _textView.layoutMetrics = _layoutMetrics; [_textView setNeedsDisplay]; [self setNeedsLayout]; + + if (_selectableTextView) { + [self updateSelectableTextStorage]; + } } - (void)prepareForRecycle { [super prepareForRecycle]; + if (_selectableTextView) { + [self _disableSelection]; + } _textView.state = nullptr; _accessibilityProvider = nil; } @@ -162,7 +216,114 @@ - (void)layoutSubviews { [super layoutSubviews]; + if (_selectableTextView) { + _selectableTextView.frame = self.bounds; + } else { + _textView.frame = self.bounds; + } +} + +#pragma mark - Selection Management + +- (void)_enableSelection +{ + if (_selectableTextView) { + return; + } + + _selectableTextView = [[RCTParagraphSelectableTextView alloc] initWithFrame:self.bounds]; + +#if TARGET_OS_OSX // [macOS + _selectableTextView.delegate = self; + _selectableTextView.usesFontPanel = NO; + _selectableTextView.drawsBackground = NO; + _selectableTextView.linkTextAttributes = @{}; + _selectableTextView.editable = NO; + _selectableTextView.selectable = YES; + _selectableTextView.verticallyResizable = NO; + _selectableTextView.layoutManager.usesFontLeading = NO; +#else // macOS] + _selectableTextView.editable = NO; + _selectableTextView.selectable = YES; + _selectableTextView.scrollEnabled = NO; + _selectableTextView.textContainerInset = UIEdgeInsetsZero; + _selectableTextView.textContainer.lineFragmentPadding = 0; + _selectableTextView.backgroundColor = [UIColor clearColor]; +#endif // macOS] + + // Sync text content into the native text view. + [self updateSelectableTextStorage]; + + // Swap: remove the default text view, install the selectable one. + [_textView removeFromSuperview]; + self.contentView = _selectableTextView; + +#if !TARGET_OS_OSX // [macOS] + // On iOS, also enable the context menu (long press to copy). + [self enableContextMenu]; +#endif // [macOS] +} + +- (void)_disableSelection +{ + if (!_selectableTextView) { + return; + } + +#if !TARGET_OS_OSX // [macOS] + [self disableContextMenu]; +#endif // [macOS] + + // Swap back: remove the selectable text view, restore the default one. + [_selectableTextView removeFromSuperview]; + _selectableTextView = nil; + + self.contentView = _textView; _textView.frame = self.bounds; + [_textView setNeedsDisplay]; +} + +- (void)updateSelectableTextStorage +{ + if (!_selectableTextView || !_textView.state) { + return; + } + + const auto &stateData = _textView.state->getData(); + auto textLayoutManager = stateData.layoutManager.lock(); + if (!textLayoutManager) { + return; + } + + RCTTextLayoutManager *nativeTextLayoutManager = + (RCTTextLayoutManager *)unwrapManagedObject(textLayoutManager->getNativeTextLayoutManager()); + CGRect frame = RCTCGRectFromRect(_layoutMetrics.getContentFrame()); + + NSTextStorage *textStorage = [nativeTextLayoutManager getTextStorageForAttributedString:stateData.attributedString + paragraphAttributes:_paragraphAttributes + size:frame.size]; + +#if TARGET_OS_OSX // [macOS + // Sync the layout infrastructure into the NSTextView. + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + [_selectableTextView replaceTextContainer:textContainer]; + + // Detach layout managers from the source text storage before syncing content. + NSArray *managers = [[textStorage layoutManagers] copy]; + for (NSLayoutManager *manager in managers) { + [textStorage removeLayoutManager:manager]; + } + + _selectableTextView.minSize = frame.size; + _selectableTextView.maxSize = frame.size; + _selectableTextView.frame = frame; + [[_selectableTextView textStorage] setAttributedString:textStorage]; +#else // macOS] + // On iOS, set the attributed text directly. UITextView manages its own layout. + _selectableTextView.attributedText = textStorage; + _selectableTextView.frame = frame; +#endif // macOS] } #pragma mark - Accessibility @@ -189,7 +350,7 @@ - (BOOL)isAccessibilityElement return NO; } -#if !TARGET_OS_OSX // [macOS +#if !TARGET_OS_OSX // [macOS] - (NSArray *)accessibilityElements { const auto ¶graphProps = static_cast(*_props); @@ -352,6 +513,89 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture } #endif // [macOS] +#pragma mark - macOS Mouse Event Handling + +#if TARGET_OS_OSX // [macOS + +- (NSView *)hitTest:(NSPoint)point +{ + NSView *hitView = [super hitTest:point]; + + if (!_selectableTextView) { + return hitView; + } + + // Intercept clicks on the selectable text view so we can manage selection ourselves, + // preventing it from swallowing events that may be handled in JS (e.g. onPress). + NSEventType eventType = NSApp.currentEvent.type; + BOOL isMouseClickEvent = NSEvent.pressedMouseButtons > 0; + BOOL isMouseMoveEventType = eventType == NSEventTypeMouseMoved || + eventType == NSEventTypeMouseEntered || + eventType == NSEventTypeMouseExited || + eventType == NSEventTypeCursorUpdate; + BOOL isMouseMoveEvent = !isMouseClickEvent && isMouseMoveEventType; + BOOL isTextViewClick = (hitView && hitView == _selectableTextView) && !isMouseMoveEvent; + + return isTextViewClick ? self : hitView; +} + +- (void)rightMouseDown:(NSEvent *)event +{ + if (!_selectableTextView) { + [super rightMouseDown:event]; + return; + } + + RCTCancelTouchesForView(self); + [_selectableTextView rightMouseDown:event]; +} + +- (void)mouseDown:(NSEvent *)event +{ + if (!_selectableTextView) { + [super mouseDown:event]; + return; + } + + // Double/triple-clicks should be forwarded to the NSTextView for word/line selection. + BOOL shouldForward = event.clickCount > 1; + + if (!shouldForward) { + // Peek at next event to know if a drag (selection) is beginning. + NSEvent *nextEvent = [self.window nextEventMatchingMask:NSEventMaskLeftMouseUp | NSEventMaskLeftMouseDragged + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:NO]; + shouldForward = nextEvent.type == NSEventTypeLeftMouseDragged; + } + + if (shouldForward) { + NSView *contentView = self.window.contentView; + // -[NSView hitTest:] takes coordinates in a view's superview coordinate system. + NSPoint point = [contentView.superview convertPoint:event.locationInWindow fromView:nil]; + + if ([contentView hitTest:point] == self) { + RCTCancelTouchesForView(self); + [self.window makeFirstResponder:_selectableTextView]; + [_selectableTextView mouseDown:event]; + } + } else { + // Clear selection for single clicks. + _selectableTextView.selectedRange = NSMakeRange(NSNotFound, 0); + } +} + +#pragma mark - NSTextViewDelegate + +- (void)textDidEndEditing:(NSNotification *)notification +{ + _selectableTextView.selectedRange = NSMakeRange(NSNotFound, 0); +} + +#endif // macOS] + +#pragma mark - Responder Chain + - (BOOL)canBecomeFirstResponder { const auto ¶graphProps = static_cast(*_props); @@ -369,7 +613,19 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender return [self.nextResponder canPerformAction:action withSender:sender]; } -#endif // [macOS] +#else // [macOS + +- (BOOL)resignFirstResponder +{ + // Don't relinquish first responder while selecting text. + if (_selectableTextView && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) { + return NO; + } + + return [super resignFirstResponder]; +} + +#endif // macOS] - (void)copy:(id)sender { @@ -404,6 +660,8 @@ - (void)copy:(id)sender return RCTParagraphComponentView.class; } +#pragma mark - RCTParagraphTextView Implementation + @implementation RCTParagraphTextView { CAShapeLayer *_highlightLayer; } @@ -450,3 +708,43 @@ - (void)drawRect:(CGRect)rect } @end + +// [macOS +#pragma mark - RCTParagraphSelectableTextView Implementation + +@implementation RCTParagraphSelectableTextView + +#if TARGET_OS_OSX + +- (void)setNeedsDisplay +{ + [self setNeedsDisplay:YES]; +} + +- (BOOL)canBecomeKeyView +{ + // Prevent this NSTextView from participating in the key view loop directly. + // The parent RCTParagraphComponentView manages focus instead. + return NO; +} + +- (BOOL)resignFirstResponder +{ + // Don't relinquish first responder while the user is actively selecting text. + if (self.selectable && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) { + return NO; + } + + return [super resignFirstResponder]; +} + +#else + +// On iOS, let UITextView handle touches natively for selection support. +// No hitTest override needed — UITextView's built-in gesture recognizers +// handle long-press-to-select and other selection interactions. + +#endif + +@end +// macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h index adb9430531e..40f44f78dfa 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.h @@ -59,11 +59,11 @@ using RCTTextLayoutFragmentEnumerationBlock = frame:(CGRect)frame usingBlock:(RCTTextLayoutFragmentEnumerationBlock)block; -#if TARGET_OS_OSX // [macOS +// [macOS iOS - (NSTextStorage *)getTextStorageForAttributedString:(facebook::react::AttributedString)attributedString paragraphAttributes:(facebook::react::ParagraphAttributes)paragraphAttributes size:(CGSize)size; -#endif // macOS] +// macOS iOS] @end diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm index ac7152c7ec4..61ec47af863 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 @@ -438,7 +438,7 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage return TextMeasurement{{size.width, size.height}, attachments}; } -#if TARGET_OS_OSX // [macOS +// [macOS iOS - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attributedString paragraphAttributes:(ParagraphAttributes)paragraphAttributes size:(CGSize)size @@ -447,9 +447,9 @@ - (NSTextStorage *)getTextStorageForAttributedString:(AttributedString)attribute NSTextStorage *textStorage = [self _textStorageAndLayoutManagerWithAttributesString:nsAttributedString paragraphAttributes:paragraphAttributes size:size]; - + return textStorage; } -#endif // macOS] +// macOS iOS] @end