diff options
Diffstat (limited to 'devtools/server/actors/emulation')
-rw-r--r-- | devtools/server/actors/emulation/moz.build | 10 | ||||
-rw-r--r-- | devtools/server/actors/emulation/responsive.js | 83 | ||||
-rw-r--r-- | devtools/server/actors/emulation/touch-simulator.js | 309 |
3 files changed, 402 insertions, 0 deletions
diff --git a/devtools/server/actors/emulation/moz.build b/devtools/server/actors/emulation/moz.build new file mode 100644 index 0000000000..cf229e6fe1 --- /dev/null +++ b/devtools/server/actors/emulation/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "responsive.js", + "touch-simulator.js", +) diff --git a/devtools/server/actors/emulation/responsive.js b/devtools/server/actors/emulation/responsive.js new file mode 100644 index 0000000000..829579cab6 --- /dev/null +++ b/devtools/server/actors/emulation/responsive.js @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { + responsiveSpec, +} = require("resource://devtools/shared/specs/responsive.js"); + +/** + * This actor overrides various browser features to simulate different environments to + * test how pages perform under various conditions. + * + * The design below, which saves the previous value of each property before setting, is + * needed because it's possible to have multiple copies of this actor for a single page. + * When some instance of this actor changes a property, we want it to be able to restore + * that property to the way it was found before the change. + * + * A subtle aspect of the code below is that all get* methods must return non-undefined + * values, so that the absence of a previous value can be distinguished from the value for + * "no override" for each of the properties. + */ +class ResponsiveActor extends Actor { + constructor(conn, targetActor) { + super(conn, responsiveSpec); + this.targetActor = targetActor; + this.docShell = targetActor.docShell; + } + + destroy() { + this.targetActor = null; + this.docShell = null; + + super.destroy(); + } + + get win() { + return this.docShell.chromeEventHandler.ownerGlobal; + } + + /* Touch events override */ + + _previousTouchEventsOverride = undefined; + + /** + * Set the current element picker state. + * + * True means the element picker is currently active and we should not be emulating + * touch events. + * False means the element picker is not active and it is ok to emulate touch events. + * + * This actor method is meant to be called by the DevTools front-end. The reason for + * this is the following: + * RDM is the only current consumer of the touch simulator. RDM instantiates this actor + * on its own, whether or not the Toolbox is opened. That means it does so in its own + * DevTools Server instance. + * When the Toolbox is running, it uses a different DevToolsServer. Therefore, it is not + * possible for the touch simulator to know whether the picker is active or not. This + * state has to be sent by the client code of the Toolbox to this actor. + * If a future use case arises where we want to use the touch simulator from the Toolbox + * too, then we could add code in here to detect the picker mode as described in + * https://bugzilla.mozilla.org/show_bug.cgi?id=1409085#c3 + + * @param {Boolean} state + * @param {String} pickerType + */ + setElementPickerState(state, pickerType) { + this.targetActor.touchSimulator.setElementPickerState(state, pickerType); + } + + /** + * Dispatches an "orientationchange" event. + */ + async dispatchOrientationChangeEvent() { + const { CustomEvent } = this.win; + const orientationChangeEvent = new CustomEvent("orientationchange"); + this.win.dispatchEvent(orientationChangeEvent); + } +} + +exports.ResponsiveActor = ResponsiveActor; diff --git a/devtools/server/actors/emulation/touch-simulator.js b/devtools/server/actors/emulation/touch-simulator.js new file mode 100644 index 0000000000..4d4b6b4c6e --- /dev/null +++ b/devtools/server/actors/emulation/touch-simulator.js @@ -0,0 +1,309 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +loader.lazyRequireGetter( + this, + "PICKER_TYPES", + "resource://devtools/shared/picker-constants.js" +); + +var isClickHoldEnabled = Services.prefs.getBoolPref( + "ui.click_hold_context_menus" +); +var clickHoldDelay = Services.prefs.getIntPref( + "ui.click_hold_context_menus.delay", + 500 +); + +// Touch state constants are derived from values defined in: nsIDOMWindowUtils.idl +const TOUCH_CONTACT = 0x02; +const TOUCH_REMOVE = 0x04; + +const TOUCH_STATES = { + touchstart: TOUCH_CONTACT, + touchmove: TOUCH_CONTACT, + touchend: TOUCH_REMOVE, +}; + +const EVENTS_TO_HANDLE = [ + "mousedown", + "mousemove", + "mouseup", + "touchstart", + "touchend", + "mouseenter", + "mouseover", + "mouseout", + "mouseleave", +]; + +const kStateHover = 0x00000004; // ElementState::HOVER + +/** + * Simulate touch events for platforms where they aren't generally available. + */ +class TouchSimulator { + /** + * @param {ChromeEventHandler} simulatorTarget: The object we'll use to listen for click + * and touch events to handle. + */ + constructor(simulatorTarget) { + this.simulatorTarget = simulatorTarget; + this._currentPickerMap = new Map(); + } + + enabled = false; + + start() { + if (this.enabled) { + // Simulator is already started + return; + } + + EVENTS_TO_HANDLE.forEach(evt => { + // Only listen trusted events to prevent messing with + // event dispatched manually within content documents + this.simulatorTarget.addEventListener(evt, this, true, false); + }); + + this.enabled = true; + } + + stop() { + if (!this.enabled) { + // Simulator isn't running + return; + } + EVENTS_TO_HANDLE.forEach(evt => { + this.simulatorTarget.removeEventListener(evt, this, true); + }); + this.enabled = false; + } + + _isPicking() { + const types = Object.values(PICKER_TYPES); + return types.some(type => this._currentPickerMap.get(type)); + } + + /** + * Set the state value for one of DevTools pickers (either eyedropper or + * element picker). + * If any content picker is currently active, we should not be emulating + * touch events. Otherwise it is ok to emulate touch events. + * In theory only one picker can ever be active at a time, but tracking the + * different pickers independantly avoids race issues in the client code. + * + * @param {Boolean} state + * True if the picker is currently active, false otherwise. + * @param {String} pickerType + * One of PICKER_TYPES. + */ + setElementPickerState(state, pickerType) { + if (!Object.values(PICKER_TYPES).includes(pickerType)) { + throw new Error( + "Unsupported type in setElementPickerState: " + pickerType + ); + } + this._currentPickerMap.set(pickerType, state); + } + + // eslint-disable-next-line complexity + handleEvent(evt) { + // Bail out if devtools is in pick mode in the same tab. + if (this._isPicking()) { + return; + } + + const content = this.getContent(evt.target); + if (!content) { + return; + } + + // App touchstart & touchend should also be dispatched on the system app + // to match on-device behavior. + if (evt.type.startsWith("touch")) { + const sysFrame = content.realFrameElement; + if (!sysFrame) { + return; + } + const sysDocument = sysFrame.ownerDocument; + const sysWindow = sysDocument.defaultView; + + const touchEvent = sysDocument.createEvent("touchevent"); + const touch = evt.touches[0] || evt.changedTouches[0]; + const point = sysDocument.createTouch( + sysWindow, + sysFrame, + 0, + touch.pageX, + touch.pageY, + touch.screenX, + touch.screenY, + touch.clientX, + touch.clientY, + 1, + 1, + 0, + 0 + ); + + const touches = sysDocument.createTouchList(point); + const targetTouches = touches; + const changedTouches = touches; + touchEvent.initTouchEvent( + evt.type, + true, + true, + sysWindow, + 0, + false, + false, + false, + false, + touches, + targetTouches, + changedTouches + ); + sysFrame.dispatchEvent(touchEvent); + return; + } + + // Ignore all but real mouse event coming from physical mouse + // (especially ignore mouse event being dispatched from a touch event) + if ( + evt.button || + evt.inputSource != evt.MOZ_SOURCE_MOUSE || + evt.isSynthesized + ) { + return; + } + + const eventTarget = this.target; + let type = ""; + switch (evt.type) { + case "mouseenter": + case "mouseover": + case "mouseout": + case "mouseleave": + // Don't propagate events which are not related to touch events + evt.stopPropagation(); + evt.preventDefault(); + + // We don't want to trigger any visual changes to elements whose content can + // be modified via hover states. We can avoid this by removing the element's + // content state. + InspectorUtils.removeContentState(evt.target, kStateHover); + break; + + case "mousedown": + this.target = evt.target; + + // If the click-hold feature is enabled, start a timeout to convert long clicks + // into contextmenu events. + // Just don't do it if the event occurred on a scrollbar. + if (isClickHoldEnabled && !evt.originalTarget.closest("scrollbar")) { + this._contextMenuTimeout = this.sendContextMenu(evt); + } + + this.startX = evt.pageX; + this.startY = evt.pageY; + + // Capture events so if a different window show up the events + // won't be dispatched to something else. + evt.target.setCapture(false); + + type = "touchstart"; + break; + + case "mousemove": + if (!eventTarget) { + // Don't propagate mousemove event when touchstart event isn't fired + evt.stopPropagation(); + return; + } + + type = "touchmove"; + break; + + case "mouseup": + if (!eventTarget) { + return; + } + this.target = null; + + content.clearTimeout(this._contextMenuTimeout); + type = "touchend"; + + // Only register click listener after mouseup to ensure + // catching only real user click. (Especially ignore click + // being dispatched on form submit) + if (evt.detail == 1) { + this.simulatorTarget.addEventListener("click", this, { + capture: true, + once: true, + }); + } + break; + } + + const target = eventTarget || this.target; + if (target && type) { + this.synthesizeNativeTouch(content, evt.screenX, evt.screenY, type); + } + + evt.preventDefault(); + evt.stopImmediatePropagation(); + } + + sendContextMenu({ target, clientX, clientY, screenX, screenY }) { + const view = target.ownerGlobal; + const { MouseEvent } = view; + const evt = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view, + screenX, + screenY, + clientX, + clientY, + }); + const content = this.getContent(target); + const timeout = content.setTimeout(() => { + target.dispatchEvent(evt); + }, clickHoldDelay); + + return timeout; + } + + /** + * Synthesizes a native touch action on a given target element. + * + * @param {Window} win + * The target window. + * @param {Number} screenX + * The `x` screen coordinate relative to the screen origin. + * @param {Number} screenY + * The `y` screen coordinate relative to the screen origin. + * @param {String} type + * A key appearing in the TOUCH_STATES associative array. + */ + synthesizeNativeTouch(win, screenX, screenY, type) { + // Native events work in device pixels. + const utils = win.windowUtils; + const deviceScale = win.devicePixelRatio; + const pt = { x: screenX * deviceScale, y: screenY * deviceScale }; + + utils.sendNativeTouchPoint(0, TOUCH_STATES[type], pt.x, pt.y, 1, 90, null); + return true; + } + + getContent(target) { + const win = target?.ownerDocument ? target.ownerGlobal : null; + return win; + } +} + +exports.TouchSimulator = TouchSimulator; |