POSTS

Guide to Developing Chrome Context Menu Applications Part 4

Blog

Interlude

In the previous section we set up our communications conduit, and wrestled with the basics of the Chrome Extension API. We’re now going to do a pass where we change the “debuggy” messages from being noise into being canned responses.

Let’s create a new branch called: boilerplate-pass-through. Our goal is to get our canned responses passed through from background to content scripts.

Build Context Menu UI

Per our user stories, we want to add a contextMenu element. Let’s add something trivial to verify it works. You should now see the pattern that many APIs require an additional permission. To use the contextMenus API you must add the contextMenus permission.

manifest.json.

"permissions": ["webNavigation", "*://stevengharms.com/*", "contextMenus"]

Now, let’s use our background script to create a menu item:

background.js

chrome.webNavigation.onCompleted.addListener(function(details) {
  chrome.tabs.executeScript(details.tabId, {
    file: "canned_response.js"
  }, () => {
    chrome.contextMenus.create({
      id: String(details.tabId),
      title: "Canned Response",
      documentUrlPatterns: [ "*://stevengharms.com/*" ]
    });
  })
}, {
  url: [{ "hostContains": "stevengharms.com" }]
});

Refresh the plugin, reload the page. When you Right-click on the page you should see a new entry: “Canned Response.” Furthermore, you will only see it on stevengharms.com sited pages. Cool, eh?

Top-level context menu

Now you might think, “Sweet, I bet I can add a callback for when this menu is clicked.” You’re right! Let’s do it and look at onclick in the docs:

Google docs on proper use of onclick in Extensions

Read that text twice:

A function that will be called back when the menu item is clicked. Event pages
cannot use this; instead, they should register a listener for
chrome.contextMenus.onClicked.

Well, heck, we’re an event page, so we need to follow this advice.

Let’s do what the docs say. I’m going to do the simplest thing possible and add this to background.js:

chrome.contextMenus.onClicked.addListener(function() {
  debugger
})

…and reload the plugin, refresh, etc. Summon the context menu and click.

Hitting our onClicked debugger point

We hit the debugger! Awesome! We’re in the right spot. The arguments hold a number of data that we can use to do our work.

  • The 0th element is information about the menu that was clicked
  • The 1st element is about the tab

As such, a more self-documenting implementation of the callback might be:

chrome.contextMenus.onClicked.addListener(function(menuInfo, tabInfo) {
  debugger
})

At this point, I’m confident that we know how to set up a menu and handle an event and we probably have the required data to handle any decision-making logic. Let’s correct our menu to hold canned responses.

Build Canned Responses Into Menu UI

We’ll keep the responses simple for ease of debugging and store them in the global _CANNED_RESPONSES

Let’s build up the menus:

background.js

const MENU_PREFIX = "CANNED_REPONSE_CONTEXT_MENU-";

var _CANNED_RESPONSE_REGISTRY = {
}

var _CANNED_RESPONSES = [
  ["Alpha", "Alpha body"],
  ["Beta", "Beta body"],
];

chrome.webNavigation.onCompleted.addListener(function(details) {
  chrome.tabs.executeScript(details.tabId, {
    file: "canned_response.js"
  }, () => {
    // Create top-level menu
    var parentMenuId = chrome.contextMenus.create({
      id: MENU_PREFIX + details.tabId,
      title: "Canned Response",
      documentUrlPatterns: [ "*://stevengharms.com/*" ]
    });

    // Create a canned responses menu
    _CANNED_RESPONSES.forEach((response, i) => {
      var id = chrome.contextMenus.create({
        title: response[0],
        parentId: parentMenuId,
        id: MENU_PREFIX + details.tabId + i,
        documentUrlPatterns: [ "*://stevengharms.com/*" ]
      })
      _CANNED_RESPONSE_REGISTRY[id] = response[1];
    });
  })
}, {
  url: [{ "hostContains": "stevengharms.com" }]
});

chrome.contextMenus.onClicked.addListener(function() {
  debugger
});

As a quick note, Event Pages are required to give their contextMenus ids. It’s something Chrome will complain about if they’re not present. They’re used to make menu loading more efficient.

Now, in the code above, the new code performs the population of the _CANNED_RESPONSE_REGISTRY based on iterating the _CANNED_RESPONSES. The goal is simple: we iterate the Array of Arrays and add new menu items under our “top-level” menu item. Items created with a parentId become sub-menus.

Additionally we populate a registry that associates menuIds (the GUI element) with the associated response body. This will serve as a lookup table to fetch the response we need and which we will pass to the content script.

Contents of the _CANNED_RESPONSE_REGISTRY

Recall in the onClicked handler we knew about the menuId as an attribute on the object passed in as the 0th argument. This table will be our way to look up what the appropriate response is.

Looking up a body based on arguments[0].menuId

So let’s update menu click handler:

