21

I am trying to create entries on the Chrome context menu based on what is selected. I found several questions about this on Stackoverflow, and for all of them the answer is: use a content script with a "mousedown" listener that looks at the current selection and creates the Context Menu.

I implemented this, but it does not always work. Sometimes all the log messages say that the context menu was modified as I wanted, but the context menu that appears is not updated.

Based on this I suspected it was a race condition: sometimes chrome starts rendering the context menu before the code ran completely.

I tried adding a eventListener to "contextmenu" and "mouseup". The later triggers when the user selects the text with the mouse, so it changes the contextmenu much before it appears (even seconds). Even with this technique, I still see the same error happening!

This happens very often in Chrome 22.0.1229.94 (Mac), occasionally in Chromium 20.0.1132.47 (linux) and it did not happen in 2 minutes trying on Windows (Chrome 22.0.1229.94).

What is happening exactly? How can I fix that? Is there any other workaround?


Here is a simplified version of my code (not so simple because I am keeping the log messages):

manifest.json:

{
  "name": "Test",
  "version": "0.1",
  "permissions": ["contextMenus"],
  "content_scripts": [{
    "matches": ["http://*/*", "https://*/*"],
    "js": ["content_script.js"]
  }],
  "background": {
    "scripts": ["background.js"]
  },
  "manifest_version": 2
}

content_script.js

function loadContextMenu() {
  var selection = window.getSelection().toString().trim();
  chrome.extension.sendMessage({request: 'loadContextMenu', selection: selection}, function (response) {
    console.log('sendMessage callback');
  });
}

document.addEventListener('mousedown', function(event){
  if (event.button == 2) {
    loadContextMenu();
  }
}, true);

background.js

function SelectionType(str) {
  if (str.match("^[0-9]+$"))
    return "number";
  else if (str.match("^[a-z]+$"))
    return "lowercase string";
  else
    return "other";
}

chrome.extension.onMessage.addListener(function(msg, sender, sendResponse) {
  console.log("msg.request = " + msg.request);
  if (msg.request == "loadContextMenu") {
    var type = SelectionType(msg.selection);
    console.log("selection = " + msg.selection + ", type = " + type);
    if (type == "number" || type == "lowercase string") {
      console.log("Creating context menu with title = " + type);
      chrome.contextMenus.removeAll(function() {
        console.log("contextMenus.removeAll callback");
        chrome.contextMenus.create(
            {"title": type,
             "contexts": ["selection"],
             "onclick": function(info, tab) {alert(1);}},
            function() {
                console.log("ContextMenu.create callback! Error? " + chrome.extension.lastError);});
      });
    } else {
      console.log("Removing context menu")
      chrome.contextMenus.removeAll(function() {
          console.log("contextMenus.removeAll callback");
      });
    }
    console.log("handling message 'loadContextMenu' done.");
  }
  sendResponse({});
});
wOxxOm
  • 65,848
  • 11
  • 132
  • 136
dbarbosa
  • 2,969
  • 5
  • 25
  • 29

2 Answers2

39

The contextMenus API is used to define context menu entries. It does not need to be called right before a context menu is opened. So, instead of creating the entries on the contextmenu event, use the selectionchange event to continuously update the contextmenu entry.

I will show a simple example which just displays the selected text in the context menu entry, to show that the entries are synchronized well.

Use this content script:

document.addEventListener('selectionchange', function() {
    var selection = window.getSelection().toString().trim();
    chrome.runtime.sendMessage({
        request: 'updateContextMenu',
        selection: selection
    });
});

At the background, we're going to create the contextmenu entry only once. After that, we update the contextmenu item (using the ID which we get from chrome.contextMenus.create).
When the selection is empty, we remove the context menu entry if needed.

// ID to manage the context menu entry
var cmid;
var cm_clickHandler = function(clickData, tab) {
    alert('Selected ' + clickData.selectionText + ' in ' + tab.url);
};

chrome.runtime.onMessage.addListener(function(msg, sender, sendResponse) {
    if (msg.request === 'updateContextMenu') {
        var type = msg.selection;
        if (type == '') {
            // Remove the context menu entry
            if (cmid != null) {
                chrome.contextMenus.remove(cmid);
                cmid = null; // Invalidate entry now to avoid race conditions
            } // else: No contextmenu ID, so nothing to remove
        } else { // Add/update context menu entry
            var options = {
                title: type,
                contexts: ['selection'],
                onclick: cm_clickHandler
            };
            if (cmid != null) {
                chrome.contextMenus.update(cmid, options);
            } else {
                // Create new menu, and remember the ID
                cmid = chrome.contextMenus.create(options);
            }
        }
    }
});

To keep this example simple, I assumed that there's only one context menu entry. If you want to support more entries, create an array or hash to store the IDs.

Tips

  • Optimization - To reduce the number of chrome.contextMenus API calls, cache the relevant values of the parameters. Then, use a simple === comparison to check whether the contextMenu item need to be created/updated.
  • Debugging - All chrome.contextMenus methods are asynchronous. To debug your code, pass a callback function to the .create, .remove or .update methods.
Xan
  • 74,770
  • 16
  • 179
  • 206
Rob W
  • 341,306
  • 83
  • 791
  • 678
  • thank you very much, I will award the bounty although I would really also like if you could explain why the previous code (in the question) did not work, or works randomly :) – epoch Dec 07 '12 at 13:08
  • 2
    @epoch When the user uses the right mouse button, a message is sent to the background page, *asynchronously*. Then the background page removes all existing context menu entries, *asynchronously*. Finally, the context menu entry is created, also *asynchronously*. You can imagine that these actions take relatively much time. After the contextmenu/mouseup events have been triggered without `event.preventDefault()`, then the context menu will open. Shortly after, the `chrome.contextMenus.create` method finishes. – Rob W Dec 07 '12 at 17:32
  • 1
    What I've just said makes sense, but if you want to see it yourself: Put `alert('Hi');` before the `chrome.contextMenus.create` method. You'll see the context menu flashing: It shows up because of the rightclick event, but hides because a modal dialog (alert) is displayed. Recall that the `alert` is put before you created/updated the contextmenu entry, so this proofs that the `chrome.contextMenu.*` calls are too late. – Rob W Dec 07 '12 at 17:35
  • Will this fail if the user starts selecting the text, and then presses the right button before letting go of the left button? – Xan Jul 09 '16 at 11:02
  • @Xan The `selectionchange` event is fired multiple times while making a selection, so yes, it will work even if you don't release the mouse button. – Rob W Jul 09 '16 at 20:05
  • @RobW I'm stuck in the same situation but can't seem to solve. I am updating ContextMenu items based on the right-clicked element together with the above described 'Text selection'. I've about 30 items to be updated each time an element is right clicked on text is selected and each time chrome.contextMenus.update() is called (items are created on load). I see that chrome takes about 3 seconds to update all items and if i right click and see items they remain un-updated initially. I think with 25-30 items its slowing down. can you please help me here taking down the update time? – Praveen Tiwari Jun 18 '17 at 11:13
  • 3
    IS this still the best answer? It seems more intuitive to have a listener somewhere when the menu are drawn. – maugch Apr 24 '19 at 12:15
3

MDN doc for menus.create(), 'title' param

You can use "%s" in the string. If you do this in a menu item, and some text is selected in the page when the menu is shown, then the selected text will be interpolated into the title.

https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create

Thus

browser.contextMenus.create({
  id: 'menu-search',
  title: "Search '%s'",  // selected text as %s
  contexts: ['selection'], // show only if selection exist
})
WcW
  • 401
  • 5
  • 9