Skip to content

Instantly share code, notes, and snippets.

@lyahdav
Created November 19, 2020 22:29
Show Gist options
  • Save lyahdav/0ee567426d9001cd812b6b1dda05a700 to your computer and use it in GitHub Desktop.
Save lyahdav/0ee567426d9001cd812b6b1dda05a700 to your computer and use it in GitHub Desktop.
#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