Pseudo-custom events in Prototype 1.6

Posted in Articles, Prototype

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 than — mouseover 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. Pingback
  2. Pingback
  3. Pingback
  4. Pingback
  5. Pingback