Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onMouseEnter and onMouseLeave to Text (cherry-picked from 0.73-stable) #2149

Merged
merged 6 commits into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading