Skip to content

Commit

Permalink
Add onMouseEnter and onMouseLeave to Text (cherry-picked from 0.73-st…
Browse files Browse the repository at this point in the history
…able) (#2149)

* [0.73-stable] New events for RCTUIView (#2137)

* Move mouse events from RCTView to superclass RCTUIView

* Add focus and responder events

* Move mouse event implementations to RCTUIView class

---------

Co-authored-by: Adam Gleitman <adgleitm@microsoft.com>

* [0.73-stable] Add mouse hover events to `RCTTextView` (#2143)

* Initial implementation

* Refactor and dedupe some code

* Basic error handling

* Clarify comment about mousemove order

* Cleanup: enumerate all text attributes at once instead of doing multiple passes

* Use *shadow* view traversal for handling nested mouse events

* Remove potentially confusing comment

* descendantViewTags doesn't need to worry about duplicates

* Distinguish between embedded views and virtual text subviews

* Scope _virtualSubviews to macOS only

* nit: use separate #if blocks for `setTextStorage:...` and `getRectForCharRange:` inclusions

* TARGET_OS_OSX blocks for virtualSubviewTags

* Remove #if TARGET_OS_OSX blocks, since these changes are potentially upstreamable

* Clarify a TODO

---------

Co-authored-by: Adam Gleitman <adgleitm@microsoft.com>

* Add an example for Text mouse hover events

* Add missing macOS props to Text.d.ts

* Fix flow errors

* Fix lint errors

---------

Co-authored-by: Adam Gleitman <adgleitm@microsoft.com>
  • Loading branch information
amgleitman and Adam Gleitman authored Jul 27, 2024
1 parent 73cdf93 commit 10b1cd8
Show file tree
Hide file tree
Showing 12 changed files with 446 additions and 176 deletions.
11 changes: 11 additions & 0 deletions packages/react-native/Libraries/Text/Text.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,20 @@ export interface TextPropsAndroid {
android_hyphenationFrequency?: 'normal' | 'none' | 'full' | undefined;
}

// [macOS
export interface TextPropsMacOS {
enableFocusRing?: boolean | undefined;
focusable?: boolean | undefined;
onMouseEnter?: ((event: MouseEvent) => void) | undefined;
onMouseLeave?: ((event: MouseEvent) => void) | undefined;
tooltip?: string | undefined;
}
// macOS]

// https://reactnative.dev/docs/text#props
export interface TextProps
extends TextPropsIOS,
TextPropsMacOS, // [macOS]
TextPropsAndroid,
AccessibilityProps {
/**
Expand Down
42 changes: 32 additions & 10 deletions packages/react-native/Libraries/Text/Text/RCTTextShadowView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,24 @@ - (void)uiManagerWillPerformMounting

NSNumber *tag = self.reactTag;
NSMutableArray<NSNumber *> *descendantViewTags = [NSMutableArray new];
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
if (!shadowView) {
return;
}
NSMutableArray<NSNumber *> *virtualSubviewTags = [NSMutableArray new]; // [macOS]

// [macOS - Enumerate embedded shadow views and virtual subviews in one loop
[textStorage enumerateAttributesInRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:^(NSDictionary<NSAttributedStringKey, id> *_Nonnull attrs, NSRange range, __unused BOOL * _Nonnull stop) {
id embeddedViewAttribute = attrs[RCTBaseTextShadowViewEmbeddedShadowViewAttributeName];
if ([embeddedViewAttribute isKindOfClass:[RCTShadowView class]]) {
RCTShadowView *embeddedShadowView = (RCTShadowView *)embeddedViewAttribute;
[descendantViewTags addObject:embeddedShadowView.reactTag];
}

[descendantViewTags addObject:shadowView.reactTag];
}];
id tagAttribute = attrs[RCTTextAttributesTagAttributeName];
if ([tagAttribute isKindOfClass:[NSNumber class]] && ![tagAttribute isEqualToNumber:tag]) {
[virtualSubviewTags addObject:tagAttribute];
}
}];
// macOS]

[_bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, RCTPlatformView *> *viewRegistry) { // [macOS]
RCTTextView *textView = (RCTTextView *)viewRegistry[tag];
Expand All @@ -113,11 +121,25 @@ - (void)uiManagerWillPerformMounting
[descendantViews addObject:descendantView];
}];

// [macOS
NSMutableArray<RCTVirtualTextView *> *virtualSubviews = [NSMutableArray arrayWithCapacity:virtualSubviewTags.count];
[virtualSubviewTags
enumerateObjectsUsingBlock:^(NSNumber *_Nonnull virtualSubviewTag, NSUInteger index, BOOL *_Nonnull stop) {
RCTPlatformView *virtualSubview = viewRegistry[virtualSubviewTag];
if ([virtualSubview isKindOfClass:[RCTVirtualTextView class]]) {
[virtualSubviews addObject:(RCTVirtualTextView *)virtualSubview];
}
}];
// macOS]

// Removing all references to Shadow Views to avoid unnecessary retaining.
[textStorage removeAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
range:NSMakeRange(0, textStorage.length)];

[textView setTextStorage:textStorage contentFrame:contentFrame descendantViews:descendantViews];
[textView setTextStorage:textStorage
contentFrame:contentFrame
descendantViews:descendantViews
virtualSubviews:virtualSubviews]; // [macOS]
}];
}

Expand Down
8 changes: 8 additions & 0 deletions packages/react-native/Libraries/Text/Text/RCTTextView.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#import <React/RCTComponent.h>
#import <React/RCTEventDispatcher.h> // [macOS]
#import <React/RCTVirtualTextView.h> // [macOS]

#import <React/RCTUIKit.h> // [macOS]

Expand All @@ -22,6 +23,13 @@ NS_ASSUME_NONNULL_BEGIN
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews; // [macOS]

// [macOS
- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews
virtualSubviews:(NSArray<RCTVirtualTextView *> *_Nullable)virtualSubviews;
// macOS]

/**
* (Experimental and unused for Paper) Pointer event handlers.
*/
Expand Down
139 changes: 139 additions & 0 deletions packages/react-native/Libraries/Text/Text/RCTTextView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#endif // [macOS]

#import <React/RCTAssert.h> // [macOS]
#import <React/RCTUIManager.h> // [macOS]
#import <React/RCTUtils.h>
#import <React/UIView+React.h>
#import <React/RCTFocusChangeEvent.h> // [macOS]
Expand Down Expand Up @@ -62,6 +63,8 @@ @implementation RCTTextView {

id<RCTEventDispatcherProtocol> _eventDispatcher; // [macOS]
NSArray<RCTUIView *> *_Nullable _descendantViews; // [macOS]
NSArray<RCTVirtualTextView *> *_Nullable _virtualSubviews; // [macOS]
RCTUIView *_Nullable _currentHoveredSubview; // [macOS]
NSTextStorage *_Nullable _textStorage;
CGRect _contentFrame;
}
Expand Down Expand Up @@ -99,6 +102,7 @@ - (instancetype)initWithFrame:(CGRect)frame
_textView.layoutManager.usesFontLeading = NO;
_textStorage = _textView.textStorage;
[self addSubview:_textView];
_currentHoveredSubview = nil;
#endif // macOS]
RCTUIViewSetContentModeRedraw(self); // [macOS]
}
Expand Down Expand Up @@ -162,6 +166,20 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews // [macOS]
{
// [macOS - to keep track of virtualSubviews as well
[self setTextStorage:textStorage
contentFrame:contentFrame
descendantViews:descendantViews
virtualSubviews:nil];
}

- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
descendantViews:(NSArray<RCTPlatformView *> *)descendantViews
virtualSubviews:(NSArray<RCTVirtualTextView *> *)virtualSubviews
{
// macOS]

// 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 // [macOS]
Expand Down Expand Up @@ -204,6 +222,8 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
[self addSubview:view];
}

_virtualSubviews = virtualSubviews; // [macOS]

[self setNeedsDisplay];
}

Expand Down Expand Up @@ -398,6 +418,21 @@ - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture
}
#else // [macOS

- (BOOL)hasMouseHoverEvent
{
if ([super hasMouseHoverEvent]) {
return YES;
}

// We only care about virtual subviews here.
// Embedded views (e.g., <Text> <View /> </Text>) handle mouse hover events themselves.
NSUInteger indexOfChildWithMouseHoverEvent = [_virtualSubviews indexOfObjectPassingTest:^BOOL(RCTVirtualTextView *_Nonnull childView, NSUInteger idx, BOOL *_Nonnull stop) {
*stop = [childView hasMouseHoverEvent];
return *stop;
}];
return indexOfChildWithMouseHoverEvent != NSNotFound;
}

- (NSView *)hitTest:(NSPoint)point
{
// We will forward mouse click events to the NSTextView ourselves to prevent NSTextView from swallowing events that may be handled in JS (e.g. long press).
Expand All @@ -412,6 +447,110 @@ - (NSView *)hitTest:(NSPoint)point
return isTextViewClick ? self : hitView;
}

- (NSNumber *)reactTagAtMouseLocationFromEvent:(NSEvent *)event
{
NSPoint locationInSelf = [self convertPoint:event.locationInWindow fromView:nil];
NSPoint locationInInnerTextView = [self convertPoint:locationInSelf toView:_textView]; // This is needed if the parent <Text> view has padding
return [self reactTagAtPoint:locationInInnerTextView];
}

- (void)mouseEntered:(NSEvent *)event
{
// superclass invokes self.onMouseEnter, so do this first
[super mouseEntered:event];

[self updateHoveredSubviewWithEvent:event];
}

- (void)mouseExited:(NSEvent *)event
{
[self updateHoveredSubviewWithEvent:event];

// superclass invokes self.onMouseLeave, so do this last
[super mouseExited:event];
}

- (void)mouseMoved:(NSEvent *)event
{
[super mouseMoved:event];
[self updateHoveredSubviewWithEvent:event];
}

- (void)updateHoveredSubviewWithEvent:(NSEvent *)event
{
RCTUIView *hoveredView = nil;

if ([event type] != NSEventTypeMouseExited && _virtualSubviews != nil) {
NSNumber *reactTagOfHoveredView = [self reactTagAtMouseLocationFromEvent:event];

if (reactTagOfHoveredView == nil) {
// This happens if we hover over an embedded view, which will handle its own mouse events
return;
}

if ([reactTagOfHoveredView isEqualToNumber:self.reactTag]) {
// We're hovering over the root Text element
hoveredView = self;
} else {
// Maybe we're hovering over a child Text element?
NSUInteger index = [_virtualSubviews indexOfObjectPassingTest:^BOOL(RCTVirtualTextView *_Nonnull view, NSUInteger idx, BOOL *_Nonnull stop) {
*stop = [[view reactTag] isEqualToNumber:reactTagOfHoveredView];
return *stop;
}];
if (index != NSNotFound) {
hoveredView = _virtualSubviews[index];
}
}
}

if (_currentHoveredSubview == hoveredView) {
return;
}

// self will always be an ancestor of any views we pass in here, so it serves as a good default option.
// Also, if we do set from/to nil, we have to call the relevant events on the entire subtree.
RCTUIManager *uiManager = [[_eventDispatcher bridge] uiManager];
RCTShadowView *oldShadowView = [uiManager shadowViewForReactTag:[(_currentHoveredSubview ?: self) reactTag]];
RCTShadowView *newShadowView = [uiManager shadowViewForReactTag:[(hoveredView ?: self) reactTag]];

// Find the common ancestor between the two shadow views
RCTShadowView *commonAncestor = [oldShadowView ancestorSharedWithShadowView:newShadowView];

for (RCTShadowView *exitedShadowView = oldShadowView; exitedShadowView != commonAncestor && exitedShadowView != nil; exitedShadowView = [exitedShadowView reactSuperview]) {
RCTPlatformView *exitedView = [uiManager viewForReactTag:[exitedShadowView reactTag]];
if (![exitedView isKindOfClass:[RCTUIView class]]) {
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [exitedView class]);
continue;
}

RCTUIView *exitedReactView = (RCTUIView *)exitedView;
[self sendMouseEventWithBlock:[exitedReactView onMouseLeave]
locationInfo:[self locationInfoFromEvent:event]
modifierFlags:event.modifierFlags
additionalData:nil];
}

// We cache these so we can call them from outermost to innermost
NSMutableArray<RCTUIView *> *enteredViewHierarchy = [NSMutableArray new];
for (RCTShadowView *enteredShadowView = newShadowView; enteredShadowView != commonAncestor && enteredShadowView != nil; enteredShadowView = [enteredShadowView reactSuperview]) {
RCTPlatformView *enteredView = [uiManager viewForReactTag:[enteredShadowView reactTag]];
if (![enteredView isKindOfClass:[RCTUIView class]]) {
RCTLogError(@"Unexpected view of type %@ found in hierarchy, must be RCTUIView or subclass", [enteredView class]);
continue;
}

[enteredViewHierarchy addObject:(RCTUIView *)enteredView];
}
for (NSInteger i = [enteredViewHierarchy count] - 1; i >= 0; i--) {
[self sendMouseEventWithBlock:[[enteredViewHierarchy objectAtIndex:i] onMouseEnter]
locationInfo:[self locationInfoFromEvent:event]
modifierFlags:event.modifierFlags
additionalData:nil];
}

_currentHoveredSubview = hoveredView;
}

