Source: Input.js

import Radio from './Radio';
import keycodes from './lib/keycodes';

/**
 * @class       Input
 * @description A module for handling keyboard, mouse, and touch events on the canvas
 * @author      Chris Peters
 * @requires    Radio
 * @requires    lib/keycodes
 *
 * @param {HTMLEntity} canvas                   The canvas element to interact with
 * @param {Object}     [opts]
 * @param {Boolean}    [opts.canvasFit]         Set to true if using css to fit the canvas in the viewport
 * @param {Boolean}    [opts.listenForMouse]    Whether or not to listen for mouse events
 * @param {Boolean}    [opts.listenForTouch]    Whether or not to listen for touch events
 * @param {Boolean}    [opts.listenForKeyboard] Whether or not to listen for keyboard events
 * @param {Object}     [opts.window]            window object for testing
 * @param {Object}     [opts.document]          document object for testing
 */
export default class Input {
    constructor(canvas, opts = {}) {
        // options
        this._canvas = canvas;
        this._canvasFit = opts.canvasFit || true;
        this._listenForMouse = opts.listenForMouse || true;
        this._listenForTouch = opts.listenForTouch || false;
        this._listenForKeyboard = opts.listenForKeyboard || true;
        this._window = opts.window || window;
        this._document = opts.document || document;

        this._uiEvents = {
            DBL_CLICK: 'dblclick',
            DBL_TAP: 'dbltap',

            DRAG: 'drag',
            DRAG_END: 'dragend',
            DRAG_START: 'dragstart',

            CLICK: 'click',
            TAP: 'tap',

            MOUSE_DOWN: 'mousedown',
            MOUSE_UP: 'mouseup',
            TOUCH_START: 'touchstart',
            TOUCH_END: 'touchend',

            MOUSE_MOVE: 'mousemove',
            TOUCH_MOVE: 'touchmove',

            KEY_UP: 'keyup',
            KEY_DOWN: 'keydown'
        };

        // listeners values are arrays of objects containing handlers and (optional) targets
        // eg: this._listeners.keyup = [{
        //         handler: function () {...},
        //         target: { name: 'foo', x: 32, y: 64, ...}
        //     }];
        this._listeners = {};

        for (let key in this._uiEvents) {
            this._listeners[this._uiEvents[key]] = [];
        }

        this._keycodes = keycodes;
        this._canDrag = false;
        this._isDragging = false;
        this._keysDown = {};
        this._userHitTestMethod = null;
        this._queuedEvents = [];

        if (this._listenForKeyboard) {
            this._addKeyboardListeners();
        }

        if (this._listenForMouse) {
            this._addMouseListeners();
        }

        if (this._listenForTouch) {
            this._addTouchListeners();
        }

        this._onTick = this._onTick.bind(this);
        Radio.tuneIn(this._document, 'tick', this._onTick);
    }

    /**
     * Adds keyboard listeners
     *
     * @method Input#_addKeyboardListeners
     * @private
     */
    _addKeyboardListeners() {
        let events = ['keyup', 'keydown'];

        for (let event of events) {
            Radio.tuneIn(this._canvas, event, this._handleKeyboard.bind(this));
        }
    }

    /**
     * Adds mouse listeners
     *
     * @method Input#_addMouseListeners
     * @private
     */
    _addMouseListeners() {
        let events = ['click', 'dblclick', 'mousedown', 'mouseup', 'mousemove'];

        for (let event of events) {
            Radio.tuneIn(this._canvas, event, this._handleMouseAndTouch.bind(this));
        }
    }

    /**
     * Adds touch listeners
     *
     * @method Input#_addTouchListeners
     * @private
     */
    _addTouchListeners() {
        let events = ['tap', 'dbltap', 'touchstart', 'touchend', 'touchmove'];

        for (let event of events) {
            Radio.tuneIn(this._canvas, event, this._handleMouseAndTouch.bind(this));
        }
    }

    /**
     * get the scale ratio of the canvas based on with/heght attrs and css width/height
     *
     * @method Input#_getScaleFactor
     * @return {Float}
     */
    _getScaleFactor() {
        let factor = 1;
        let canvasWidth;

        if (this._canvas.style.width) {
            canvasWidth = parseInt(this._canvas.style.width, 10);
            factor = canvasWidth / this._canvas.width;
        }

        return 100 / factor / 100;
    }

    /**
     * Checks if point is inside rectangle
     *
     * @method Input#_hitTest
     * @param  {Integer} x          [description]
     * @param  {Integer} y          [description]
     * @param  {Object} boundingBox [description]
     * @return {Boolean}
     */
    _hitTest(x, y, boundingBox) {
        return x >= boundingBox.minX && x <= boundingBox.maxX &&
            y >= boundingBox.minY && y <= boundingBox.maxY;
    }

    /**
     * Handler for DOM events. Creates custom event object with helpful properties
     *
     * @method Input#_handleKeyboard
     * @param {object} inputEvent the DOM input event object
     * @private
     */
    _handleKeyboard(inputEvent) {
        inputEvent.preventDefault();

        let keyName = this._keycodes[inputEvent.keyCode];
        let event = {
            domEvent: inputEvent,
            type: inputEvent.type,
            keyCode: inputEvent.keyCode,
            keyName: typeof keyName === 'object' && keyName.length ?
                keyName[0] :
                keyName
        };

        switch (event.type) {
            case this._uiEvents.KEY_DOWN:
                this._keysDown[keyName] = inputEvent.keyCode;
                break;
            case this._uiEvents.KEY_UP:
                delete this._keysDown[keyName];
                break;
        }

        event.keysDown = this.getKeysDown();

        this._queuedEvents.push(event);
    }

