Skip to content

Commit cf48f8d

Browse files
shwantonlyahdavShawn Dempseyappden
authored
Support arbitrary selectable text in Text component (#1346)
* 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 #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 * Fix GH tags * Revert RNTesterPage style change * Fix hit-testing in RCTTextView for selection Summary: This fixes a regression introduced by D29340382 since the `contentView` of the window was changed to the `RCTRootView` instance. The problem isn't there though, but is due to now the `contentView` having flipped geometry. The `hitTest:` method expects coordinates in the superview's coordinate space: > A point that is in the coordinate system of the view’s superview, not of the view itself. Also see how `RCTTouchHandler` also calls `convertPoint:` on the `superview` before passing to `hitTest:` for the same reason: https://fburl.com/diffusion/krx4lxao Test Plan: {F628902534} Reviewers: lyahdav Reviewed By: lyahdav Subscribers: eliwhite Differential Revision: https://phabricator.intern.facebook.com/D29469639 Tasks: T94420821 Signature: 29469639:1625001662:97028699aee404282c83e35cd66f6308bc793a2a * Revert changes to touch handler * Only keep NSTextView changes * Remove unused property * Re-add focus ring for selected text * Fix typos * Ensure that RCTTextView manages the key loop view * move focusable property lower in list * Fix macos tags * Remove iOS only highlighted prop that was causing re-render issues on macOS * yarn lint Co-authored-by: Liron Yahdav <[email protected]> Co-authored-by: Shawn Dempsey <[email protected]> Co-authored-by: Scott Kyle <[email protected]>
1 parent f76be1d commit cf48f8d

File tree

7 files changed

+173
-82
lines changed

7 files changed

+173
-82
lines changed

Libraries/Components/View/ReactNativeViewAttributes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const UIView = {
3737
// [TODO(macOS GH#774)
3838
acceptsFirstMouse: true,
3939
enableFocusRing: true,
40+
focusable: true,
4041
onMouseEnter: true,
4142
onMouseLeave: true,
4243
onDragEnter: true,

Libraries/Text/Text.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,9 @@ const Text: React.AbstractComponent<
4242
onResponderTerminationRequest,
4343
onStartShouldSetResponder,
4444
pressRetentionOffset,
45-
suppressHighlighting,
4645
...restProps
4746
} = props;
4847

49-
const [isHighlighted, setHighlighted] = useState(false);
50-
5148
const isPressable =
5249
(onPress != null ||
5350
onLongPress != null ||
@@ -64,11 +61,9 @@ const Text: React.AbstractComponent<
6461
onLongPress,
6562
onPress,
6663
onPressIn(event) {
67-
setHighlighted(!suppressHighlighting);
6864
onPressIn?.(event);
6965
},
7066
onPressOut(event) {
71-
setHighlighted(false);
7267
onPressOut?.(event);
7368
},
7469
onResponderTerminationRequest_DEPRECATED:
@@ -86,7 +81,6 @@ const Text: React.AbstractComponent<
8681
onPressOut,
8782
onResponderTerminationRequest,
8883
onStartShouldSetResponder,
89-
suppressHighlighting,
9084
],
9185
);
9286

@@ -162,7 +156,6 @@ const Text: React.AbstractComponent<
162156
<NativeVirtualText
163157
{...restProps}
164158
{...eventHandlersForText}
165-
isHighlighted={isHighlighted}
166159
isPressable={isPressable}
167160
numberOfLines={numberOfLines}
168161
selectionColor={selectionColor}
@@ -177,7 +170,6 @@ const Text: React.AbstractComponent<
177170
accessible={accessible !== false}
178171
allowFontScaling={allowFontScaling !== false}
179172
ellipsizeMode={ellipsizeMode ?? 'tail'}
180-
isHighlighted={isHighlighted}
181173
numberOfLines={numberOfLines}
182174
selectionColor={selectionColor}
183175
style={style}

