Source: ARCore/touches.js

/**
 * Handles MultiTouch event, creating appropritate Touch and MultiTouch
 * objects and firing events on the DOM.
 *
 * @name Touches
 * @namespace
 * @private
 */
define(["lib/jquery",                "lib/underscore",        "common/util/log",
        "common/network/dispatcher", "common/models/touch",   "common/models/multi_touch",
        "common/util/dom_utils",     "common/util/multi_map", "common/network/tracer",
        "ARCore/coordinate",         "ARCore/embed"],
function($,                           _,                       Log,
         Dispatcher,                  Touch,                   MultiTouch,
         DOMUtils,                    MultiMap,                Tracer,
         Coordinate,                  Embed) {

    "use strict";

    var logger = Log("Touches");

    var touches = {
        // touch id -> array of touches (one for each element the touch affects)
        // the touches are in order of specificity; e.g. the first item is
        // the element touched and the last item is the document.
        byId: MultiMap(),

        // element -> array of touches that started on that element
        byEl: MultiMap(),

        // touch id -> whether it's eligible to become a click
        clicks: {}
    };

    // eventName -> MultiMap{element -> [handler...]}
    var capturePhaseHandlers = {
        "lens:touchstart": MultiMap(),
        "lens:touchmove":  MultiMap(),
        "lens:touchend":   MultiMap()
    };

    // multitouch id -> MultiTouch
    var multiTouches = {};

    // given a element -> [Touch...] MultiMap, dispatches the given event
    // on each element, using the Touch array as the changedTouches array
    //
    // Returns a deferred that is resolved with true if event propagation is
    // not stopped and false if it is.
    var dispatchEvents = function(map, eventName, bridgeProtocolName, invalidateClicks) {
        var deferred = $.Deferred();

        if(map.size() === 0) {
            deferred.resolve(true);
            return deferred;
        }

        var allTouches = MultiTouch(touches.byId.firsts(), document.body);

        // builds the properties for a touch event on el
        var evProps = function(changedTouches, el) {
            var targetTouches  = MultiTouch(touches.byEl.get(el), el);
            changedTouches = MultiTouch(changedTouches, el);

            return {
                touch:          changedTouches[0],
                touches:        allTouches,
                targetTouches:  targetTouches,
                changedTouches: changedTouches
            };
        };

        // stops the given touches from becoming clicks if invalidateClicks is true
        var stopClicks = function(touchesToStop) {
            if(invalidateClicks) {
                _.each(touchesToStop, function(touch) {
                    touches.clicks[touch.id] = false;
                });
            }
        };

        var fireCapturing = function() {
            return map.everyReverse(function(changedTouches, el) {
                var handlers = capturePhaseHandlers[eventName].get(el);
                if(handlers.length > 0) {
                    var props = evProps(changedTouches, el);
                    var evt = DOMUtils.buildEvent(eventName, false, props);

                    _.each(handlers, function(handler) {
                        handler.call(el, evt);
                    });

                    if(evt._propStopped) {
                        stopClicks(props.targetTouches);
                        return false;
                    }
                    else {
                        return true;
                    }
                }

                return true;
            });
        };


        var fireBubbling = function() {
            return map.every(function(changedTouches, el) {
                var props = evProps(changedTouches, el);
                var shouldContinue = DOMUtils.fire(eventName, el, props);

                if(!shouldContinue) {
                    stopClicks(props.targetTouches);
                }

                return shouldContinue;
            });
        };


        if(fireCapturing()) {
            // made it through the capturing phase
            var lowestPair = map.pairs()[0];
            var lowestElement = lowestPair[0];
            var touchesOnLowestElement = lowestPair[1];

            var embed = $(lowestElement).data("lens-embed");
            if(embed) {
                // it's an embedded lens; propagate the event across the iframe
                // boundary
                var offset = $(lowestElement).offset();

                var data = {};
                data[bridgeProtocolName] = touchesOnLowestElement.map(function(touch) {
                    return {
                        x: touch.pageX - offset.left,
                        y: touch.pageY - offset.top,
                        w: touch.w,
                        h: touch.h,
                        id: touch.id
                    };
                });

                embed.dispatcher.callRPC("lens:touchevent", data, function(shouldContinue) {
                    if(shouldContinue) {
                        deferred.resolve(fireBubbling());
                    }
                    else {
                        stopClicks();
                        deferred.resolve(false);
                    }
                });
            }
            else {
                deferred.resolve(fireBubbling());
            }
        }
        else {
            // didn't make it though capturing phase
            deferred.resolve(false);
        }

        return deferred;
    };

    // Handles incoming touch data.
    //
    // Returns a deferred that is resolved with true if event propagation is
    // not stopped and false if it is.
    var handleIncomingMessage = function(data, traceId) {
        var deferred = $.Deferred();

        // element -> fingers down on that element
        var touchstartEvents = MultiMap();

        // handle data.down: register new touches and populate touchstartEvents
        _.each(data.down, function(touchData) {
            logger.info("New touch:", touchData.id,
                        "at (" + touchData.x + "," + touchData.y + ")");

            var els = DOMUtils.elementsAt(touchData.x, touchData.y);
            MultiMap.updateOrder(els);

            // register a Touch for each element
            _.each(els, function(el) {
                var touch =  Touch(touchData.id, touchData.x, touchData.y,
                                   touchData.w, touchData.h, el, els[0]);

                touches.byId.put(touchData.id, touch);
                touches.byEl.put(el, touch);
                touchstartEvents.put(el, touch);
            });

            // add it to the map of touches eligable to be clicks
            touches.clicks[touchData.id] = true;
        });


        // element -> fingers up on that element
        var touchendEvents = MultiMap();

        // handle data.up: deregister touches and populate touchendEvents
        _.each(data.up, function(touchData) {
            logger.info("Touch ended:", touchData.id);

            _.each(touches.byId.get(touchData.id), function(touch) {
                // add to touchendEvents
                touchendEvents.put(touch.el, touch);

                // remove from touches.byEl and touches.byId
                touches.byEl.removeVal(touch.el, touch);

                // inform end subscribers for the touch
                touch._markDone();
            });

            // the the touch is eligible to become a click, fire a click event
            if(touches.clicks[touchData.id]) {
                // find the lowest element the touch is still in
                var clickTarget = _.find(touches.byId.get(touchData.id), function() {
                    return true; //touch.isInEl(); TODO: check if the touch is in the element
                });

                logger.info("clicking ", clickTarget);
                if(clickTarget) {
                    $(clickTarget.el).click();
                }
            }
            delete touches.clicks[touchData.id];
            touches.byId.delete(touchData.id);

        });

        // element -> fingers moved on that element
        var touchmoveEvents = MultiMap();

        // handled data.move: update touches and populate touchmoveEvents
        _.each(data.move, function(touchData) {
            _.each(touches.byId.get(touchData.id), function(touch) {
                // add to touchmoveEvents
                touchmoveEvents.put(touch.el, touch);

                // update touch, informing move subscribers
                touch._update(touchData.x, touchData.y);
            });
        });

        // handle multitouches
        var endedTouches = _.pluck(data.up,   "id");
        var movedTouches = _.pluck(data.move, "id");

        _.each(multiTouches, function(multiTouch) {
            // check if any of the touches have ended; if so, the multitouch has
            // ended.
            var hasEnded = _.any(multiTouch, function(touch) {
                return (endedTouches.indexOf(touch.id) !== -1);
            });

            if(hasEnded) {
                logger.info("MultiTouch Ended:", multiTouch);
                multiTouch._markDone();
            }
            else {
                // check if any of the touches have moved; if so, the multitouch
                // has moved.
                var hasMoved = _.any(multiTouch, function(touch) {
                    return (movedTouches.indexOf(touch.id) !== -1);
                });

                if(hasMoved) {
                    multiTouch._update();
                }
            }
        });

        Tracer.post("Constructed Touch objects", traceId);

        // fire events
        $.when(dispatchEvents(touchstartEvents, "lens:touchstart", "down", true),
               dispatchEvents(touchendEvents,   "lens:touchend",   "up"),
               dispatchEvents(touchmoveEvents,  "lens:touchmove",  "move"))
         .then(function(down, up, move) {
            deferred.resolve(down && up && move);
        });

        Tracer.post("Fired events", traceId);

        return deferred;
    };

    if(Embed.parentDispatcher) {
        // in a child lens, we listen to the parent for touch events
        Embed.parentDispatcher.registerAsyncRPC("lens:touchevent", function(data, callback) {
            handleIncomingMessage(data).done(function(result) {
                callback(result);
            });
        });
    }
    else {
        // in a top-level lens, we listen to the bridge for touch events
        Dispatcher._bridge.subscribe("touch", function(data, traceId) {
            Tracer.post("Touches got event", traceId);
            handleIncomingMessage(data);
        });
    }

    var Touches = /** @lends Touches */ {
        /**
         * Registers a MultiTouch for updates. By default, MultiTouches aren't
         * updated. However, once they have subscribers, the should call this
         * method to start receiving updates.
         */
        registerMultiTouch: function(multiTouch) {
            logger.info("Tracking MultiTouch:", multiTouch);
            multiTouches[multiTouch.id] = multiTouch;
        },

        /**
         * Stops a multitouch from getting updates.
         */
        deregisterMultiTouch: function(multiTouch) {
            delete multiTouches[multiTouch.id];
        }
    };

    // override Element#addEventListener to properly handling capture phase
    // handlers
    Element.prototype.addEventListener = _.wrap(Element.prototype.addEventListener, function(_super, name, handler, useCapture) {

        if(useCapture && (capturePhaseHandlers.hasOwnProperty(name))) {
            capturePhaseHandlers[name].put(this, handler);
        }
        else {
            _super.call(this, name, handler, useCapture);
        }

    });

    return Touches;
});