// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* @fileoverview The file contains the base class for input devices such as
* the keyboard, mouse, and touchscreen.
*/
goog.provide('bot.Device');
goog.provide('bot.Device.EventEmitter');
goog.require('bot');
goog.require('bot.Error');
goog.require('bot.ErrorCode');
goog.require('bot.dom');
goog.require('bot.events');
goog.require('bot.locators');
goog.require('bot.userAgent');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.userAgent');
goog.require('goog.userAgent.product');
/**
* A Device class that provides common functionality for input devices.
* @param {bot.Device.ModifiersState=} opt_modifiersState state of modifier
* keys. The state is shared, not copied from this parameter.
* @param {bot.Device.EventEmitter=} opt_eventEmitter An object that should be
* used to fire events.
* @constructor
*/
bot.Device = function (opt_modifiersState, opt_eventEmitter) {
/**
* Element being interacted with.
* @private {!Element}
*/
this.element_ = bot.getDocument().documentElement;
/**
* If the element is an option, this is its parent select element.
* @private {Element}
*/
this.select_ = null;
// If there is an active element, make that the current element instead.
var activeElement = bot.dom.getActiveElement(this.element_);
if (activeElement) {
this.setElement(activeElement);
}
/**
* State of modifier keys for this device.
* @protected {bot.Device.ModifiersState}
*/
this.modifiersState = opt_modifiersState || new bot.Device.ModifiersState();
/** @protected {!bot.Device.EventEmitter} */
this.eventEmitter = opt_eventEmitter || new bot.Device.EventEmitter();
};
/**
* Returns the element with which the device is interacting.
*
* @return {!Element} Element being interacted with.
* @protected
*/
bot.Device.prototype.getElement = function () {
return this.element_;
};
/**
* Sets the element with which the device is interacting.
*
* @param {!Element} element Element being interacted with.
* @protected
*/
bot.Device.prototype.setElement = function (element) {
this.element_ = element;
if (bot.dom.isElement(element, goog.dom.TagName.OPTION)) {
this.select_ = /** @type {Element} */ (goog.dom.getAncestor(element,
function (node) {
return bot.dom.isElement(node, goog.dom.TagName.SELECT);
}));
} else {
this.select_ = null;
}
};
/**
* Fires an HTML event given the state of the device.
*
* @param {!bot.events.EventFactory_} type HTML Event type.
* @return {boolean} Whether the event fired successfully; false if cancelled.
* @protected
*/
bot.Device.prototype.fireHtmlEvent = function (type) {
return this.eventEmitter.fireHtmlEvent(this.element_, type);
};
/**
* Fires a keyboard event given the state of the device and the given arguments.
* TODO: Populate the modifier keys in this method.
*
* @param {!bot.events.EventFactory_} type Keyboard event type.
* @param {bot.events.KeyboardArgs} args Keyboard event arguments.
* @return {boolean} Whether the event fired successfully; false if cancelled.
* @protected
*/
bot.Device.prototype.fireKeyboardEvent = function (type, args) {
return this.eventEmitter.fireKeyboardEvent(this.element_, type, args);
};
/**
* Fires a mouse event given the state of the device and the given arguments.
* TODO: Populate the modifier keys in this method.
*
* @param {!bot.events.EventFactory_} type Mouse event type.
* @param {!goog.math.Coordinate} coord The coordinate where event will fire.
* @param {number} button The mouse button value for the event.
* @param {Element=} opt_related The related element of this event.
* @param {?number=} opt_wheelDelta The wheel delta value for the event.
* @param {boolean=} opt_force Whether the event should be fired even if the
* element is not interactable, such as the case of a mousemove or
* mouseover event that immediately follows a mouseout.
* @param {?number=} opt_pointerId The pointerId associated with the event.
* @param {?number=} opt_count Number of clicks that have been performed.
* @return {boolean} Whether the event fired successfully; false if cancelled.
* @protected
*/
bot.Device.prototype.fireMouseEvent = function (type, coord, button,
opt_related, opt_wheelDelta, opt_force, opt_pointerId, opt_count) {
if (!opt_force && !bot.dom.isInteractable(this.element_)) {
return false;
}
if (opt_related &&
!(bot.events.EventType.MOUSEOVER == type ||
bot.events.EventType.MOUSEOUT == type)) {
throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
'Event type does not allow related target: ' + type);
}
var args = {
clientX: coord.x,
clientY: coord.y,
button: button,
altKey: this.modifiersState.isAltPressed(),
ctrlKey: this.modifiersState.isControlPressed(),
shiftKey: this.modifiersState.isShiftPressed(),
metaKey: this.modifiersState.isMetaPressed(),
wheelDelta: opt_wheelDelta || 0,
relatedTarget: opt_related || null,
count: opt_count || 1
};
var pointerId = opt_pointerId || bot.Device.MOUSE_MS_POINTER_ID;
var target = this.element_;
// On click and mousedown events, captured pointers are ignored and the
// event always fires on the original element.
if (type != bot.events.EventType.CLICK &&
type != bot.events.EventType.MOUSEDOWN &&
pointerId in bot.Device.pointerElementMap_) {
target = bot.Device.pointerElementMap_[pointerId];
} else if (this.select_) {
target = this.getTargetOfOptionMouseEvent_(type);
}
return target ? this.eventEmitter.fireMouseEvent(target, type, args) : true;
};
/**
* Fires a touch event given the state of the device and the given arguments.
*
* @param {!bot.events.EventFactory_} type Event type.
* @param {number} id The touch identifier.
* @param {!goog.math.Coordinate} coord The coordinate where event will fire.
* @param {number=} opt_id2 The touch identifier of the second finger.
* @param {!goog.math.Coordinate=} opt_coord2 The coordinate of the second
* finger, if any.
* @return {boolean} Whether the event fired successfully or was cancelled.
* @protected
*/
bot.Device.prototype.fireTouchEvent = function (type, id, coord, opt_id2,
opt_coord2) {
var args = {
touches: [],
targetTouches: [],
changedTouches: [],
altKey: this.modifiersState.isAltPressed(),
ctrlKey: this.modifiersState.isControlPressed(),
shiftKey: this.modifiersState.isShiftPressed(),
metaKey: this.modifiersState.isMetaPressed(),
relatedTarget: null,
scale: 0,
rotation: 0
};
var pageOffset = goog.dom.getDomHelper(this.element_).getDocumentScroll();
function addTouch(identifier, coords) {
// Android devices leave identifier to zero.
var touch = {
identifier: identifier,
screenX: coords.x,
screenY: coords.y,
clientX: coords.x,
clientY: coords.y,
pageX: coords.x + pageOffset.x,
pageY: coords.y + pageOffset.y
};
args.changedTouches.push(touch);
if (type == bot.events.EventType.TOUCHSTART ||
type == bot.events.EventType.TOUCHMOVE) {
args.touches.push(touch);
args.targetTouches.push(touch);
}
}
addTouch(id, coord);
if (opt_id2 !== undefined) {
addTouch(opt_id2, opt_coord2);
}
return this.eventEmitter.fireTouchEvent(this.element_, type, args);
};
/**
* Fires a MSPointer event given the state of the device and the given
* arguments.
*
* @param {!bot.events.EventFactory_} type MSPointer event type.
* @param {!goog.math.Coordinate} coord The coordinate where event will fire.
* @param {number} button The mouse button value for the event.
* @param {number} pointerId The pointer id for this event.
* @param {number} device The device type used for this event.
* @param {boolean} isPrimary Whether the pointer represents the primary point
* of contact.
* @param {Element=} opt_related The related element of this event.
* @param {boolean=} opt_force Whether the event should be fired even if the
* element is not interactable, such as the case of a mousemove or
* mouseover event that immediately follows a mouseout.
* @return {boolean} Whether the event fired successfully; false if cancelled.
* @protected
*/
bot.Device.prototype.fireMSPointerEvent = function (type, coord, button,
pointerId, device, isPrimary, opt_related, opt_force) {
if (!opt_force && !bot.dom.isInteractable(this.element_)) {
return false;
}
if (opt_related &&
!(bot.events.EventType.MSPOINTEROVER == type ||
bot.events.EventType.MSPOINTEROUT == type)) {
throw new bot.Error(bot.ErrorCode.INVALID_ELEMENT_STATE,
'Event type does not allow related target: ' + type);
}
var args = {
clientX: coord.x,
clientY: coord.y,
button: button,
altKey: false,
ctrlKey: false,
shiftKey: false,
metaKey: false,
relatedTarget: opt_related || null,
width: 0,
height: 0,
pressure: 0, // Pressure is only given when a stylus is used.
rotation: 0,
pointerId: pointerId,
tiltX: 0,
tiltY: 0,
pointerType: device,
isPrimary: isPrimary
};
var target = this.select_ ?
this.getTargetOfOptionMouseEvent_(type) : this.element_;
if (bot.Device.pointerElementMap_[pointerId]) {
target = bot.Device.pointerElementMap_[pointerId];
}
var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(this.element_));
var originalMsSetPointerCapture;
if (owner && type == bot.events.EventType.MSPOINTERDOWN) {
// Overwrite msSetPointerCapture on the Element's msSetPointerCapture
// because synthetic pointer events cause an access denied exception.
// The prototype is modified because the pointer event will bubble up and
// we do not know which element will handle the pointer event.
originalMsSetPointerCapture =
owner['Element'].prototype.msSetPointerCapture;
owner['Element'].prototype.msSetPointerCapture = function (id) {
bot.Device.pointerElementMap_[id] = this;
};
}
var result =
target ? this.eventEmitter.fireMSPointerEvent(target, type, args) : true;
if (originalMsSetPointerCapture) {
owner['Element'].prototype.msSetPointerCapture =
originalMsSetPointerCapture;
}
return result;
};
/**
* A mouse event fired "on" an option element, doesn't always fire on the
* option element itself. Sometimes it fires on the parent select element
* and sometimes not at all, depending on the browser and event type. This
* returns the true target element of the event, or null if none is fired.
*
* @param {!bot.events.EventFactory_} type Type of event.
* @return {Element} Element the event should be fired on, null if none.
* @private
*/
bot.Device.prototype.getTargetOfOptionMouseEvent_ = function (type) {
// IE either fires the event on the parent select or not at all.
if (goog.userAgent.IE) {
switch (type) {
case bot.events.EventType.MOUSEOVER:
case bot.events.EventType.MSPOINTEROVER:
return null;
case bot.events.EventType.CONTEXTMENU:
case bot.events.EventType.MOUSEMOVE:
case bot.events.EventType.MSPOINTERMOVE:
return this.select_.multiple ? this.select_ : null;
default:
return this.select_;
}
}
// WebKit always fires on the option element of multi-selects.
// On single-selects, it either fires on the parent or not at all.
if (goog.userAgent.WEBKIT) {
switch (type) {
case bot.events.EventType.CLICK:
case bot.events.EventType.MOUSEUP:
return this.select_.multiple ? this.element_ : this.select_;
default:
return this.select_.multiple ? this.element_ : null;
}
}
// Firefox fires every event or the option element.
return this.element_;
};
/**
* A helper function to fire click events. This method is shared between
* the mouse and touchscreen devices.
*
* @param {!goog.math.Coordinate} coord The coordinate where event will fire.
* @param {number} button The mouse button value for the event.
* @param {boolean=} opt_force Whether the click should occur even if the
* element is not interactable, such as when an element is hidden by a
* mouseup handler.
* @param {?number=} opt_pointerId The pointer id associated with the click.
* @protected
*/
bot.Device.prototype.clickElement = function (coord, button, opt_force,
opt_pointerId) {
if (!opt_force && !bot.dom.isInteractable(this.element_)) {
return;
}
// bot.events.fire(element, 'click') can trigger all onclick events, but may
// not follow links (FORM.action or A.href).
// TAG IE GECKO WebKit
// A(href) No No Yes
// FORM(action) No Yes Yes
var targetLink = null;
var targetButton = null;
if (!bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_) {
for (var e = this.element_; e; e = e.parentNode) {
if (bot.dom.isElement(e, goog.dom.TagName.A)) {
targetLink = /**@type {!Element}*/ (e);
break;
} else if (bot.Device.isFormSubmitElement(e)) {
targetButton = e;
break;
}
}
}
// When an element is toggled as the result of a click, the toggling and the
// change event happens before the click event on some browsers. However, on
// radio buttons and checkboxes, the click handler can prevent the toggle from
// happening, so we must fire the click first to see if it is cancelled.
var isRadioOrCheckbox = !this.select_ && bot.dom.isSelectable(this.element_);
var wasChecked = isRadioOrCheckbox && bot.dom.isSelected(this.element_);
// NOTE: Clicking on a form submit button is a little broken:
// (1) When clicking a form submit button in IE, firing a click event or
// calling Form.submit() will not by itself submit the form, so we call
// Element.click() explicitly, but as a result, the coordinates of the click
// event are not provided. Also, when clicking on an , the
// coordinates click that are submitted with the form are always (0, 0).
// (2) When clicking a form submit button in GECKO, while the coordinates of
// the click event are correct, those submitted with the form are always (0,0)
// .
// TODO: See if either of these can be resolved, perhaps by adding
// hidden form elements with the coordinates before the form is submitted.
if (goog.userAgent.IE && targetButton) {
targetButton.click();
return;
}
var performDefault = this.fireMouseEvent(
bot.events.EventType.CLICK, coord, button, null, 0, opt_force,
opt_pointerId);
if (!performDefault) {
return;
}
if (targetLink && bot.Device.shouldFollowHref_(targetLink)) {
bot.Device.followHref_(targetLink);
} else if (isRadioOrCheckbox) {
this.toggleRadioButtonOrCheckbox_(wasChecked);
}
};
/**
* Focuses on the given element and returns true if it supports being focused
* and does not already have focus; otherwise, returns false. If another element
* has focus, that element will be blurred before focusing on the given element.
*
* @return {boolean} Whether the element was given focus.
* @protected
*/
bot.Device.prototype.focusOnElement = function () {
var elementToFocus = goog.dom.getAncestor(
this.element_,
function (node) {
return !!node && bot.dom.isElement(node) &&
bot.dom.isFocusable(/** @type {!Element} */(node));
},
true /* Return this.element_ if it is focusable. */);
elementToFocus = elementToFocus || this.element_;
var activeElement = bot.dom.getActiveElement(elementToFocus);
if (elementToFocus == activeElement) {
return false;
}
// If there is a currently active element, try to blur it.
if (activeElement && (typeof activeElement.blur === 'function' ||
// IE reports native functions as being objects.
goog.userAgent.IE && (typeof activeElement.blur === 'object' && activeElement.blur !== null))) {
// In IE, the focus() and blur() functions fire their respective events
// asynchronously, and as the result, the focus/blur events fired by the
// the atoms actions will often be in the wrong order on IE. Firing a blur
// out of order sometimes causes IE to throw an "Unspecified error", so we
// wrap it in a try-catch and catch and ignore the error in this case.
if (!bot.dom.isElement(activeElement, goog.dom.TagName.BODY)) {
try {
activeElement.blur();
} catch (e) {
if (!(goog.userAgent.IE && e.message == 'Unspecified error.')) {
throw e;
}
}
}
// Sometimes IE6 and IE7 will not fire an onblur event after blur()
// is called, unless window.focus() is called immediately afterward.
// Note that IE8 will hit this branch unless the page is forced into
// IE8-strict mode. This shouldn't hurt anything, we just use the
// useragent sniff so we can compile this out for proper browsers.
if (goog.userAgent.IE && !bot.userAgent.isEngineVersion(8)) {
goog.dom.getWindow(goog.dom.getOwnerDocument(elementToFocus)).focus();
}
}
// Try to focus on the element.
if (typeof elementToFocus.focus === 'function' ||
goog.userAgent.IE && (typeof elementToFocus.focus === 'object' && elementToFocus.focus !== null)) {
/** @type {function()} */ (elementToFocus.focus).call(elementToFocus);
return true;
}
return false;
};
/**
* Whether links must be manually followed when clicking (because firing click
* events doesn't follow them).
* @private {boolean}
* @const
*/
bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_ = goog.userAgent.WEBKIT;
/**
* @param {Node} element The element to check.
* @return {boolean} Whether the element is a submit element in form.
* @protected
*/
bot.Device.isFormSubmitElement = function (element) {
if (bot.dom.isElement(element, goog.dom.TagName.INPUT)) {
var type = element.type.toLowerCase();
if (type == 'submit' || type == 'image') {
return true;
}
}
if (bot.dom.isElement(element, goog.dom.TagName.BUTTON)) {
var type = element.type.toLowerCase();
if (type == 'submit') {
return true;
}
}
return false;
};
/**
* Indicates whether we should manually follow the href of the element we're
* clicking.
*
* Versions of firefox from 4+ will handle links properly when this is used in
* an extension. Versions of Firefox prior to this may or may not do the right
* thing depending on whether a target window is opened and whether the click
* has caused a change in just the hash part of the URL.
*
* @param {!Element} element The element to consider.
* @return {boolean} Whether following an href should be skipped.
* @private
*/
bot.Device.shouldFollowHref_ = function (element) {
if (bot.Device.ALWAYS_FOLLOWS_LINKS_ON_CLICK_ || !element.href) {
return false;
}
if (!(bot.userAgent.WEBEXTENSION)) {
return true;
}
if (element.target || element.href.toLowerCase().indexOf('javascript') == 0) {
return false;
}
var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(element));
var sourceUrl = owner.location.href;
var destinationUrl = bot.Device.resolveUrl_(owner.location, element.href);
var isOnlyHashChange =
sourceUrl.split('#')[0] === destinationUrl.split('#')[0];
return !isOnlyHashChange;
};
/**
* Explicitly follows the href of an anchor.
*
* @param {!Element} anchorElement An anchor element.
* @private
*/
bot.Device.followHref_ = function (anchorElement) {
var targetHref = anchorElement.href;
var owner = goog.dom.getWindow(goog.dom.getOwnerDocument(anchorElement));
// IE7 and earlier incorrect resolve a relative href against the top window
// location instead of the window to which the href is assigned. As a result,
// we have to resolve the relative URL ourselves. We do not use Closure's
// goog.Uri to resolve, because it incorrectly fails to support empty but
// undefined query and fragment components and re-encodes the given url.
if (goog.userAgent.IE && !bot.userAgent.isEngineVersion(8)) {
targetHref = bot.Device.resolveUrl_(owner.location, targetHref);
}
if (anchorElement.target) {
owner.open(targetHref, anchorElement.target);
} else {
owner.location.href = targetHref;
}
};
/**
* Toggles the selected state of the current element if it is an option. This
* is a noop if the element is not an option, or if it is selected and belongs
* to a single-select, because it can't be toggled off.
*
* @protected
*/
bot.Device.prototype.maybeToggleOption = function () {
// If this is not an