Hand.js: a polyfill for supporting pointer events on every browser

How about being able to write a single code base for handling mouse, pen, touch in your web site that will work across all modern browsers?
Here is a polyfill that will help you use Pointer Events and offer users a great experience on your site independently of the modern browser they are using.

Back in September 2012, Microsoft proposed a specification to W3C for unifying touch, pen and mouse events called Pointer Events and based on the APIs available today in IE10 on Windows 8. The W3C has since created a new Working Group which already published a last call draft.The Microsoft Open Technologies, Inc. team has also released a Pointer Events initial prototype for Webkit on HTML5Labs to further contribute to the technical discussions in the W3C Pointer Events Working Group.    

My smart colleague David Rousset wrote an excellent article on this subject: https://blogs.msdn.com/b/davrous/archive/2013/02/20/handling-touch-in-your-html5-apps-thanks-to-the-pointer-events-of-ie10-and-windows-8.aspx.

As this specification is not final, modern browsers are not implementing it yet (only Internet Explorer 10 on Windows 8 implements it with vendor-prefixes MSPointerXXX). To help you ready your code for this upcoming standard, this article will show you how to write a polyfill that will allow you to have a single markup code that will work across all modern browsers even if they don’t support the Pointer Events specification yet.    

To illustrate how simple Hand.js is to use, I took the first sample in David Rousset’s article, I changed every MSPointerXXX to PointerXXX and I added a reference to “Hand.js” and voila! (just move your finger or your mouse on top of this iframe):


  

The test code

To test Hand.js I developed a simple HTML page (available here: https://www.catuhe.com/msdn/handjs/index.html)

<!DOCTYPE html>
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
    <title></title>
    <link href="index.css" rel="stylesheet" />
    <script src="hand.js"></script>
    <script src="index.js"></script>
</head>
<body>
    <div id="title">Hand.js: a framework for pointer events</div>
    <canvas id="plainCanvas"></canvas>
</body>
</html>

The following JavaScript is used to draw inside the canvas using normalized pointer events:

var context;
var plainCanvas;
var pointerDown = {};
var lastPositions = {};
var colors = ["rgb(100, 255, 100)", "rgb(255, 0, 0)", "rgb(0, 255, 0)", 
"rgb(0, 0, 255)", "rgb(0, 255, 100)", "rgb(10, 255, 255)", "rgb(255, 0, 100)"]; var onPointerMove = function(evt) { evt.preventDefault(); if (pointerDown[evt.pointerId]) { var color = colors[evt.pointerId % colors.length]; context.strokeStyle = color; context.beginPath(); context.lineWidth = 2; context.moveTo(lastPositions[evt.pointerId].x, lastPositions[evt.pointerId].y); context.lineTo(evt.clientX, evt.clientY); context.closePath(); context.stroke(); lastPositions[evt.pointerId] = { x: evt.clientX, y: evt.clientY}; } }; var onPointerOut = function (evt) { evt.preventDefault(); pointerDown[evt.pointerId] = false; }; var onPointerUp = function (evt) { evt.preventDefault(); pointerDown[evt.pointerId] = false; }; var onPointerDown = function (evt) { evt.preventDefault(); pointerDown[evt.pointerId] = true; lastPositions[evt.pointerId] = { x: evt.clientX, y: evt.clientY}; }; var onload = function() { plainCanvas = document.getElementById("plainCanvas"); plainCanvas.width = plainCanvas.clientWidth; plainCanvas.height = plainCanvas.clientHeight; context = plainCanvas.getContext("2d"); context.fillStyle = "rgba(50, 50, 50, 1)"; context.fillRect(0, 0, plainCanvas.width, plainCanvas.height); plainCanvas.addEventListener("PointerDown", onPointerDown, false); plainCanvas.addEventListener("PointerMove", onPointerMove, false); plainCanvas.addEventListener("PointerUp", onPointerUp, false); plainCanvas.addEventListener("PointerOut", onPointerUp, false); }; document.addEventListener("DOMContentLoaded", onload, false);

You will note that I’m using “PointerXXX” which is not supported right now on modern browsers, except for Internet Explorer 10 on Windows 8 that supports the prefixed version “_MSPointerXXX”.

_

The goal of Hand.js is to allow this code to work on every browser whether it supports touch or not, allowing for a seamless and unified experience independent of the input method utilized (touch, mouse or pen).

Rerouting addEventListener

The usage of Hand.js must be transparent for the developer. He just has to reference the library and let the magic works:

<script src="hand.js"></script>

The way it works in Hand.js is by intercepting calls to addEventListener to seamlessly inject our code:

 

var supportedEventsNames = ["PointerDown", "PointerUp", "PointerMove", "PointerOver", "PointerOut", 
"PointerCancel", "PointerEnter", "PointerLeave", "pointerdown", "pointerup", "pointermove", "pointerover", "pointerout",
"pointercancel", "pointerenter", "pointerleave"];
// Intercept addEventListener calls by changing the prototype
var interceptAddEventListener = function (root) {
    var current = root.prototype.addEventListener;

    var customAddEventListener = function (name, func, capture) {
       // Branch when a PointerXXX is used
       if (supportedEventsNames.indexOf(name) != -1) {
           makeTouchAware(this, name);
       }

       current.call(this, name, func, capture);
    };

    root.prototype.addEventListener = customAddEventListener;

    return (root.prototype.addEventListener != customAddEventListener);
};
interceptAddEventListener(HTMLBodyElement);
interceptAddEventListener(HTMLCanvasElement);
interceptAddEventListener(HTMLDivElement);
interceptAddEventListener(HTMLImageElement);
interceptAddEventListener(HTMLSpanElement);       

The interceptAddEventListener function calls a custom function (makeTouchAware) to generate pointer events on specific DOM objects when standardized pointer events are registered (PointerXXX).

We can do that by replacing the addEventListener function on base prototypes.

For almost all browsers, we just have to work on HTMLElement root object. But some of them do not support this way and forces us to work on specific DOM root element (body, canvas, div, image, span, etc…)

Supporting MSPointerXXX

The makeTouchAware function starts like this:

var makeTouchAware = function (item, eventName) {
    // If item is already touch aware, do nothing
    if (item.onpointerdown !== undefined) {
        return;
    }

    // IE 10
    if (item.onmspointerdown !== undefined) {
        var msEventName;

        if (eventName == eventName.toLowerCase()) {
            var indexOfUpperCase = supportedEventsNames.indexOf(eventName) - 
(supportedEventsNames.length / 2); msEventName =
"MS" + supportedEventsNames[indexOfUpperCase]; } else { msEventName = "MS" + eventName; } item.addEventListener(msEventName,
function (evt) { generateTouchClonedEvent(evt, eventName); }, false); // We can return because MSPointerXXX integrate mouse support return; }

If onmspointerdown is not undefined, then we can just register on prefixed event (MSPointerXXX) and call a function to raise a standardized event (PointerXXX) when the source event is fired (We will come back to the generateTouchClonedEvent later).

Supporting touchstart / touchmove / touchend / touchcancel

For browsers that not supported Pointer events but implement the Touch events specification, we simply have to map the events names by adding the following code:

// Chrome, Firefox
if (item.ontouchstart !== undefined) {
    switch (eventName.toLowerCase()) {
        case "pointerdown":
            item.addEventListener("touchstart", function (evt) { 
handleOtherEvent(evt, eventName); },
false); break; case "pointermove": item.addEventListener("touchmove", function (evt) {
handleOtherEvent(evt, eventName); },
false); break; case "pointerup": item.addEventListener("touchend", function (evt) {
handleOtherEvent(evt, eventName); },
false); break; case "pointercancel": item.addEventListener("touchcancel", function (evt) {
handleOtherEvent(evt, eventName); },
false); break; } }

Based on the existence of the ontouchstart function, we can use the following function to generate pointer events:

var handleOtherEvent = function (eventObject, name) {
    if (eventObject.preventManipulation)
        eventObject.preventManipulation();

    for (var i = 0; i < eventObject.changedTouches.length; ++i) {
        var touchPoint = eventObject.changedTouches[i];
        var touchPointId = touchPoint.identifier + 2; // Just to not override mouse id

        touchPoint.pointerId = touchPointId;
        touchPoint.pointerType = POINTER_TYPE_TOUCH; 
        touchPoint.currentTarget = eventObject.currentTarget;

        if (eventObject.preventDefault !== undefined) {
            touchPoint.preventDefault = function () {
                eventObject.preventDefault();
            };
        }

        generateTouchClonedEvent(touchPoint, name);
    }
};

handleOtherEvent mainly dispatches every changedTouch to a pointer event. Please note the added function named preventDefault in order to route call from pointer event to root touch event.

Integrating mouse events

The Pointer Event specification indicates that mouse events are also gathered inside the same model. Hand.js handles mouse events and generates corresponding Pointer events:

// Fallback to mouse
switch (eventName.toLowerCase()) {
    case "pointerdown":
     item.addEventListener("mousedown", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointermove":
     item.addEventListener("mousemove", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerup":
     item.addEventListener("mouseup", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerover":
     item.addEventListener("mouseover", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerout":
     item.addEventListener("mouseout", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerenter":
     item.addEventListener("mouseenter", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
    case "pointerleave":
     item.addEventListener("mouseleave", function (evt) { generateMouseProxy(evt, eventName); }, false);
     break;
}

The generateMouseProxy produces a pointer event based on the mouse event:

var generateMouseProxy = function (evt, eventName) {
    evt.pointerId = 1;
    evt.pointerType = POINTER_TYPE_MOUSE; //event.POINTER_TYPE_MOUSE
    generateTouchClonedEvent(evt, eventName);
};

Generating custom events

The last missing piece of code is the generateTouchClonedEvent function. This function is in charge of mimicking the pointer event structure:

var POINTER_TYPE_TOUCH = "touch";
var POINTER_TYPE_PEN = "pen";
var POINTER_TYPE_MOUSE = "mouse";
// Touch events
var generateTouchClonedEvent = function (sourceEvent, newName) {
    // Considering touch events are almost like super mouse events
    var evObj = document.createEvent('MouseEvents');
    evObj.initMouseEvent(newName, true, true, window, 1, sourceEvent.screenX, sourceEvent.screenY,
        sourceEvent.clientX, sourceEvent.clientY, sourceEvent.ctrlKey, sourceEvent.altKey,
        sourceEvent.shiftKey, sourceEvent.metaKey, sourceEvent.button, null);

    // offsets
    if (evObj.offsetX === undefined) {
        if (sourceEvent.offsetX !== undefined) {

            // For Opera which creates readonly properties
            if (Object && Object.defineProperty !== undefined) {
                Object.defineProperty(evObj, "offsetX", {
                    writable: true
                });
                Object.defineProperty(evObj, "offsetY", {
                    writable: true
                });
            }

            evObj.offsetX = sourceEvent.offsetX;
            evObj.offsetY = sourceEvent.offsetY;
        }
        else if (sourceEvent.layerX !== undefined) {
            evObj.offsetX = sourceEvent.layerX - sourceEvent.currentTarget.offsetLeft;
            evObj.offsetY = sourceEvent.layerY - sourceEvent.currentTarget.offsetTop;
        }
    }

    // adding missing properties
    evObj.pointerId = sourceEvent.pointerId;
    evObj.pointerType = sourceEvent.pointerType;

    if (sourceEvent.isPrimary !== undefined)
        evObj.isPrimary = sourceEvent.isPrimary;
    else
        evObj.isPrimary = true;

    if (sourceEvent.pressure)
        evObj.pressure = sourceEvent.pressure;
    else {
        var button = 0;

        if (sourceEvent.which !== undefined)
            button = sourceEvent.which;
        else if (sourceEvent.button !== undefined) {
            button = sourceEvent.button;
        }
        evObj.pressure = (button == 0) ? 0 : 0.5;
    }


    if (sourceEvent.rotation)
        evObj.rotation = sourceEvent.rotation;
    else
        evObj.rotation = 0;

    // Timestamp
    if (sourceEvent.hwTimestamp)
        evObj.hwTimestamp = sourceEvent.hwTimestamp;
    else
        evObj.hwTimestamp = 0;

    // Tilts
    if (sourceEvent.tiltX)
        evObj.tiltX = sourceEvent.tiltX;
    else
        evObj.tiltX = 0;

    if (sourceEvent.tiltY)
        evObj.tiltY = sourceEvent.tiltY;
    else
        evObj.tiltY = 0;

    // Width and Height
    if (sourceEvent.height)
        evObj.height = sourceEvent.height;
    else
        evObj.height = 0;

    if (sourceEvent.width)
        evObj.width = sourceEvent.width;
    else
        evObj.width = 0;

    // PreventDefault
    evObj.preventDefault = function () {
        if (sourceEvent.preventDefault !== undefined)
            sourceEvent.preventDefault();
    };

    // Constants
    evObj.POINTER_TYPE_TOUCH = POINTER_TYPE_TOUCH;
    evObj.POINTER_TYPE_PEN = POINTER_TYPE_PEN;
    evObj.POINTER_TYPE_MOUSE = POINTER_TYPE_MOUSE;

    // If force preventDefault
    if (sourceEvent.currentTarget.handjs_forcePreventDefault === true)
        evObj.preventDefault();

    // Fire event
    sourceEvent.target.dispatchEvent(evObj);
};

Using JavaScript custom events mechanism, we are able to dispatch the new event with dispatchEvent function. This code is fairly simple. It copies event properties from source to destination and creates default values if required.

Please note that for Opera we have to update offsetX and offsetY properties to make them writable and for Firefox, we have to compute offsetX and offsetY.

Cleaning things

To be complete we must also override the removeEventListener to remove the event handlers injected previously.

First of all, we have to hook the removeEventLister with our own function:

// Intercept removeEventListener calls by changing the prototype
var interceptRemoveEventListener = function (root) {
    var current = root.prototype.removeEventListener;

    var customRemoveEventListener = function (name, func, capture) {
        // Branch when a PointerXXX is used
        if (supportedEventsNames.indexOf(name) != –1){
            removeTouchAware(this, name);
        }

        current.call(this, name, func, capture);
    };

    root.prototype.removeEventListener = customRemoveEventListener;
};

// Hooks
interceptAddEventListener(HTMLBodyElement);
interceptAddEventListener(HTMLCanvasElement);
interceptAddEventListener(HTMLDivElement);
interceptAddEventListener(HTMLImageElement);
interceptAddEventListener(HTMLSpanElement);

interceptRemoveEventListener(HTMLBodyElement);
interceptRemoveEventListener(HTMLCanvasElement);
interceptRemoveEventListener(HTMLDivElement);
interceptRemoveEventListener(HTMLImageElement);
interceptRemoveEventListener(HTMLSpanElement);

The removeTouchAware function is similar to the makeTouchAware except that it removes the event handlers:

var removeTouchAware = function (item, eventName) {
    // If item is already touch aware, do nothing
    if (item.onpointerdown !== undefined) {
        return;
    }

    // IE 10
    if (item.onmspointerdown !== undefined) {
        var msEventName;

        if (eventName == eventName.toLowerCase()) {
            var indexOfUpperCase = supportedEventsNames.indexOf(eventName) - 
(supportedEventsNames.length / 2); msEventName =
"MS" + supportedEventsNames[indexOfUpperCase]; } else { msEventName = "MS" + eventName; } item.removeEventListener(msEventName, function (evt) {
generateTouchClonedEvent(evt, eventName); });
return; } // Chrome, Firefox if (item.ontouchstart !== undefined) { switch (eventName.toLowerCase()) { case "pointerdown": item.removeEventListener("touchstart", function (evt) { handleOtherEvent(evt, eventName); }); break; case "pointermove": item.removeEventListener("touchmove", function (evt) { handleOtherEvent(evt, eventName); }); break; case "pointerup": item.removeEventListener("touchend", function (evt) { handleOtherEvent(evt, eventName); }); break; case "pointercancel": item.removeEventListener("touchcancel", function (evt) { handleOtherEvent(evt, eventName); }); break; } } // Fallback to mouse switch (eventName.toLowerCase()) { case "pointerdown": item.removeEventListener("mousedown", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointermove": item.removeEventListener("mousemove", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointerup": item.removeEventListener("mouseup", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointerover": item.removeEventListener("mouseover", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointerout": item.removeEventListener("mouseout", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointerenter": item.removeEventListener("mouseenter", function (evt) { generateMouseProxy(evt, eventName); }); break; case "pointerleave": item.removeEventListener("mouseleave", function (evt) { generateMouseProxy(evt, eventName); }); break; } };

Extending Navigator object

According to the specification, the navigator object must provide two properties:

  • pointerEnabled to indicate if the browser wiil fire pointer events for point input. For Hand.js it will return true.
  • maxTouchPoints indicate the maximum number of simultaneous touch contacts supported by the device. Alas only Internet Explorer 10 supports this feature. For others browsers I was not able to find an API for this value.

 

For the sake of the specification, I added the following code to hand.js:

// Extension to navigator
if (navigator.pointerEnabled === undefined) {

    // Indicates if the browser will fire pointer events for pointing input
    navigator.pointerEnabled = true;

    // IE
    if (navigator.msPointerEnabled) {
        navigator.maxTouchPoints = navigator.msMaxTouchPoints;
    }
}

 

Integrating “touch-action” css rule

The touch-action CSS rule determines whether touch input may trigger default behavior supplied by user agent. This includes, but is not limited to, behaviors such as panning or zooming.

If you want to remove default behavior, you can use this rule like that (or obviously the vendor-specific version):

#plainCanvas {
    position:absolute;
    left:10%;
    top:10%;
    width:80%;
    height:80%;
    touch-action:none;
}

Hand.js will have to determine if the user defined this rule. To do so, we have to handle both inline and referenced styles. And we must take in account that browsers will remove unknown css rules (for instance the touch-action rule). So we cannot use th stylesheets property of the document (because it contains only valid rules).

To get unfiltered styles we must use this code which download the original css file or get the inner HTML for inline styles:

// Handling touch-action css rule
if (document.styleSheets) {
    document.addEventListener("DOMContentLoaded", function () {

        var trim = function (string) {
            return string.replace(/^s+|s+$/, '');
        }

// Looking for touch-action in referenced stylesheets for (var index = 0; index < document.styleSheets.length; index++) { var sheet = document.styleSheets[index]; if (sheet.href == undefined) { // it is an inline style continue; } // Loading the original stylesheet var xhr = new XMLHttpRequest(); xhr.open("get", sheet.href, false); xhr.send(); var unfilteredSheet = xhr.responseText.replace(/(n|r)/g, ""); processStylesheet(unfilteredSheet); } // Looking for touch-action in inline styles var styles = document.getElementsByTagName("style"); for (var index = 0; index < styles.length; index++) { var sheet = styles[index]; var unfilteredSheet = trim(sheet.innerHTML.replace(/(n|r)/g, "")); processStylesheet(unfilteredSheet); } }, false); }

The processStylesheet function uses regular expressions to determine if the “touch-action:none” string is present in the style:

var processStylesheet = function(unfilteredSheet) {
    var globalRegex = new RegExp(".+?{.*?}", "m");
    var selectorRegex = new RegExp(".+?{", "m");

    while (unfilteredSheet != "") {
        var block = globalRegex.exec(unfilteredSheet)[0];
        unfilteredSheet = trim(unfilteredSheet.replace(block, ""));
        var selectorText = trim(selectorRegex.exec(block)[0].replace("{", ""));

        // Checking if the user wanted to deactivate the default behavior
        if (block.replace(/s/g, "").indexOf("touch-action:none") != -1) {
            var elements = document.querySelectorAll(selectorText);

            for (var elementIndex = 0; elementIndex < elements.length; elementIndex++) {
                var element = elements[elementIndex];

                if (element.style.msTouchAction !== undefined) {
                    element.style.msTouchAction = "none";
                }
                else {
                    element.handjs_forcePreventDefault = true;
                }
            }
        }
    }
}

The handjs_forcePreventDefault is a boolean used by the generateTouchClonedEvent to define if the preventDefault must be forced:

// If force preventDefault
if (sourceEvent.currentTarget.handjs_forcePreventDefault === true)
    evObj.preventDefault();

Final code

Finally the complete code of Hand.js can be downloaded here: https://handjs.codeplex.com

Feel free to use this code for your own web pages in order to be able to handle touch and mouse in a unified way leveraging the Pointer Events model.

In order to further help the discussions in the W3C Pointer Events Working Group, we would love to hear from you on the specification itself. Do not hesitate to comment on this post and I will forward!