Nov7

Pseudo-custom events in Prototype 1.6

One major goal of frameworks is to minimize the amount of code branching an individual developer has to do. Usually that means the framework does the branching itself, then builds around it an API that will work in all major browsers. By that metric, support for custom events in Prototype 1.6 might be our biggest new feature in a long, long time.

The wrong way

After we released 1.5.1, the core team started to assemble a wish list for the events overhaul that would be the centerpiece of 1.6. We went down the wrong path at first, writing a bunch of code that, while useful, gunked things up and made our source code harder to comprehend.

For instance, we added universal support for mouseenter and mouseleave events, the proprietary IE events that are similar to — but more useful thanmouseover and mouseout.

If we had continued down that path, the next step would’ve been support for a universal mouse wheel event. All browsers fire some sort of event when the mouse wheel is moved, but some call it mousewheel and others call it DOMMouseScroll. And each one seems to report the “delta” (the overall change in rotation of the mouse wheel) in a different way.

Back to the point: we refocused, trimmed the fat, and added a whole bunch of features to the event system while still excluding thie kitchen sink. We picked some low-hanging fruit; for instance, we normalized the event object so that properties like target exist in all browsers, and we ensured events fire in the scope of the element in IE (so that this refers to the proper thing).

But we also added cross-browser support for custom events. Now developers can fire their own events alongside native browser events and can listen for both types with the same API. Custom events will make Prototype add-ons at least 50% more righteous, allowing for even more control than the standard callback pattern. Imagine TableKit firing an event when a cell gets edited, or PWC firing an event when a dialog is resized.

Since the 1.6 RC1 release, several people have asked whether we have any plans to add native support for mouseenter, mouseleave, or mousewheel. I think we ought not, lest the event codebase become an unholy thicket of special-casing. That’s the sort of environment where bugs thrive.

But, as Sam points out, the addition of custom events makes it easy for third parties to add their own support for proprietary browser events. To demonstrate, today we’ll write 20 lines of code to add sane, cross-browser support for mouse wheel events.

I’m calling these pseudo-custom events because they serve the same purpose as standard browser events: they report on certain occurrences in the UI. Here we’re using custom events to act as uniform façades to inconsistently-implemented events. Together we’ll write some code to generate mouse:wheel events. At the end of this article, you’ll know enough to be able to write code to generate mouse:enter and mouse:leave events document-wide. 1

The idea

Adomas Paltanavicius demonstrates how hard it is to wrangle a common meaning out of various mouse wheel events. His code accurately reports the direction of the movement (up or down) and does its best to estimate the distance of the movement.

It’s excellent work, but it forces developers to attach listeners for mouse wheel events in a different manner from all other event types. Instead, we can create a custom event that serves as a proxy for the native event:

  1. We’ll set up a function (let’s call it wheel) and set it to listen for both mousewheel and DOMMouseScroll events.

  2. Inside that function, we’ll interpret the native event, using Adomas’s code as a base. Once we figure out the direction and distance of the mouse wheel movement, we’ll fire a custom mouse:wheel event that reports the delta.

  3. We’ll attach any necessary mouse wheel listeners onto the mouse:wheel event instead of mousewheel or DOMMouseScroll. The event object will look and feel like any other —and will even abide by calls to stopPropagation (to halt event bubbling) and preventDefault (to tell the browser not to scroll the page).

Normalizing the delta

First, we need to adapt Adomas’s code. Our wheel function will accept an event and figure out the delta of the mouse wheel event. Anything greater than zero is an upward movement; anything less than zero is a downward movement.

function wheel(event) {
  var realDelta;

 // normalize the delta
 if (event.wheelDelta) // IE & Opera
   realDelta = event.wheelDelta / 120;
 else if (event.detail) // W3C
   realDelta = -event.detail / 3;

 /* ... */
}

Good so far. But what do we do with it? Adomas’s approach is to call a handle function that represents the real handler for the event. This handler takes only one argument: the normalized delta.

Our approach will be to fire a custom event, storing the normalized delta as a property of the event object. To do so, we need the new Element#fire method.

function wheel(event) {
  var realDelta;

  // normalize the delta
  if (event.wheelDelta) // IE & Opera
    realDelta = event.wheelDelta / 120;
  else if (event.detail) // W3C
    realDelta = -event.detail / 3;

  if (!realDelta) return;

  event.element().fire("mouse:wheel", {
   delta: realDelta });
}

The first argument is the name of the event to be fired. Prototype requires that custom events have a colon (so that they can be distinguished from ordinary events), so we’ll call it mouse:wheel.

The second argument is an object containing any properties we want to pass along with the event object. This argument will be assigned to the event’s memo property; for instance, handlers will be able to read our delta value by looking at event.memo.delta.

You’ll notice we call the fire method on event.element() —the element that received the event. In the case of mouse wheel events, the target is the element underneath the pointer when the wheel was moved. The custom event will start there, then bubble up through the DOM tree (parent node to parent node) like any native event.

Now let’s hook up our wheel function to the native mouse wheel events:

document.observe('mousewheel', wheel);
document.observe('DOMMouseScroll', wheel);

We listen for both because every browser implements either one or the other. These listeners will catch any mouse wheel event on the page and pass them to our wheel function, where the events get re-fired as mouse:wheel events on the element from which they originated.

Stopping the real scroll

In addition to the custom behaviors assigned by JavaScript, most events have default behaviors assigned by the browser. For instance: a link, when clicked on, brings the user to a new page, even if a script has attached a custom listener. Likewise, a mouse wheel event, by default, will scroll the page (or any focused container with scrollbars) in addition to whatever behavior you attach.

