?

Log in

phantom gray

ProColor, Part 2: Mouse Capturing

It's time for the second article in my occasional series on the ProColor color-picker. Last time, I gave a rough overview of what ProColor is and why it's designed the way it is. In this article, I'm going to cover some the topics in reverse and discuss capturing the mouse on a web page.

What's an event?

Let's start at the beginning: What's an event, and why would you ever want to capture one?

An event in most user-interfaces means an action performed by the user: A key press, a mouse movement, or a mouse-button press, among others. There are other kinds of events, too, but these are the ones we'll be concerned about. Events are little structures, little tiny chunks of data, that describe what happened and where it happened, in full, so that they can be sent to appropriate places to be processed. A key-press event, for example, typically includes which key was pressed, whether Ctrl or Shift or Alt was held down while it was being pressed, and maybe where the mouse was on screen at the time.

Capturing and focusing are two different ways of ensuring that the events go where they need to. Usually, events go to whatever window is appropriate for them: A "this window was just hidden" event gets sent to the window that was hidden, for example. But what about keypresses? Where should they go? The keyboard is external, after all, and not necessarily attached to any one window.

Focusing solves this problem: Only one window can have the focus at a time, and input from devices that have no way of directing themselves to a specific window (keyboards, joysticks, microphones, etc.) all have their input go to the focus window. So when you click on, say, a textbox, the window system assigns that textbox the focus, and then all input events get directed to that textbox. At least that's the theory: Different window systems do this with different levels of directness and precision, but all of them have the concept of focus for the keyboard device at least.

Capturing solves a different problem. Some devices, like the mouse, have the concept of position built into them: When you move the mouse, the window that should receive the events for that action is obvious, because the mouse is "over" it. But what if your program doesn't want those events to go to that window?

You might be wondering why events shouldn't go where they belong, but it's a common need in user interfaces. When you press the mouse button while you're over a scrollbar's thumb, the scrollbar should receive all events until you release the mouse button so that the thumb can be dragged around to follow the mouse — even if the mouse isn't over the scrollbar.

Capturing mouse events, then, is a core component of modern window-system functionality, and most window systems provide some sort of tools to support it: Microsoft gives us SetCapture(), X-Windows gives us XGrabPointer(), and others have a variety of ways to handle the problem, from calling functions to attaching event handlers to creating whole new specialized event-processing loops.

Unfortunately, the web doesn't.

And this means that implementing page elements that track the mouse — elements that look like buttons or scrollbars or dropdowns, for example — isn't something that's built into your web browser. Fortunately, there's a very good way around this problem, and that's what we're going to talk about here.

How events get dispatched

Many of us programmers know how a traditional event-processing model works in a window system: The window system collects events from devices, posts those events into a queue, and your program retrieves those events and (optionally) dispatches them to some target window or processes them directly. Most window systems work like this; there's GetMessage() in Windows; there XNextEvent() in X-Windows; there's even EvtGetEvent() on the old Palm Pilot to retrieve events, and dispatching them is usually an indirect function call or giant switch statement.

Sadly, the web doesn't work like that.

On the web, the event-processing loop occurs outside your page in the browser itself. The browser collects events and dispatches them to the various page elements whenever it feels like doing so. In current implementations, you at least have the guarantee that the events are dispatched using a single thread, but the Document Object Model (the DOM) doesn't actually guarantee even that much. Worse, there's no function named Event.BeginCapturing or something like it. So if event dispatching is totally outside your control, how do you take control of it?

Well, the good news is that every time an event arrives and needs to be dispatched, the web browser sends it to not one page element but several. The event dispatches are grouped into two categories: capturing and bubbling. During the capture phase, the web browser walks down the DOM tree until it reaches the target element, asking each parent element if it wants the event. Then, during the bubbling phase, the web browser goes back up the tree, asking each element again if it wants the event. Only then if the event gets all the way back to the top of the tree and nobody wants it does the web browser perform default actions like "select text" or "click checkbox."

This is better explained visually. Consider a very simple page like the one below. It has a left sidebar with some links, and a central area with a form and an <input> field and also a <div> that has a background image that makes the <div> look like a snazzy shiny black pushbutton.



Let's say the user clicks on the <div> "pushbutton." The web browser first follows the red arrows, asking the <body> and then the <div> and then the <form> and then finally the innermost <div> if they want the event (the "capturing" phase, so named because it was designed to make capturing events easy). And then the browser goes back up the blue arrows, asking the innermost <div> and then the <form> and then the <div> and then finally the <body> if they each want the event (the "bubbling" phase, so named because the event seems to "bubble" to the top of the document).

"Great," you think. "So all I have to do to capture events, then, is to attach an event-handling function to the <body> and have it grab events on their way down the tree, right? Easy as pie!"

