Dive Into Greasemonkey

Teaching an old web new tricks

5.7. Case study: Zoom Textarea

Adding buttons to zoom textareas

Zoom Textarea alters web forms to add a toolbar above every <textarea> element (used for entering multiple lines of text). The toolbar lets you increase or decrease the text size of the <textarea>, without changing the style of the rest of the page. The buttons are fully keyboard-accessible; you can tab to them and press ENTER instead of clicking them with your mouse. (I mention this up front because accessibility matters, and also because it was harder than it sounds.)

Example: zoomtextarea.user.js

// ==UserScript==
// @name          Zoom Textarea
// @namespace     http://diveintogreasemonkey.org/download/
// @description   add controls to zoom textareas
// @include       *
// ==/UserScript==

var textareas, textarea;

textareas = document.getElementsByTagName('textarea');
if (!textareas.length) { return; }

function textarea_zoom_in(event) {
    var link, textarea, s;
    link = event.currentTarget;
    textarea = link._target;
    s = getComputedStyle(textarea, "");
    textarea.style.width = (parseFloat(s.width) * 1.5) + "px";
    textarea.style.height = (parseFloat(s.height) * 1.5) + "px";
    textarea.style.fontSize = (parseFloat(s.fontSize) + 7.0) + 'px';
    event.preventDefault();
}

function textarea_zoom_out(event) {
    var link, textarea, s;
    link = event.currentTarget;
    textarea = link._target;
    s = getComputedStyle(textarea, "");
    textarea.style.width = (parseFloat(s.width) * 2.0 / 3.0) + "px";
    textarea.style.height = (parseFloat(s.height) * 2.0 / 3.0) + "px";
    textarea.style.fontSize = (parseFloat(s.fontSize) - 7.0) + "px";
    event.preventDefault();
}

function createButton(target, func, title, width, height, src) {
    var img, button;
    img = document.createElement('img');
    img.width = width;
    img.height = height;
    img.style.borderTop = img.style.borderLeft = "1px solid #ccc";
    img.style.borderRight = img.style.borderBottom = "1px solid #888";
    img.style.marginRight = "2px";
    img.src = src;
    button = document.createElement('a');
    button._target = target;
    button.title = title;
    button.href = '#';
    button.onclick = func;
    button.appendChild(img);
    return button;
}

