21

I'm using a WKWebView in a Mac OS X application. I want to override the contextual menu that appears when the user Control + clicks or right clicks in the WKWebView, but I cannot find a way to accomplish this.

It should be noted that the context menu changes depending on the state of the WKWebView and what element is under the mouse when the context menu is invoked. For example, the context menu only has a single "Reload" item when the mouse is over an "empty" part of the content, whereas right clicking a link presents the options "Open Link", "Open Link In New Window", and so on. It would be helpful to have granular control over these different menus if possible.

The older WebUIDelegate provides the - webView:contextMenuItemsForElement:defaultMenuItems: method that allows you to customize the context menu for WebView instances; I'm essentially looking for the analog to this method for WKWebView, or any way to duplicate the functionality.

Justin Michael
  • 5,634
  • 1
  • 29
  • 32

4 Answers4

23

You can do this by intercepting the contextmenu event in your javascript, reporting the event back to your OSX container through a scriptMessageHandler, then popping up a menu from OSX. You can pass context back through the body field of the script message to show an appropriate menu, or use a different handler for each one.

Setting up callback handler in Objective C:

WKUserContentController *contentController = [[WKUserContentController alloc]init];
[contentController addScriptMessageHandler:self name:@"callbackHandler"];
config.userContentController = contentController;
self.mainWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:config];

Javascript code using jquery:

$(nodeId).on("contextmenu", function (evt) {
   window.webkit.messageHandlers.callbackHandler.postMessage({body: "..."});
   evt.preventDefault();
});

Responding to it from Objective C:

-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
    if ([message.name isEqualToString:@"callbackHandler"]) {
      [self popupMenu:message.body];
    }
}

-(void)popupMenu:(NSString *)context {
    NSMenu *theMenu = [[NSMenu alloc] initWithTitle:@"Context Menu"];
    [theMenu insertItemWithTitle:@"Beep" action:@selector(beep:) keyEquivalent:@"" atIndex:0];
    [theMenu insertItemWithTitle:@"Honk" action:@selector(honk:) keyEquivalent:@"" atIndex:1];
    [theMenu popUpMenuPositioningItem:theMenu.itemArray[0] atLocation:NSPointFromCGPoint(CGPointMake(0,0)) inView:self.view];
}

-(void)beep:(id)val {
    NSLog(@"got beep %@", val);
}

-(void)honk:(id)val {
    NSLog(@"got honk %@", val);
}
Ken Cooper
  • 851
  • 1
  • 9
  • 17
21

You can intercept context menu items of the WKWebView class by subclassing it and implementing the willOpenMenu method like this:

class MyWebView: WKWebView {

    override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
        for menuItem in menu.items {
            if  menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" ||
                menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" {
                menuItem.action = #selector(menuClick(_:))
                menuItem.target = self
            }
        }
    }

    @objc func menuClick(_ sender: AnyObject) {
        if let menuItem = sender as? NSMenuItem {
            Swift.print("Menu \(menuItem.title) clicked")
        }
    }
}

Instead of this you can also simply hide the menu items with menuItem.isHidden = true

Detecting the chosen menu item is one thing, but knowing what the user actually clicked in the WKWebView control is the next challenge :)

It's also possible to add new menu items to the menu.items array.

Ely
  • 8,259
  • 1
  • 54
  • 67
  • I did add some items to the menu and the related actions were called fine. – AirXygène Jun 13 '18 at 20:58
  • 3
    I think that's the best answer, using native APIs instead of JavaScript-based hacks. – Greg de J Nov 17 '19 at 08:44
  • 2
    That was very helpful, how would i get the url which was right clicked, in this case. help will be appreciated. – Parminder Jul 17 '20 at 13:58
  • I added a class that will help you know what the user clicked on in my answer: https://stackoverflow.com/a/66836354/1711103. Thanks for your answer, it was very valuable for me. – Andrew Rondeau Mar 27 '21 at 21:37
2

Objective C solution. The best solution is to subclass WKWebView and intercept mouse clicks. It works great.

@implementation WKReportWebView

// Ctrl+click seems to send this not rightMouse
-(void)mouseDown:(NSEvent *)event
{
    if(event.modifierFlags & NSEventModifierFlagControl)
        return [self rightMouseDown:event];

    [super mouseDown:event]; // Catch scrollbar mouse events
}

