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;
}
}