for (var i = 0; i < textareas.length; i++) {
    textarea = textareas[i];
    textarea.parentNode.insertBefore(
        createButton(
            textarea,
            textarea_zoom_in,
            'Increase textarea size',
            20,
            20,
            'data:image/gif;base64,'+
'R0lGODlhFAAUAOYAANPS1tva3uTj52NjY2JiY7KxtPf3%2BLOys6WkpmJiYvDw8fX19vb'+
'296Wlpre3uEZFR%2B%2Fv8aqpq9va3a6tr6Kho%2Bjo6bKytZqZml5eYMLBxNra21JSU3'+
'Jxc3RzdXl4emJhZOvq7KamppGQkr29vba2uGBgYdLR1dLS0lBPUVRTVYB%2Fgvj4%2BYK'+
'Bg6SjptrZ3cPDxb69wG1tbsXFxsrJy29vccDAwfT09VJRU6uqrFlZW6moqo2Mj4yLjLKy'+
's%2Fj4%2BK%2Busu7t783Nz3l4e19fX7u6vaalqNPS1MjHylZVV318ftfW2UhHSG9uccv'+
'KzfHw8qqqrNPS1eXk5tvb3K%2BvsHNydeLi40pKS2JhY2hnalpZWlVVVtDQ0URDRJmZm5'+
'mYm11dXp2cnm9vcFxcXaOjo0pJSsC%2FwuXk6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC'+
'H5BAAAAAAALAAAAAAUABQAAAeagGaCg4SFhoeIiYqKTSQUFwgwi4JlB0pOCkEiRQKKRxM'+
'gKwMGDFEqBYpPRj4GAwwLCkQsijwQBAQJCUNSW1mKSUALNiVVJzIvSIo7GRUaGzUOPTpC'+
'igUeMyNTIWMHGC2KAl5hCBENYDlcWC7gOB1LDzRdWlZMAZOEJl83VPb3ggAfUnDo5w%2F'+
'AFRQxJPj7J4aMhYWCoPyASFFRIAA7'),
        textarea);
    textarea.parentNode.insertBefore(
        createButton(
            textarea,
            textarea_zoom_out,
            'Decrease textarea size',
            20,
            20,
            'data:image/gif;base64,'+
'R0lGODlhFAAUAOYAANPS1uTj59va3vDw8bKxtGJiYrOys6Wkpvj4%2BPb29%2FX19mJiY'+
'%2Ff3%2BKqqrLe3uLKytURDRFpZWqmoqllZW9va3aOjo6Kho4KBg729vWJhZK%2BuskZF'+
'R4B%2FgsLBxHNydY2Mj%2Ff396amptLS0l9fX9fW2dDQ0W1tbpmZm8DAwfT09fHw8n18f'+
'uLi49LR1V5eYOjo6VBPUa6tr769wEhHSNra20pJStPS1KuqrNPS1ZmYm%2B7t77Kys8rJ'+
'y%2Fj4%2BaSjpm9uca%2BvsMjHyqalqHRzdVJRU8PDxVRTVcvKzc3Nz0pKS9rZ3evq7MC'+
'%2FwsXFxp2cnnl4e1VVVu%2Fv8ba2uM7Oz29vcbu6vZqZmnJxc9vb3PHx8uXk5mhnamJh'+
'Y1xcXZGQklZVV29vcHl4eoyLjKqpq6Wlpl1dXuXk6AAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAACH5BAAAAAAALAAAAAAUABQAAAeZgGaCg4SFhoeIiYqKR1IWVgcyi4JMBiQqA0heQgG'+
'KQTFLPQgMCVocBIoNNqMgCQoDVReKYlELCwUFI1glEYorOgopWSwiTUVfih8dLzRTKA47'+
'Ek%2BKBGE8GEAhFQYuPooBOWAHY2ROExBbSt83QzMbVCdQST8Ck4QtZUQe9faCABlGrvD'+
'rB4ALDBMU%2BvnrUuOBQkE4NDycqCgQADs%3D'),
        textarea);
    textarea.parentNode.insertBefore(
        document.createElement('br'),
        textarea);
}

The code looks complicated, and it is complicated, but for different reasons. It looks complicated because of the large multiline jibberish-looking strings in the middle of it. Those are data: URIs, which look like hell but are easy to generate. The real complexity lies elsewhere.

First, I get a list of all the <textarea> elements on the page. See Doing something for every instance of a specific HTML element for more details on this pattern. If there aren't any, there's no point continuing, so return.

textareas = document.getElementsByTagName('textarea');
if (!textareas.length) { return; }

Now let's skip around a little. Our “toolbar” is visually a row of buttons, but each button is really just an image of something that looks pushable, wrapped in a link that executes one of our Javascript functions.

Since we'll be creating more than one button (this script only has two, but you could easily extend it with more functionality), I created a function to encapsulate all the button-making logic.

function createButton(target, func, title, width, height, src) {

The createButton function takes 6 arguments:

target
an element object, the <textarea> element that this button will control
func
a function object, the Javascript function to be called when the user clicks the button with the mouse or activates it with the keyboard
title
a string, the text of the tooltip when the user moves their cursor over the button
width
an integer, the width of the button. This should be the width of the graphic given in the src argument.
height
an integer, the height of the button. This should be the height of the graphic given in the src argument.
src
a string, the URL, path, or data: URI of the button graphic

Creating a button is split into two halves: creating the <img> element, and then creating the <a> element around it.

I create the <img> element by calling document.createElement and setting a few attributes, including style attributes. See Setting an element's style for more details on this pattern.

    img = document.createElement('img');
    img._target = target;
    img.width = width;
    img.height = height;
    img.style.borderTop = img.style.borderLeft = "1px solid #ccc";
    img.style.borderRight = img.style.borderBottom = "1px solid #888";
    img.style.marginRight = "2px";
    img.src = src;

One thing I just want to point out quickly, because I think it's cool. You can assign the same value to two attributes at once by using this syntax:

    img.style.borderTop = img.style.borderLeft = "1px solid #ccc";

OK, moving on. The second half of creating a button is creating the link (an <a> element) and putting the <img> element inside it.

    button = document.createElement('a');
    button._target = target;
    button.title = title;
    button.href = '#';
    button.onclick = func;
    button.appendChild(img);

There are two things I want to point out here. First, I need to assign a bogus href attribute to the link, otherwise Firefox would treat it a named anchor and wouldn't add it to the tab index (i.e. you wouldn't be able to tab to it, making it inaccessible with the keyboard). Second, I'm setting the _target attribute to store a reference to the target <textarea>. This is perfectly legal in Javascript; you can create new attributes on an object just by assigning them a value. I'll access the custom _target attribute later, in the onclick event handler.

Now let's jump back to the onclick handler itself. Each handler is a function that takes one parameter, event.

function textarea_zoom_in(event)

The event object has several properties, only one of which I'm interested in at the moment: currentTarget.

    link = event.currentTarget;

If you read the documentation on the Event object, you'll see that there are several target-related properties, including one simply called target. You might be tempted to use event.target to get a reference to the clicked link, but it behaves (in my opinion) inconsistently. When the user tabs to the button and hits ENTER, event.target is the link, but when the user clicks the button with the mouse, event.target is the image inside the link! I'm sure there's a reasonable explanation for this, but it is beyond my level of understanding of the DOM event model. In any case, event.currentTarget returns the link in all cases, so I use that.

Next I retrieve the reference to the <textarea> I'm actually going to be zooming, via the custom _target property that I set when I created the button.

    textarea = link._target;

Now the real fun begins. (And you thought you were having fun already!) I need to get the current dimensions and font size of the <textarea>, so that I can make them bigger. Simply retrieving the appropriate attributes from textarea.style (textarea.style.width, textarea.style.height, and textarea.style.fontSize) will not work, because those only get set if the page actually defined them in a style attribute on the <textarea> itself. That's not what I want; I want the actual current style. For that I need getComputedStyle. See Getting an element's style for more details on this function.

    s = getComputedStyle(textarea, "");
    textarea.style.width = (parseFloat(s.width) * 1.5) + "px";
    textarea.style.height = (parseFloat(s.height) * 1.5) + "px";
    textarea.style.fontSize = (parseFloat(s.fontSize) + 7.0) + 'px';

Finally, do you remember that bogus href value I added to my button link to make sure it was keyboard-accessible? Well, it's now become an annoyance, because after Firefox finishes executing the onclick handler, it's going to try to follow that link. Since it points to a non-existent anchor, Firefox is going to jump to the top of the page, regardless of where the button is. This is annoying, and to stop it, I need to call event.preventDefault() before finishing my onclick handler.

    event.preventDefault();

The rest of the script is straightforward. I loop through all the <textarea> elements, create zoom buttons for each of them (each with its own onclick handler and button graphic), and insert the zoom buttons before the <textarea>. For each image, I use a data: URI to create an inline graphic, so users don't need to hit a central server to get the button graphics. See Inserting content before an element and Adding images without hitting a central server for more details on these patterns.