- (void)rightMouseDown:(NSEvent *)event
{

Expand Down
14 changes: 14 additions & 0 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,5 +288,19 @@ export type TextProps = $ReadOnly<{|
* @platform macos
*/
enableFocusRing?: ?boolean,

/**
* This event is called when the mouse hovers over this component.
*
* @platform macos
*/
onMouseEnter?: ?(event: MouseEvent) => void,

/**
* This event is called when the mouse moves off of this component.
*
* @platform macos
*/
onMouseLeave?: ?(event: MouseEvent) => void,
// macOS]
|}>;
30 changes: 30 additions & 0 deletions packages/react-native/React/Base/RCTUIKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ NS_ASSUME_NONNULL_END

#import <AppKit/AppKit.h>

#import <React/RCTComponent.h>

NS_ASSUME_NONNULL_BEGIN

//
Expand Down Expand Up @@ -403,6 +405,16 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);

- (void)setNeedsDisplay;

// Methods related to mouse events
- (BOOL)hasMouseHoverEvent;
- (NSDictionary*)locationInfoFromDraggingLocation:(NSPoint)locationInWindow;
- (NSDictionary*)locationInfoFromEvent:(NSEvent*)event;

- (void)sendMouseEventWithBlock:(RCTDirectEventBlock)block
locationInfo:(NSDictionary*)locationInfo
modifierFlags:(NSEventModifierFlags)modifierFlags
additionalData:(NSDictionary*)additionalData;