    /**
     * Handler for DOM events. Creates custom event object with helpful properties
     * Creates event objects with x/y coordinates based on scaling and absX/absY for
     * absolute x/y regardless of scale offset
     * Only uses first touch event, thus not currently supporting multi-touch
     *
     * @method Input#
     * @param {object} inputEvent The DOM input event object
     */
    _handleMouseAndTouch(inputEvent) {
        inputEvent.preventDefault();

        let scaleFactor = this._canvasFit ? this._getScaleFactor() : 1;
        let event = {
            domEvent: inputEvent,
            type: inputEvent.type
        };

        this._queuedEvents.push(event);

        if (inputEvent.hasOwnProperty('touches')) {
            event.absX = inputEvent.touches[0].pageX - this._canvas.offsetLeft;
            event.absY = inputEvent.touches[0].pageY - this._canvas.offsetTop;
        } else {
            event.absX = inputEvent.pageX - this._canvas.offsetLeft;
            event.absY = inputEvent.pageY - this._canvas.offsetTop;
        }

        // coordinate positions relative to canvas scaling
        event.x = Math.round(event.absX * scaleFactor);
        event.y = Math.round(event.absY * scaleFactor);

        switch (event.type) {
            case this._uiEvents.MOUSE_DOWN:
            case this._uiEvents.TOUCH_START:

                this._canDrag = true;

                break;

            case this._uiEvents.MOUSE_UP:
            case this._uiEvents.TOUCH_END:

                this._canDrag = false;

                if (this._isDragging) {
                    this._isDragging = false;

                    this._queuedEvents.push(Object.assign({}, event, {
                        type: this._uiEvents.DRAG_END
                    }));
                }

                break;

            case this._uiEvents.MOUSE_MOVE:
            case this._uiEvents.TOUCH_MOVE:

                if (this._canDrag) {
                    if (!this._isDragging) {
                        this._isDragging = true;

                        this._queuedEvents.push(Object.assign({}, event, {
                            type: this._uiEvents.DRAG_START
                        }));
                    }

                    this._queuedEvents.push(Object.assign({}, event, {
                        type: this._uiEvents.DRAG
                    }));
                }

                break;
        }
    }

    /**
     * Checks for duplicate handler in the listener tyoe being added
     *
     * @method Input#_isDuplicateHandler
     * @param  {Function} handler  The handler to check
     * @param  {Array}    handlers The handlers of the listener type being added
     * @return {Boolean}
     * @private
     */
    _isDuplicateHandler(handler, handlerObjects) {
        let dup = false;

        for (let handlerObject of handlerObjects) {
            if (handler === handlerObject.handler) {
                dup = true;
                break;
            }
        }

        return dup;
    }

    /**
     * Triggers all queued events. Passes the factor and ticks from {@link Ticker}
     *
     * @method Input#_onTick
     * @param  {Object} e The event object
     */
    _onTick(e) {
        for (let event of this._queuedEvents) {
            this._triggerHandlers(event);
        }

        this._queuedEvents = [];
    }

    /**
     * executes handlers of the given event's type
     *
     * @method Input#_triggerHandlers
     * @param {object} event
     * @private
     */
    _triggerHandlers(event) {
        for (let handlerObject of this._listeners[event.type]) {

            if (handlerObject.target) {
                let hitTest = this._userHitTestMethod || this._hitTest;

                if (hitTest(event.x, event.y,
                    handlerObject.target.getBoundingArea())) {

                    event.target = handlerObject.target;

                    // if event was bound with a target trigger handler ONLY if target hit
                    handlerObject.handler(event);
                }
            } else {
                handlerObject.handler(event);
            }
        }
    }

    /**
     * Adds a handler to a {@link Sprite} for the given event type
     *
     * @method Input#addListener
     * @param  {string}   type     The event type
     * @param  {function} handler  The function to execute when event triggered
     * @param  {object}   [target] The target to check event trigger against
     * @return {boolean}           Returns true if added and false if callback already exists
     */
    addListener(type, handler, target) {
        let handlerObjects = this._listeners[type];
        let dup;


        if (! handlerObjects) {
            throw new TypeError(`Event type "${type}" does not exist.`);
        }

        if (handlerObjects.length) {
            dup = this._isDuplicateHandler(handler, handlerObjects);
        }

        if (!dup) {
            handlerObjects.push({
                handler, target
            });
            return true;
        }

        return false;
    }

    /**
     * Removes matching handler if found
     *
     * @method Input#removeListener
     * @param  {string}   type    the event type
     * @param  {function} handler the handler to remove
     * @return {boolean}  removed Returns true if removed and otherwise false
     */
    removeListener(type, handler) {
        let handlers = this._listeners[type];
        let removed = false;

        if (! handlers) {
            throw new TypeError(`Event type "${type}" does not exist.`);
        }

        for (let i = 0, len = handlers.length; i < len; i++) {
            let handlerObject = handlers[i];
            if (handlerObject.handler === handler) {
                handlers.splice(i, 1);
                removed = true;
                break;
            }
        }

        return removed;
    }

    /**
     * returns an object of the keys currently being pressed
     * eg: <code>{ LEFT_ARROW: 37, UP_ARROW: 38 }</code>
     *
     * @method Input#getKeysDown
     * @return {Object}
     */
    getKeysDown() {
        return this._keysDown;
    }

    /**
     * Allows user to set a custom hit test method
     *
     * @method Input#setHitTestMethod
     * @param {Function} fn The user's hit test method
     */
    setHitTestMethod(fn) {
        if (typeof fn !== 'function') {
            throw new TypeError('Input#setHitTestMethod parameter must be a function');
        }

        this._userHitTestMethod = fn;
    }
}