for (var i = 0; i < textareas.length; i++) {
    textarea = textareas[i];
    textarea.parentNode.insertBefore(
        createButton(
            textarea,
            textarea_zoom_in,
            'Increase textarea size',
            20,
            20,
            'data:image/gif;base64,'+
'R0lGODlhFAAUAOYAANPS1tva3uTj52NjY2JiY7KxtPf3%2BLOys6WkpmJiYvDw8fX19vb'+
'296Wlpre3uEZFR%2B%2Fv8aqpq9va3a6tr6Kho%2Bjo6bKytZqZml5eYMLBxNra21JSU3'+
'Jxc3RzdXl4emJhZOvq7KamppGQkr29vba2uGBgYdLR1dLS0lBPUVRTVYB%2Fgvj4%2BYK'+
'Bg6SjptrZ3cPDxb69wG1tbsXFxsrJy29vccDAwfT09VJRU6uqrFlZW6moqo2Mj4yLjLKy'+
's%2Fj4%2BK%2Busu7t783Nz3l4e19fX7u6vaalqNPS1MjHylZVV318ftfW2UhHSG9uccv'+
'KzfHw8qqqrNPS1eXk5tvb3K%2BvsHNydeLi40pKS2JhY2hnalpZWlVVVtDQ0URDRJmZm5'+
'mYm11dXp2cnm9vcFxcXaOjo0pJSsC%2FwuXk6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC'+
'H5BAAAAAAALAAAAAAUABQAAAeagGaCg4SFhoeIiYqKTSQUFwgwi4JlB0pOCkEiRQKKRxM'+
'gKwMGDFEqBYpPRj4GAwwLCkQsijwQBAQJCUNSW1mKSUALNiVVJzIvSIo7GRUaGzUOPTpC'+
'igUeMyNTIWMHGC2KAl5hCBENYDlcWC7gOB1LDzRdWlZMAZOEJl83VPb3ggAfUnDo5w%2F'+
'AFRQxJPj7J4aMhYWCoPyASFFRIAA7'),
        textarea);
    textarea.parentNode.insertBefore(
        createButton(
            textarea,
            textarea_zoom_out,
            'Decrease textarea size',
            20,
            20,
            'data:image/gif;base64,'+
'R0lGODlhFAAUAOYAANPS1uTj59va3vDw8bKxtGJiYrOys6Wkpvj4%2BPb29%2FX19mJiY'+
'%2Ff3%2BKqqrLe3uLKytURDRFpZWqmoqllZW9va3aOjo6Kho4KBg729vWJhZK%2BuskZF'+
'R4B%2FgsLBxHNydY2Mj%2Ff396amptLS0l9fX9fW2dDQ0W1tbpmZm8DAwfT09fHw8n18f'+
'uLi49LR1V5eYOjo6VBPUa6tr769wEhHSNra20pJStPS1KuqrNPS1ZmYm%2B7t77Kys8rJ'+
'y%2Fj4%2BaSjpm9uca%2BvsMjHyqalqHRzdVJRU8PDxVRTVcvKzc3Nz0pKS9rZ3evq7MC'+
'%2FwsXFxp2cnnl4e1VVVu%2Fv8ba2uM7Oz29vcbu6vZqZmnJxc9vb3PHx8uXk5mhnamJh'+
'Y1xcXZGQklZVV29vcHl4eoyLjKqpq6Wlpl1dXuXk6AAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
'AAACH5BAAAAAAALAAAAAAUABQAAAeZgGaCg4SFhoeIiYqKR1IWVgcyi4JMBiQqA0heQgG'+
'KQTFLPQgMCVocBIoNNqMgCQoDVReKYlELCwUFI1glEYorOgopWSwiTUVfih8dLzRTKA47'+
'Ek%2BKBGE8GEAhFQYuPooBOWAHY2ROExBbSt83QzMbVCdQST8Ck4QtZUQe9faCABlGrvD'+
'rB4ALDBMU%2BvnrUuOBQkE4NDycqCgQADs%3D'),
        textarea);
    textarea.parentNode.insertBefore(
        document.createElement('br'),
        textarea);
}
← Case study: Frownies
Case study: Access Bar →