Instantly share code, notes, and snippets.
Created
November 19, 2020 22:29
-
Star
(2)
2
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save lyahdav/0ee567426d9001cd812b6b1dda05a700 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import "ContextMenuNativeModule-macOS.h" | |
#import <React/RCTImageLoaderWithAttributionProtocol.h> | |
#import <React/RCTImageSource.h> | |
#import <React/RCTUIManager.h> | |
typedef NS_ENUM(NSInteger, MenuPlacement) { | |
MenuPlacementTopLeft, | |
MenuPlacementTopRight, | |
MenuPlacementBottomLeft, | |
MenuPlacementBottomRight | |
}; | |
@interface ContextMenuItem : NSObject | |
@property (nonatomic) BOOL checked; | |
@property (nonatomic) BOOL disabled; | |
@property (nonatomic, strong) RCTImageSource* iconSource; | |
@property (nonatomic) NSInteger index; | |
@property (nonatomic) BOOL isSeparator; | |
@property (nonatomic, strong) NSString* label; | |
@property (nonatomic, strong) NSArray<ContextMenuItem*>* submenu; | |
@end | |
@implementation ContextMenuItem | |
@end | |
@interface RCTConvert (ContextMenuItem) | |
+ (ContextMenuItem*)ContextMenuItem:(id)json; | |
+ (NSArray<ContextMenuItem*>*)ContextMenuItemArray:(id)json; | |
@end | |
@implementation RCTConvert (ContextMenuItem) | |
+ (ContextMenuItem*)ContextMenuItem:(id)json { | |
if ([json isKindOfClass:[NSDictionary class]]) { | |
ContextMenuItem* item = [ContextMenuItem new]; | |
item.label = [self NSString:json[@"label"]]; | |
item.index = [json[@"index"] integerValue]; | |
if (![json[@"icon"] isEqual:[NSNull null]]) { | |
item.iconSource = [self RCTImageSource:json[@"icon"]]; | |
} | |
if (json[@"disabled"]) { | |
item.disabled = [json[@"disabled"] boolValue]; | |
} | |
if (json[@"checked"]) { | |
item.checked = [json[@"checked"] boolValue]; | |
} | |
if ([json[@"type"] isEqualToString:@"separator"]) { | |
item.isSeparator = YES; | |
} | |
if (json[@"submenu"] && ![json[@"submenu"] isEqual:[NSNull null]]) { | |
item.submenu = [RCTConvert ContextMenuItemArray:json[@"submenu"]]; | |
} | |
return item; | |
} else { | |
return nil; | |
} | |
} | |
RCT_ARRAY_CONVERTER(ContextMenuItem) | |
RCT_ENUM_CONVERTER(MenuPlacement, | |
(@{ | |
@"top-left" : @(MenuPlacementTopLeft), | |
@"top-right" : @(MenuPlacementTopRight), | |
@"bottom-left" : @(MenuPlacementBottomLeft), | |
@"bottom-right" : @(MenuPlacementBottomRight), | |
}), | |
MenuPlacementBottomLeft, | |
integerValue); | |
@end | |
@implementation ContextMenuNativeModule { | |
RCTResponseSenderBlock _onMenuItemClick; | |
RCTResponseSenderBlock _onCancel; | |
NSMenu* _menu; | |
} | |
@synthesize bridge = _bridge; | |
RCT_EXPORT_MODULE(); | |
- (dispatch_queue_t)methodQueue { | |
return dispatch_get_main_queue(); | |
} | |
RCT_EXPORT_METHOD(showMenu | |
: (nonnull NSNumber*)target | |
: (NSString*)placementString | |
: (NSArray<ContextMenuItem*>*)items | |
: (RCTResponseSenderBlock)onMenuItemClick | |
: (RCTResponseSenderBlock)onCancel) { | |
if (_menu) { | |
[self hideMenu]; | |
} | |
__block NSView* targetView = nil; | |
if (target.integerValue > 0) { | |
targetView = [self.bridge.uiManager viewForReactTag:target]; | |
} | |
_onMenuItemClick = onMenuItemClick; | |
_onCancel = onCancel; | |
_menu = [self menuFromItems:items]; | |
__block NSPoint location = | |
[self locationForTargetView:targetView placement:[RCTConvert MenuPlacement:placementString]]; | |
// Run the event tracking loop outside the dispatch queue so it accept further dispatches. | |
[NSRunLoop.mainRunLoop performBlock:^{ | |
if (!targetView) { | |
targetView = NSApp.currentEvent.window.contentView; | |
if (targetView) { | |
location = [targetView.window convertPointFromScreen:location]; | |
location = [targetView convertPoint:location fromView:nil]; | |
} | |
} | |
NSMenu* menu = self->_menu; | |
[menu popUpMenuPositioningItem:nil atLocation:location inView:targetView]; | |
// Ensure this menu was not already overridden above. | |
if (menu == self->_menu) { | |
if (self->_onCancel) { | |
self->_onCancel(@[]); | |
} | |
// This block is paused while menu is up, so safe to clear these here. | |
self->_onMenuItemClick = nil; | |
self->_onCancel = nil; | |
self->_menu = nil; | |
} | |
}]; | |
} | |
RCT_EXPORT_METHOD(hideMenu) { | |
if (!_menu) { | |
return; | |
} | |
[_menu cancelTracking]; | |
// Ensure the correct onCancel callback is called. | |
if (_onCancel) { | |
_onCancel(@[]); | |
_onCancel = nil; | |
} | |
} | |
- (NSMenu*)menuFromItems:(NSArray<ContextMenuItem*>*)items { | |
NSMenu* menu = [[NSMenu alloc] initWithTitle:@""]; | |
menu.autoenablesItems = NO; | |
[items enumerateObjectsUsingBlock:^(ContextMenuItem* item, NSUInteger index, BOOL* stop) { | |
if (item.isSeparator) { | |
[menu addItem:[NSMenuItem separatorItem]]; | |
return; | |
} | |
NSMenuItem* menuItem = [menu addItemWithTitle:item.label | |
action:item.submenu ? nil : @selector(didClickMenuItem:) | |
keyEquivalent:@""]; | |
menuItem.target = self; | |
menuItem.tag = item.index; | |
menuItem.enabled = !item.disabled; | |
if (item.checked) { | |
menuItem.state = NSControlStateValueOn; | |
} | |
if (item.iconSource) { | |
[self setMenuItem:menuItem imageFromRequest:item.iconSource.request]; | |
} | |
if (item.submenu) { | |
[menuItem setSubmenu:[self menuFromItems:item.submenu]]; | |
} | |
}]; | |
return menu; | |
} | |
- (void)setMenuItem:(NSMenuItem*)menuItem imageFromRequest:(NSURLRequest*)request { | |
// We must set a placeholder image so that the menu size is computed correctly above before images | |
// are done loading. | |
auto imageSize = CGSizeMake(16, 16); | |
NSImage* placeholderImage = [[NSImage alloc] initWithSize:imageSize]; | |
menuItem.image = placeholderImage; | |
id<RCTImageLoaderWithAttributionProtocol> imageLoader = [self.bridge moduleForName:@"ImageLoader" | |
lazilyLoadIfNecessary:YES]; | |
__weak NSMenuItem* weakMenuItem = menuItem; | |
[imageLoader loadImageWithURLRequest:request | |
size:imageSize | |
scale:NSApp.currentEvent.window.backingScaleFactor ?: 2 | |
clipped:YES | |
resizeMode:RCTResizeModeContain | |
progressBlock:nil | |
partialLoadBlock:nil | |
completionBlock:^(NSError* error, UIImage* image) { | |
if (error) { | |
RCTLogError(@"Error loading image: %@", error); | |
} else { | |
RCTExecuteOnMainQueue(^{ | |
// Copy the image since image loader may cache images. | |
NSImage* imageCopy = [image copy]; | |
imageCopy.size = imageSize; | |
weakMenuItem.image = imageCopy; | |
}); | |
} | |
}]; | |
} | |
- (void)didClickMenuItem:(NSMenuItem*)sender { | |
// We clear this ivar so that it doesn't get called later on above. | |
_onCancel = nil; | |
_onMenuItemClick(@[ [NSNull null], @(sender.tag) ]); | |
} | |
- (NSPoint)locationForTargetView:(NSView*)targetView placement:(MenuPlacement)placement { | |
if (!targetView) { | |
return NSEvent.mouseLocation; | |
} | |
NSPoint location; | |
switch (placement) { | |
case MenuPlacementTopLeft: | |
location = NSMakePoint(0, -_menu.size.height); | |
break; | |
case MenuPlacementTopRight: | |
location = NSMakePoint(targetView.bounds.size.width - _menu.size.width, -_menu.size.height); | |
break; | |
case MenuPlacementBottomRight: | |
location = | |
NSMakePoint(targetView.bounds.size.width - _menu.size.width, targetView.bounds.size.height); | |
break; | |
// We default to bottom left of target since that's macOS default behavior. | |
default: | |
location = NSMakePoint(0, targetView.bounds.size.height); | |
break; | |
} | |
// NSMenu won't show if it's positioned off the screen, but only on a side of the screen which | |
// borders another monitor. This fits the location such that this won't happen. | |
auto pointInScreen = [targetView.window convertPointToScreen:[targetView convertPoint:location | |
toView:nil]]; | |
auto screenRect = targetView.window.screen.visibleFrame; | |
auto screenRectInsetForMenu = NSMakeRect(screenRect.origin.x, | |
screenRect.origin.y, | |
screenRect.size.width - _menu.size.width, | |
screenRect.size.height - _menu.size.height); | |
auto fittedPointInScreen = fitPointInRect(pointInScreen, screenRectInsetForMenu); | |
location.x -= pointInScreen.x - fittedPointInScreen.x; | |
location.y -= pointInScreen.y - fittedPointInScreen.y; | |
return location; | |
} | |
static NSPoint fitPointInRect(NSPoint point, NSRect rect) { | |
auto x = fmax(fmin(point.x, rect.origin.x + rect.size.width), rect.origin.x); | |
auto y = fmax(fmin(point.y, rect.origin.y + rect.size.height), rect.origin.y); | |
return NSMakePoint(x, y); | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment