Skip to content

Commit

Permalink
[0.73-stable] Add mouse hover events to RCTTextView (#2143)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
amgleitman and Adam Gleitman authored Jul 10, 2024
1 parent e4ce256 commit a08aa42
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 33 deletions.
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]
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) {
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
{
// 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

0 comments on commit a08aa42

Please sign in to comment.