Jump To …
Home | View this file on Github

pointy.js

/*!
 * Pointy.js
 * Pointer Events polyfill for jQuery
 * https://github.com/vistaprint/PointyJS
 *
 * Depends on jQuery, see http://jquery.org
 *
 * Developed by Vistaprint.com
 */

(function ($, window, document, undefined) {

    var support = {
        touch: "ontouchend" in document,
        pointer: !! (navigator.pointerEnabled || navigator.msPointerEnabled)
    };

    $.extend($.support, support);

    function triggerCustomEvent(elem, eventType, originalEvent) {

support for IE7-IE8

        originalEvent = originalEvent || window.event;


Create a writable copy of the event object and normalize some properties

        var event = new jQuery.Event(originalEvent);
        event.type = eventType;


Copy over properties for ease of access

        var i, copy = $.event.props.concat($.event.pointerHooks.props);
        i = copy.length;
        while (i--) {
            var prop = copy[i];
            event[prop] = originalEvent[prop];
        }


Support: IE<9 Fix target property (#1925)

        if (!event.target) {
            event.target = originalEvent.srcElement || document;
        }


Support: Chrome 23+, Safari? Target should not be a text node (#504, #13143)

        if (event.target.nodeType === 3) {
            event.target = event.target.parentNode;
        }


Support: IE<9 For mouse/key events, metaKey==false if it's undefined (#3368, #11328)

        event.metaKey = !! event.metaKey;


run the filter now

        event = $.event.pointerHooks.filter(event, originalEvent);


trigger the emulated pointer event the filter can return an array (only if the original was a touchmove), which means we need to trigger independent events

        if ($.isArray(event)) {
            $.each(event, function (i, ev) {
                $.event.dispatch.call(elem, ev);
            });
        } else {
            $.event.dispatch.call(elem, event);
        }


return the manipulated jQuery event

        return event;
    }

    function addEvent(elem, type, selector, func) {

when we have a selector, let jQuery do the delegation

        if (selector) {
            func._pointerEventWrapper = function (event) {
                return func.call(elem, event.originalEvent);
            };

            $(elem).on(type, selector, func._pointerEventWrapper);
        }


if we do not have a selector, we optimize by cutting jQuery out

        else {
            if (elem.addEventListener) {
                elem.addEventListener(type, func, false);
            } else if (elem.attachEvent) {


bind the function to correct "this" for IE8-

                func._pointerEventWrapper = function (e) {
                    return func.call(elem, e);
                };

                elem.attachEvent("on" + type, func._pointerEventWrapper);
            }
        }
    }

    function removeEvent(elem, type, selector, func) {

Make sure for IE8- we unbind the wrapper

        if (func._pointerEventWrapper) {
            func = func._pointerEventWrapper;
        }

        if (selector) {
            $(elem).off(type, selector, func);
        } else {
            $.removeEvent(elem, type, func);
        }
    }


get the standardized "buttons" property as per the Pointer Events spec from a mouse event

    function getStandardizedButtonsProperty(event) {

in the DOM LEVEL 3 spec there is a new standard for the "buttons" property sadly, no browser currently supports this and only sends us the single "button" property

        if (event.buttons) {
            return event.buttons;
        }


standardize "which" property for use

        var which = event.which;
        if (!which && event.button !== undefined) {
            which = (event.button & 1 ? 1 : (event.button & 2 ? 3 : (event.button & 4 ? 2 : 0)));
        }


no button down (can happen on mousemove)

        if (which === 0) {
            return 0;
        }

left button

        else if (which === 1) {
            return 1;
        }

middle mouse

        else if (which === 2) {
            return 4;
        }

right mouse

        else if (which === 3) {
            return 2;
        }


unknown?

        return 0;
    }

    function returnFalse() { return false; }
    function returnTrue() { return true; }

    var POINTER_TYPE_UNAVAILABLE = "unavailable";
    var POINTER_TYPE_TOUCH = "touch";
    var POINTER_TYPE_PEN = "pen";
    var POINTER_TYPE_MOUSE = "mouse";


indicator to mark whether touch events are in progress when null, it means we have never received a touch event when number, user is currently touching something when false, user just released their finger (reset on mousedown if needed)

    var _touching = null;


bitmask to identify which buttons are currently down

    var _buttons = 0;


storage of the last seen touches provided by the native touch events spec

    var _lastTouches = [];


------ NOTE: THIS IS UNUSED, WE DO NOT ASSIGN BUTTON ------ pointer events defines the "button" property as: mouse move (no buttons down) -1 left mouse, touch contact and normal pen contact 0 middle mouse 1 right mouse, pen with barrel button pressed 2 x1 (back button on mouse) 3 x2 (forward button on mouse) 4 pen contact with eraser button pressed 5 ------ NOTE: THIS IS UNUSED, WE DO NOT ASSIGN BUTTON ------



pointer events defines the "buttons" property as: mouse move (no buttons down) 0 left mouse, touch contact, and normal pen contact 1 middle mouse 4 right mouse, pen contact with barrel button pressed 2 x1 (back) mouse 8 x2 (forward) mouse 16 pen contact with eraser button pressed 32



add our own pointer event hook/filter

    $.event.pointerHooks = {
        props: "pointerType pointerId pressure buttons clientX clientY relatedTarget fromElement offsetX offsetY pageX pageY screenX screenY width height toElement".split(" "),
        filter: function (event, original) {

Calculate pageX/Y if missing and clientX/Y available this is just copied from jQuery's standard pageX/pageY fix

            if (!original.touches && event.pageX == null && original.clientX != null) {
                var eventDoc = event.target.ownerDocument || document;
                var doc = eventDoc.documentElement;
                var body = eventDoc.body;

                event.pageX = original.clientX + (doc && doc.scrollLeft || body && body.scrollLeft || 0) - (doc && doc.clientLeft || body && body.clientLeft || 0);
                event.pageY = original.clientY + (doc && doc.scrollTop || body && body.scrollTop || 0) - (doc && doc.clientTop || body && body.clientTop || 0);
            }


Add relatedTarget, if necessary also copied from jQuery's standard event fix

            if (!event.relatedTarget && original.fromElement) {
                event.relatedTarget = original.fromElement === event.target ? original.toElement : original.fromElement;
            }


Add pointerType

            if (!event.pointerType || typeof event.pointerType === "number") {
                if (event.pointerType == 2) {
                    event.pointerType = POINTER_TYPE_TOUCH;
                } else if (event.pointerType == 3) {
                    event.pointerType = POINTER_TYPE_PEN;
                } else if (event.pointerType == 4) {
                    event.pointerType = POINTER_TYPE_MOUSE;
                } else if (/^touch/i.test(original.type)) {
                    event.pointerType = POINTER_TYPE_TOUCH;
                    event.buttons = original.type === "touchend" || original.type === "touchcancel" ? 0 : 1;
                } else if (/^mouse/i.test(original.type) || original.type === "click") {
                    event.pointerId = 1; // as per the pointer events spec, the mouse is always pointer id 1
                    event.pointerType = POINTER_TYPE_MOUSE;
                    event.buttons = original.type === "mouseup" ? 0 : getStandardizedButtonsProperty(original);
                } else {
                    event.pointerType = POINTER_TYPE_UNAVAILABLE;
                    event.buttons = 0;
                }
            }


if we have the bitmask for the depressed buttons from the mouse events polyfill, use it to mimic buttons for browsers that do not support the HTML DOM LEVEL 3 events spec

            if (event.type !== "pointerdown" && event.pointerType === "mouse" && _touching === null && _buttons !== event.buttons) {
                event.buttons = _buttons;
            }


standardize the pressure attribute

            if (!event.pressure) {
                event.pressure = event.buttons > 0 ? 0.5 : 0;
            }


standardize the width and height, these represent the contact geometry

            if (event.width === undefined || event.height === undefined) {
                event.width = event.height = 0;
            }


prevent the following native "click" event from occurring, can be used to prevent clicks on "pointerdown" or "pointerup" or from gestures like "press" and "presshold"

            event.preventClick = function () {
                event.isClickPrevented = returnTrue;
                $(event.target).one("click", returnFalse);
            };

            event.isClickPrevented = returnFalse;


touch events send an array of touches we need to convert to the pointer events format which means we need to fire multiple events per touch

            if (original.touches && event.type !== "pointercancel") {
                var touches = original.touches;
                var events = [];
                var ev, i, j;


the problem with this is that on "touchend" it will remove the touch which has ended from the touches list, this means we do not want to fire "pointerup" for touches that are still there, we instead want to send a "pointerup" with the removed touch's identifier

                if (event.type === "pointerup") {

convert TouchList to a standard array

                    _lastTouches = Array.prototype.slice.call(_lastTouches);


find the touch that was removed

                    for (i = 0; i < original.touches.length; i++) {
                        for (j = 0; j < _lastTouches.length; j++) {
                            if (_lastTouches[j].identifier === original.touches[i].identifier) {
                                _lastTouches.splice(j, 1);
                            }
                        }
                    }


if we narrowed down the ended touch to one, then we found it

                    if (_lastTouches.length === 1) {
                        event.pointerId = _lastTouches[0].identifier;
                        _lastTouches = original.touches;
                        return event;
                    }
                }

on "pointerdown" we need to only trigger a new "pointerdown" for the new touches (fingers), and not the touches that were already active. Therefore we filter out all of the touches based on their identifier that we already knew were active before

                else if (event.type === "pointerdown") {

convert TouchList to a standard array

                    touches = Array.prototype.slice.call(original.touches);


find the new touch that was just added

                    for (i = 0; i < touches.length; i++) {

last touches will be a list with one less touch

                        for (j = 0; j < _lastTouches.length; j++) {
                            if (touches[i].identifier === _lastTouches[j].identifier) {
                                touches.splice(i, 1);
                            }
                        }
                    }
                }


this will be used on pointermove and pointerdown

                for (i = 0; i < original.touches.length; i++) {
                    var touch = original.touches[i];
                    ev = $.extend({}, event);

copy over information from the touch to the event

                    ev.clientX = touch.clientX;
                    ev.clientY = touch.clientY;
                    ev.pageX = touch.pageX;
                    ev.pageY = touch.pageY;
                    ev.screenX = touch.screenX;
                    ev.screenY = touch.screenY;

the touch id on emulated touch events from chrome is always 0 (zero)

                    ev.pointerId = touch.identifier;
                    events.push(ev);
                }


do as little processing as you can here, this is done on touchmove and there can be a lot of those events firing quickly, we do not want the polyfill slowing down the application

                _lastTouches = original.touches;
                return events;
            }

            return event;
        }
    };

    $.event.delegateSpecial = function (setup) {
        return function (handleObj) {
            var thisObject = this,
                data = jQuery._data(thisObject);

            if (!data.pointerEvents) {
                data.pointerEvents = {};
            }

            if (!data.pointerEvents[handleObj.type]) {
                data.pointerEvents[handleObj.type] = [];
            }

            if (!data.pointerEvents[handleObj.type].length) {
                setup.call(thisObject, handleObj);
            }

            data.pointerEvents[handleObj.type].push(handleObj);
        };
    };

    var indexOfArray = function (arr, obj) {
        if (arr.indexOf) {
            return arr.indexOf(obj);
        }

        for (var i=0; i<arr.length; i++) {
            if (arr[i] === obj) {
                return i;
            }
        }

        return -1;
    };

    $.event.delegateSpecial.remove = function (teardown) {
        return function (handleObj) {
            var handlers,
                thisObject = this,
                data = jQuery._data(thisObject);

            if (!data.pointerEvents) {
                data.pointerEvents = {};
            }

            handlers = data.pointerEvents[handleObj.type];

            handlers.splice(indexOfArray(handlers, handleObj), 1);

            if (!handlers.length) {
                teardown.call(thisObject, handleObj);
            }
        };
    };


allow jQuery's native $.event.fix to find our pointer hooks

    $.extend($.event.fixHooks, {
        pointerdown: $.event.pointerHooks,
        pointerup: $.event.pointerHooks,
        pointermove: $.event.pointerHooks,
        pointerover: $.event.pointerHooks,
        pointerout: $.event.pointerHooks,
        pointercancel: $.event.pointerHooks
    });


if browser does not natively handle pointer events, create special custom events to mimic them

    if (!support.pointer) {

stores the scroll-y offest on "touchstart" and is compared on touchend to see if we should trigger a click event

        var _startScrollOffset;


utility to return the scroll-y position

        var scrollY = function () {
            return Math.floor(window.scrollY || $(window).scrollTop());
        };

        $.event.special.pointerdown = {
            touch: function (event) {

set the pointer as currently down to prevent chorded "pointerdown" events

                _touching = event.timeStamp;


trigger a new "pointerdown" event

                triggerCustomEvent(this, "pointerdown", event);


set the scroll offset which is compared on TouchEnd

                _startScrollOffset = scrollY();


no matter what, you cannot simply always call preventDefault() on "touchstart" this disables scrolling when touching the binded element, which is not appropriate.

            },
            mouse: function (event) {

if we just had a "touchstart", ignore this "mousedown" event, to prevent double firing of "pointerdown"

                if (typeof _touching === "number") {
                    return;
                }


we reset the touch to null, to indicate that we're listening to mouse events currently

                _touching = null;


update the _buttons bitmask

                var button = getStandardizedButtonsProperty(event);
                var wasAButtonDownAlready = _buttons !== 0;
                _buttons |= button;


do not trigger another "pointerdown" event if a button is currently down, this prevents chorded "pointerdown" events which is defined in the Pointer Events spec

                if (wasAButtonDownAlready && _buttons !== button) {

per the Pointer Events spec, when the active buttons change it fires only a "pointermove" event

                    triggerCustomEvent(this, "pointermove", event);
                    return;
                }

                triggerCustomEvent(this, "pointerdown", event);
            },
            add: $.event.delegateSpecial(function (handleObj) {

bind to touch events, some devices (chromebook) can send both touch and mouse events

                if (support.touch) {
                    addEvent(this, "touchstart", handleObj.selector, $.event.special.pointerdown.touch);
                }


bind to mouse events

                addEvent(this, "mousedown", handleObj.selector, $.event.special.pointerdown.mouse);


ensure we also bind to "pointerup" to properly clear signals and fire click event on "touchend"

                handleObj.pointerup = $.noop;
                $(this).on("pointerup", handleObj.selector, handleObj.pointerup);
            }),
            remove: $.event.delegateSpecial.remove(function (handleObj) {

unbind touch events

                if (support.touch) {
                    removeEvent(this, "touchstart", handleObj.selector, $.event.special.pointerdown.touch);
                }


unbind mouse events

                removeEvent(this, "mousedown", handleObj.selector, $.event.special.pointerdown.mouse);


unbind the special "pointerup" we added for cleanup

                if (handleObj.pointerup) {
                    $(this).off("pointerup", handleObj.selector, handleObj.pointerup);
                }
            })
        };

        $.event.special.pointerup = {
            touch: function (event) {

prevent default to prevent the emulated "mouseup" event from being triggered

                event.preventDefault();


safety check, if _touching is null then we just had a mouse event and shouldn't listen to touch events right now

                if (_touching === null) {
                    return;
                }


timeStamp of when the last "touchstart" event was triggered, used to prevent double fire issues on iOS 7+ devices when needed

                var lastTouchStarted = _touching;


trigger the emulated "pointerup" event, getting back the event

                var jEvent = triggerCustomEvent(this, "pointerup", event);


ensure we have a target before we attempt to deal with a "click" event

                if (!event.target) {
                    _touching = false; // release the "pointerdown" lock
                    return;
                }


while you may think if we are preventing the next "click" event (see jEvent.isClickPrevented) we could simply not trigger one below, that turns out to be a bad assumption. Due to the complex issue of dealing with various devices, often a click event is triggered regardless of calling preventDefault on "touchend" so we have to still go on and determine if a "click" event was triggered, if so, render it noop, if not, we don't have to trigger one then.



we confirm that the user did not scroll, as touch events are very related to scrolling on touch devices, it's possible we may mis-fire a click event on an anchor tag causing navigation even though this was a scroll attempt. We let the browser's built in scrolling threshold prevent accidental scrolling so we only need to check if they scrolled at all.

                if (_startScrollOffset !== scrollY()) {
                    _touching = false; // release the "pointerdown" lock
                    return;
                }


on "touchend", calling prevent default prevents the "mouseup" and "click" event however on native "mouseup" events preventing default does not cancel the "click" event as per the pointer event spec on "pointerup" preventing default should not cancel the "click" event

so we really want to have a "click" event all the time. If a function binded to this emulated "poiunterup" calls prevent default it would result in preventing the "click" event, which would cause inconsistent behavior. To prevent the possibility of two click events though, we call prevent default all the time (see above) and then trigger a "click" event here.

Additionally, we are currently looking at the "touchend" event, mobile devices usually add a 300ms delay before triggering click to check for double tap events (zooming action on most devices). In certain situations the mobile device will still fire a "click" event even if preventDefault is called on "touchend", so we wait the 300ms and look for a click, then only fire a click if none was fired by the browser

                var clickTimer = setTimeout(function () {
                    if (_touching === lastTouchStarted) {
                        _touching = false; // release the "pointerdown" lock
                    }


at this point, if no click event was triggered, and we don't want to to trigger a "click" event, we can simply not trigger one to begin with.

                    if (jEvent.isClickPrevented()) {
                        $(event.target).off("click", returnFalse);
                        return;
                    }

                    if (event.target.click) {
                        event.target.click();
                    } else {

iOS 5 and older Android browsers do not define native .click events on all elements

                        $(event.target).click();
                    }
                }, 300);

                $(event.target).one("click", function () {
                    if (_touching === lastTouchStarted) {
                        _touching = false;
                    }

                    if (clickTimer) {
                        clearTimeout(clickTimer);
                    }
                });
            },
            mouse: function (event) {

if originally we had a "touchstart" or we ended with a "touchend" event, ignore this "mouseup"

                if (_touching !== null) {
                    return;
                }


on mouseup, the event.button will be the button that was just released

                _buttons ^= getStandardizedButtonsProperty(event);


we only trigger a "pointerup" event if no buttons are down, prevent chorded PointerDown events

                if (_buttons === 0) {
                    triggerCustomEvent(this, "pointerup", event);
                } else {

per the Pointer Events spec, when the active buttons change it fires only a PointerMove event

                    triggerCustomEvent(this, "pointermove", event);
                }


Mouse Events spec shows that after a "mouseup" it fires a "mousemove", which will trigger the "pointermove" needed to follow the Pointer Events spec which describes the same thing

            },
            add: $.event.delegateSpecial(function (handleObj) {

bind to touch events, some devices (chromebook) can send both touch and mouse events

                if (support.touch) {
                    addEvent(this, "touchend", handleObj.selector, $.event.special.pointerup.touch);
                }


bind mouse events

                addEvent(this, "mouseup", handleObj.selector, $.event.special.pointerup.mouse);
            }),
            remove: $.event.delegateSpecial.remove(function (handleObj) {

unbind touch events

                if (support.touch) {
                    removeEvent(this, "touchend", handleObj.selector, $.event.special.pointerup.touch);
                }


unbind mouse events

                removeEvent(this, "mouseup", handleObj.selector, $.event.special.pointerup.mouse);
            })
        };

        $.event.special.pointermove = {
            touch: function (event) {
                triggerCustomEvent(this, "pointermove", event);
            },
            mouse: function (event) {

_touching will be a number if they currently have their finger (touch only) down because we cannot call preventDefault on the "touchmove" without preventing scrolling on most things, we do this check to ensure we don't double fire move events.

                if (typeof _touching === "number") {
                    return;
                }

                triggerCustomEvent(this, "pointermove", event);
            },
            add: $.event.delegateSpecial(function (handleObj) {

bind to touch events, some devices (chromebook) can send both touch and mouse events

                if (support.touch) {
                    addEvent(this, "touchmove", handleObj.selector, $.event.special.pointermove.touch);
                }


bind mouse events

                addEvent(this, "mousemove", handleObj.selector, $.event.special.pointermove.mouse);
            }),
            remove: $.event.delegateSpecial.remove(function (handleObj) {

unbind touch events

                if (support.touch) {
                    removeEvent(this, "touchmove", handleObj.selector, $.event.special.pointermove.touch);
                }


unbind mouse events

                removeEvent(this, "mousemove", handleObj.selector, $.event.special.pointermove.mouse);
            })
        };

        jQuery.each({
            pointerover: {
                mouse: "mouseover"
            },
            pointerout: {
                mouse: "mouseout"
            },
            pointercancel: {
                touch: "touchcancel"
            }
        }, function (pointerEventType, natives) {
            function onTouch(event) {

pointercancel, reset everything

                if (event.type === "touchcancel") {
                    _touching = null;
                    _buttons = 0;
                }

                triggerCustomEvent(this, pointerEventType, event);
            }

            function onMouse(event) {
                triggerCustomEvent(this, pointerEventType, event);
            }

            $.event.special[pointerEventType] = {
                setup: function () {
                    if (support.touch && natives.touch) {
                        addEvent(this, natives.touch, null, onTouch);
                    }
                    if (natives.mouse) {
                        addEvent(this, natives.mouse, null, onMouse);
                    }
                },
                teardown: function () {
                    if (support.touch && natives.touch) {
                        removeEvent(this, natives.touch, null, onTouch);
                    }
                    if (natives.mouse) {
                        removeEvent(this, natives.mouse, null, onMouse);
                    }
                }
            };
        });
    }


for IE10 specific, we proxy though events so we do not need to deal with the various names or renaming of events.

    else if (navigator.msPointerEnabled && !navigator.pointerEnabled) {
        $.extend($.event.special, {
            pointerdown: {
                delegateType: "MSPointerDown",
                bindType: "MSPointerDown"
            },
            pointerup: {
                delegateType: "MSPointerUp",
                bindType: "MSPointerUp"
            },
            pointermove: {
                delegateType: "MSPointerMove",
                bindType: "MSPointerMove"
            },
            pointerover: {
                delegateType: "MSPointerOver",
                bindType: "MSPointerOver"
            },
            pointerout: {
                delegateType: "MSPointerOut",
                bindType: "MSPointerOut"
            },
            pointercancel: {
                delegateType: "MSPointerCancel",
                bindType: "MSPointerCancel"
            }
        });

        $.extend($.event.fixHooks, {
            MSPointerDown: $.event.pointerHooks,
            MSPointerUp: $.event.pointerHooks,
            MSPointerMove: $.event.pointerHooks,
            MSPointerOver: $.event.pointerHooks,
            MSPointerOut: $.event.pointerHooks,
            MSPointerCancel: $.event.pointerHooks
        });
    }


add support for pointerenter and pointerlave pointerenter and pointerleave were added in IE11, they do not exist in IE10

    if (!support.pointer || (navigator.msPointerEnabled && !navigator.pointerEnabled)) {

Create a wrapper similar to jQuery's mouseenter/leave events using pointer events (pointerover/out) and event-time checks

        jQuery.each({
            pointerenter: navigator.msPointerEnabled ? "MSPointerOver" : "mouseover",
            pointerleave: navigator.msPointerEnabled ? "MSPointerOut" : "mouseout"
        }, function (orig, fix) {
            jQuery.event.special[orig] = {
                delegateType: fix,
                bindType: fix,
                handle: function (event) {
                    var ret,
                        target = this,
                        related = event.relatedTarget,
                        handleObj = event.handleObj;


For mousenter/leave call the handler if related is outside the target. NB: No relatedTarget if the mouse left/entered the browser window

                    if (!related || (related !== target && !jQuery.contains(target, related))) {
                        event.type = handleObj.origType;
                        ret = handleObj.handler.apply(this, arguments);
                        event.type = fix;
                    }
                    return ret;
                }
            };
        });
    }

})(jQuery, window, document);