justjs: node.js tutorials

New here? You might want to start at the beginning.

7/28 '12

Chrome extensions: how to enhance Google Calendar and other web apps

You can do just about anything with JavaScript these days. That includes altering the behavior of your web browser to suit your needs - not just on your own sites, but on any site you care to improve. And anyone who shares your definition of "improvement" can get the same benefits if you package your work as a Google Chrome extension and make it available via the Chrome store.

My friends are in a band. The band is called Hot Breakfast. They are "Delaware's premier acoustic dork-rock power duo." I heart them and their silly covers and clever original songs. But the point of this love fest is that, last week, they tweeted:

"We would like it if someone wrote a snap-in for Google Calendar, so when you click "Today" you hear Neil Diamond's voice. @boutell? :)"

I am not one to shrink from a challenge!

Getting Started: The Manifest

Here's how to create a Google Chrome extension that enhances Google Calendar - or any website - even if that site insists on constantly re-creating its buttons with different IDs (I'm looking at you, Google).

Start by creating a folder called "chrome-extensions", then create a folder called "today" (or whatever your extension will be called) inside that.

Now pop that folder open in your favorite text editor and create a file called "manifest.json." Here's mine:

{
  "name": "Today!",
  "version": "1.0.3",
  "manifest_version": 2,
  "description": "Makes the \"Today\" button of Google Calendar more patriotic.",
  "background": {
    "scripts": ["background.js"]
  },
  "browser_action": {
    "default_icon": "icon.png"
  },
  "permissions": [ "tabs", "https://www.google.com/" ],
  "web_accessible_resources": [ "today.wav", "neildiamond.png" ]
}

Some tips:

Set your own "name" property, of course.

"version" is also under your control. Versions like "1.0.0" work. Versions like "1.00" do not. Use the x.y.z syntax. You need to change "version" to a higher number with every new update of your extension that you release.

"manifest_version" should always be 2 (as of this writing).

"description" is yours to change.

"background" should contain a list of JavaScript files that will be launched by Chrome, attached to "background pages" - invisible pages the user never sees. A typical extension just needs a single "background page" on which to listen for Chrome events that are made available to all extensions. Your JavaScript code running in your background page can respond to those events by injecting other JavaScript files into pages the user can see - such as the Google Calendar tab. So this should be all you need here:

scripts: ["background.js"]

The "browser_action" setting is used to establish an icon for your extension that appears at all times in the upper right corner of Chrome. You can skip this entirely if you don't need one, or supply an icon in PNG format. A 128x128 PNG file works fine, but 19x19 is all you really need. You can also extend your browser_action settings to respond to clicks by showing a popup.

The "permissions" setting determines what your extension is allowed to do. There are several permissions, including "tabs," that refer to aspects of the browser. You need the "tabs" permission in order to respond to events that tell your extension when the user opens a new tab or navigates to a new page in a tab, which is important for our purposes so that we can spot access to Google Calendar. We also request access to https://www.google.com/ so that we can interact with the Google site from the extension's background page if we want to.

Finally, "web_accessible_resources" allows us to make files we bundle with the extension visible to our JavaScript code, like this:

chrome-extension://ID-OF-YOUR-APP/neildiamond.png

"Great, what's the ID of my app?" Good question. Google generates these for you. You'll have one ID during development and another once you add it to the Chrome store. Which means you'll need to update your JavaScript with the final ID once you spot it in your address bar after registering the extension. I'll explain that part in more detail when we get to that stage.

Background Pages and Chrome Events

OK, our manifest file is complete. Let's look at some code. The background.js file, as I mentioned, runs on a "background page." What that really means is that there is no visible page in the browser for it to interact with, but it can still listen to Chrome events in order to learn about what's happening in any tab and start interacting with pages in any tab. A background page can in fact use pretty much all of the features documented in the Chrome Developer's Guide. But it can't directly modify a page that is visible in a tab.

Since this file can't interact directly with the Google Calendar tab, its main job is to take notice when the user opens Google Calendar and then inject a separate JavaScript file into that tab. Here's how it works:

chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
    if (changeInfo.status !== 'complete')
    {
        return;
    }
    var match = 'https://www.google.com/calendar/render';
    if (tab.url.substring(0, match.length) === match)
    {
        chrome.tabs.executeScript(tabId, { file: 'today.js' });
    }
});