This is usually an undesirable side effect. So Adomas has his handler prevent the default action by using Event#preventDefault. We can do slightly better: we can hook up our custom event to the real event, so that you can decide whether to prevent the scroll action on a case-by-case basis.

Element#fire works synchronously. It’ll dispatch the custom event, wait for its life-cycle to complete, then return the custom event object. So we’ll still be inside the wheel function when it’s done. That means we can check to see whether the user stopped the custom event and, if so, stop the native event.

Oddly, it’s not easy to figure out whether an event’s default action has been prevented simply by inspecting it. In other words, if I receive an event object after it’s finished propagating, I have no cross-browser way of knowing whether preventDefault has been called. Ideally, we’d be able to read from some sort of boolean property, like event.defaultPrevented, but that doesn’t exist.

We can get close, though. In addition to the standard preventDefault and stopPropagation methods, Prototype defines Event#stop for calling both at the same time. Event#stop also sets a stopped property on the event when it gets called. So we can check for event.stopped to see whether the custom event has been stopped. It’s not perfect, but it’s good enough. 2

function wheel(event) {
  var realDelta;

  // normalize the delta
  if (event.wheelDelta) // IE & Opera
    realDelta = event.wheelDelta / 120;
  else if (event.detail) // W3C
    realDelta = -event.detail / 3;

  if (!realDelta) return;

  var customEvent = event.element().fire("mouse:wheel", {
   delta: realDelta });
  if (customEvent.stopped) event.stop();
}

Wrapping it up

One last step. In the interest of stuffing the ugliness into a black box (and avoiding intrusion upon the global namespace), we should wrap all this code inside an anonymous function.

(function() {
  function wheel(event) {
    var realDelta;

    // normalize the delta
    if (event.wheelDelta) // IE & Opera
      realDelta = event.wheelDelta / 120;
    else if (event.detail) // W3C
      realDelta = -event.detail / 3;

    if (!realDelta) return;

    var customEvent = event.element().fire("mouse:wheel", {
     delta: realDelta });
    if (customEvent.stopped) event.stop();
  }

 document.observe("mousewheel",     wheel);
 document.observe("DOMMouseScroll", wheel);
})();

Now the wheel function is hidden from the global scope, but it can still be triggered as the result of an event.

Again, this isn’t perfect, but it’s a thorough solution —and a vast improvement over dealing with mouse wheel events manually. The test page demonstrates the flexibility. We can, for instance, set a listener to respond to mouse wheel events only within a certain element. Or instead we can set a document-wide listener and wait until the custom event bubbles up.

Feel free to grab the code we just wrote. For more on custom events, be sure to read kangax’s post on using custom events to detect an idle state.

Bonus points

Now that you get the general idea, your homework is to write code that simulates mouseenter and mouseleave events in the same manner. Blog comments are not the ideal place to paste code, so if you pick up the gauntlet, I’d suggest you post to your own blog (or use Pastie) and post a link in the comments.

Update: Upon kangax’s suggestion, I’ve changed the code to use event.element() instead of event.target.

  1. The preferred naming convention for custom events is noun:verbed (e.g., address:changed or dom:loaded). I’ve gone against the convention here in order to have the custom event names resemble as closely as possible the real events they shadow.
  2. Safari 3 (I don’t have previous versions on hand to check) behaves weirdly with mouse wheel events. Small movements of the wheel don’t fire a mousewheel event, but they do scroll the page, so if the user scrolls the wheel very slowly there’s nothing you can do to prevent the page from scrolling in the general case. I observed this phenomenon with both a trackpad (with two-finger scrolling) and with an external mouse. In both cases, Firefox and Opera behaved themselves, so I’m certain it’s not a hardware issue.

Comments

  1. Great post, Andrew!

    I think we can go even further and use event.element() instead of that ternary target normalization.

    best, kangax

  2. @kangax: Good point. I forgot that the normalized target property isn’t run through Element.extend, so IE was complaining. I’d hoped that Event.element would eventually be deprecated, but perhaps there’s use for it after all.

  3. Pingback Nov 9th, 2007
    at 10:35 am
  4. Pingback Nov 9th, 2007
    at 11:54 am
  5. Hi

    Thanks to mention PWC :), just want to say that the new version of PWC included in prototype UI now use this new great Event model of prototype 1.6.

    Prototype rocks!

  6. superb!

  7. Pingback Nov 9th, 2007
    at 2:44 pm
  8. Pingback Nov 9th, 2007
    at 4:15 pm
  9. Pingback Nov 9th, 2007
    at 5:34 pm
  10. Nice, thanks for the read.

    I can see why this solution works nicely for mousewheel, as you can fall back on existing mousewheel and DOMMouseScroll events. But since there are no such events to fall back on for enter/leave, making Event.observe know how to handle them makes more sense to me then writing custom events that monitor the entire document. But maybe I’m just to lazy to do my homework.

    Discussed here

    Woil has written a patch to do just this: mouseenter & mouseleave for 1.6

    Adding mousewheel to this would give native support for all mouse events.

  11. @Staaky: The links you posted have the right idea.

    Your instincts are noble and true — you ought to be wary about monitoring the entire document. But here it’s not a bad strategy.

    The logic would be costly in IE, but we don’t have to do it for IE. Firefox, Safari, and Opera have faster DOM engines.

    For a further push in the right direction, take a look at the Prototype changelog over the week preceding the final release. One change was made as a direct result of the mouse:enter/mouse:leave exercise.

Painfully Obvious was built with WordPress, Prototype, Slicehost, and other accoutrements. Colophon →