Good thought, but sorry, you're wrong.

You see, there's a minor problem with the above diagram, which is that's how most web browsers send events. Unfortunately, Internet Explorer does it differently: IE doesn't do the "capturing" phase at all — no red arrows, which means that an event handler attached to the <body> will never see an event if that innermost <div> — or any other element higher up — processes it first.

Doesn't it figure that IE's the fly in our ointment?

Capturing events, portably

Since you can't use the "capturing" side of event processing — and the Prototype Javascript library doesn't even support that on non-IE browsers because they don't want you to get your hopes up that it'll actually work — how do you actually capture? The answer, as is often true with hacky technology, is that you need to cheat.

In this case, we're going to do something that you can do with web browsers but that you can't readily do with window systems: We're going to use transparent objects.

First, rather than thinking of your page as flat, imagine looking at it sideways. Each element is like a flat chunk of plastic and sticks out just a little bit. Elements are not "inside" other elements but "stack on top of" other elements, so that really "deep" elements in the tree are actually very "tall" when looked at from the side. You can see this depicted below:


From this perspective, the browser's actions during the bubbling phase take on a new appearance: Rather than starting at the deepest element in the tree, the browser starts the event at the highest element in the stack and walks down the stack with the event until it runs out of elements or until one of the elements actually claims the event.

This distinction is important, because it allows us to take control of the dispatching in a way that's almost a little magical: What if we had another element higher than all the others? If we did, that element would be the one that received the event first! In fact, if that layer covers the entire page, then the event will only "bubble" across two elements total: That "cover" element and the <body> itself — none of the other page elements would ever see the mouse event at all.



This, then, is how we reliably capture mouse events reliably in Javascript: During the period where we want to capture events, we create a new topmost layer and attach the event handler to it, and then when we're done capturing, we destroy the topmost layer.

Implementing MouseCapture

Once you know the principle, the code is pretty easy. In fact, ProColor comes with a MouseCapture class that encapsulates this basic principle in a simple, reusable way. The complete code for the MouseCapture class is short, and is included at the end of this article.

Let's start by seeing how you might use the MouseCapture class to track a pushbutton. I'm going to dispense with the usual event-handling boilerplate code and just use a onmousedown handler to keep the presentation simple (but note that in real code, you'll want to handle the "mousedown" events properly).

Here's our example HTML:
<img id='mybutton' src='mybutton.png' width='120' height='30'
    alt='Button' onmousedown='TrackButton("mybutton")' />

<script type='text/javascript'><!--
function TrackButton(id) {
    $(id).src = 'mybutton_pressed.png';
    var capture = new MouseCapture;
    capture.begin(function(event) {
        switch (event.type) {
        case 'mouseup':
            $(id).src = 'mybutton.png';
            capture.end();
            break;
        }
    });
}
--></script>
Let's look at how this works. The TrackButton() function will get called when the mouse button is pressed over the image. The TrackButton() function changes the image to appear "pressed," and then creates a new MouseCapture object. MouseCapture.begin() is used to initiate the capture, and it gets passed a callback function — a function that will receive all of the events until the capture ends. Our capture function here watches the events it receives, and when the mouse button is released ("mouseup"), it changes the image back to the unpressed version and ends the capture.

Simple enough, really. There are a few caveats. First, the event's coordinates (the mouse's coordinates) are relative to the entire document — not any one specific element within it: They're absolute coordinates. You can retrieve those coordinates by calling the standard Prototype functions event.pointerX() and event.pointerY(). You can also cheat by calling Element.isEventIn(), a custom function included with ProColor that tells you whether an event occurred within a given element.

Let's use Element.isEventIn() to implement a second version of the tracking routine; in this version, if the user slides the mouse off of the "pushbutton," the "pushbutton" will pop up:
<img id='mybutton' src='mybutton.png' width='120' height='30'
    alt='Button' onmousedown='TrackButton("mybutton")' />

<script type='text/javascript'><!--
function TrackButton(id) {
    $(id).src = 'mybutton_pressed.png';
    var button_is_pressed = true;
    var capture = new MouseCapture;
    capture.begin(function(event) {
        switch (event.type) {
        case 'mousemove':
            var mouse_is_over = Element.isEventIn(id, event);
            if (mouse_is_over != button_is_pressed) {
                if (mouse_is_over) $(id).src = 'mybutton.png';
                else $(id).src = 'mybutton_pressed.png';
                button_is_pressed = mouse_is_over;
            }
            break;
        case 'mouseup':
            $(id).src = 'mybutton.png';
            capture.end();
            break;
        }
    });
}
--></script>
This is a slightly more complex version of the same routine. This one watches when the mouse moves while the mouse button is being pressed, and if the mouse moves outside the "pushbutton", the "pushbutton" is redrawn as unpressed; and if the mouse moves back in, the "pushbutton" is redrawn as pressed.

