From e23bf89c30c9ec0aceb2e2102d9f5d88bb84f0a7 Mon Sep 17 00:00:00 2001 From: Liron Yahdav Date: Wed, 31 Mar 2021 15:38:23 -0700 Subject: [PATCH] Support arbitrary selectable text in Text component Summary: RN Mac's implementation of the `selectable` prop on `Text` only allows selecting the entire Text component and right click to copy. This diff makes the `Text.selectable` prop on Mac allow arbitrary selection. To do this we used `NSTextView` to render the `Text` component instead of RN Mac's custom text rendering, because it has a `selectable` property which gives us the behavior we want. We also allow adding custom menu items in the context menu. Note the change to RNTesterPage.js was required to fix https://github.com/microsoft/react-native-macos/issues/754. Test Plan: See test plan of D27250072 for integration to Zeratul. Confirmed text selection works in RNTester Text example: {F588619781} --- Also I went to RNTester Text examples and did an image diff comparison before and after these changes (differences are in pink): {F588602710} - The font smoothing isn't something we need --- {F588602715} - The examples with images are different because they load random images - The pink background on "With size and background color" isn't a difference, the background color is pink in code --- {F588602706} - The example has an off by 1 pixel difference that wasn't trivial to fix and doesn't seem significant enough to investigate Reviewers: skyle, ericroz Reviewed By: skyle Subscribers: eliwhite Differential Revision: https://phabricator.intern.facebook.com/D27484533 Tasks: T83817888 Signature: 27484533:1617928003:6c1c60a15db8ef3551aafe22229fafc9fea0053e # Conflicts: # Libraries/Text/Text/RCTTextView.m # React/Base/RCTTouchHandler.h # React/Base/RCTTouchHandler.m --- Libraries/Text/Text/RCTTextView.h | 3 + Libraries/Text/Text/RCTTextView.m | 189 +++++++++++------- React/Base/RCTTouchHandler.h | 1 + React/Base/RCTTouchHandler.m | 5 + .../rn-tester/js/components/RNTesterPage.js | 1 - 5 files changed, 125 insertions(+), 74 deletions(-) diff --git a/Libraries/Text/Text/RCTTextView.h b/Libraries/Text/Text/RCTTextView.h index b562b0f889a65a..9ba026c29cbf18 100644 --- a/Libraries/Text/Text/RCTTextView.h +++ b/Libraries/Text/Text/RCTTextView.h @@ -17,6 +17,9 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher; // TODO(OSS Candidate ISS#2710739) @property (nonatomic, assign) BOOL selectable; +#if TARGET_OS_OSX // TODO(macOS ISS#2323203) +@property (nonatomic, strong) NSArray *additionalMenuItems; +#endif // TODO(macOS ISS#2323203) - (void)setTextStorage:(NSTextStorage *)textStorage contentFrame:(CGRect)contentFrame diff --git a/Libraries/Text/Text/RCTTextView.m b/Libraries/Text/Text/RCTTextView.m index 5b39b5a3a2732d..40ee6f6611239f 100644 --- a/Libraries/Text/Text/RCTTextView.m +++ b/Libraries/Text/Text/RCTTextView.m @@ -22,6 +22,14 @@ #import // TODO(macOS GH#774) +#if TARGET_OS_OSX // TODO(macOS ISS#2323203) +#import +#import + +@interface RCTTextView () +@end +#endif + @implementation RCTTextView { CAShapeLayer *_highlightLayer; @@ -29,6 +37,7 @@ @implementation RCTTextView UILongPressGestureRecognizer *_longPressGestureRecognizer; #else // [TODO(macOS GH#774) NSString * _accessibilityLabel; + NSTextView *_textView; #endif // ]TODO(macOS GH#774) RCTEventDispatcher *_eventDispatcher; // TODO(OSS Candidate ISS#2710739) @@ -57,6 +66,16 @@ - (instancetype)initWithFrame:(CGRect)frame self.accessibilityRole = NSAccessibilityStaticTextRole; // Fix blurry text on non-retina displays. self.canDrawSubviewsIntoLayer = YES; + // The NSTextView is responsible for drawing text and managing selection. + _textView = [[NSTextView alloc] initWithFrame:self.bounds]; + _textView.delegate = self; + _textView.drawsBackground = NO; + _textView.editable = NO; + _textView.selectable = NO; + _textView.verticallyResizable = NO; + _textView.layoutManager.usesFontLeading = NO; + _textStorage = _textView.textStorage; + [self addSubview:_textView]; #endif // ]TODO(macOS GH#774) self.opaque = NO; RCTUIViewSetContentModeRedraw(self); // TODO(macOS GH#774) and TODO(macOS ISS#3536887) @@ -65,40 +84,74 @@ - (instancetype)initWithFrame:(CGRect)frame } #if TARGET_OS_OSX // [TODO(macOS GH#774) -- (void)dealloc +- (NSView *)hitTest:(NSPoint)point { - [self removeAllTextStorageLayoutManagers]; + // We will forward mouse events to the NSTextView ourselves. + NSView *hitView = [super hitTest:point]; + return (hitView && hitView == _textView) ? self : hitView; } -- (void)removeAllTextStorageLayoutManagers +- (void)mouseDown:(NSEvent *)event { - // On macOS AppKit can throw an uncaught exception - // (-[NSConcretePointerArray pointerAtIndex:]: attempt to access pointer at index ...) - // during the dealloc of NSLayoutManager. The _textStorage and its - // associated NSLayoutManager dealloc later in an autorelease pool. - // Manually removing the layout managers from _textStorage prior to release - // works around this issue in AppKit. - NSArray *managers = [[_textStorage layoutManagers] copy]; - for (NSLayoutManager *manager in managers) { - [_textStorage removeLayoutManager:manager]; + if (!self.selectable) { + [super mouseDown:event]; + return; } -} -- (BOOL)canBecomeKeyView -{ - // RCTText should not get any keyboard focus unless its `selectable` prop is true - return _selectable; -} + // Double/triple-clicks should be forwarded to the NSTextView. + BOOL shouldForward = event.clickCount > 1; -- (void)drawFocusRingMask { - if ([self enableFocusRing]) { - NSRectFill([self bounds]); + if (!shouldForward) { + // Peek at next event to know if a selection should begin. + NSEvent *nextEvent = [self.window nextEventMatchingMask:NSLeftMouseUpMask | NSLeftMouseDraggedMask + untilDate:[NSDate distantFuture] + inMode:NSEventTrackingRunLoopMode + dequeue:NO]; + shouldForward = nextEvent.type == NSLeftMouseDragged; + } + + if (shouldForward) { + NSView *contentView = self.window.contentView; + NSPoint point = [contentView convertPoint:event.locationInWindow fromView:nil]; + + // Start selection if we're still selectable and hit-testable. + if (self.selectable && [contentView hitTest:point] == self) { + [self.touchHandler cancelTouchWithEvent:event]; + [self.window makeFirstResponder:_textView]; + [_textView mouseDown:event]; + } + } else { + // Clear selection for single clicks. + _textView.selectedRange = NSMakeRange(NSNotFound, 0); } } - (NSRect)focusRingMaskBounds { return [self bounds]; } + +- (void)rightMouseDown:(NSEvent *)event +{ + if (!self.selectable) { + [super rightMouseDown:event]; + return; + } + + [_textView rightMouseDown:event]; +} + +- (RCTTouchHandler *)touchHandler +{ + NSView *rootView = self.superview; + while (rootView != nil) { + if ([rootView isKindOfClass:[RCTRootContentView class]]) { + return [(RCTRootContentView*)rootView touchHandler]; + } + rootView = rootView.superview; + } + + return nil; +} #endif // ]TODO(macOS GH#774) #if DEBUG // TODO(macOS GH#774) description is a debug-only feature @@ -126,6 +179,8 @@ - (void)setSelectable:(BOOL)selectable else { [self disableContextMenu]; } +#else + _textView.selectable = _selectable; #endif // TODO(macOS GH#774) } @@ -150,12 +205,34 @@ - (void)setTextStorage:(NSTextStorage *)textStorage descendantViews:(NSArray *)descendantViews // TODO(macOS ISS#3536887) { #if TARGET_OS_OSX // [TODO(macOS GH#774) - [self removeAllTextStorageLayoutManagers]; + _textStorage = textStorage; #endif // ]TODO(macOS GH#774) - _textStorage = textStorage; _contentFrame = contentFrame; +#if TARGET_OS_OSX // [TODO(macOS ISS#2323203) + NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + + [_textView replaceTextContainer:textContainer]; + + // On macOS AppKit can throw an uncaught exception + // (-[NSConcretePointerArray pointerAtIndex:]: attempt to access pointer at index ...) + // during the dealloc of NSLayoutManager. The textStorage and its + // associated NSLayoutManager dealloc later in an autorelease pool. + // Manually removing the layout managers from textStorage prior to release + // works around this issue in AppKit. + NSArray *managers = [[textStorage layoutManagers] copy]; + for (NSLayoutManager *manager in managers) { + [textStorage removeLayoutManager:manager]; + } + + _textView.minSize = contentFrame.size; + _textView.maxSize = contentFrame.size; + _textView.frame = contentFrame; + _textView.textStorage.attributedString = textStorage; +#endif // ]TODO(macOS ISS#2323203) + // FIXME: Optimize this. for (RCTUIView *view in _descendantViews) { // TODO(macOS ISS#3536887) [view removeFromSuperview]; @@ -173,6 +250,11 @@ - (void)setTextStorage:(NSTextStorage *)textStorage - (void)drawRect:(CGRect)rect { [super drawRect:rect]; + +#if TARGET_OS_OSX // [TODO(macOS ISS#2323203) + return; +#endif // ]TODO(macOS ISS#2323203) + if (!_textStorage) { return; } @@ -376,71 +458,31 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture } #else // [TODO(macOS GH#774) -- (void)rightMouseDown:(NSEvent *)event -{ - if (_selectable == NO) { - [super rightMouseDown:event]; - return; - } - NSText *fieldEditor = [self.window fieldEditor:YES forObject:self]; - NSMenu *fieldEditorMenu = [fieldEditor menuForEvent:event]; - - RCTAssert(fieldEditorMenu, @"Unable to obtain fieldEditor's context menu"); - - if (fieldEditorMenu) { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; - - for (NSMenuItem *fieldEditorMenuItem in fieldEditorMenu.itemArray) { - if (fieldEditorMenuItem.action == @selector(copy:)) { - NSMenuItem *item = [fieldEditorMenuItem copy]; - - item.target = self; - [menu addItem:item]; - - break; - } - } - - RCTAssert(menu.numberOfItems > 0, @"Unable to create context menu with \"Copy\" item"); - - if (menu.numberOfItems > 0) { - [NSMenu popUpContextMenu:menu withEvent:event forView:self]; +- (NSMenu *)textView:(NSTextView *)view menu:(NSMenu *)menu forEvent:(NSEvent *)event atIndex:(NSUInteger)charIndex { + if (_additionalMenuItems.count > 0) { + [menu insertItem:[NSMenuItem separatorItem] atIndex:0]; + for (NSMenuItem* item in [_additionalMenuItems reverseObjectEnumerator]) { + [menu insertItem:item atIndex:0]; } } -} - -- (BOOL)becomeFirstResponder -{ - if (![super becomeFirstResponder]) { - return NO; - } - // If we've gained focus, notify listeners - [_eventDispatcher sendEvent:[RCTFocusChangeEvent focusEventWithReactTag:self.reactTag]]; + [self.touchHandler willShowMenuWithEvent:event]; - return YES; + return menu; } -- (BOOL)resignFirstResponder +- (void)textDidEndEditing:(NSNotification *)notification { - if (![super resignFirstResponder]) { - return NO; - } - - // If we've lost focus, notify listeners - [_eventDispatcher sendEvent:[RCTFocusChangeEvent blurEventWithReactTag:self.reactTag]]; - - return YES; + _textView.selectedRange = NSMakeRange(NSNotFound, 0); } #endif // ]TODO(macOS GH#774) -- (BOOL)canBecomeFirstResponder +#if !TARGET_OS_OSX // TODO(macOS GH#774)- (BOOL)canBecomeFirstResponder { return _selectable; } -#if !TARGET_OS_OSX // TODO(macOS GH#774) - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if (_selectable && action == @selector(copy:)) { @@ -470,6 +512,7 @@ - (void)copy:(id)sender UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; pasteboard.items = @[item]; #elif TARGET_OS_OSX // TODO(macOS GH#774) + [_textView copy:sender]; NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; [pasteboard clearContents]; [pasteboard writeObjects:[NSArray arrayWithObjects:attributedText.string, rtf, nil]]; diff --git a/React/Base/RCTTouchHandler.h b/React/Base/RCTTouchHandler.h index ab50e8af2f03be..b576548c228029 100644 --- a/React/Base/RCTTouchHandler.h +++ b/React/Base/RCTTouchHandler.h @@ -20,6 +20,7 @@ - (void)cancel; #if TARGET_OS_OSX // [TODO(macOS GH#774) +- (void)cancelTouchWithEvent:(NSEvent*)event; - (void)willShowMenuWithEvent:(NSEvent*)event; #endif // ]TODO(macOS GH#774) diff --git a/React/Base/RCTTouchHandler.m b/React/Base/RCTTouchHandler.m index e7a2cd5a1b3c0f..017d5a3c589fe4 100644 --- a/React/Base/RCTTouchHandler.m +++ b/React/Base/RCTTouchHandler.m @@ -572,6 +572,11 @@ - (void)cancel } #if TARGET_OS_OSX // [TODO(macOS GH#774) +- (void)cancelTouchWithEvent:(NSEvent*)event +{ + [self interactionsCancelled:[NSSet setWithObject:event] withEvent:event]; +} + - (void)willShowMenuWithEvent:(NSEvent*)event { if (event.type == NSEventTypeRightMouseDown) { diff --git a/packages/rn-tester/js/components/RNTesterPage.js b/packages/rn-tester/js/components/RNTesterPage.js index cd36411d8916fb..8b63345681d1f9 100644 --- a/packages/rn-tester/js/components/RNTesterPage.js +++ b/packages/rn-tester/js/components/RNTesterPage.js @@ -67,7 +67,6 @@ const styles = StyleSheet.create({ }, wrapper: { flex: 1, - paddingTop: 10, }, });