-(void)rightMouseDown:(NSEvent *)theEvent
{
    NSMenu *rightClickMenu = [[NSMenu alloc] initWithTitle:@"Print Menu"];
    [rightClickMenu insertItemWithTitle:NSLocalizedString(@"Print", nil) action:@selector(print:) keyEquivalent:@"" atIndex:0];
    [NSMenu popUpContextMenu:rightClickMenu withEvent:theEvent forView:self];
}
@end
Cliff Ribaudo
  • 8,932
  • 2
  • 55
  • 78
1

This answer builds on the excellent answers in this thread.

The challenges in working with the WKWebView's context menu are:

  • It can only be manipulated in a subclass of WKWebView
  • WebKit does not expose any information about the HTML element that the user right-clicked on. Thus, information about the element must be intercepted in JavaScript and plumbed back into Swift.

Intercepting and finding information about the element the user clicked on happens by injecting JavaScript into the page prior to rendering, and then by establishing a callback into Swift. Here is the class that I wrote to do this. It works on the WKWebView's configuration object. It also assumes that there is only one context menu available at a time:

class GlobalScriptMessageHandler: NSObject, WKScriptMessageHandler {

public private(set) static var instance = GlobalScriptMessageHandler()

public private(set) var contextMenu_nodeName: String?
public private(set) var contextMenu_nodeId: String?
public private(set) var contextMenu_hrefNodeName: String?
public private(set) var contextMenu_hrefNodeId: String?
public private(set) var contextMenu_href: String?

static private var WHOLE_PAGE_SCRIPT = """
window.oncontextmenu = (event) => {
    var target = event.target

    var href = target.href
    var parentElement = target
    while (href == null && parentElement.parentElement != null) {
        parentElement = parentElement.parentElement
        href = parentElement.href
    }

    if (href == null) {
        parentElement = null;
    }

    window.webkit.messageHandlers.oncontextmenu.postMessage({
        nodeName: target.nodeName,
        id: target.id,
        hrefNodeName: parentElement?.nodeName,
        hrefId: parentElement?.id,
        href
    });
}
"""

private override init() {
    super.init()
}

public func ensureHandles(configuration: WKWebViewConfiguration) {
    
    var alreadyHandling = false
    for userScript in configuration.userContentController.userScripts {
        if userScript.source == GlobalScriptMessageHandler.WHOLE_PAGE_SCRIPT {
            alreadyHandling = true
        }
    }
    
    if !alreadyHandling {
        let userContentController = configuration.userContentController
        userContentController.add(self, name: "oncontextmenu")

        let userScript = WKUserScript(source: GlobalScriptMessageHandler.WHOLE_PAGE_SCRIPT, injectionTime: .atDocumentStart, forMainFrameOnly: false)
        userContentController.addUserScript(userScript)
    }
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    if let body = message.body as? NSDictionary {

        contextMenu_nodeName = body["nodeName"] as? String
        contextMenu_nodeId = body["id"] as? String
        contextMenu_hrefNodeName = body["hrefNodeName"] as? String
        contextMenu_hrefNodeId = body["hrefId"] as? String
        contextMenu_href = body["href"] as? String
    }
}

Next, to enable this in your WKWebView, you must subclass it and call GlobalScriptMessageHandler.instance.ensureHandles in your constructor:

class WebView: WKWebView {

public var webViewDelegate: WebViewDelegate?

init() {
    super.init(frame: CGRect(), configuration: WKWebViewConfiguration())
    GlobalScriptMessageHandler.instance.ensureHandles(configuration: self.configuration)
}

Finally, (as other answers have pointed out,) you override the context menu handler. In this case I changed the action in target for the "Open Link" menu item. You can change them as you see fit:

    override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
    for index in 0...(menu.items.count - 1) {
        let menuItem = menu.items[index]
        
        if menuItem.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" {
            menuItem.action = #selector(openLink(_:))
            menuItem.target = self

And then, in your method to handle the menu item, use GlobalScriptMessageHandler.instance.contextMenu_href to get the URL that the user right-clicked:

    @objc func openLink(_ sender: AnyObject) {
    if let url = GlobalScriptMessageHandler.instance.contextMenu_href {
        let url = URL(string: url)!
        self.load(URLRequest(url: url))
    }
}
Andrew Rondeau
  • 667
  • 7
  • 18