chrome.contextMenus.onClicked.addListener((info, tab) => {
  var boilerplate = _CANNED_RESPONSE_REGISTRY[info.menuItemId];
  if (boilerplate) {
    // "Sends a single message to the content script(s) in the specified tab,
    // with an optional callback to run when a response is sent back."
    chrome.tabs.sendMessage(tab.id, { canned_response: boilerplate })
  }
});

and let’s update it’s partner message update handler in the content script, canned_response.js

chrome.runtime.onMessage.addListener( (message) => {
  _CANNED_RESPONSE_ACTIVE_INPUT.value = message.canned_response;
});

Be sure and notice that I stopped referring to message.magic_word as we want our naming to be more descriptive of what’s coming in.

Track Selected Input Field for Updating

Let’s add code in “DOM land” or the content script so that when the input is activated, we track it.

var _CANNED_RESPONSE_ACTIVE_INPUT;

// ...

document.querySelector("input[type='text']").addEventListener('focus', e => _CANNED_RESPONSE_ACTIVE_INPUT = e.target)

Only Present the Menu When In an Editing Place

We’ll also change the context of the menus to appear only in editable fields. Add contexts: ["editable"] to the contextMenus.create calls. A full diff will appear below with them.

OK, let’s reload the plugin, reload the page, click in the search field on stevengharms.com, right-click for a contextMenu and have our extension paste in the text!

Extension working on stevengharms.com

Whew! That was a lot of work, but we now have our system working, albeit on the wrong site. From here on out it’s just going to be minor tweaks! Here’s the diff:

diff --git a/background.js b/background.js
index b42c5e2..0762faf 100644
--- a/background.js
+++ b/background.js
@@ -1,7 +1,46 @@
+const MENU_PREFIX = "CANNED_REPONSE_CONTEXT_MENU-";
+
+var _CANNED_RESPONSE_REGISTRY = {
+}
+
+var _CANNED_RESPONSES = [
+  ["Alpha", "Alpha body"],
+  ["Beta", "Beta body"],
+];
+
 chrome.webNavigation.onCompleted.addListener(function(details) {
   chrome.tabs.executeScript(details.tabId, {
-	file: "canned_response.js"
-  }, () => chrome.tabs.sendMessage(details.tabId, { magic_word: "ROMY ZOMIE" }, txt => alert("called me back: " + txt)) );
+    file: "canned_response.js"
+  }, () => {
+    // Create top-level menu
+    var parentMenuId = chrome.contextMenus.create({
+      id: MENU_PREFIX + details.tabId,
+      title: "Canned Response",
+      contexts: ["editable"],
+      documentUrlPatterns: [ "*://stevengharms.com/*" ]
+    });
+
+    // Create a canned responses menu
+    _CANNED_RESPONSES.forEach((response, i) => {
+      var id = chrome.contextMenus.create({
+        title: response[0],
+        parentId: parentMenuId,
+        id: MENU_PREFIX + details.tabId + i,
+        contexts: ["editable"],
+        documentUrlPatterns: [ "*://stevengharms.com/*" ]
+      })
+      _CANNED_RESPONSE_REGISTRY[id] = response[1];
+    });
+  })
 }, {
   url: [{ "hostContains": "stevengharms.com" }]
 });
+
+chrome.contextMenus.onClicked.addListener((info, tab) => {
+  var boilerplate = _CANNED_RESPONSE_REGISTRY[info.menuItemId];
+  if (boilerplate) {
+    // "Sends a single message to the content script(s) in the specified tab,
+    // with an optional callback to run when a response is sent back."
+    chrome.tabs.sendMessage(tab.id, { canned_response: boilerplate })
+  }
+});
diff --git a/canned_response.js b/canned_response.js
index 634d501..3f30afb 100644
--- a/canned_response.js
+++ b/canned_response.js
@@ -1,4 +1,8 @@
-chrome.runtime.onMessage.addListener( (message, sender, cb) => {
-  alert(message.magic_word);
-  cb(document.querySelector("h1.post-title").innerText);
+console.log("running");
+var _CANNED_RESPONSE_ACTIVE_INPUT;
+
+chrome.runtime.onMessage.addListener( (message) => {
+  _CANNED_RESPONSE_ACTIVE_INPUT.value = message.canned_response;
 });
+
+document.querySelector("input[type='text']").addEventListener('focus', e => _CANNED_RESPONSE_ACTIVE_INPUT = e.target);
diff --git a/manifest.json b/manifest.json
index d2cb2b5..d585fb3 100644
--- a/manifest.json
+++ b/manifest.json
@@ -9,5 +9,5 @@
     "persistent": false
   },

-  "permissions": ["webNavigation", "*://stevengharms.com/*"]
+  "permissions": ["webNavigation", "*://stevengharms.com/*", "contextMenus"]
 }

Let’s commit and merge!

git commit -avm 'Can pass canned responses on stevengharms.com'; gco master; git merge --no-ff boilerplate-pass-through

This is a major moment. From here on out we’re going to our deployment target. Create a new branch called prepare-for-linked-in and I’ll see you in the next installment.

Next