What's going on here? When we call chrome.tabs.onUpdated.addListener, we're passing a callback function that will be invoked every time a tab is updated - meaning that the user has created a new tab or navigated to a new page in a tab. This allows us to listen to what the user is doing and spot the Google Calendar tab when it is opened.

The callback function receives a tabId, which can be used to identify the tab we want to manipulate, a changeInfo object, which has a "status" property that tells us what is happening to the tab right now, and a "tab" object that has a "url" property. The url property is useful for figuring out whether this is the tab we want to interact with.

We're only interested when a tab has loaded a page and is ready to interact with, so we'll ignore all events that don't specify that the tab is "complete":

    if (changeInfo.status !== 'complete')
    {
        return;
    }

And we're only interested in URLs that are part of Google Calendar, so let's spot those by using tab.url.substring() to check whether the URL begins appropriately:

    var match = 'https://www.google.com/calendar/render';
    if (tab.url.substring(0, match.length) === match) { ... }

Now we can inject a second JavaScript file into the tab. We use tabId to identify the tab we're interested in and specify the file we want to inject. We'll be sure to bundle that file with our extension:

        chrome.tabs.executeScript(tabId, { file: 'today.js' });

"What does injecting a JavaScript file into a tab do?" It loads the JavaScript code in that file and executes it in the context of the page, so it can do anything that a JavaScript file loaded directly in that page via a script tag would be able to do.

today.js: adding our own JavaScript to Google Calendar

So far, so simple. Now let's check out today.js, the file that does the tricky work of overriding Google Calendar's "today" button... even though Google loves to replace that button. Really, really loves it a lot.

We'll start by wrapping the entire file in an anonymous function. We do that to avoid polluting the global namespace. In other words, we do that to avoid jamming a named function into the page that might conflict with Google's functions that we know nothing about. After all, we have no need to do so! Our own functions can call each other perfectly well from inside the closure created by the anonymous function. And since we're trespassers on somebody else's lawn, it behooves us to avoid leaving behind a mess.

This is a great way to wrap code in an anonymous function and then immediately invoke it:

(function() { ... })();

I'm honestly not sure why the first set of parentheses are necessary. But they are.

Inside our closure, let's get started by establishing a variable to hold the id of the extension, so that we only have to change it in one place:

    var id = 'myextensionid';

Now let's preload the audio file that we're bundling into our extension, so that we can play it whenever we're ready (specifically, when the user clicks the "today" button). In my experience there is still a brief delay on the first playback, which is annoying. If you know a workaround, I'm all ears.

    var myAudio = new Audio('chrome-extension://' + id + '/today.wav');
    myAudio.load();

Notice that we're taking advantage of the ability to access files bundles with the extension via a chrome-extension:// URL, as I mentioned earlier. Just be sure you include them in the web_accessible_resources section of your manifest.json file.

Next we'll preload an image to be animated when the "today" button is clicked:

    var myImg = new Image();
    myImg.src = 'chrome-extension://' + id + '/neildiamond.png';

We'll be adding this image object to the page, exactly as if it had been placed there with an <img src="..." /> tag. But first, we want to make sure it's not visible until we want it to be. We also want it to be absolutely positioned so that we can easily animate it by changing its CSS properties. It's easy to access the CSS properties of any HTML element with [removed]

    myImg.style.position = 'absolute';
    myImg.style.display = 'none';

And now we'll add it to the end of the body of the page. Since today.js runs in the context of the page this is just as easy as it would be for a script tag inside the page:

    document.body.appendChild(myImg);

"Hey, you're doing a lot of DOM and CSS manipulation here and you're not using jQuery. What gives?" Well, you're right: I could use jQuery, and it would make my code shorter. However, that would require that I bundle jQuery with the extension, or rely on it already being present on the page. I'm not crazy about either of those ideas. Google is free to change how their page works, and I want as few dependencies on that as possible. And the more we bundle with the extension the more we affect the performance of the browser. An extension like this is fun right up until the moment it's perceived as a performance killer; then it's game over. Chrome extensions that modify existing web apps should be as light as possible.

Also, since we know for a fact that we're running in Chrome, we can rely on modern JavaScript functions that might be missing in other browsers - functions that jQuery uses internally to implement its snazzy selectors with better performance in modern browsers. In a nutshell, working without jQuery is a lot more pleasant in Chrome.

Enhancing Buttons: Every 100 Milliseconds, They Come To America

Now the job is simple: find the "Today" button, add an event handler, do some animation and play some audio. That's all there is to it, right?

Well... not quite. Since Chrome replaces the "Today" button pretty much every time you navigate, any event handlers we add to it are likely to be gone by the time we want them.

Fortunately, we can work around it. Here's the basic strategy:

1. Every 100 milliseconds (10 times a second), check the page for buttons with the "date-nav-today" class.

2. Dig into those and locate the "goog-imageless-button-content" class. This is the actual text of the button, and it doesn't already have a click event handler attached to it, which means we can attach our own. We'll call this element "inner."

3. Check whether "inner" has been enhanced already. If it has, leave it alone so we don't install two click handlers. If doesn't, add our enhancement and set a data attribute so we can detect that we already did so.

Setting up a function to be called every 100 milliseconds is easy:

    setInterval(function() { ... }, 100);

Note that when you set up functions to be called on an interval like this and you are enhancing someone else's page, you should make very sure they use only a small fraction of the time between calls to do their work. If you can't do it fast, do it less often.

Now let's delve into the function we're calling at regular intervals. Here's the logic to grab the outer wrapper of the "today" buttons:

        var buttons = document.getElementsByClassName('date-nav-today');

document.getElementsByClassName doesn't exist in some older browsers, but it's safe as houses in Chrome.

Now loop through those wrappers. For each one, locate the inner button:

        var i;
        for (i = 0; (i < buttons.length); i++)
        {
            var button = buttons[i];
            var inner = button.getElementsByClassName('goog-imageless-button-content')[0];

Make sure we didn't enhance it already:

            if (!inner.getAttribute('data-' + id + '-set')) { ... }

Notice that we use the id of our extension as part of the attribute name. That's a safe way to avoid a collision with any data attribute names Google may already be using.

Now we can attach our click handler:

                inner.addEventListener('click', function() {
                    myAudio.currentTime = 0;
                    myAudio.play();
                    startAnimation();
                    return true;
                });

The click handler callback plays the audio, after first resetting it to its beginning so that we can play it more than once. And it also kicks off the animation, which we'll look at in a moment.

Once we've added the event handler, we need to set our data attribute to avoid enhancing the same button over and over:

                inner.setAttribute('data-' + id + '-set', 1);

A Little Light Trigonometry

That's it! Hot Breakfast's original request has been satisfied. Honor has been served. Today is a good day to click "Today." Except... I'm vaguely dissatisfied. Sure, the band will be amused, but they won't be awestruck. We should fix that.

So let's animate a picture of the beloved artist to swoop up from the lower left corner and fade out at right after sweeping out a semicircle.

We'll start by taking note of the usable dimensions of the browser window and note that our "x" coordinate starts out at far left:

                    var width = window.innerWidth;
                    var height = window.innerHeight;
                    var x = 0;

Now let's use another interval timer to call a function every 20 milliseconds (50 times a second) to move the artist across the screen, one step at a time:

                    var interval = setInterval(function() { ... }, 50);                                                                         

This time around, we capture the interval timer in a variable called "interval." We do that so that we can cancel the interval timer when the artist reaches the far right side of the screen.

Let's look inside the callback of our interval timer. The first step is to move the artist's image element on the X axis. This is easy; just set the "left" CSS property. Don't forget to add "px" to the string, otherwise it's not valid CSS:

                        myImg.style.left = x + 'px';

The Y axis is a little trickier. We'll start by scaling our X coordinate to a value between 0 and 1 rather than a number of pixels. That allows us to think about a unit circle (a circle of radius 1), which is easier:

                        var cx = (x - (width / 2)) / (width / 2);

Now we can compute the corresponding Y coordinate on the unit circle. Remember, x squared plus y squared equals the radius. This is a simple unit circle (radius 1), so solving for y:

                        var cy = Math.sqrt(1 - cx * cx);

Now we can pick a reasonable Y coordinate, scaling up our Y coordinate on the unit circle by half the height in pixels and offsetting that from a reasonable starting point in the window:

                        myImg.style.top = (height - 225 - cy * (height / 2)) + 'px';

Finally, don't forget to make the image element visible (recall we started out with display = 'none'):

                        myImg.style.display = 'block';

We'll also set the z index to ensure the artist floats majestically above the rest of the page:

                        myImg.style.zIndex = 999;

Having the artist sweep across the screen and just disappear is a bit jarring. We could move him all the way off the screen, but when we do that a distracting horizontal scrollbar appears for a moment. It'd be nicer if the artist faded in and out at the beginning and end. So let's use the "opacity" CSS property to achieve that. An opacity of 1.0 is 100% opaque, while an opacity of 0.0 is 100% transparent (completely faded out). While the artist is at the far left and right, we'll fade him in and out; while the artist is in the middle we'll leave him 100% visible:

                        if (x < 175)
                        {
                            myImg.style.opacity = x / 175;
                        }
                        else if (x > (width - 350))
                        {
                            myImg.style.opacity = 1.0 - ((x - (width - 350)) / 175);
                        }
                        else
                        {
                            myImg.style.opacity = 1.0;
                        }


Finally, let's nudge the artist forward 30 pixels on each interval. If the artist has reached the right edge, it's time to hide his visage and clear the interval timer until next time:

                        x += 30;
                        if (x > (width - 175))
                        {
                            myImg.style.display = 'none';
                            clearInterval(interval);
                        }

Testing Chrome Extensions

That wasn't too hard (except for the trigonometry refresher perhaps). But how do we run our extension in Chrome?

Running a Chrome extension on your own computer for testing purposes is really easy. Just go to the Chrome extensions page in Chrome:

chrome://chrome/extensions/

Once you're there, check the "Developer Mode" box at top right. You'll gain the new option "Load unpacked extension."

Click the "Load unpacked extension" button, then browse to your "today" folder (or whatever you called your extension's folder) and select the folder. Google Chrome will load your extension and display it along with the rest of your Chrome extensions. The ID of your extension will be visible in the list.

Now go to your background.js file and paste in the ID of your extension on this line:

var id = "....";

Then click "Update extensions now" to reload your extension with the correct setting for the id. The icon of your extension should appear at upper right, next to the wrench to the right of the address bar. Clicking the icon won't do anything unless you configured a popup file.

Now open a new tab and visit your Google Calendar. Click the arrow to navigate to next week, then click "Today," and...

Well! Wasn't that special!

(If an extension doesn't work at first, you may find that restarting Chrome is helpful.)

Sharing Your Extension With the World via the Chrome Web Store

We've learned how to inject a little culture into the "Today" button of Google Calendar... but naturally we want to share our extension with the rest of the world. The easiest way to do that is by releasing your extension in the Chrome web store via the developer dashboard. This is a refreshingly simple process for anyone who has suffered through releasing an iPhone app! You do have to make a one-time $5 payment, but I trust $5 is not a huge dealbreaker for you. After that, all you have to do is upload a .zip file of your extension's folder. You'll be prompted to describe your extension and given an opportunity to add a separate icon for the store as well as screenshots.

But don't announce your extension to your friends yet! Once you have uploaded your extension, you'll be able to see your ID in the URL in the address bar:

https://chrome.google.com/webstore/detail/your-id-is-here

This is your final ID in the chrome store, which is different from the one you had in development. Why yes, it is rather annoying.

Update the ID in your background.js file, change the "version" property in your manifest.json file to the next minor version (change 1.0.0 to 1.0.1 for instance), update the zip file of your folder to include that updated file, and click "Edit" on the developer dashboard to upload the updated zip file.

Now go to your own Chrome extensions page and remove the development version of your extension. Then return to your extension's page in the Chrome web store. Make sure the displayed version number is 1.0.1 (there may be a delay; you may need to refresh the page a bit). Then click "Add to Chrome."

If you get a complaint about your manifest.json file at this point, try restarting Chrome. This only seems to happen to me as a developer; my end users have no trouble adding the extension.

Once you've successfully added the published version of your extension to your browser and tested it out with Google Calendar, you're ready to tell the world about your extension. You can do that by sharing the URL of your extension's page in the web store.

We've learned how to do a frivolous, pointless, totally absurd thing! However, we've also learned how to do a deeply practical thing: enhance web apps we didn't write ourselves. Chrome Extensions provide a way for users to opt in to enhanced experiences all over the web. It's a very democratic phenomenon. I like to think the artist would approve.

You can install the finished Chrome extension here.

Copyright note: the song "Coming to America" and Neil Diamond's publicity photographs are the property of Neil Diamond and his assigns. I will gladly remove the brief audio sample and the image if the artist or those to whom he has assigned the relevant rights state any objection. I hope this will be viewed as a harmless form of fan fiction dedicated to an artist many of us rediscovered via the Pulp Fiction soundtrack. But it's their call to make.

 

blog comments powered by Disqus