Libraries/Text/Text/RCTTextView.m

Lines changed: 118 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,35 @@
2222

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

25+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
26+
27+
// We are managing the key view loop using the RCTTextView.
28+
// Disable key view for backed NSTextView so we don't get double focus.
29+
@interface RCTUnfocusableTextView : NSTextView
30+
@end
31+
32+
@implementation RCTUnfocusableTextView
33+
34+
- (BOOL)canBecomeKeyView
35+
{
36+
return NO;
37+
}
38+
39+
@end
40+
41+
@interface RCTTextView () <NSTextViewDelegate>
42+
@end
43+
44+
#endif // ]TODO(macOS GH#774)
45+
2546
@implementation RCTTextView
2647
{
2748
CAShapeLayer *_highlightLayer;
2849
#if !TARGET_OS_OSX // TODO(macOS GH#774)
2950
UILongPressGestureRecognizer *_longPressGestureRecognizer;
3051
#else // [TODO(macOS GH#774)
3152
NSString * _accessibilityLabel;
53+
NSTextView *_textView;
3254
#endif // ]TODO(macOS GH#774)
3355

3456
RCTEventDispatcher *_eventDispatcher; // TODO(OSS Candidate ISS#2710739)
@@ -57,6 +79,18 @@ - (instancetype)initWithFrame:(CGRect)frame
5779
self.accessibilityRole = NSAccessibilityStaticTextRole;
5880
// Fix blurry text on non-retina displays.
5981
self.canDrawSubviewsIntoLayer = YES;
82+
// The NSTextView is responsible for drawing text and managing selection.
83+
_textView = [[RCTUnfocusableTextView alloc] initWithFrame:self.bounds];
84+
_textView.delegate = self;
85+
_textView.usesFontPanel = NO;
86+
_textView.drawsBackground = NO;
87+
_textView.linkTextAttributes = @{};
88+
_textView.editable = NO;
89+
_textView.selectable = NO;
90+
_textView.verticallyResizable = NO;
91+
_textView.layoutManager.usesFontLeading = NO;
92+
_textStorage = _textView.textStorage;
93+
[self addSubview:_textView];
6094
#endif // ]TODO(macOS GH#774)
6195
self.opaque = NO;
6296
RCTUIViewSetContentModeRedraw(self); // TODO(macOS GH#774) and TODO(macOS ISS#3536887)
@@ -65,40 +99,7 @@ - (instancetype)initWithFrame:(CGRect)frame
6599
}
66100

67101
#if TARGET_OS_OSX // [TODO(macOS GH#774)
68-
- (void)dealloc
69-
{
70-
[self removeAllTextStorageLayoutManagers];
71-
}
72-
73-
- (void)removeAllTextStorageLayoutManagers
74-
{
75-
// On macOS AppKit can throw an uncaught exception
76-
// (-[NSConcretePointerArray pointerAtIndex:]: attempt to access pointer at index ...)
77-
// during the dealloc of NSLayoutManager. The _textStorage and its
78-
// associated NSLayoutManager dealloc later in an autorelease pool.
79-
// Manually removing the layout managers from _textStorage prior to release
80-
// works around this issue in AppKit.
81-
NSArray<NSLayoutManager *> *managers = [[_textStorage layoutManagers] copy];
82-
for (NSLayoutManager *manager in managers) {
83-
[_textStorage removeLayoutManager:manager];
84-
}
85-
}
86102

87-
- (BOOL)canBecomeKeyView
88-
{
89-
// RCTText should not get any keyboard focus unless its `selectable` prop is true
90-
return _selectable;
91-
}
92-
93-
- (void)drawFocusRingMask {
94-
if ([self enableFocusRing]) {
95-
NSRectFill([self bounds]);
96-
}
97-
}
98-
99-
- (NSRect)focusRingMaskBounds {
100-
return [self bounds];
101-
}
102103
#endif // ]TODO(macOS GH#774)
103104

