// 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 an abstraction of a mouse for * simulating the mouse actions. */ goog.provide('bot.Mouse'); goog.provide('bot.Mouse.Button'); goog.provide('bot.Mouse.State'); goog.require('bot'); goog.require('bot.Device'); goog.require('bot.Error'); goog.require('bot.ErrorCode'); goog.require('bot.dom'); goog.require('bot.events.EventType'); goog.require('bot.userAgent'); goog.require('goog.dom'); goog.require('goog.dom.TagName'); goog.require('goog.math.Coordinate'); goog.require('goog.userAgent'); /** * A mouse that provides atomic mouse actions. This mouse currently only * supports having one button pressed at a time. * @param {bot.Mouse.State=} opt_state The mouse's initial state. * @param {bot.Device.ModifiersState=} opt_modifiersState State of the keyboard. * @param {bot.Device.EventEmitter=} opt_eventEmitter An object that should be * used to fire events. * @constructor * @extends {bot.Device} */ bot.Mouse = function (opt_state, opt_modifiersState, opt_eventEmitter) { goog.base(this, opt_modifiersState, opt_eventEmitter); /** @private {?bot.Mouse.Button} */ this.buttonPressed_ = null; /** @private {Element} */ this.elementPressed_ = null; /** @private {!goog.math.Coordinate} */ this.clientXY_ = new goog.math.Coordinate(0, 0); /** @private {boolean} */ this.nextClickIsDoubleClick_ = false; /** * Whether this Mouse has ever explicitly interacted with any element. * @private {boolean} */ this.hasEverInteracted_ = false; if (opt_state) { if (goog.isNumber(opt_state['buttonPressed'])) { this.buttonPressed_ = opt_state['buttonPressed']; } try { if (bot.dom.isElement(opt_state['elementPressed'])) { this.elementPressed_ = opt_state['elementPressed']; } } catch (ignored) { this.buttonPressed_ = null; } this.clientXY_ = new goog.math.Coordinate( opt_state['clientXY']['x'], opt_state['clientXY']['y']); this.nextClickIsDoubleClick_ = !!opt_state['nextClickIsDoubleClick']; this.hasEverInteracted_ = !!opt_state['hasEverInteracted']; try { if (opt_state['element'] && bot.dom.isElement(opt_state['element'])) { this.setElement(/** @type {!Element} */(opt_state['element'])); } } catch (ignored) { this.buttonPressed_ = null; } } }; goog.inherits(bot.Mouse, bot.Device); /** * Describes the state of the mouse. This type should be treated as a * dictionary with all properties accessed using array notation to * ensure properties are not renamed by the compiler. * @typedef {{buttonPressed: ?bot.Mouse.Button, * elementPressed: Element, * clientXY: {x: number, y: number}, * nextClickIsDoubleClick: boolean, * hasEverInteracted: boolean, * element: Element}} */ bot.Mouse.State; /** * Enumeration of mouse buttons that can be pressed. * * @enum {number} */ bot.Mouse.Button = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; /** * Index to indicate no button pressed in bot.Mouse.MOUSE_BUTTON_VALUE_MAP_. * @private {number} * @const */ bot.Mouse.NO_BUTTON_VALUE_INDEX_ = 3; /** * Maps mouse events to an array of button argument value for each mouse button. * The array is indexed by the bot.Mouse.Button values. It encodes this table, * where each cell contains the (left/middle/right/none) button values. *
 *               click/    mouseup/   mouseout/  mousemove  contextmenu
 *               dblclick  mousedown  mouseover
 * IE_DOC_PRE9   0 0 0 X   1 4 2 X    0 0 0 0    1 4 2 0    X X 0 X
 * WEBKIT/IE9    0 1 2 X   0 1 2 X    0 1 2 0    0 1 2 0    X X 2 X
 * GECKO         0 1 2 X   0 1 2 X    0 0 0 0    0 0 0 0    X X 2 X
 * 
