diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/responsive/ui.js | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/responsive/ui.js')
-rw-r--r-- | devtools/client/responsive/ui.js | 1075 |
1 files changed, 1075 insertions, 0 deletions
diff --git a/devtools/client/responsive/ui.js b/devtools/client/responsive/ui.js new file mode 100644 index 0000000000..9e8b24973d --- /dev/null +++ b/devtools/client/responsive/ui.js @@ -0,0 +1,1075 @@ +/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + getOrientation, +} = require("resource://devtools/client/responsive/utils/orientation.js"); +const Constants = require("resource://devtools/client/responsive/constants.js"); +const { + CommandsFactory, +} = require("resource://devtools/shared/commands/commands-factory.js"); + +loader.lazyRequireGetter( + this, + "throttlingProfiles", + "resource://devtools/client/shared/components/throttling/profiles.js" +); +loader.lazyRequireGetter( + this, + "message", + "resource://devtools/client/responsive/utils/message.js" +); +loader.lazyRequireGetter( + this, + "showNotification", + "resource://devtools/client/responsive/utils/notification.js", + true +); +loader.lazyRequireGetter( + this, + "PriorityLevels", + "resource://devtools/client/shared/components/NotificationBox.js", + true +); +loader.lazyRequireGetter( + this, + "l10n", + "resource://devtools/client/responsive/utils/l10n.js" +); +loader.lazyRequireGetter( + this, + "asyncStorage", + "resource://devtools/shared/async-storage.js" +); +loader.lazyRequireGetter( + this, + "captureAndSaveScreenshot", + "resource://devtools/client/shared/screenshot.js", + true +); + +const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions."; +const RELOAD_NOTIFICATION_PREF = + "devtools.responsive.reloadNotification.enabled"; + +function debug(msg) { + // console.log(`RDM manager: ${msg}`); +} + +/** + * ResponsiveUI manages the responsive design tool for a specific tab. The + * actual tool itself lives in a separate chrome:// document that is loaded into + * the tab upon opening responsive design. This object acts a helper to + * integrate the tool into the surrounding browser UI as needed. + */ +class ResponsiveUI { + /** + * @param {ResponsiveUIManager} manager + * The ResponsiveUIManager instance. + * @param {ChromeWindow} window + * The main browser chrome window (that holds many tabs). + * @param {Tab} tab + * The specific browser <tab> element this responsive instance is for. + */ + constructor(manager, window, tab) { + this.manager = manager; + // The main browser chrome window (that holds many tabs). + this.browserWindow = window; + // The specific browser tab this responsive instance is for. + this.tab = tab; + + // Flag set when destruction has begun. + this.destroying = false; + // Flag set when destruction has ended. + this.destroyed = false; + // The iframe containing the RDM UI. + this.rdmFrame = null; + + // Bind callbacks for resizers. + this.onResizeDrag = this.onResizeDrag.bind(this); + this.onResizeStart = this.onResizeStart.bind(this); + this.onResizeStop = this.onResizeStop.bind(this); + + this.onTargetAvailable = this.onTargetAvailable.bind(this); + + this.networkFront = null; + // Promise resovled when the UI init has completed. + this.inited = this.init(); + + EventEmitter.decorate(this); + } + + get toolWindow() { + return this.rdmFrame.contentWindow; + } + + get docShell() { + return this.toolWindow.docShell; + } + + get viewportElement() { + return this.browserStackEl.querySelector("browser"); + } + + get currentTarget() { + return this.commands.targetCommand.targetFront; + } + + get watcherFront() { + return this.resourceCommand.watcherFront; + } + + /** + * Open RDM while preserving the state of the page. + */ + async init() { + debug("Init start"); + + this.initRDMFrame(); + + // Hide the browser content temporarily while things move around to avoid displaying + // strange intermediate states. + this.hideBrowserUI(); + + // Watch for tab close and window close so we can clean up RDM synchronously + this.tab.addEventListener("TabClose", this); + this.browserWindow.addEventListener("unload", this); + this.rdmFrame.contentWindow.addEventListener("message", this); + + this.tab.linkedBrowser.enterResponsiveMode(); + + // Listen to FullZoomChange events coming from the browser window, + // so that we can zoom the size of the viewport by the same amount. + this.browserWindow.addEventListener("FullZoomChange", this); + + // Get the protocol ready to speak with responsive emulation actor + debug("Wait until RDP server connect"); + await this.connectToServer(); + + // Restore the previous UI state. + await this.restoreUIState(); + + // Show the browser UI now that its state is ready. + this.showBrowserUI(); + + // Non-blocking message to tool UI to start any delayed init activities + message.post(this.toolWindow, "post-init"); + + debug("Init done"); + } + + /** + * Initialize the RDM iframe inside of the browser document. + */ + initRDMFrame() { + const { document: doc, gBrowser } = this.browserWindow; + const rdmFrame = doc.createElement("iframe"); + rdmFrame.src = "chrome://devtools/content/responsive/toolbar.xhtml"; + rdmFrame.classList.add("rdm-toolbar"); + + // Create resizer handlers + const resizeHandle = doc.createElement("div"); + resizeHandle.classList.add( + "rdm-viewport-resize-handle", + "viewport-resize-handle" + ); + const resizeHandleX = doc.createElement("div"); + resizeHandleX.classList.add( + "rdm-viewport-resize-handle", + "viewport-horizontal-resize-handle" + ); + const resizeHandleY = doc.createElement("div"); + resizeHandleY.classList.add( + "rdm-viewport-resize-handle", + "viewport-vertical-resize-handle" + ); + + this.browserContainerEl = gBrowser.getBrowserContainer( + gBrowser.getBrowserForTab(this.tab) + ); + this.browserStackEl = + this.browserContainerEl.querySelector(".browserStack"); + + this.browserContainerEl.classList.add("responsive-mode"); + + // Prepend the RDM iframe inside of the current tab's browser container. + this.browserContainerEl.prepend(rdmFrame); + + this.browserStackEl.append(resizeHandle); + this.browserStackEl.append(resizeHandleX); + this.browserStackEl.append(resizeHandleY); + + // Wait for the frame script to be loaded. + message.wait(rdmFrame.contentWindow, "script-init").then(async () => { + // Notify the frame window that the Resposnive UI manager has begun initializing. + // At this point, we can render our React content inside the frame. + message.post(rdmFrame.contentWindow, "init"); + // Wait for the tools to be rendered above the content. The frame script will + // then dispatch the necessary actions to the Redux store to give the toolbar the + // state it needs. + message.wait(rdmFrame.contentWindow, "init:done").then(() => { + rdmFrame.contentWindow.addInitialViewport({ + userContextId: this.tab.userContextId, + }); + }); + }); + + this.rdmFrame = rdmFrame; + + this.resizeHandle = resizeHandle; + this.resizeHandle.addEventListener("mousedown", this.onResizeStart); + + this.resizeHandleX = resizeHandleX; + this.resizeHandleX.addEventListener("mousedown", this.onResizeStart); + + this.resizeHandleY = resizeHandleY; + this.resizeHandleY.addEventListener("mousedown", this.onResizeStart); + + this.resizeToolbarObserver = new this.browserWindow.ResizeObserver( + entries => { + for (const entry of entries) { + // If the toolbar needs extra space for the UA input, then set a class + // that will accomodate its height. We should also make sure to keep + // the width value we're toggling against in sync with the media-query + // in devtools/client/responsive/index.css + this.rdmFrame.classList.toggle( + "accomodate-ua", + entry.contentBoxSize[0].inlineSize < 520 + ); + } + } + ); + + this.resizeToolbarObserver.observe(this.browserStackEl); + } + + /** + * Close RDM and restore page content back into a regular tab. + * + * @param object + * Destroy options, which currently includes a `reason` string. + * @return boolean + * Whether this call is actually destroying. False means destruction + * was already in progress. + */ + async destroy(options) { + if (this.destroying) { + return false; + } + this.destroying = true; + + // If our tab is about to be closed, there's not enough time to exit + // gracefully, but that shouldn't be a problem since the tab will go away. + // So, skip any waiting when we're about to close the tab. + const isTabDestroyed = + !this.tab.linkedBrowser || this.responsiveFront.isDestroyed(); + const isWindowClosing = options?.reason === "unload" || isTabDestroyed; + const isTabContentDestroying = + isWindowClosing || options?.reason === "TabClose"; + + // Ensure init has finished before starting destroy + if (!isTabContentDestroying) { + await this.inited; + + // Restore screen orientation of physical device. + await Promise.all([ + this.updateScreenOrientation("landscape-primary", 0), + this.updateMaxTouchPointsEnabled(false), + ]); + + // Hide browser UI to avoid displaying weird intermediate states while closing. + this.hideBrowserUI(); + + // Resseting the throtting needs to be done before the + // network events watching is stopped. + await this.updateNetworkThrottling(); + } + + this.tab.removeEventListener("TabClose", this); + this.browserWindow.removeEventListener("unload", this); + this.tab.linkedBrowser.leaveResponsiveMode(); + + this.browserWindow.removeEventListener("FullZoomChange", this); + this.rdmFrame.contentWindow.removeEventListener("message", this); + + // Remove observers on the stack. + this.resizeToolbarObserver.unobserve(this.browserStackEl); + + // Cleanup the frame content before disconnecting the frame element. + this.rdmFrame.contentWindow.destroy(); + + this.rdmFrame.remove(); + + // Clean up resize handlers + this.resizeHandle.remove(); + this.resizeHandleX.remove(); + this.resizeHandleY.remove(); + + this.browserContainerEl.classList.remove("responsive-mode"); + this.browserStackEl.style.removeProperty("--rdm-width"); + this.browserStackEl.style.removeProperty("--rdm-height"); + this.browserStackEl.style.removeProperty("--rdm-zoom"); + + // Ensure the tab is reloaded if required when exiting RDM so that no emulated + // settings are left in a customized state. + if (!isTabContentDestroying) { + let reloadNeeded = false; + await this.updateDPPX(null); + reloadNeeded |= + (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); + if (reloadNeeded) { + await this.reloadBrowser(); + } + + // Unwatch targets & resources as the last step. If we are not waching for + // any resource & target anymore, the JSWindowActors will be unregistered + // which will trigger an early destruction of the RDM target, before we + // could finalize the cleanup. + this.commands.targetCommand.unwatchTargets({ + types: [this.commands.targetCommand.TYPES.FRAME], + onAvailable: this.onTargetAvailable, + }); + + this.resourceCommand.unwatchResources( + [this.resourceCommand.TYPES.NETWORK_EVENT], + { onAvailable: this.onNetworkResourceAvailable } + ); + + this.commands.targetCommand.destroy(); + } + + // Show the browser UI now. + this.showBrowserUI(); + + // Destroy local state + this.browserContainerEl = null; + this.browserStackEl = null; + this.browserWindow = null; + this.tab = null; + this.inited = null; + this.rdmFrame = null; + this.resizeHandle = null; + this.resizeHandleX = null; + this.resizeHandleY = null; + this.resizeToolbarObserver = null; + + // Destroying the commands will close the devtools client used to speak with responsive emulation actor. + // The actor handles clearing any overrides itself, so it's not necessary to clear + // anything on shutdown client side. + const commandsDestroyed = this.commands.destroy(); + if (!isTabContentDestroying) { + await commandsDestroyed; + } + this.commands = this.responsiveFront = null; + this.destroyed = true; + + return true; + } + + async connectToServer() { + this.commands = await CommandsFactory.forTab(this.tab); + this.resourceCommand = this.commands.resourceCommand; + + await this.commands.targetCommand.startListening(); + + await this.commands.targetCommand.watchTargets({ + types: [this.commands.targetCommand.TYPES.FRAME], + onAvailable: this.onTargetAvailable, + }); + + // To support network throttling the resource command + // needs to be watching for network resources. + await this.resourceCommand.watchResources( + [this.resourceCommand.TYPES.NETWORK_EVENT], + { onAvailable: this.onNetworkResourceAvailable } + ); + + this.networkFront = await this.watcherFront.getNetworkParentActor(); + } + + /** + * Show one-time notification about reloads for responsive emulation. + */ + showReloadNotification() { + if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) { + showNotification(this.browserWindow, this.tab, { + msg: l10n.getFormatStr("responsive.reloadNotification.description2"), + }); + Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false); + } + } + + reloadOnChange(id) { + this.showReloadNotification(); + const pref = RELOAD_CONDITION_PREF_PREFIX + id; + return Services.prefs.getBoolPref(pref, false); + } + + hideBrowserUI() { + this.tab.linkedBrowser.style.visibility = "hidden"; + this.resizeHandle.style.visibility = "hidden"; + } + + showBrowserUI() { + this.tab.linkedBrowser.style.removeProperty("visibility"); + this.resizeHandle.style.removeProperty("visibility"); + } + + handleEvent(event) { + const { browserWindow, tab } = this; + + switch (event.type) { + case "message": + this.handleMessage(event); + break; + case "FullZoomChange": + // Get the current device size and update to that size, which + // will pick up changes to the zoom. + const { width, height } = this.getViewportSize(); + this.updateViewportSize(width, height); + break; + case "TabClose": + case "unload": + this.manager.closeIfNeeded(browserWindow, tab, { + reason: event.type, + }); + break; + } + } + + handleMessage(event) { + if (event.origin !== "chrome://devtools") { + return; + } + + switch (event.data.type) { + case "change-device": + this.onChangeDevice(event); + break; + case "change-network-throttling": + this.onChangeNetworkThrottling(event); + break; + case "change-pixel-ratio": + this.onChangePixelRatio(event); + break; + case "change-touch-simulation": + this.onChangeTouchSimulation(event); + break; + case "change-user-agent": + this.onChangeUserAgent(event); + break; + case "exit": + this.onExit(); + break; + case "remove-device-association": + this.onRemoveDeviceAssociation(); + break; + case "viewport-orientation-change": + this.onRotateViewport(event); + break; + case "viewport-resize": + this.onResizeViewport(event); + break; + case "screenshot": + this.onScreenshot(); + break; + case "toggle-left-alignment": + this.onToggleLeftAlignment(event); + break; + case "update-device-modal": + this.onUpdateDeviceModal(event); + break; + } + } + + async onChangeDevice(event) { + const { pixelRatio, touch, userAgent } = event.data.device; + let reloadNeeded = false; + await this.updateDPPX(pixelRatio); + + // Get the orientation values of the device we are changing to and update. + const { device, viewport } = event.data; + const { type, angle } = getOrientation(device, viewport); + await this.updateScreenOrientation(type, angle); + await this.updateMaxTouchPointsEnabled(touch); + + reloadNeeded |= + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(touch, reloadOnTouchSimulationChange); + + if (reloadNeeded) { + this.reloadBrowser(); + } + + // Used by tests + this.emitForTests("device-changed", { + reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange, + }); + } + + async onChangeNetworkThrottling(event) { + const { enabled, profile } = event.data; + await this.updateNetworkThrottling(enabled, profile); + // Used by tests + this.emit("network-throttling-changed"); + } + + onChangePixelRatio(event) { + const { pixelRatio } = event.data; + this.updateDPPX(pixelRatio); + } + + async onChangeTouchSimulation(event) { + const { enabled } = event.data; + + await this.updateMaxTouchPointsEnabled(enabled); + + await this.updateTouchSimulation( + enabled, + this.reloadOnChange("touchSimulation") + ); + + // Used by tests + this.emit("touch-simulation-changed"); + } + + async onChangeUserAgent(event) { + const { userAgent } = event.data; + const reloadNeeded = + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + if (reloadNeeded) { + this.reloadBrowser(); + } + this.emit("user-agent-changed"); + } + + onExit() { + const { browserWindow, tab } = this; + this.manager.closeIfNeeded(browserWindow, tab); + } + + async onRemoveDeviceAssociation() { + let reloadNeeded = false; + await this.updateDPPX(null); + reloadNeeded |= + (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); + + // Don't reload on the server if we're already doing a reload on the client + const reloadOnTouchSimulationChange = + this.reloadOnChange("touchSimulation") && !reloadNeeded; + await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); + if (reloadNeeded) { + this.reloadBrowser(); + } + // Used by tests + this.emitForTests("device-association-removed", { + reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange, + }); + } + + /** + * Resizing the browser on mousemove + */ + onResizeDrag({ screenX, screenY }) { + if (!this.isResizing || !this.rdmFrame.contentWindow) { + return; + } + + const zoom = this.tab.linkedBrowser.fullZoom; + + let deltaX = (screenX - this.lastScreenX) / zoom; + let deltaY = (screenY - this.lastScreenY) / zoom; + + const leftAlignmentEnabled = Services.prefs.getBoolPref( + "devtools.responsive.leftAlignViewport.enabled", + false + ); + + if (!leftAlignmentEnabled) { + // The viewport is centered horizontally, so horizontal resize resizes + // by twice the distance the mouse was dragged - on left and right side. + deltaX = deltaX * 2; + } + + if (this.ignoreX) { + deltaX = 0; + } + if (this.ignoreY) { + deltaY = 0; + } + + const viewportSize = this.getViewportSize(); + + let width = Math.round(viewportSize.width + deltaX); + let height = Math.round(viewportSize.height + deltaY); + + if (width < Constants.MIN_VIEWPORT_DIMENSION) { + width = Constants.MIN_VIEWPORT_DIMENSION; + } else if (width != viewportSize.width) { + this.lastScreenX = screenX; + } + + if (height < Constants.MIN_VIEWPORT_DIMENSION) { + height = Constants.MIN_VIEWPORT_DIMENSION; + } else if (height != viewportSize.height) { + this.lastScreenY = screenY; + } + + // Update the RDM store and viewport size with the new width and height. + this.rdmFrame.contentWindow.setViewportSize({ width, height }); + this.updateViewportSize(width, height); + + // Change the device selector back to an unselected device + if (this.rdmFrame.contentWindow.getAssociatedDevice()) { + this.rdmFrame.contentWindow.clearDeviceAssociation(); + } + } + + /** + * Start the process of resizing the browser. + */ + onResizeStart({ target, screenX, screenY }) { + this.browserWindow.addEventListener("mousemove", this.onResizeDrag, true); + this.browserWindow.addEventListener("mouseup", this.onResizeStop, true); + + this.isResizing = true; + this.lastScreenX = screenX; + this.lastScreenY = screenY; + this.ignoreX = target === this.resizeHandleY; + this.ignoreY = target === this.resizeHandleX; + } + + /** + * Stop the process of resizing the browser. + */ + onResizeStop() { + this.browserWindow.removeEventListener( + "mousemove", + this.onResizeDrag, + true + ); + this.browserWindow.removeEventListener("mouseup", this.onResizeStop, true); + + this.isResizing = false; + this.lastScreenX = 0; + this.lastScreenY = 0; + this.ignoreX = false; + this.ignoreY = false; + + // Used by tests. + this.emit("viewport-resize-dragend"); + } + + onResizeViewport(event) { + const { width, height } = event.data; + this.updateViewportSize(width, height); + this.emit("viewport-resize", { + width, + height, + }); + } + + async onRotateViewport(event) { + const { orientationType: type, angle, isViewportRotated } = event.data; + await this.updateScreenOrientation(type, angle, isViewportRotated); + } + + async onScreenshot() { + const messages = await captureAndSaveScreenshot( + this.currentTarget, + this.browserWindow + ); + + const priorityMap = { + error: PriorityLevels.PRIORITY_CRITICAL_HIGH, + warn: PriorityLevels.PRIORITY_WARNING_HIGH, + }; + for (const { text, level } of messages) { + // captureAndSaveScreenshot returns "saved" messages, that indicate where the + // screenshot was saved. We don't want to display them as the download UI can be + // used to open the file. + if (level !== "warn" && level !== "error") { + continue; + } + + showNotification(this.browserWindow, this.tab, { + msg: text, + priority: priorityMap[level], + }); + } + + message.post(this.rdmFrame.contentWindow, "screenshot-captured"); + } + + onToggleLeftAlignment(event) { + this.updateUIAlignment(event.data.leftAlignmentEnabled); + } + + onUpdateDeviceModal(event) { + this.rdmFrame.classList.toggle("device-modal-opened", event.data.isOpen); + } + + async hasDeviceState() { + const deviceState = await asyncStorage.getItem( + "devtools.responsive.deviceState" + ); + return !!deviceState; + } + + /** + * Restores the previous UI state. + */ + async restoreUIState() { + const leftAlignmentEnabled = Services.prefs.getBoolPref( + "devtools.responsive.leftAlignViewport.enabled", + false + ); + + this.updateUIAlignment(leftAlignmentEnabled); + + const height = Services.prefs.getIntPref( + "devtools.responsive.viewport.height", + 0 + ); + const width = Services.prefs.getIntPref( + "devtools.responsive.viewport.width", + 0 + ); + this.updateViewportSize(width, height); + } + + /** + * Restores the previous actor state. + * + * @param {Boolean} isTargetSwitching + */ + async restoreActorState(isTargetSwitching) { + // It's possible the target will switch to a page loaded in the + // parent-process (i.e: about:robots). When this happens, the values set + // on the BrowsingContext by RDM are not preserved. So we need to call + // enterResponsiveMode whenever there is a target switch. + this.tab.linkedBrowser.enterResponsiveMode(); + + // If the target follows the window global lifecycle, the configuration was already + // restored from the server during target switch, so we can stop here. + // This function is still called at startup to restore potential state from previous + // RDM session so we only stop here during target switching. + if ( + isTargetSwitching && + this.commands.targetCommand.targetFront.targetForm + .followWindowGlobalLifeCycle + ) { + return; + } + + const hasDeviceState = await this.hasDeviceState(); + if (hasDeviceState) { + // Return if there is a device state to restore, this will be done when the + // device list is loaded after the post-init. + return; + } + + const height = Services.prefs.getIntPref( + "devtools.responsive.viewport.height", + 0 + ); + const pixelRatio = Services.prefs.getIntPref( + "devtools.responsive.viewport.pixelRatio", + 0 + ); + const touchSimulationEnabled = Services.prefs.getBoolPref( + "devtools.responsive.touchSimulation.enabled", + false + ); + const userAgent = Services.prefs.getCharPref( + "devtools.responsive.userAgent", + "" + ); + const width = Services.prefs.getIntPref( + "devtools.responsive.viewport.width", + 0 + ); + + // Restore the previously set orientation, or get it from the initial viewport if it + // wasn't set yet. + const { type, angle } = + this.commands.targetConfigurationCommand.configuration + .rdmPaneOrientation || + this.getInitialViewportOrientation({ + width, + height, + }); + + await this.updateDPPX(pixelRatio); + await this.updateScreenOrientation(type, angle); + await this.updateMaxTouchPointsEnabled(touchSimulationEnabled); + + if (touchSimulationEnabled) { + await this.updateTouchSimulation(touchSimulationEnabled); + } + + let reloadNeeded = false; + if (userAgent) { + reloadNeeded |= + (await this.updateUserAgent(userAgent)) && + this.reloadOnChange("userAgent"); + } + if (reloadNeeded) { + await this.reloadBrowser(); + } + } + + /** + * Set or clear the emulated device pixel ratio. + * + * @param {Number|null} dppx: The ratio to simulate. Set to null to disable the + * simulation and roll back to the original ratio + */ + async updateDPPX(dppx = null) { + await this.commands.targetConfigurationCommand.updateConfiguration({ + overrideDPPX: dppx, + }); + } + + /** + * Set or clear network throttling. + * + * @return boolean + * Whether a reload is needed to apply the change. + * (This is always immediate, so it's always false.) + */ + async updateNetworkThrottling(enabled, profile) { + if (!enabled) { + await this.networkFront.clearNetworkThrottling(); + return false; + } + const data = throttlingProfiles.find(({ id }) => id == profile); + const { download, upload, latency } = data; + await this.networkFront.setNetworkThrottling({ + downloadThroughput: download, + uploadThroughput: upload, + latency, + }); + return false; + } + + /** + * Set or clear the emulated user agent. + * + * @param {String|null} userAgent: The user agent to set on the page. Set to null to revert + * the user agent to its original value + * @return {Boolean} Whether a reload is needed to apply the change. + */ + async updateUserAgent(userAgent) { + const getConfigurationCustomUserAgent = () => + this.commands.targetConfigurationCommand.configuration.customUserAgent || + ""; + const previousCustomUserAgent = getConfigurationCustomUserAgent(); + await this.commands.targetConfigurationCommand.updateConfiguration({ + customUserAgent: userAgent, + }); + + const updatedUserAgent = getConfigurationCustomUserAgent(); + return previousCustomUserAgent !== updatedUserAgent; + } + + /** + * Set or clear touch simulation. When setting to true, this method will + * additionally set meta viewport override. + * When setting to false, this method will clear all touch simulation and meta viewport + * overrides, returning to default behavior for both settings. + * + * @param {boolean} enabled + * @param {boolean} reloadOnTouchSimulationToggle: Set to true to trigger a page reload + * if the touch simulation state changes. + */ + async updateTouchSimulation(enabled, reloadOnTouchSimulationToggle) { + await this.commands.targetConfigurationCommand.updateConfiguration({ + touchEventsOverride: enabled ? "enabled" : null, + reloadOnTouchSimulationToggle, + }); + } + + /** + * Sets the screen orientation values of the simulated device. + * + * @param {String} type + * The orientation type to update the current device screen to. + * @param {Number} angle + * The rotation angle to update the current device screen to. + * @param {Boolean} isViewportRotated + * Whether or not the reason for updating the screen orientation is a result + * of actually rotating the device via the RDM toolbar. If true, then an + * "orientationchange" event is simulated. Otherwise, the screen orientation is + * updated because of changing devices, opening RDM, or the page has been + * reloaded/navigated to, so we should not be simulating "orientationchange". + */ + async updateScreenOrientation(type, angle, isViewportRotated = false) { + await this.commands.targetConfigurationCommand.simulateScreenOrientationChange( + { + type, + angle, + isViewportRotated, + } + ); + } + + /** + * Sets whether or not maximum touch points are supported for the simulated device. + * + * @param {Boolean} touchSimulationEnabled + * Whether or not touch is enabled for the simulated device. + */ + async updateMaxTouchPointsEnabled(touchSimulationEnabled) { + return this.commands.targetConfigurationCommand.updateConfiguration({ + rdmPaneMaxTouchPoints: touchSimulationEnabled ? 1 : 0, + }); + } + + /** + * Sets whether or not the RDM UI should be left-aligned. + * + * @param {Boolean} leftAlignmentEnabled + * Whether or not the UI is left-aligned. + */ + updateUIAlignment(leftAlignmentEnabled) { + this.browserContainerEl.classList.toggle( + "left-aligned", + leftAlignmentEnabled + ); + } + + /** + * Sets the browser element to be the given width and height. + * + * @param {Number} width + * The viewport's width. + * @param {Number} height + * The viewport's height. + */ + updateViewportSize(width, height) { + const zoom = this.tab.linkedBrowser.fullZoom; + + // Setting this with a variable on the stack instead of directly as width/height + // on the <browser> because we'll need to use this for the alert dialog as well. + this.browserStackEl.style.setProperty("--rdm-width", `${width}px`); + this.browserStackEl.style.setProperty("--rdm-height", `${height}px`); + this.browserStackEl.style.setProperty("--rdm-zoom", zoom); + + // This is a bit premature, but we emit a content-resize event here. It + // would be preferrable to wait until the viewport is actually resized, + // but the "resize" event is not triggered by this style change. The + // content-resize message is only used by tests, and if needed those tests + // can use the testing function setViewportSizeAndAwaitReflow to ensure + // the viewport has had time to reach this size. + this.emit("content-resize", { + width, + height, + }); + } + + /** + * Helper for tests. Assumes a single viewport for now. + */ + getViewportSize() { + // The getViewportSize function is loaded in index.js, and might not be + // available yet. + if (this.toolWindow.getViewportSize) { + return this.toolWindow.getViewportSize(); + } + + return { width: 0, height: 0 }; + } + + /** + * Helper for tests, etc. Assumes a single viewport for now. + */ + async setViewportSize(size) { + await this.inited; + + // Ensure that width and height are valid. + let { width, height } = size; + if (!size.width) { + width = this.getViewportSize().width; + } + + if (!size.height) { + height = this.getViewportSize().height; + } + + this.rdmFrame.contentWindow.setViewportSize({ width, height }); + this.updateViewportSize(width, height); + } + + /** + * Helper for tests/reloading the viewport. Assumes a single viewport for now. + */ + getViewportBrowser() { + return this.tab.linkedBrowser; + } + + /** + * Helper for contacting the viewport content. Assumes a single viewport for now. + */ + getViewportMessageManager() { + return this.getViewportBrowser().messageManager; + } + + /** + * Helper for getting the initial viewport orientation. + */ + getInitialViewportOrientation(viewport) { + return getOrientation(viewport, viewport); + } + + /** + * Helper for tests to get the browser's window. + */ + getBrowserWindow() { + return this.browserWindow; + } + + async onTargetAvailable({ targetFront, isTargetSwitching }) { + if (this.destroying) { + return; + } + + if (targetFront.isTopLevel) { + this.responsiveFront = await targetFront.getFront("responsive"); + + if (this.destroying) { + return; + } + + await this.restoreActorState(isTargetSwitching); + this.emitForTests("responsive-ui-target-switch-done"); + } + } + // This just needed to setup watching for network resources, + // to support network throttling. + onNetworkResourceAvailable() {} + + /** + * Reload the current tab. + */ + async reloadBrowser() { + await this.commands.targetCommand.reloadTopLevelTarget(); + } +} + +module.exports = ResponsiveUI; |