diff --git a/Libraries/Components/View/ReactNativeViewAttributes.js b/Libraries/Components/View/ReactNativeViewAttributes.js index 86fcc722c2a631..816284710ed9d6 100644 --- a/Libraries/Components/View/ReactNativeViewAttributes.js +++ b/Libraries/Components/View/ReactNativeViewAttributes.js @@ -37,6 +37,7 @@ const UIView = { // [TODO(macOS GH#774) acceptsFirstMouse: true, enableFocusRing: true, + focusable: true, onMouseEnter: true, onMouseLeave: true, onDragEnter: true, diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 35db91d2bdc82f..979bd6ec938f06 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -42,12 +42,9 @@ const Text: React.AbstractComponent< onResponderTerminationRequest, onStartShouldSetResponder, pressRetentionOffset, - suppressHighlighting, ...restProps } = props; - const [isHighlighted, setHighlighted] = useState(false); - const isPressable = (onPress != null || onLongPress != null || @@ -64,11 +61,9 @@ const Text: React.AbstractComponent< onLongPress, onPress, onPressIn(event) { - setHighlighted(!suppressHighlighting); onPressIn?.(event); }, onPressOut(event) { - setHighlighted(false); onPressOut?.(event); }, onResponderTerminationRequest_DEPRECATED: @@ -86,7 +81,6 @@ const Text: React.AbstractComponent< onPressOut, onResponderTerminationRequest, onStartShouldSetResponder, - suppressHighlighting, ], ); @@ -162,7 +156,6 @@ const Text: React.AbstractComponent< // TODO(macOS GH#774) +#if TARGET_OS_OSX // [TODO(macOS GH#774) + +// We are managing the key view loop using the RCTTextView. +// Disable key view for backed NSTextView so we don't get double focus. +@interface RCTUnfocusableTextView : NSTextView +@end + +@implementation RCTUnfocusableTextView + +- (BOOL)canBecomeKeyView +{ + return NO; +} + +@end + +@interface RCTTextView () +@end + +#endif // ]TODO(macOS GH#774) + @implementation RCTTextView { CAShapeLayer *_highlightLayer; @@ -29,6 +50,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 +79,18 @@ - (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 = [[RCTUnfocusableTextView alloc] initWithFrame:self.bounds]; + _textView.delegate = self; + _textView.usesFontPanel = NO; + _textView.drawsBackground = NO; + _textView.linkTextAttributes = @{}; + _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 +99,7 @@ - (instancetype)initWithFrame:(CGRect)frame } #if TARGET_OS_OSX // [TODO(macOS GH#774) -- (void)dealloc -{ - [self removeAllTextStorageLayoutManagers]; -} - -- (void)removeAllTextStorageLayoutManagers -{ - // 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]; - } -} -- (BOOL)canBecomeKeyView -{ - // RCTText should not get any keyboard focus unless its `selectable` prop is true - return _selectable; -} - -- (void)drawFocusRingMask { - if ([self enableFocusRing]) { - NSRectFill([self bounds]); - } -} - -- (NSRect)focusRingMaskBounds { - return [self bounds]; -} #endif // ]TODO(macOS GH#774) #if DEBUG // TODO(macOS GH#774) description is a debug-only feature @@ -119,14 +120,19 @@ - (void)setSelectable:(BOOL)selectable _selectable = selectable; -#if !TARGET_OS_OSX // TODO(macOS GH#774) +#if !TARGET_OS_OSX // [TODO(macOS GH#774) if (_selectable) { [self enableContextMenu]; } else { [self disableContextMenu]; } -#endif // TODO(macOS GH#774) +#else + _textView.selectable = _selectable; + if (_selectable) { + [self setFocusable:YES]; + } +#endif // ]TODO(macOS GH#774) } #if !TARGET_OS_OSX // TODO(macOS GH#774) @@ -149,13 +155,37 @@ - (void)setTextStorage:(NSTextStorage *)textStorage contentFrame:(CGRect)contentFrame descendantViews:(NSArray *)descendantViews // TODO(macOS ISS#3536887) { -#if TARGET_OS_OSX // [TODO(macOS GH#774) - [self removeAllTextStorageLayoutManagers]; + // This lets the textView own its text storage on macOS + // We update and replace the text container `_textView.textStorage.attributedString` when text/layout changes +#if !TARGET_OS_OSX // [TODO(macOS GH#774) + _textStorage = textStorage; #endif // ]TODO(macOS GH#774) - _textStorage = textStorage; _contentFrame = contentFrame; +#if TARGET_OS_OSX // [TODO(macOS GH#774) + 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 GH#774) + // FIXME: Optimize this. for (RCTUIView *view in _descendantViews) { // TODO(macOS ISS#3536887) [view removeFromSuperview]; @@ -173,6 +203,13 @@ - (void)setTextStorage:(NSTextStorage *)textStorage - (void)drawRect:(CGRect)rect { [super drawRect:rect]; + + // For iOS, UITextView api is not used for legacy performance reasons. A custom draw implementation is used instead. + // On desktop, we use NSTextView to access api's for arbitrary selection, custom cursors etc... +#if TARGET_OS_OSX // [TODO(macOS GH#774) + return; +#endif // ]TODO(macOS GH#774) + if (!_textStorage) { return; } @@ -408,6 +445,39 @@ - (void)rightMouseDown:(NSEvent *)event } } } +#endif // ]TODO(macOS GH#774) + +#pragma mark - Selection + +#if TARGET_OS_OSX // [TODO(macOS GH#774) +- (void)textDidEndEditing:(NSNotification *)notification +{ + _textView.selectedRange = NSMakeRange(NSNotFound, 0); +} +#endif // ]TODO(macOS GH#774) + +#pragma mark - Responder chain + +#if !TARGET_OS_OSX // TODO(macOS GH#774) +- (BOOL)canBecomeFirstResponder +{ + return _selectable; +} +#else +- (BOOL)canBecomeKeyView +{ + return self.focusable; +} + +- (void)drawFocusRingMask { + if (self.focusable && self.enableFocusRing) { + NSRectFill([self bounds]); + } +} + +- (NSRect)focusRingMaskBounds { + return [self bounds]; +} - (BOOL)becomeFirstResponder { @@ -423,22 +493,19 @@ - (BOOL)becomeFirstResponder - (BOOL)resignFirstResponder { - if (![super resignFirstResponder]) { + // Don't relinquish first responder while selecting text. + if (_selectable && NSRunLoop.currentRunLoop.currentMode == NSEventTrackingRunLoopMode) { return NO; } - - // If we've lost focus, notify listeners - [_eventDispatcher sendEvent:[RCTFocusChangeEvent blurEventWithReactTag:self.reactTag]]; - - return YES; + + return [super resignFirstResponder]; } -#endif // ]TODO(macOS GH#774) - - (BOOL)canBecomeFirstResponder { - return _selectable; + return self.focusable; } +#endif #if !TARGET_OS_OSX // TODO(macOS GH#774) - (BOOL)canPerformAction:(SEL)action withSender:(id)sender @@ -451,6 +518,8 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender } #endif // TODO(macOS GH#774) +#pragma mark - Copy/Paste + - (void)copy:(id)sender { NSAttributedString *attributedText = _textStorage; @@ -470,6 +539,8 @@ - (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/Libraries/Text/TextNativeComponent.js b/Libraries/Text/TextNativeComponent.js index 7fa492956a0e7b..eb64ec996a2841 100644 --- a/Libraries/Text/TextNativeComponent.js +++ b/Libraries/Text/TextNativeComponent.js @@ -33,6 +33,7 @@ export const NativeText: HostComponent = isPressable: true, numberOfLines: true, ellipsizeMode: true, + focusable: true, allowFontScaling: true, dynamicTypeRamp: true, maxFontSizeMultiplier: true, diff --git a/Libraries/Text/TextProps.js b/Libraries/Text/TextProps.js index 7b273f9f1f5c7c..aa0772f6eccd2e 100644 --- a/Libraries/Text/TextProps.js +++ b/Libraries/Text/TextProps.js @@ -225,4 +225,16 @@ export type TextProps = $ReadOnly<{| * Specifies the Tooltip for the button view */ tooltip?: ?string, + + /** + * When `true`, indicates that the text can be focused in key view loop + * By default, when `selectable={true}` the text view will be focusable unless disabled + */ + focusable?: ?boolean, + + /** + * Specifies whether focus ring should be drawn when the view has the first responder status. + * Only works when `focusable={true}` + */ + enableFocusRing?: ?boolean, // TODO(macOS GH#774) |}>; diff --git a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js index ac21daad72cc14..76018606ccbc31 100644 --- a/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js +++ b/packages/rn-tester/js/examples/KeyboardEventsExample/KeyboardEventsExample.js @@ -47,7 +47,8 @@ function KeyEventExample(): React.Node { validKeysDown={['g', 'Escape', 'Enter', 'ArrowLeft']} onKeyDown={e => appendLog('Key Down:' + e.nativeEvent.key)} validKeysUp={['c', 'd']} - onKeyUp={e => appendLog('Key Up:' + e.nativeEvent.key)}> + onKeyUp={e => appendLog('Key Up:' + e.nativeEvent.key)} + /> TextInput validKeysDown: [ArrowRight, ArrowDown]{'\n'} diff --git a/packages/rn-tester/js/examples/Text/TextExample.ios.js b/packages/rn-tester/js/examples/Text/TextExample.ios.js index 52c66116bae2e0..d8bc3a4b4a17c9 100644 --- a/packages/rn-tester/js/examples/Text/TextExample.ios.js +++ b/packages/rn-tester/js/examples/Text/TextExample.ios.js @@ -714,6 +714,45 @@ exports.examples = [ ); }, }, + // [TODO(macOS GH#774) + { + title: 'Focusable', + render: function (): React.Node { + return ( + + + This text is + not selectable yet{' '} + focusable with a visible + focus ring + + + This text is selectable{' '} + and focusable with a + visible focus ring + + + This text is selectable{' '} + and focusable without a + visible focus ring (CMD-C to copy contents) + + + This text is selectable{' '} + and not focusable + + + + ); + }, + }, + // [TODO(macOS GH#774) { title: 'Text Decoration', render: function (): React.Node { @@ -990,32 +1029,6 @@ exports.examples = [ ); }, }, - { - title: 'Text highlighting (tap the link to see highlight)', - render: function (): React.Node { - return ( - - - Lorem ipsum dolor sit amet,{' '} - null}> - consectetur adipiscing elit, sed do eiusmod tempor incididunt ut - labore et dolore magna aliqua. Ut enim ad minim veniam, quis - nostrud - {' '} - exercitation ullamco laboris nisi ut aliquip ex ea commodo - consequat. - - - ); - }, - }, { title: 'allowFontScaling attribute', render: function (): React.Node {