// 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