You can use similar techniques for all sorts of tricks: For example, ProColor uses the MouseCapture solution to handle dragging of the selection on the color wheel, to handle dragging of the slider next to the color wheel, to handle clicks on the color palette, and to handle clicks on the color-picker dropdown. There are lots of other ways you could potentially use it as well. Use your imagination: The sky's the limit!

Conclusion

I hope I've shed some light on how a very useful programming technique can be readily applied to elements on web pages as well. The invisible upper-layer technique is simple and flexible, and works on every major browser; my MouseCapture class and example scripts will run just fine on IE6, IE7, IE8, Firefox, Safari, Opera 8+, and Chrome — anywhere Prototype will run.

In closing, below you can find the source code to my MouseCapture class and the associated Element.build() and Element.isEventIn() methods. This is designed to work with Prototype, but I have no doubt that with some effort, you could rework it to behave nicely with JQuery or Dojo. If you have any questions, please feel free to post them: This is a blog, and that's what the comment section is for!

The Code
/*---------------------------------------------------------------------------------
**  Add two useful methods to Element.  Many other libraries add build(), and ours
**  is, in theory, compatible with all of their versions.
*/

Element.addMethods({
    /* Create a child element with the given options (attributes) and style. */
    build: function(element, type, options, style) {
        var e = $(document.createElement(type));
        $H(options).each(function(pair) { e[pair.key] = pair.value; });
        if (style) e.setStyle(style);
        element.appendChild(e);
        return e;
    },

    /* Return true if this event was a mouse event inside the given element. */
    isEventIn: function(element, event) {
        var d = element.getDimensions();
        var p = element.cumulativeOffset();
        var x = event.pointerX(), y = event.pointerY();
        return (x >= p.left && y >= p.top
            && x < p.left+d.width && y < p.top+d.height);
    }
});


/*---------------------------------------------------------------------------------
**  This is a simple class for capturing all mouse input after a mouse button
**  is held down until the mouse button is released.
*/

var MouseCapture = Class.create({

    initialize: function() { },

    onEvent: function(event, callback) {
        if (callback && event.type != 'mouseover' && event.type != 'mouseout')
            callback(event, event.type);
            event.stop();
    },

    setCursor: function(c) {
        if (this.div)
            this.div.setStyle({ cursor: c });
    },

    begin: function(callback) {
        /* Create our event listener.  We'll need this object now, and later on to
           be able to stop listening to events too. */
        this.listener = this.onEvent.bindAsEventListener(this, callback);

        /* Start observing events. */
        Event.observe(document, 'mouseup', this.listener);
        Event.observe(document, 'mousemove', this.listener);
        Event.observe(document, 'mousedown', this.listener);
        Event.observe(document, 'mouseover', this.listener);
        Event.observe(document, 'mouseout', this.listener);
        Event.observe(document, 'keyup', this.listener);
        Event.observe(document, 'keydown', this.listener);

        /* Don't let the browser perform text-selection while capturing. */
        this.old_body_ondrag = document.body.ondrag;
        this.old_body_onselectstart = document.body.onselectstart;
        document.body.ondrag = function () { return false; };
        document.body.onselectstart = function () { return false; };

        var body = Element.extend(document.body);
        var dim = body.getDimensions();

        /* Build a (nearly) invisible <div> that covers the entire document and that
           will capture all events, even those that would go to iframes. */
        this.div = body.build('div', { }, {
            display: 'block',
            position: 'absolute',
            top: '0px', left: '0px',
            width: dim.width + 'px', height: dim.height + 'px',
            zIndex: 999999999,
            cursor: 'default',
            backgroundColor: '#FFFFFF',
            opacity: 0.0001
        });
    },

    end: function() {
        /* Remove our invisible event-capturing <div>. */
        this.div.remove();

        /* Stop event observing. */
        Event.stopObserving(document, 'mouseup', this.listener);
        Event.stopObserving(document, 'mousemove', this.listener);
        Event.stopObserving(document, 'mousedown', this.listener);
        Event.stopObserving(document, 'mouseover', this.listener);
        Event.stopObserving(document, 'mouseout', this.listener);
        Event.stopObserving(document, 'keyup', this.listener);
        Event.stopObserving(document, 'keydown', this.listener);

        /* Reenable selection. */
        document.body.ondrag = this.old_body_ondrag;
        document.body.onselectstart = this.old_body_onselectstart;
        delete this.old_body_ondrag;
        delete this.old_body_onselectstart;
    }
});

Comments