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

[0.73-stable] Add mouse hover events to RCTTextView #2143

Merged
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 *, RCTUIView *> *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]

#if TARGET_OS_OSX // [macOS
- (NSRect)getRectForCharRange:(NSRange)charRange;
#endif // macOS]
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]
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -414,6 +434,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 @@ -428,6 +463,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) {
amgleitman marked this conversation as resolved.
Show resolved Hide resolved
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
1 change: 1 addition & 0 deletions packages/react-native/React/Base/RCTUIKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ CGPathRef UIBezierPathCreateCGPathRef(UIBezierPath *path);
- (void)setNeedsDisplay;

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

Expand Down
30 changes: 30 additions & 0 deletions packages/react-native/React/Base/macOS/RCTUIKit.m
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ @implementation RCTUIView
BOOL _clipsToBounds;
BOOL _hasMouseOver;
BOOL _userInteractionEnabled;
NSTrackingArea *_trackingArea;
BOOL _mouseDownCanMoveWindow;
}

Expand Down Expand Up @@ -349,6 +350,13 @@ - (void)viewBoundsChanged:(NSNotification*)__unused inNotif
}
}

- (BOOL)hasMouseHoverEvent
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
{
// This can be overridden by subclasses as needed.
// e.g., RCTTextView, which consolidates its JS children into a single gigantic NSAttributedString
return self.onMouseEnter || self.onMouseLeave;
}

- (NSDictionary*)locationInfoFromDraggingLocation:(NSPoint)locationInWindow
{
NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil];
Expand Down Expand Up @@ -425,6 +433,28 @@ - (void)sendMouseEventWithBlock:(RCTDirectEventBlock)block
block(body);
}

- (void)updateTrackingAreas
{
BOOL hasMouseHoverEvent = [self hasMouseHoverEvent];
BOOL wouldRecreateIdenticalTrackingArea = hasMouseHoverEvent && _trackingArea && NSEqualRects(self.bounds, [_trackingArea rect]);

if (!wouldRecreateIdenticalTrackingArea) {
if (_trackingArea) {
[self removeTrackingArea:_trackingArea];
}

if (hasMouseHoverEvent) {
_trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds
options:NSTrackingActiveAlways|NSTrackingMouseEnteredAndExited
owner:self
userInfo:nil];
[self addTrackingArea:_trackingArea];
}
}

[super updateTrackingAreas];
}

- (BOOL)mouseDownCanMoveWindow{
return _mouseDownCanMoveWindow;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native/React/Views/RCTShadowView.h
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ typedef void (^RCTApplierBlock)(NSDictionary<NSNumber *, RCTPlatformView *> *vie
*/
- (CGRect)measureLayoutRelativeToAncestor:(RCTShadowView *)ancestor;

// [macOS
/**
* Returns the closest ancestor shared by this shadow view and another specified shadow view.
*/
- (RCTShadowView *)ancestorSharedWithShadowView:(RCTShadowView *)shadowView;
// macOS]

/**
* Checks if the current shadow view is a descendant of the provided `ancestor`
*/
Expand Down
19 changes: 19 additions & 0 deletions packages/react-native/React/Views/RCTShadowView.m
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ - (CGRect)measureLayoutRelativeToAncestor:(RCTShadowView *)ancestor
return (CGRect){offset, self.layoutMetrics.frame.size};
}

// [macOS
- (RCTShadowView *)ancestorSharedWithShadowView:(RCTShadowView *)shadowView
{
// TODO: Can this be optimized by climbing up both hierarchies at the same time?
NSMutableSet<RCTShadowView *> *selfSuperviews = [NSMutableSet set];
for (RCTShadowView *view = self; view != nil; view = [view reactSuperview]) {
[selfSuperviews addObject:view];
}

for (RCTShadowView *candidateView = shadowView; candidateView != nil; candidateView = [candidateView reactSuperview]) {
if ([selfSuperviews containsObject:candidateView]) {
return candidateView;
}
}

return nil;
}
// macOS]

- (BOOL)viewIsDescendantOf:(RCTShadowView *)ancestor
{
RCTShadowView *shadowView = self;
Expand Down
Loading