diff options
Diffstat (limited to 'devtools/server/actors/emulation')
-rw-r--r-- | devtools/server/actors/emulation/content-viewer.js | 128 | ||||
-rw-r--r-- | devtools/server/actors/emulation/moz.build | 11 | ||||
-rw-r--r-- | devtools/server/actors/emulation/responsive.js | 424 | ||||
-rw-r--r-- | devtools/server/actors/emulation/touch-simulator.js | 426 |
4 files changed, 989 insertions, 0 deletions
diff --git a/devtools/server/actors/emulation/content-viewer.js b/devtools/server/actors/emulation/content-viewer.js new file mode 100644 index 0000000000..3bbc6eeead --- /dev/null +++ b/devtools/server/actors/emulation/content-viewer.js @@ -0,0 +1,128 @@ +/* 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 { Ci } = require("chrome"); +const protocol = require("devtools/shared/protocol"); +const { contentViewerSpec } = require("devtools/shared/specs/content-viewer"); + +/** + * This actor emulates various browser content environments by using methods available + * on the ContentViewer exposed by the platform. + */ +const ContentViewerActor = protocol.ActorClassWithSpec(contentViewerSpec, { + initialize(conn, targetActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.targetActor = targetActor; + this.docShell = targetActor.docShell; + + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onWindowReady = this.onWindowReady.bind(this); + + this.targetActor.on("will-navigate", this.onWillNavigate); + this.targetActor.on("window-ready", this.onWindowReady); + }, + + destroy() { + this.stopPrintMediaSimulation(); + this.setEmulatedColorScheme(); + + this.targetActor.off("will-navigate", this.onWillNavigate); + this.targetActor.off("window-ready", this.onWindowReady); + + this.targetActor = null; + this.docShell = null; + + protocol.Actor.prototype.destroy.call(this); + }, + + onWillNavigate({ isTopLevel }) { + // Make sure that print simulation is stopped before navigating to another page. We + // need to do this since the browser will cache the last state of the page in its + // session history. + if (this._printSimulationEnabled && isTopLevel) { + this.stopPrintMediaSimulation(true); + } + }, + + onWindowReady({ isTopLevel }) { + // Since `emulateMedium` only works for the current page, we need to ensure persistent + // print simulation for when the user navigates to a new page while its enabled. + // To do this, we need to tell the page to begin print simulation before the DOM + // content is available to the user: + if (this._printSimulationEnabled && isTopLevel) { + this.startPrintMediaSimulation(); + } + }, + + /* Color scheme simulation */ + + /** + * Returns the currently emulated color scheme. + */ + getEmulatedColorScheme() { + return this._emulatedColorScheme; + }, + + /** + * Sets the currently emulated color scheme or if an invalid value is given, + * the override is cleared. + */ + setEmulatedColorScheme(scheme = null) { + if (this._emulatedColorScheme === scheme) { + return; + } + + let internalColorScheme; + switch (scheme) { + case "light": + internalColorScheme = Ci.nsIContentViewer.PREFERS_COLOR_SCHEME_LIGHT; + break; + case "dark": + internalColorScheme = Ci.nsIContentViewer.PREFERS_COLOR_SCHEME_DARK; + break; + default: + internalColorScheme = Ci.nsIContentViewer.PREFERS_COLOR_SCHEME_NONE; + } + + this._emulatedColorScheme = scheme; + this.docShell.contentViewer.emulatePrefersColorScheme(internalColorScheme); + }, + + // The current emulated color scheme value. It's possible values are listed in the + // COLOR_SCHEMES constant in devtools/client/inspector/rules/constants. + _emulatedColorScheme: null, + + /* Simulating print media for the page */ + + _printSimulationEnabled: false, + + getIsPrintSimulationEnabled() { + return this._printSimulationEnabled; + }, + + async startPrintMediaSimulation() { + this._printSimulationEnabled = true; + this.targetActor.docShell.contentViewer.emulateMedium("print"); + }, + + /** + * Stop simulating print media for the current page. + * + * @param {Boolean} state + * Whether or not to set _printSimulationEnabled to false. If true, we want to + * stop simulation print media for the current page but NOT set + * _printSimulationEnabled to false. We do this specifically for the + * "will-navigate" event where we still want to continue simulating print when + * navigating to the next page. Defaults to false, meaning we want to completely + * stop print simulation. + */ + async stopPrintMediaSimulation(state = false) { + this._printSimulationEnabled = state; + this.targetActor.docShell.contentViewer.stopEmulatingMedium(); + }, +}); + +exports.ContentViewerActor = ContentViewerActor; diff --git a/devtools/server/actors/emulation/moz.build b/devtools/server/actors/emulation/moz.build new file mode 100644 index 0000000000..2ab4b0f676 --- /dev/null +++ b/devtools/server/actors/emulation/moz.build @@ -0,0 +1,11 @@ +# -*- 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( + "content-viewer.js", + "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..8e688ec760 --- /dev/null +++ b/devtools/server/actors/emulation/responsive.js @@ -0,0 +1,424 @@ +/* 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 { Ci } = require("chrome"); +const Services = require("Services"); +const protocol = require("devtools/shared/protocol"); +const { responsiveSpec } = require("devtools/shared/specs/responsive"); + +loader.lazyRequireGetter( + this, + "ScreenshotActor", + "devtools/server/actors/screenshot", + true +); +loader.lazyRequireGetter( + this, + "TouchSimulator", + "devtools/server/actors/emulation/touch-simulator", + true +); + +const FLOATING_SCROLLBARS_SHEET = Services.io.newURI( + "chrome://devtools/skin/floating-scrollbars-responsive-design.css" +); + +/** + * 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. + */ +const ResponsiveActor = protocol.ActorClassWithSpec(responsiveSpec, { + initialize(conn, targetActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.targetActor = targetActor; + this.docShell = targetActor.docShell; + + this.onWindowReady = this.onWindowReady.bind(this); + + this.targetActor.on("window-ready", this.onWindowReady); + }, + + destroy() { + this.clearDPPXOverride(); + this.clearNetworkThrottling(); + this.clearTouchEventsOverride(); + this.clearMetaViewportOverride(); + this.clearUserAgentOverride(); + + this.targetActor.off("window-ready", this.onWindowReady); + + this.targetActor = null; + this.docShell = null; + this._screenshotActor = null; + this._touchSimulator = null; + + protocol.Actor.prototype.destroy.call(this); + }, + + async onWindowReady() { + await this.setFloatingScrollbars(true); + }, + + /** + * Retrieve the console actor for this tab. This allows us to expose network throttling + * as part of emulation settings, even though it's internally connected to the network + * monitor, which for historical reasons is part of the console actor. + */ + get _consoleActor() { + if (this.targetActor.exited || this.targetActor.isDestroyed()) { + return null; + } + const form = this.targetActor.form(); + return this.conn._getOrCreateActor(form.consoleActor); + }, + + get screenshotActor() { + if (!this._screenshotActor) { + this._screenshotActor = new ScreenshotActor(this.conn, this.targetActor); + this.manage(this._screenshotActor); + } + + return this._screenshotActor; + }, + + get touchSimulator() { + if (!this._touchSimulator) { + this._touchSimulator = new TouchSimulator( + this.targetActor.chromeEventHandler + ); + } + + return this._touchSimulator; + }, + + get win() { + return this.docShell.chromeEventHandler.ownerGlobal; + }, + + /* DPPX override */ + + _previousDPPXOverride: undefined, + + setDPPXOverride(dppx) { + if (this.getDPPXOverride() === dppx) { + return false; + } + + if (this._previousDPPXOverride === undefined) { + this._previousDPPXOverride = this.getDPPXOverride(); + } + + this.docShell.contentViewer.overrideDPPX = dppx; + + return true; + }, + + getDPPXOverride() { + return this.docShell.contentViewer.overrideDPPX; + }, + + clearDPPXOverride() { + if (this._previousDPPXOverride !== undefined) { + return this.setDPPXOverride(this._previousDPPXOverride); + } + + return false; + }, + + /* Network Throttling */ + + _previousNetworkThrottling: undefined, + + /** + * Transform the RDP format into the internal format and then set network throttling. + */ + setNetworkThrottling({ downloadThroughput, uploadThroughput, latency }) { + const throttleData = { + latencyMean: latency, + latencyMax: latency, + downloadBPSMean: downloadThroughput, + downloadBPSMax: downloadThroughput, + uploadBPSMean: uploadThroughput, + uploadBPSMax: uploadThroughput, + }; + return this._setNetworkThrottling(throttleData); + }, + + _setNetworkThrottling(throttleData) { + const current = this._getNetworkThrottling(); + // Check if they are both objects or both null + let match = throttleData == current; + // If both objects, check all entries + if (match && current && throttleData) { + match = Object.entries(current).every(([k, v]) => { + return throttleData[k] === v; + }); + } + if (match) { + return false; + } + + if (this._previousNetworkThrottling === undefined) { + this._previousNetworkThrottling = current; + } + + const consoleActor = this._consoleActor; + if (!consoleActor) { + return false; + } + consoleActor.startListeners(["NetworkActivity"]); + consoleActor.setPreferences({ + "NetworkMonitor.throttleData": throttleData, + }); + return true; + }, + + /** + * Get network throttling and then transform the internal format into the RDP format. + */ + getNetworkThrottling() { + const throttleData = this._getNetworkThrottling(); + if (!throttleData) { + return null; + } + const { downloadBPSMax, uploadBPSMax, latencyMax } = throttleData; + return { + downloadThroughput: downloadBPSMax, + uploadThroughput: uploadBPSMax, + latency: latencyMax, + }; + }, + + _getNetworkThrottling() { + const consoleActor = this._consoleActor; + if (!consoleActor) { + return null; + } + const prefs = consoleActor.getPreferences(["NetworkMonitor.throttleData"]); + return prefs.preferences["NetworkMonitor.throttleData"] || null; + }, + + clearNetworkThrottling() { + if (this._previousNetworkThrottling !== undefined) { + return this._setNetworkThrottling(this._previousNetworkThrottling); + } + + return false; + }, + + /* 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.touchSimulator.setElementPickerState(state, pickerType); + }, + + setTouchEventsOverride(flag) { + if (this.getTouchEventsOverride() == flag) { + return false; + } + if (this._previousTouchEventsOverride === undefined) { + this._previousTouchEventsOverride = this.getTouchEventsOverride(); + } + + // Start or stop the touch simulator depending on the override flag + // See BrowsingContext.webidl `TouchEventsOverride` enum for values. + if (flag == "enabled") { + this.touchSimulator.start(); + } else { + this.touchSimulator.stop(); + } + + this.docShell.browsingContext.touchEventsOverride = flag; + return true; + }, + + getTouchEventsOverride() { + return this.docShell.browsingContext.touchEventsOverride; + }, + + clearTouchEventsOverride() { + if (this._previousTouchEventsOverride !== undefined) { + return this.setTouchEventsOverride(this._previousTouchEventsOverride); + } + return false; + }, + + /* Meta viewport override */ + + _previousMetaViewportOverride: undefined, + + setMetaViewportOverride(flag) { + if (this.getMetaViewportOverride() == flag) { + return false; + } + if (this._previousMetaViewportOverride === undefined) { + this._previousMetaViewportOverride = this.getMetaViewportOverride(); + } + + this.docShell.metaViewportOverride = flag; + return true; + }, + + getMetaViewportOverride() { + return this.docShell.metaViewportOverride; + }, + + clearMetaViewportOverride() { + if (this._previousMetaViewportOverride !== undefined) { + return this.setMetaViewportOverride(this._previousMetaViewportOverride); + } + return false; + }, + + /* User agent override */ + + _previousUserAgentOverride: undefined, + + setUserAgentOverride(userAgent) { + if (this.getUserAgentOverride() == userAgent) { + return false; + } + if (this._previousUserAgentOverride === undefined) { + this._previousUserAgentOverride = this.getUserAgentOverride(); + } + // Bug 1637494: TODO - customUserAgent should only be set from parent + // process. + this.docShell.customUserAgent = userAgent; + return true; + }, + + getUserAgentOverride() { + return this.docShell.browsingContext.customUserAgent; + }, + + clearUserAgentOverride() { + if (this._previousUserAgentOverride !== undefined) { + return this.setUserAgentOverride(this._previousUserAgentOverride); + } + return false; + }, + + setScreenOrientation(type, angle) { + if ( + this.win.screen.orientation.angle !== angle || + this.win.screen.orientation.type !== type + ) { + this.docShell.browsingContext.setRDMPaneOrientation(type, angle); + } + }, + + /** + * Simulates the "orientationchange" event when device screen is rotated. + * + * @param {String} type + * The orientation type of the rotated device. + * @param {Number} angle + * The rotated angle of the device. + * @param {Boolean} isViewportRotated + * Whether or not screen orientation change is a result of rotating the viewport. + * If true, then dispatch the "orientationchange" event on the content window. + */ + async simulateScreenOrientationChange( + type, + angle, + isViewportRotated = false + ) { + // Don't dispatch the "orientationchange" event if orientation change is a result + // of switching to a new device, location change, or opening RDM. + if (!isViewportRotated) { + this.setScreenOrientation(type, angle); + return; + } + + const { CustomEvent } = this.win; + const orientationChangeEvent = new CustomEvent("orientationchange"); + + this.setScreenOrientation(type, angle); + this.win.dispatchEvent(orientationChangeEvent); + }, + + async captureScreenshot() { + return this.screenshotActor.capture({}); + }, + + /** + * Applies a mobile scrollbar overlay to the content document. + * + * @param {Boolean} applyFloatingScrollbars + */ + async setFloatingScrollbars(applyFloatingScrollbars) { + const docShell = this.docShell; + const allDocShells = [docShell]; + + for (let i = 0; i < docShell.childCount; i++) { + const child = docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell); + allDocShells.push(child); + } + + for (const d of allDocShells) { + const win = d.contentViewer.DOMDocument.defaultView; + const winUtils = win.windowUtils; + try { + if (applyFloatingScrollbars) { + winUtils.loadSheet(FLOATING_SCROLLBARS_SHEET, this.win.AGENT_SHEET); + } else { + winUtils.removeSheet(FLOATING_SCROLLBARS_SHEET, this.win.AGENT_SHEET); + } + } catch (e) {} + } + + this.flushStyle(); + }, + + async setMaxTouchPoints(touchSimulationEnabled) { + const maxTouchPoints = touchSimulationEnabled ? 1 : 0; + this.docShell.browsingContext.setRDMPaneMaxTouchPoints(maxTouchPoints); + }, + + flushStyle() { + // Force presContext destruction + const isSticky = this.docShell.contentViewer.sticky; + this.docShell.contentViewer.sticky = false; + this.docShell.contentViewer.hide(); + this.docShell.contentViewer.show(); + this.docShell.contentViewer.sticky = isSticky; + }, +}); + +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..dbbc5adb17 --- /dev/null +++ b/devtools/server/actors/emulation/touch-simulator.js @@ -0,0 +1,426 @@ +/* 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 { Services } = require("resource://gre/modules/Services.jsm"); + +loader.lazyRequireGetter(this, "InspectorUtils", "InspectorUtils"); +loader.lazyRequireGetter( + this, + "PICKER_TYPES", + "devtools/shared/picker-constants" +); + +var systemAppOrigin = (function() { + let systemOrigin = "_"; + try { + systemOrigin = Services.io.newURI( + Services.prefs.getCharPref("b2g.system_manifest_url") + ).prePath; + } catch (e) { + // Fall back to default value + } + return systemOrigin; +})(); + +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 kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER + +function TouchSimulator(simulatorTarget) { + this.simulatorTarget = simulatorTarget; + this._currentPickerMap = new Map(); +} + +/** + * Simulate touch events for platforms where they aren't generally available. + */ +TouchSimulator.prototype = { + events: [ + "mousedown", + "mousemove", + "mouseup", + "touchstart", + "touchend", + "mouseenter", + "mouseover", + "mouseout", + "mouseleave", + ], + + contextMenuTimeout: null, + + simulatorTarget: null, + + enabled: false, + + start() { + if (this.enabled) { + // Simulator is already started + return; + } + + this.events.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; + } + this.events.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; + } + + // The gaia system window use an hybrid system even on the device which is + // a mix of mouse/touch events. So let's not cancel *all* mouse events + // if it is the current target. + const content = this.getContent(evt.target); + if (!content) { + return; + } + const isSystemWindow = content.location + .toString() + .startsWith(systemAppOrigin); + + // App touchstart & touchend should also be dispatched on the system app + // to match on-device behavior. + if (evt.type.startsWith("touch") && !isSystemWindow) { + 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.mozInputSource != 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( + this.getContent(evt.target), + evt.clientX, + evt.clientY, + evt.screenX, + evt.screenY, + type + ); + } + + if (!isSystemWindow) { + 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. The `x` and `y` values + * passed to this function should be relative to the layout viewport (what is returned + * by `MouseEvent.clientX/clientY`) and are reported in CSS pixels. + * + * @param {Window} win + * The target window. + * @param {Number} x + * The `x` CSS coordinate relative to the layout viewport. + * @param {Number} y + * The `y` CSS coordinate relative to the layout viewport. + * @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, x, y, screenX, screenY, type) { + // Native events work in device pixels, so calculate device coordinates from + // the screen coordinates. + const utils = win.windowUtils; + const deviceScale = utils.screenPixelsPerCSSPixelNoOverride; + const pt = { x: screenX * deviceScale, y: screenY * deviceScale }; + + utils.sendNativeTouchPoint(0, TOUCH_STATES[type], pt.x, pt.y, 1, 90, null); + return true; + }, + + sendTouchEvent(evt, target, name) { + const win = target.ownerGlobal; + const content = this.getContent(target); + if (!content) { + return; + } + + // To avoid duplicating logic for creating and dispatching touch events on the JS + // side, we should use what's already implemented for WindowUtils.sendTouchEvent. + const utils = win.windowUtils; + utils.sendTouchEvent( + name, + [0], + [evt.clientX], + [evt.clientY], + [1], + [1], + [0], + [1], + 0, + false + ); + }, + + getContent(target) { + const win = target?.ownerDocument ? target.ownerGlobal : null; + return win; + }, + + getDelayBeforeMouseEvent(evt) { + // On mobile platforms, Firefox inserts a 300ms delay between + // touch events and accompanying mouse events, except if the + // content window is not zoomable and the content window is + // auto-zoomed to device-width. + + // If the preference dom.meta-viewport.enabled is set to false, + // we couldn't read viewport's information from getViewportInfo(). + // So we always simulate 300ms delay when the + // dom.meta-viewport.enabled is false. + const savedMetaViewportEnabled = Services.prefs.getBoolPref( + "dom.meta-viewport.enabled" + ); + if (!savedMetaViewportEnabled) { + return 300; + } + + const content = this.getContent(evt.target); + if (!content) { + return 0; + } + + const utils = content.windowUtils; + + const allowZoom = {}; + const minZoom = {}; + const maxZoom = {}; + const autoSize = {}; + + utils.getViewportInfo( + content.innerWidth, + content.innerHeight, + {}, + allowZoom, + minZoom, + maxZoom, + {}, + {}, + autoSize + ); + + // FIXME: On Safari and Chrome mobile platform, if the css property + // touch-action set to none or manipulation would also suppress 300ms + // delay. But Firefox didn't support this property now, we can't get + // this value from utils.getVisitedDependentComputedStyle() to check + // if we should suppress 300ms delay. + if ( + !allowZoom.value || // user-scalable = no + minZoom.value === maxZoom.value || // minimum-scale = maximum-scale + autoSize.value // width = device-width + ) { + return 0; + } + return 300; + }, +}; + +exports.TouchSimulator = TouchSimulator; |