104105
#if DEBUG // TODO(macOS GH#774) description is a debug-only feature
@@ -119,14 +120,19 @@ - (void)setSelectable:(BOOL)selectable
119120

120121
_selectable = selectable;
121122

122-
#if !TARGET_OS_OSX // TODO(macOS GH#774)
123+
#if !TARGET_OS_OSX // [TODO(macOS GH#774)
123124
if (_selectable) {
124125
[self enableContextMenu];
125126
}
126127
else {
127128
[self disableContextMenu];
128129
}
129-
#endif // TODO(macOS GH#774)
130+
#else
131+
_textView.selectable = _selectable;
132+
if (_selectable) {
133+
[self setFocusable:YES];
134+
}
135+
#endif // ]TODO(macOS GH#774)
130136
}
131137

132138
#if !TARGET_OS_OSX // TODO(macOS GH#774)
@@ -149,13 +155,37 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
149155
contentFrame:(CGRect)contentFrame
150156
descendantViews:(NSArray<RCTUIView *> *)descendantViews // TODO(macOS ISS#3536887)
151157
{
152-
#if TARGET_OS_OSX // [TODO(macOS GH#774)
153-
[self removeAllTextStorageLayoutManagers];
158+
// This lets the textView own its text storage on macOS
159+
// We update and replace the text container `_textView.textStorage.attributedString` when text/layout changes
160+
#if !TARGET_OS_OSX // [TODO(macOS GH#774)
161+
_textStorage = textStorage;
154162
#endif // ]TODO(macOS GH#774)
155163

156-
_textStorage = textStorage;
157164
_contentFrame = contentFrame;
158165

166+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
167+
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
168+
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
169+
170+
[_textView replaceTextContainer:textContainer];
171+
172+
// On macOS AppKit can throw an uncaught exception
173+
// (-[NSConcretePointerArray pointerAtIndex:]: attempt to access pointer at index ...)
174+
// during the dealloc of NSLayoutManager. The textStorage and its
175+
// associated NSLayoutManager dealloc later in an autorelease pool.
176+
// Manually removing the layout managers from textStorage prior to release
177+
// works around this issue in AppKit.
178+
NSArray<NSLayoutManager *> *managers = [[textStorage layoutManagers] copy];
179+
for (NSLayoutManager *manager in managers) {
180+
[textStorage removeLayoutManager:manager];
181+
}
182+
183+
_textView.minSize = contentFrame.size;
184+
_textView.maxSize = contentFrame.size;
185+
_textView.frame = contentFrame;
186+
_textView.textStorage.attributedString = textStorage;
187+
#endif // ]TODO(macOS GH#774)
188+
159189
// FIXME: Optimize this.
160190
for (RCTUIView *view in _descendantViews) { // TODO(macOS ISS#3536887)
161191
[view removeFromSuperview];
@@ -173,6 +203,13 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
173203
- (void)drawRect:(CGRect)rect
174204
{
175205
[super drawRect:rect];
206+
207+
// For iOS, UITextView api is not used for legacy performance reasons. A custom draw implementation is used instead.
208+
// On desktop, we use NSTextView to access api's for arbitrary selection, custom cursors etc...
209+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
210+
return;
211+
#endif // ]TODO(macOS GH#774)
212+
176213
if (!_textStorage) {
177214
return;
178215
}
@@ -408,6 +445,39 @@ - (void)rightMouseDown:(NSEvent *)event
408445
}
409446
}
410447
}
448+
#endif // ]TODO(macOS GH#774)
449+
450+
#pragma mark - Selection
451+
452+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
453+
- (void)textDidEndEditing:(NSNotification *)notification
454+
{
455+
_textView.selectedRange = NSMakeRange(NSNotFound, 0);
456+
}
457+
#endif // ]TODO(macOS GH#774)
458+
459+
#pragma mark - Responder chain
460+
461+
#if !TARGET_OS_OSX // TODO(macOS GH#774)
462+
- (BOOL)canBecomeFirstResponder
463+
{
464+
return _selectable;
465+
}
466+
#else
467+
- (BOOL)canBecomeKeyView
468+
{
469+
return self.focusable;
470+
}
471+
472+
- (void)drawFocusRingMask {
473+
if (self.focusable && self.enableFocusRing) {
474+
NSRectFill([self bounds]);
475+
}
476+
}
477+
478+
- (NSRect)focusRingMaskBounds {
479+
return [self bounds];
480+
}
411481