// FUTURE: When Xcode 14 is no longer supported (CI is building with Xcode 15), we can remove this override since it's now declared on NSView
@property BOOL clipsToBounds;
@property (nonatomic, copy) NSColor *backgroundColor;
Expand All @@ -426,6 +438,24 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);
*/
@property (nonatomic, assign) BOOL enableFocusRing;

// Mouse events
@property (nonatomic, copy) RCTDirectEventBlock onMouseEnter;
@property (nonatomic, copy) RCTDirectEventBlock onMouseLeave;
@property (nonatomic, copy) RCTDirectEventBlock onDragEnter;
@property (nonatomic, copy) RCTDirectEventBlock onDragLeave;
@property (nonatomic, copy) RCTDirectEventBlock onDrop;

// Focus events
@property (nonatomic, copy) RCTBubblingEventBlock onBlur;
@property (nonatomic, copy) RCTBubblingEventBlock onFocus;

@property (nonatomic, copy) RCTBubblingEventBlock onResponderGrant;
@property (nonatomic, copy) RCTBubblingEventBlock onResponderMove;
@property (nonatomic, copy) RCTBubblingEventBlock onResponderRelease;
@property (nonatomic, copy) RCTBubblingEventBlock onResponderTerminate;
@property (nonatomic, copy) RCTBubblingEventBlock onResponderTerminationRequest;
@property (nonatomic, copy) RCTBubblingEventBlock onStartShouldSetResponder;

@end

// UIScrollView
Expand Down
Loading

0 comments on commit 10b1cd8

Please sign in to comment.