Skip to content

Commit

Permalink
Support arbitrary selectable text in Text component
Browse files Browse the repository at this point in the history
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 microsoft#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 <TextInput multiline/> 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
  • Loading branch information
lyahdav authored and Shawn Dempsey committed Aug 9, 2022
1 parent 3ebfa69 commit e23bf89
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 74 deletions.
3 changes: 3 additions & 0 deletions Libraries/Text/Text/RCTTextView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSMenuItem *> *additionalMenuItems;
#endif // TODO(macOS ISS#2323203)

- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
Expand Down
189 changes: 116 additions & 73 deletions Libraries/Text/Text/RCTTextView.m
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,22 @@

#import <QuartzCore/QuartzCore.h> // TODO(macOS GH#774)

#if TARGET_OS_OSX // TODO(macOS ISS#2323203)
#import <React/RCTRootContentView.h>
#import <React/RCTTouchHandler.h>

@interface RCTTextView () <NSTextViewDelegate>
@end
#endif

@implementation RCTTextView
{
CAShapeLayer *_highlightLayer;
#if !TARGET_OS_OSX // TODO(macOS GH#774)
UILongPressGestureRecognizer *_longPressGestureRecognizer;
#else // [TODO(macOS GH#774)
NSString * _accessibilityLabel;
NSTextView *_textView;
#endif // ]TODO(macOS GH#774)

RCTEventDispatcher *_eventDispatcher; // TODO(OSS Candidate ISS#2710739)
Expand Down Expand Up @@ -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)
Expand All @@ -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<NSLayoutManager *> *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
Expand Down Expand Up @@ -126,6 +179,8 @@ - (void)setSelectable:(BOOL)selectable
else {
[self disableContextMenu];
}
#else
_textView.selectable = _selectable;
#endif // TODO(macOS GH#774)
}

Expand All @@ -150,12 +205,34 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
descendantViews:(NSArray<RCTUIView *> *)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<NSLayoutManager *> *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];
Expand All @@ -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;
}
Expand Down Expand Up @@ -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:)) {
Expand Down Expand Up @@ -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]];
Expand Down
1 change: 1 addition & 0 deletions React/Base/RCTTouchHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions React/Base/RCTTouchHandler.m
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion packages/rn-tester/js/components/RNTesterPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ const styles = StyleSheet.create({
},
wrapper: {
flex: 1,
paddingTop: 10,
},
});

Expand Down

0 comments on commit e23bf89

Please sign in to comment.