412482
- (BOOL)becomeFirstResponder
413483
{
@@ -423,22 +493,19 @@ - (BOOL)becomeFirstResponder
423493

424494
- (BOOL)resignFirstResponder
425495
{
426-
if (![super resignFirstResponder]) {
496+
// Don't relinquish first responder while selecting text.
497+
if (_selectable && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) {
427498
return NO;
428499
}
429-
430-
// If we've lost focus, notify listeners
431-
[_eventDispatcher sendEvent:[RCTFocusChangeEvent blurEventWithReactTag:self.reactTag]];
432-
433-
return YES;
500+
501+
return [super resignFirstResponder];
434502
}
435503

436-
#endif // ]TODO(macOS GH#774)
437-
438504
- (BOOL)canBecomeFirstResponder
439505
{
440-
return _selectable;
506+
return self.focusable;
441507
}
508+
#endif
442509

443510
#if !TARGET_OS_OSX // TODO(macOS GH#774)
444511
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
@@ -451,6 +518,8 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
451518
}
452519
#endif // TODO(macOS GH#774)
453520

521+
#pragma mark - Copy/Paste
522+
454523
- (void)copy:(id)sender
455524
{
456525
NSAttributedString *attributedText = _textStorage;
@@ -470,6 +539,8 @@ - (void)copy:(id)sender
470539
UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
471540
pasteboard.items = @[item];
472541
#elif TARGET_OS_OSX // TODO(macOS GH#774)
542+
[_textView copy:sender];
543+
473544
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];
474545
[pasteboard clearContents];
475546
[pasteboard writeObjects:[NSArray arrayWithObjects:attributedText.string, rtf, nil]];

Libraries/Text/TextNativeComponent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const NativeText: HostComponent<NativeTextProps> =
3333
isPressable: true,
3434
numberOfLines: true,
3535
ellipsizeMode: true,
36+
focusable: true,
3637
allowFontScaling: true,
3738
dynamicTypeRamp: true,
3839
maxFontSizeMultiplier: true,

Libraries/Text/TextProps.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,4 +225,16 @@ export type TextProps = $ReadOnly<{|
225225
* Specifies the Tooltip for the button view
226226
*/
227227
tooltip?: ?string,
228+
229+
/**
230+
* When `true`, indicates that the text can be focused in key view loop
231+
* By default, when `selectable={true}` the text view will be focusable unless disabled
232+
*/
233+
focusable?: ?boolean,
234+
235+
/**
236+
* Specifies whether focus ring should be drawn when the view has the first responder status.
237+
* Only works when `focusable={true}`
238+
*/
239+
enableFocusRing?: ?boolean, // TODO(macOS GH#774)
228240
|}>;

packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ function KeyEventExample(): React.Node {
4747
validKeysDown={['g', 'Escape', 'Enter', 'ArrowLeft']}
4848
onKeyDown={e => appendLog('Key Down:' + e.nativeEvent.key)}
4949
validKeysUp={['c', 'd']}
50-
onKeyUp={e => appendLog('Key Up:' + e.nativeEvent.key)}></View>
50+
onKeyUp={e => appendLog('Key Up:' + e.nativeEvent.key)}
51+
/>
5152
<Text style={styles.title}>TextInput</Text>
5253
<Text style={styles.text}>
5354
validKeysDown: [ArrowRight, ArrowDown]{'\n'}

0 commit comments

Comments
 (0)