* @private {!Object.>} * @const */ bot.Mouse.MOUSE_BUTTON_VALUE_MAP_ = (function () { // EventTypes can safely be used as keys without collisions in a JS Object, // because its toString method returns a unique string (the event type name). var buttonValueMap = {}; if (bot.userAgent.IE_DOC_PRE9) { buttonValueMap[bot.events.EventType.CLICK] = [0, 0, 0, null]; buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 0, null]; buttonValueMap[bot.events.EventType.MOUSEUP] = [1, 4, 2, null]; buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 0, 0, 0]; buttonValueMap[bot.events.EventType.MOUSEMOVE] = [1, 4, 2, 0]; } else if (goog.userAgent.WEBKIT || bot.userAgent.IE_DOC_9) { buttonValueMap[bot.events.EventType.CLICK] = [0, 1, 2, null]; buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 2, null]; buttonValueMap[bot.events.EventType.MOUSEUP] = [0, 1, 2, null]; buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 1, 2, 0]; buttonValueMap[bot.events.EventType.MOUSEMOVE] = [0, 1, 2, 0]; } else { buttonValueMap[bot.events.EventType.CLICK] = [0, 1, 2, null]; buttonValueMap[bot.events.EventType.CONTEXTMENU] = [null, null, 2, null]; buttonValueMap[bot.events.EventType.MOUSEUP] = [0, 1, 2, null]; buttonValueMap[bot.events.EventType.MOUSEOUT] = [0, 0, 0, 0]; buttonValueMap[bot.events.EventType.MOUSEMOVE] = [0, 0, 0, 0]; } if (bot.userAgent.IE_DOC_10) { buttonValueMap[bot.events.EventType.MSPOINTERDOWN] = buttonValueMap[bot.events.EventType.MOUSEUP]; buttonValueMap[bot.events.EventType.MSPOINTERUP] = buttonValueMap[bot.events.EventType.MOUSEUP]; buttonValueMap[bot.events.EventType.MSPOINTERMOVE] = [-1, -1, -1, -1]; buttonValueMap[bot.events.EventType.MSPOINTEROUT] = buttonValueMap[bot.events.EventType.MSPOINTERMOVE]; buttonValueMap[bot.events.EventType.MSPOINTEROVER] = buttonValueMap[bot.events.EventType.MSPOINTERMOVE]; } buttonValueMap[bot.events.EventType.DBLCLICK] = buttonValueMap[bot.events.EventType.CLICK]; buttonValueMap[bot.events.EventType.MOUSEDOWN] = buttonValueMap[bot.events.EventType.MOUSEUP]; buttonValueMap[bot.events.EventType.MOUSEOVER] = buttonValueMap[bot.events.EventType.MOUSEOUT]; return buttonValueMap; })(); /** * Maps mouse events to corresponding MSPointer event. * @private {!Object.} */ bot.Mouse.MOUSE_EVENT_MAP_ = (function () { var map = {}; map[bot.events.EventType.MOUSEDOWN] = bot.events.EventType.MSPOINTERDOWN; map[bot.events.EventType.MOUSEMOVE] = bot.events.EventType.MSPOINTERMOVE; map[bot.events.EventType.MOUSEOUT] = bot.events.EventType.MSPOINTEROUT; map[bot.events.EventType.MOUSEOVER] = bot.events.EventType.MSPOINTEROVER; map[bot.events.EventType.MOUSEUP] = bot.events.EventType.MSPOINTERUP; return map; })(); /** * Attempts to fire a mousedown event and then returns whether or not the * element should receive focus as a result of the mousedown. * * @param {?number=} opt_count Number of clicks that have been performed. * @return {boolean} Whether to focus on the element after the mousedown. * @private */ bot.Mouse.prototype.fireMousedown_ = function (opt_count) { // On some browsers, a mouse down event on an OPTION or SELECT element cause // the SELECT to open, blocking further JS execution. This is undesirable, // and so needs to be detected. We always focus in this case. // TODO: This is a nasty way to avoid locking the browser var isFirefox3 = goog.userAgent.GECKO && !bot.userAgent.isProductVersion(4); var blocksOnMousedown = (goog.userAgent.WEBKIT || isFirefox3) && (bot.dom.isElement(this.getElement(), goog.dom.TagName.OPTION) || bot.dom.isElement(this.getElement(), goog.dom.TagName.SELECT)); if (blocksOnMousedown) { return true; } // On some browsers, if the mousedown event handler makes a focus() call to // change the active element, this preempts the focus that would happen by // default on the mousedown, so we should not explicitly focus in this case. var beforeActiveElement; var mousedownCanPreemptFocus = goog.userAgent.GECKO || goog.userAgent.IE; if (mousedownCanPreemptFocus) { beforeActiveElement = bot.dom.getActiveElement(this.getElement()); } var performFocus = this.fireMouseEvent_(bot.events.EventType.MOUSEDOWN, null, null, false, opt_count); if (performFocus && mousedownCanPreemptFocus && beforeActiveElement != bot.dom.getActiveElement(this.getElement())) { return false; } return performFocus; }; /** * Press a mouse button on an element that the mouse is interacting with. * * @param {!bot.Mouse.Button} button Button. * @param {?number=} opt_count Number of clicks that have been performed. */ bot.Mouse.prototype.pressButton = function (button, opt_count) { if (!goog.isNull(this.buttonPressed_)) { throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Cannot press more than one button or an already pressed button.'); } this.buttonPressed_ = button; this.elementPressed_ = this.getElement(); var performFocus = this.fireMousedown_(opt_count); if (performFocus) { if (bot.userAgent.IE_DOC_10 && this.buttonPressed_ == bot.Mouse.Button.LEFT && bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION)) { this.fireMSPointerEvent(bot.events.EventType.MSGOTPOINTERCAPTURE, this.clientXY_, 0, bot.Device.MOUSE_MS_POINTER_ID, MSPointerEvent.MSPOINTER_TYPE_MOUSE, true); } this.focusOnElement(); } }; /** * Releases the pressed mouse button. Throws exception if no button pressed. * * @param {boolean=} opt_force Whether the event should be fired even if the * element is not interactable. * @param {?number=} opt_count Number of clicks that have been performed. */ bot.Mouse.prototype.releaseButton = function (opt_force, opt_count) { if (goog.isNull(this.buttonPressed_)) { throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Cannot release a button when no button is pressed.'); } this.maybeToggleOption(); // If a mouseup event is dispatched to an interactable event, and that mouseup // would complete a click, then the click event must be dispatched even if the // element becomes non-interactable after the mouseup. var elementInteractableBeforeMouseup = bot.dom.isInteractable(this.getElement()); this.fireMouseEvent_(bot.events.EventType.MOUSEUP, null, null, opt_force, opt_count); try { // https://github.com/SeleniumHQ/selenium/issues/1509 // TODO: Middle button can also trigger click. if (this.buttonPressed_ == bot.Mouse.Button.LEFT && this.getElement() == this.elementPressed_) { if (!(bot.userAgent.WINDOWS_PHONE && bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION))) { this.clickElement(this.clientXY_, this.getButtonValue_(bot.events.EventType.CLICK), /* opt_force */ elementInteractableBeforeMouseup); } this.maybeDoubleClickElement_(); if (bot.userAgent.IE_DOC_10 && this.buttonPressed_ == bot.Mouse.Button.LEFT && bot.dom.isElement(this.elementPressed_, goog.dom.TagName.OPTION)) { this.fireMSPointerEvent(bot.events.EventType.MSLOSTPOINTERCAPTURE, new goog.math.Coordinate(0, 0), 0, bot.Device.MOUSE_MS_POINTER_ID, MSPointerEvent.MSPOINTER_TYPE_MOUSE, false); } // TODO: In Linux, this fires after mousedown event. } else if (this.buttonPressed_ == bot.Mouse.Button.RIGHT) { this.fireMouseEvent_(bot.events.EventType.CONTEXTMENU); } } catch (ignored) { } bot.Device.clearPointerMap(); this.buttonPressed_ = null; this.elementPressed_ = null; }; /** * A helper function to fire mouse double click events. * * @private */ bot.Mouse.prototype.maybeDoubleClickElement_ = function () { // Trigger an additional double click event if it is the second click. if (this.nextClickIsDoubleClick_) { this.fireMouseEvent_(bot.events.EventType.DBLCLICK); } this.nextClickIsDoubleClick_ = !this.nextClickIsDoubleClick_; }; /** * Given a coordinates (x,y) related to an element, move mouse to (x,y) of the * element. The top-left point of the element is (0,0). * * @param {!Element} element The destination element. * @param {!goog.math.Coordinate} coords Mouse position related to the target. */ bot.Mouse.prototype.move = function (element, coords) { // If the element is interactable at the start of the move, it receives the // full event sequence, even if hidden by an element mid sequence. var toElemWasInteractable = bot.dom.isInteractable(element); var rect = bot.dom.getClientRect(element); this.clientXY_.x = coords.x + rect.left; this.clientXY_.y = coords.y + rect.top; var fromElement = this.getElement(); if (element != fromElement) { // If the window of fromElement is closed, set fromElement to null as a flag // to skip the mouseout event and so relatedTarget of the mouseover is null. try { if (goog.dom.getWindow(goog.dom.getOwnerDocument(fromElement)).closed) { fromElement = null; } } catch (ignore) { // Sometimes accessing a window that no longer exists causes an error. fromElement = null; } if (fromElement) { // For the first mouse interaction on a page, if the mouse was over the // browser window, the browser will pass null as the relatedTarget for the // mouseover event. For subsequent interactions, it will pass the // last-focused element. Unfortunately, we don't have anywhere to keep the // state of which elements have been focused across Mouse instances, so we // treat every Mouse initially positioned over the documentElement or body // as if it's on a new page. Accordingly, for complex actions (e.g. // drag-and-drop), a single Mouse instance should be used for the whole // action, to ensure the correct relatedTargets are fired for any events. var isRoot = fromElement === bot.getDocument().documentElement || fromElement === bot.getDocument().body; fromElement = (!this.hasEverInteracted_ && isRoot) ? null : fromElement; this.fireMouseEvent_(bot.events.EventType.MOUSEOUT, element); } this.setElement(element); // All browsers except IE fire the mouseover before the mousemove. if (!goog.userAgent.IE) { this.fireMouseEvent_(bot.events.EventType.MOUSEOVER, fromElement, null, toElemWasInteractable); } } this.fireMouseEvent_(bot.events.EventType.MOUSEMOVE, null, null, toElemWasInteractable); // IE fires the mouseover event after the mousemove. if (goog.userAgent.IE && element != fromElement) { this.fireMouseEvent_(bot.events.EventType.MOUSEOVER, fromElement, null, toElemWasInteractable); } this.nextClickIsDoubleClick_ = false; }; /** * Scrolls the wheel of the mouse by the given number of ticks, where a positive * number indicates a downward scroll and a negative is upward scroll. * * @param {number} ticks Number of ticks to scroll the mouse wheel. */ bot.Mouse.prototype.scroll = function (ticks) { if (ticks == 0) { throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Must scroll a non-zero number of ticks.'); } // The wheelDelta value for a single up-tick of the mouse wheel is 120, and // a single down-tick is -120. The deltas in pixels (which is only relevant // for Firefox) appears to be -57 and 57, respectively. var wheelDelta = ticks > 0 ? -120 : 120; var pixelDelta = ticks > 0 ? 57 : -57; // Browsers fire a separate event (or pair of events in Gecko) for each tick. for (var i = 0; i < Math.abs(ticks); i++) { this.fireMouseEvent_(bot.events.EventType.MOUSEWHEEL, null, wheelDelta); if (goog.userAgent.GECKO) { this.fireMouseEvent_(bot.events.EventType.MOUSEPIXELSCROLL, null, pixelDelta); } } }; /** * A helper function to fire mouse events. * * @param {bot.events.EventType} type Event type. * @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. * @param {?number=} opt_count Number of clicks that have been performed. * @return {boolean} Whether the event fired successfully or was cancelled. * @private */ bot.Mouse.prototype.fireMouseEvent_ = function (type, opt_related, opt_wheelDelta, opt_force, opt_count) { this.hasEverInteracted_ = true; if (bot.userAgent.IE_DOC_10) { var msPointerEvent = bot.Mouse.MOUSE_EVENT_MAP_[type]; if (msPointerEvent) { // The pointerId for mouse events is always 1 and the mouse event is never // fired if the MSPointer event fails. if (!this.fireMSPointerEvent(msPointerEvent, this.clientXY_, this.getButtonValue_(msPointerEvent), bot.Device.MOUSE_MS_POINTER_ID, MSPointerEvent.MSPOINTER_TYPE_MOUSE, /* isPrimary */ true, opt_related, opt_force)) { return false; } } } return this.fireMouseEvent(type, this.clientXY_, this.getButtonValue_(type), opt_related, opt_wheelDelta, opt_force, null, opt_count); }; /** * Given an event type and a mouse button, sets the mouse button value used * for that event on the current browser. The mouse button value is 0 for any * event not covered by bot.Mouse.MOUSE_BUTTON_VALUE_MAP_. * * @param {bot.events.EventType} eventType Type of mouse event. * @return {number} The mouse button ID value to the current browser. * @private */ bot.Mouse.prototype.getButtonValue_ = function (eventType) { if (!(eventType in bot.Mouse.MOUSE_BUTTON_VALUE_MAP_)) { return 0; } var buttonIndex = goog.isNull(this.buttonPressed_) ? bot.Mouse.NO_BUTTON_VALUE_INDEX_ : this.buttonPressed_; var buttonValue = bot.Mouse.MOUSE_BUTTON_VALUE_MAP_[eventType][buttonIndex]; if (goog.isNull(buttonValue)) { throw new bot.Error(bot.ErrorCode.UNKNOWN_ERROR, 'Event does not permit the specified mouse button.'); } return buttonValue; }; /** * Serialize the current state of the mouse. * @return {!bot.Mouse.State} The current mouse state. */ bot.Mouse.prototype.getState = function () { // Need to use quoted literals here, so the compiler will not rename the // properties of the emitted object. When the object is created via the // "constructor", we will look for these *specific* properties. Everywhere // else internally, we use the dot-notation, so it's okay if the compiler // renames the internal variable name. return { 'buttonPressed': this.buttonPressed_, 'elementPressed': this.elementPressed_, 'clientXY': { 'x': this.clientXY_.x, 'y': this.clientXY_.y }, 'nextClickIsDoubleClick': this.nextClickIsDoubleClick_, 'hasEverInteracted': this.hasEverInteracted_, 'element': this.getElement() }; };