ui.js (34975B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const { 9 getOrientation, 10 } = require("resource://devtools/client/responsive/utils/orientation.js"); 11 const Constants = require("resource://devtools/client/responsive/constants.js"); 12 const { 13 CommandsFactory, 14 } = require("resource://devtools/shared/commands/commands-factory.js"); 15 16 loader.lazyRequireGetter( 17 this, 18 "throttlingProfiles", 19 "resource://devtools/client/shared/components/throttling/profiles.js" 20 ); 21 loader.lazyRequireGetter( 22 this, 23 "message", 24 "resource://devtools/client/responsive/utils/message.js" 25 ); 26 loader.lazyRequireGetter( 27 this, 28 "showNotification", 29 "resource://devtools/client/responsive/utils/notification.js", 30 true 31 ); 32 loader.lazyRequireGetter( 33 this, 34 "PriorityLevels", 35 "resource://devtools/client/shared/components/NotificationBox.js", 36 true 37 ); 38 loader.lazyRequireGetter( 39 this, 40 "l10n", 41 "resource://devtools/client/responsive/utils/l10n.js" 42 ); 43 loader.lazyRequireGetter( 44 this, 45 "asyncStorage", 46 "resource://devtools/shared/async-storage.js" 47 ); 48 loader.lazyRequireGetter( 49 this, 50 "captureAndSaveScreenshot", 51 "resource://devtools/client/shared/screenshot.js", 52 true 53 ); 54 55 const RELOAD_CONDITION_PREF_PREFIX = "devtools.responsive.reloadConditions."; 56 const RELOAD_NOTIFICATION_PREF = 57 "devtools.responsive.reloadNotification.enabled"; 58 const USE_DYNAMIC_TOOLBAR_PREF = "devtools.responsive.dynamicToolbar.enabled"; 59 const DYNAMIC_TOOLBAR_MAX_HEIGHT = 40; // px 60 61 function debug(_msg) { 62 // console.log(`RDM manager: ${_msg}`); 63 } 64 65 /** 66 * ResponsiveUI manages the responsive design tool for a specific tab. The 67 * actual tool itself lives in a separate chrome:// document that is loaded into 68 * the tab upon opening responsive design. This object acts a helper to 69 * integrate the tool into the surrounding browser UI as needed. 70 */ 71 class ResponsiveUI { 72 /** 73 * @param {ResponsiveUIManager} manager 74 * The ResponsiveUIManager instance. 75 * @param {ChromeWindow} window 76 * The main browser chrome window (that holds many tabs). 77 * @param {Tab} tab 78 * The specific browser <tab> element this responsive instance is for. 79 */ 80 constructor(manager, window, tab) { 81 this.manager = manager; 82 // The main browser chrome window (that holds many tabs). 83 this.browserWindow = window; 84 // The specific browser tab this responsive instance is for. 85 this.tab = tab; 86 87 // Flag set when destruction has begun. 88 this.destroying = false; 89 // Flag set when destruction has ended. 90 this.destroyed = false; 91 // The iframe containing the RDM UI. 92 this.rdmFrame = null; 93 94 // Bind callbacks for resizers. 95 this.onResizeDrag = this.onResizeDrag.bind(this); 96 this.onResizeStart = this.onResizeStart.bind(this); 97 this.onResizeStop = this.onResizeStop.bind(this); 98 99 this.onTargetAvailable = this.onTargetAvailable.bind(this); 100 this.onContentScrolled = this.onContentScrolled.bind(this); 101 102 this.networkFront = null; 103 // Promise resolved when the UI init has completed. 104 const { promise, resolve } = Promise.withResolvers(); 105 this.initialized = promise; 106 this.resolveInited = resolve; 107 108 this.dynamicToolbar = null; 109 EventEmitter.decorate(this); 110 } 111 112 get toolWindow() { 113 return this.rdmFrame.contentWindow; 114 } 115 116 get docShell() { 117 return this.toolWindow.docShell; 118 } 119 120 get viewportElement() { 121 return this.browserStackEl.querySelector("browser"); 122 } 123 124 get currentTarget() { 125 return this.commands.targetCommand.targetFront; 126 } 127 128 get watcherFront() { 129 return this.resourceCommand.watcherFront; 130 } 131 132 /** 133 * Open RDM while preserving the state of the page. 134 */ 135 async initialize() { 136 debug("Init start"); 137 138 this.initRDMFrame(); 139 140 // Hide the browser content temporarily while things move around to avoid displaying 141 // strange intermediate states. 142 this.hideBrowserUI(); 143 144 // Watch for tab close and window close so we can clean up RDM synchronously 145 this.tab.addEventListener("TabClose", this); 146 this.browserWindow.addEventListener("unload", this); 147 this.rdmFrame.contentWindow.addEventListener("message", this); 148 149 this.tab.linkedBrowser.enterResponsiveMode(); 150 151 // Listen to FullZoomChange events coming from the browser window, 152 // so that we can zoom the size of the viewport by the same amount. 153 this.browserWindow.addEventListener("FullZoomChange", this); 154 155 // Get the protocol ready to speak with responsive emulation actor 156 debug("Wait until RDP server connect"); 157 await this.connectToServer(); 158 159 // Restore the previous UI state. 160 await this.restoreUIState(); 161 162 // Show the browser UI now that its state is ready. 163 this.showBrowserUI(); 164 165 // Non-blocking message to tool UI to start any delayed init activities 166 message.post(this.toolWindow, "post-init"); 167 168 debug("Init done"); 169 this.resolveInited(); 170 } 171 172 /** 173 * Initialize the RDM iframe inside of the browser document. 174 */ 175 initRDMFrame() { 176 const { document: doc, gBrowser } = this.browserWindow; 177 const rdmFrame = doc.createElement("iframe"); 178 rdmFrame.src = "chrome://devtools/content/responsive/toolbar.xhtml"; 179 rdmFrame.classList.add("rdm-toolbar"); 180 181 // Create dynamic toolbar 182 this.dynamicToolbar = doc.createElement("div"); 183 this.dynamicToolbar.classList.add("rdm-dynamic-toolbar", "dynamic-toolbar"); 184 this.dynamicToolbar.style.visibility = "hidden"; 185 186 if (Services.prefs.getBoolPref(USE_DYNAMIC_TOOLBAR_PREF)) { 187 this.dynamicToolbar.style.visibility = "visible"; 188 this.dynamicToolbar.style.height = DYNAMIC_TOOLBAR_MAX_HEIGHT + "px"; 189 InspectorUtils.setDynamicToolbarMaxHeight( 190 this.tab.linkedBrowser.browsingContext, 191 DYNAMIC_TOOLBAR_MAX_HEIGHT 192 ); 193 InspectorUtils.setVerticalClipping( 194 this.tab.linkedBrowser.browsingContext, 195 0 196 ); 197 } 198 199 // Create resizer handlers 200 const resizeHandle = doc.createElement("div"); 201 resizeHandle.classList.add( 202 "rdm-viewport-resize-handle", 203 "viewport-resize-handle" 204 ); 205 const resizeHandleX = doc.createElement("div"); 206 resizeHandleX.classList.add( 207 "rdm-viewport-resize-handle", 208 "viewport-horizontal-resize-handle" 209 ); 210 const resizeHandleY = doc.createElement("div"); 211 resizeHandleY.classList.add( 212 "rdm-viewport-resize-handle", 213 "viewport-vertical-resize-handle" 214 ); 215 216 this.browserContainerEl = gBrowser.getBrowserContainer( 217 gBrowser.getBrowserForTab(this.tab) 218 ); 219 this.browserStackEl = 220 this.browserContainerEl.querySelector(".browserStack"); 221 222 this.browserContainerEl.classList.add("responsive-mode"); 223 224 // Prepend the RDM iframe inside of the current tab's browser container. 225 this.browserContainerEl.prepend(rdmFrame); 226 227 this.browserStackEl.append( 228 this.dynamicToolbar, 229 resizeHandle, 230 resizeHandleX, 231 resizeHandleY 232 ); 233 234 // Wait for the frame script to be loaded. 235 message.wait(rdmFrame.contentWindow, "script-init").then(async () => { 236 // Notify the frame window that the Resposnive UI manager has begun initializing. 237 // At this point, we can render our React content inside the frame. 238 message.post(rdmFrame.contentWindow, "init"); 239 // Wait for the tools to be rendered above the content. The frame script will 240 // then dispatch the necessary actions to the Redux store to give the toolbar the 241 // state it needs. 242 message.wait(rdmFrame.contentWindow, "init:done").then(() => { 243 rdmFrame.contentWindow.addInitialViewport({ 244 userContextId: this.tab.userContextId, 245 }); 246 }); 247 }); 248 249 this.rdmFrame = rdmFrame; 250 251 this.resizeHandle = resizeHandle; 252 this.resizeHandle.addEventListener("mousedown", this.onResizeStart); 253 254 this.resizeHandleX = resizeHandleX; 255 this.resizeHandleX.addEventListener("mousedown", this.onResizeStart); 256 257 this.resizeHandleY = resizeHandleY; 258 this.resizeHandleY.addEventListener("mousedown", this.onResizeStart); 259 260 this.resizeToolbarObserver = new this.browserWindow.ResizeObserver( 261 entries => { 262 for (const entry of entries) { 263 // If the toolbar needs extra space for the UA input, then set a class 264 // that will accomodate its height. We should also make sure to keep 265 // the width value we're toggling against in sync with the media-query 266 // in devtools/client/responsive/index.css 267 this.rdmFrame.classList.toggle( 268 "accomodate-ua", 269 entry.contentBoxSize[0].inlineSize <= 800 270 ); 271 } 272 } 273 ); 274 275 this.resizeToolbarObserver.observe(this.browserStackEl); 276 } 277 278 /** 279 * Close RDM and restore page content back into a regular tab. 280 * 281 * @param object 282 * Destroy options, which currently includes a `reason` string. 283 * @return boolean 284 * Whether this call is actually destroying. False means destruction 285 * was already in progress. 286 */ 287 async destroy(options) { 288 if (this.destroying) { 289 return false; 290 } 291 this.destroying = true; 292 293 // If our tab is about to be closed, there's not enough time to exit 294 // gracefully, but that shouldn't be a problem since the tab will go away. 295 // So, skip any waiting when we're about to close the tab. 296 const isTabDestroyed = !this.tab.linkedBrowser; 297 const isWindowClosing = options?.reason === "unload" || isTabDestroyed; 298 const isTabContentDestroying = 299 isWindowClosing || options?.reason === "TabClose"; 300 301 // Ensure init has finished before starting destroy 302 if (!isTabContentDestroying) { 303 await this.initialized; 304 305 // Restore screen orientation of physical device. 306 await Promise.all([ 307 this.updateScreenOrientation("landscape-primary", 0), 308 this.updateMaxTouchPointsEnabled(false), 309 ]); 310 311 // Hide browser UI to avoid displaying weird intermediate states while closing. 312 this.hideBrowserUI(); 313 314 // Resseting the throtting needs to be done before the 315 // network events watching is stopped. 316 await this.updateNetworkThrottling(); 317 } 318 319 this.tab.removeEventListener("TabClose", this); 320 this.browserWindow.removeEventListener("unload", this); 321 this.tab.linkedBrowser.leaveResponsiveMode(); 322 323 this.browserWindow.removeEventListener("FullZoomChange", this); 324 this.rdmFrame.contentWindow.removeEventListener("message", this); 325 326 // Remove observers on the stack. 327 this.resizeToolbarObserver.unobserve(this.browserStackEl); 328 329 // Cleanup the frame content before disconnecting the frame element. 330 this.rdmFrame.contentWindow.destroy(); 331 332 this.rdmFrame.remove(); 333 334 // Clean up resize handlers 335 this.resizeHandle.remove(); 336 this.resizeHandleX.remove(); 337 this.resizeHandleY.remove(); 338 this.dynamicToolbar.remove(); 339 340 this.browserContainerEl.classList.remove("responsive-mode"); 341 this.browserStackEl.style.removeProperty("--rdm-width"); 342 this.browserStackEl.style.removeProperty("--rdm-height"); 343 this.browserStackEl.style.removeProperty("--rdm-zoom"); 344 345 // Ensure the tab is reloaded if required when exiting RDM so that no emulated 346 // settings are left in a customized state. 347 if (!isTabContentDestroying) { 348 let reloadNeeded = false; 349 await this.updateDPPX(null); 350 reloadNeeded |= 351 (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); 352 353 // Don't reload on the server if we're already doing a reload on the client 354 const reloadOnTouchSimulationChange = 355 this.reloadOnChange("touchSimulation") && !reloadNeeded; 356 await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); 357 if (reloadNeeded) { 358 await this.reloadBrowser(); 359 } 360 361 // Unwatch targets & resources as the last step. If we are not waching for 362 // any resource & target anymore, the JSWindowActors will be unregistered 363 // which will trigger an early destruction of the RDM target, before we 364 // could finalize the cleanup. 365 this.commands.targetCommand.unwatchTargets({ 366 types: [this.commands.targetCommand.TYPES.FRAME], 367 onAvailable: this.onTargetAvailable, 368 }); 369 370 this.resourceCommand.unwatchResources( 371 [this.resourceCommand.TYPES.NETWORK_EVENT], 372 { onAvailable: this.onNetworkResourceAvailable } 373 ); 374 375 this.commands.targetCommand.destroy(); 376 } 377 378 // Show the browser UI now. 379 this.showBrowserUI(); 380 381 // Destroy local state 382 this.browserContainerEl = null; 383 this.browserStackEl = null; 384 this.browserWindow = null; 385 this.tab = null; 386 this.initialized = null; 387 this.rdmFrame = null; 388 this.resizeHandle = null; 389 this.resizeHandleX = null; 390 this.resizeHandleY = null; 391 this.dynamicToolbar = null; 392 this.resizeToolbarObserver = null; 393 394 // Destroying the commands will close the devtools client used to speak with responsive emulation actor. 395 // The actor handles clearing any overrides itself, so it's not necessary to clear 396 // anything on shutdown client side. 397 const commandsDestroyed = this.commands.destroy(); 398 if (!isTabContentDestroying) { 399 await commandsDestroyed; 400 } 401 this.commands = null; 402 this.destroyed = true; 403 404 return true; 405 } 406 407 async connectToServer() { 408 this.commands = await CommandsFactory.forTab(this.tab); 409 this.resourceCommand = this.commands.resourceCommand; 410 411 await this.commands.targetCommand.startListening(); 412 413 await this.commands.targetCommand.watchTargets({ 414 types: [this.commands.targetCommand.TYPES.FRAME], 415 onAvailable: this.onTargetAvailable, 416 }); 417 418 // To support network throttling the resource command 419 // needs to be watching for network resources. 420 await this.resourceCommand.watchResources( 421 [this.resourceCommand.TYPES.NETWORK_EVENT], 422 { onAvailable: this.onNetworkResourceAvailable } 423 ); 424 425 this.networkFront = await this.watcherFront.getNetworkParentActor(); 426 } 427 428 /** 429 * Show one-time notification about reloads for responsive emulation. 430 */ 431 showReloadNotification() { 432 if (Services.prefs.getBoolPref(RELOAD_NOTIFICATION_PREF, false)) { 433 showNotification(this.browserWindow, this.tab, { 434 msg: l10n.getFormatStr("responsive.reloadNotification.description2"), 435 }); 436 Services.prefs.setBoolPref(RELOAD_NOTIFICATION_PREF, false); 437 } 438 } 439 440 reloadOnChange(id) { 441 this.showReloadNotification(); 442 const pref = RELOAD_CONDITION_PREF_PREFIX + id; 443 return Services.prefs.getBoolPref(pref, false); 444 } 445 446 hideBrowserUI() { 447 this.tab.linkedBrowser.style.visibility = "hidden"; 448 this.resizeHandle.style.visibility = "hidden"; 449 } 450 451 showBrowserUI() { 452 this.tab.linkedBrowser.style.removeProperty("visibility"); 453 this.resizeHandle.style.removeProperty("visibility"); 454 } 455 456 handleEvent(event) { 457 const { browserWindow, tab } = this; 458 459 switch (event.type) { 460 case "message": 461 this.handleMessage(event); 462 break; 463 case "FullZoomChange": { 464 // Get the current device size and update to that size, which 465 // will pick up changes to the zoom. 466 const { width, height } = this.getViewportSize(); 467 this.updateViewportSize(width, height); 468 break; 469 } 470 case "TabClose": 471 case "unload": 472 this.manager.closeIfNeeded(browserWindow, tab, { 473 reason: event.type, 474 }); 475 break; 476 } 477 } 478 479 handleMessage(event) { 480 if (event.origin !== "chrome://devtools") { 481 return; 482 } 483 484 switch (event.data.type) { 485 case "change-device": 486 this.onChangeDevice(event); 487 break; 488 case "change-network-throttling": 489 this.onChangeNetworkThrottling(event); 490 break; 491 case "change-pixel-ratio": 492 this.onChangePixelRatio(event); 493 break; 494 case "change-touch-simulation": 495 this.onChangeTouchSimulation(event); 496 break; 497 case "change-user-agent": 498 this.onChangeUserAgent(event); 499 break; 500 case "exit": 501 this.onExit(); 502 break; 503 case "remove-device-association": 504 this.onRemoveDeviceAssociation(event); 505 break; 506 case "viewport-orientation-change": 507 this.onRotateViewport(event); 508 break; 509 case "viewport-resize": 510 this.onResizeViewport(event); 511 break; 512 case "screenshot": 513 this.onScreenshot(); 514 break; 515 case "toggle-left-alignment": 516 this.onToggleLeftAlignment(event); 517 break; 518 case "update-device-modal": 519 this.onUpdateDeviceModal(event); 520 break; 521 } 522 } 523 524 async onChangeDevice(event) { 525 const { pixelRatio, touch, userAgent } = event.data.device; 526 let reloadNeeded = false; 527 await this.updateDPPX(pixelRatio); 528 529 // Get the orientation values of the device we are changing to and update. 530 const { device, viewport } = event.data; 531 const { type, angle } = getOrientation(device, viewport); 532 await this.updateScreenOrientation(type, angle); 533 await this.updateMaxTouchPointsEnabled(touch); 534 535 reloadNeeded |= 536 (await this.updateUserAgent(userAgent)) && 537 this.reloadOnChange("userAgent"); 538 539 // Don't reload on the server if we're already doing a reload on the client 540 const reloadOnTouchSimulationChange = 541 this.reloadOnChange("touchSimulation") && !reloadNeeded; 542 await this.updateTouchSimulation(touch, reloadOnTouchSimulationChange); 543 544 if (reloadNeeded) { 545 this.reloadBrowser(); 546 } 547 548 // Used by tests 549 this.emitForTests("device-changed", { 550 reloadTriggered: reloadNeeded || reloadOnTouchSimulationChange, 551 }); 552 } 553 554 async onChangeNetworkThrottling(event) { 555 const { enabled, profile } = event.data; 556 await this.updateNetworkThrottling(enabled, profile); 557 // Used by tests 558 this.emit("network-throttling-changed"); 559 } 560 561 onChangePixelRatio(event) { 562 const { pixelRatio } = event.data; 563 this.updateDPPX(pixelRatio); 564 } 565 566 async onChangeTouchSimulation(event) { 567 const { enabled } = event.data; 568 569 await this.updateMaxTouchPointsEnabled(enabled); 570 571 await this.updateTouchSimulation( 572 enabled, 573 this.reloadOnChange("touchSimulation") 574 ); 575 576 // Used by tests 577 this.emit("touch-simulation-changed"); 578 } 579 580 async onChangeUserAgent(event) { 581 const { userAgent } = event.data; 582 const reloadNeeded = 583 (await this.updateUserAgent(userAgent)) && 584 this.reloadOnChange("userAgent"); 585 if (reloadNeeded) { 586 this.reloadBrowser(); 587 } 588 this.emit("user-agent-changed"); 589 } 590 591 onExit() { 592 const { browserWindow, tab } = this; 593 this.manager.closeIfNeeded(browserWindow, tab); 594 } 595 596 async onRemoveDeviceAssociation(event) { 597 const { resetProfile } = event.data; 598 599 if (resetProfile) { 600 let reloadNeeded = false; 601 await this.updateDPPX(null); 602 reloadNeeded |= 603 (await this.updateUserAgent()) && this.reloadOnChange("userAgent"); 604 605 // Don't reload on the server if we're already doing a reload on the client 606 const reloadOnTouchSimulationChange = 607 this.reloadOnChange("touchSimulation") && !reloadNeeded; 608 await this.updateTouchSimulation(null, reloadOnTouchSimulationChange); 609 if (reloadNeeded) { 610 this.reloadBrowser(); 611 } 612 } 613 614 // Used by tests 615 this.emitForTests("device-association-removed"); 616 } 617 618 /** 619 * Resizing the browser on mousemove 620 */ 621 onResizeDrag({ screenX, screenY }) { 622 if (!this.isResizing || !this.rdmFrame.contentWindow) { 623 return; 624 } 625 626 const zoom = this.tab.linkedBrowser.fullZoom; 627 628 let deltaX = (screenX - this.lastScreenX) / zoom; 629 let deltaY = (screenY - this.lastScreenY) / zoom; 630 631 const leftAlignmentEnabled = Services.prefs.getBoolPref( 632 "devtools.responsive.leftAlignViewport.enabled", 633 false 634 ); 635 636 if (!leftAlignmentEnabled) { 637 // The viewport is centered horizontally, so horizontal resize resizes 638 // by twice the distance the mouse was dragged - on left and right side. 639 deltaX = deltaX * 2; 640 } 641 642 if (this.ignoreX) { 643 deltaX = 0; 644 } 645 if (this.ignoreY) { 646 deltaY = 0; 647 } 648 649 const viewportSize = this.getViewportSize(); 650 651 let width = Math.round(viewportSize.width + deltaX); 652 let height = Math.round(viewportSize.height + deltaY); 653 654 if (width < Constants.MIN_VIEWPORT_DIMENSION) { 655 width = Constants.MIN_VIEWPORT_DIMENSION; 656 } else if (width != viewportSize.width) { 657 this.lastScreenX = screenX; 658 } 659 660 if (height < Constants.MIN_VIEWPORT_DIMENSION) { 661 height = Constants.MIN_VIEWPORT_DIMENSION; 662 } else if (height != viewportSize.height) { 663 this.lastScreenY = screenY; 664 } 665 666 // Update the RDM store and viewport size with the new width and height. 667 this.rdmFrame.contentWindow.setViewportSize({ width, height }); 668 this.updateViewportSize(width, height); 669 670 // Change the device selector back to an unselected device 671 if (this.rdmFrame.contentWindow.getAssociatedDevice()) { 672 this.rdmFrame.contentWindow.clearDeviceAssociation(); 673 } 674 } 675 676 /** 677 * Start the process of resizing the browser. 678 */ 679 onResizeStart({ target, screenX, screenY }) { 680 this.browserWindow.addEventListener("mousemove", this.onResizeDrag, true); 681 this.browserWindow.addEventListener("mouseup", this.onResizeStop, true); 682 683 this.isResizing = true; 684 this.lastScreenX = screenX; 685 this.lastScreenY = screenY; 686 this.ignoreX = target === this.resizeHandleY; 687 this.ignoreY = target === this.resizeHandleX; 688 } 689 690 /** 691 * Stop the process of resizing the browser. 692 */ 693 onResizeStop() { 694 this.browserWindow.removeEventListener( 695 "mousemove", 696 this.onResizeDrag, 697 true 698 ); 699 this.browserWindow.removeEventListener("mouseup", this.onResizeStop, true); 700 701 this.isResizing = false; 702 this.lastScreenX = 0; 703 this.lastScreenY = 0; 704 this.ignoreX = false; 705 this.ignoreY = false; 706 707 // Used by tests. 708 this.emit("viewport-resize-dragend"); 709 } 710 711 onResizeViewport(event) { 712 const { width, height } = event.data; 713 this.updateViewportSize(width, height); 714 this.emit("viewport-resize", { 715 width, 716 height, 717 }); 718 } 719 720 async onRotateViewport(event) { 721 const { orientationType: type, angle } = event.data; 722 await this.updateScreenOrientation(type, angle); 723 } 724 725 async onScreenshot() { 726 const messages = await captureAndSaveScreenshot( 727 this.currentTarget, 728 this.browserWindow 729 ); 730 731 const priorityMap = { 732 error: PriorityLevels.PRIORITY_CRITICAL_HIGH, 733 warn: PriorityLevels.PRIORITY_WARNING_HIGH, 734 }; 735 for (const { text, level } of messages) { 736 // captureAndSaveScreenshot returns "saved" messages, that indicate where the 737 // screenshot was saved. We don't want to display them as the download UI can be 738 // used to open the file. 739 if (level !== "warn" && level !== "error") { 740 continue; 741 } 742 743 showNotification(this.browserWindow, this.tab, { 744 msg: text, 745 priority: priorityMap[level], 746 }); 747 } 748 749 message.post(this.rdmFrame.contentWindow, "screenshot-captured"); 750 } 751 752 onToggleLeftAlignment(event) { 753 this.updateUIAlignment(event.data.leftAlignmentEnabled); 754 } 755 756 onUpdateDeviceModal(event) { 757 this.rdmFrame.classList.toggle("device-modal-opened", event.data.isOpen); 758 } 759 760 async hasDeviceState() { 761 const deviceState = await asyncStorage.getItem( 762 "devtools.responsive.deviceState" 763 ); 764 return !!deviceState; 765 } 766 767 /** 768 * Restores the previous UI state. 769 */ 770 async restoreUIState() { 771 const leftAlignmentEnabled = Services.prefs.getBoolPref( 772 "devtools.responsive.leftAlignViewport.enabled", 773 false 774 ); 775 776 this.updateUIAlignment(leftAlignmentEnabled); 777 778 const height = Services.prefs.getIntPref( 779 "devtools.responsive.viewport.height", 780 0 781 ); 782 const width = Services.prefs.getIntPref( 783 "devtools.responsive.viewport.width", 784 0 785 ); 786 this.updateViewportSize(width, height); 787 } 788 789 /** 790 * Restores the previous actor state. 791 * 792 * @param {boolean} isTargetSwitching 793 */ 794 async restoreActorState(isTargetSwitching) { 795 // It's possible the target will switch to a page loaded in the 796 // parent-process (i.e: about:robots). When this happens, the values set 797 // on the BrowsingContext by RDM are not preserved. So we need to call 798 // enterResponsiveMode whenever there is a target switch. 799 this.tab.linkedBrowser.enterResponsiveMode(); 800 801 // If the target follows the window global lifecycle, the configuration was already 802 // restored from the server during target switch, so we can stop here. 803 // This function is still called at startup to restore potential state from previous 804 // RDM session so we only stop here during target switching. 805 if ( 806 isTargetSwitching && 807 this.commands.targetCommand.targetFront.targetForm 808 .followWindowGlobalLifeCycle 809 ) { 810 return; 811 } 812 813 const hasDeviceState = await this.hasDeviceState(); 814 if (hasDeviceState) { 815 // Return if there is a device state to restore, this will be done when the 816 // device list is loaded after the post-init. 817 return; 818 } 819 820 const height = Services.prefs.getIntPref( 821 "devtools.responsive.viewport.height", 822 0 823 ); 824 const pixelRatio = Services.prefs.getIntPref( 825 "devtools.responsive.viewport.pixelRatio", 826 0 827 ); 828 const touchSimulationEnabled = Services.prefs.getBoolPref( 829 "devtools.responsive.touchSimulation.enabled", 830 false 831 ); 832 const userAgent = Services.prefs.getCharPref( 833 "devtools.responsive.userAgent", 834 "" 835 ); 836 const width = Services.prefs.getIntPref( 837 "devtools.responsive.viewport.width", 838 0 839 ); 840 841 // Restore the previously set orientation, or get it from the initial viewport if it 842 // wasn't set yet. 843 const { type, angle } = 844 this.commands.targetConfigurationCommand.configuration 845 .rdmPaneOrientation || 846 this.getInitialViewportOrientation({ 847 width, 848 height, 849 }); 850 851 await this.updateDPPX(pixelRatio); 852 await this.updateScreenOrientation(type, angle); 853 await this.updateMaxTouchPointsEnabled(touchSimulationEnabled); 854 855 if (touchSimulationEnabled) { 856 await this.updateTouchSimulation(touchSimulationEnabled); 857 } 858 859 let reloadNeeded = false; 860 if (userAgent) { 861 reloadNeeded |= 862 (await this.updateUserAgent(userAgent)) && 863 this.reloadOnChange("userAgent"); 864 } 865 if (reloadNeeded) { 866 await this.reloadBrowser(); 867 } 868 } 869 870 /** 871 * Set or clear the emulated device pixel ratio. 872 * 873 * @param {number | null} dppx: The ratio to simulate. Set to null to disable the 874 * simulation and roll back to the original ratio 875 */ 876 async updateDPPX(dppx = null) { 877 await this.commands.targetConfigurationCommand.updateConfiguration({ 878 overrideDPPX: dppx, 879 }); 880 } 881 882 /** 883 * Set or clear network throttling. 884 * 885 * @return boolean 886 * Whether a reload is needed to apply the change. 887 * (This is always immediate, so it's always false.) 888 */ 889 async updateNetworkThrottling(enabled, profile) { 890 if (!enabled) { 891 await this.networkFront.clearNetworkThrottling(); 892 await this.commands.targetConfigurationCommand.updateConfiguration({ 893 setTabOffline: false, 894 }); 895 return false; 896 } 897 const data = throttlingProfiles.profiles.find(({ id }) => id == profile); 898 const { download, upload, latency, id } = data; 899 900 // Update offline mode 901 await this.commands.targetConfigurationCommand.updateConfiguration({ 902 setTabOffline: id === throttlingProfiles.PROFILE_CONSTANTS.OFFLINE, 903 }); 904 905 await this.networkFront.setNetworkThrottling({ 906 downloadThroughput: download, 907 uploadThroughput: upload, 908 latency, 909 }); 910 return false; 911 } 912 913 /** 914 * Set or clear the emulated user agent. 915 * 916 * @param {string | null} userAgent: The user agent to set on the page. Set to null to revert 917 * the user agent to its original value 918 * @return {boolean} Whether a reload is needed to apply the change. 919 */ 920 async updateUserAgent(userAgent) { 921 const getConfigurationCustomUserAgent = () => 922 this.commands.targetConfigurationCommand.configuration.customUserAgent || 923 ""; 924 const previousCustomUserAgent = getConfigurationCustomUserAgent(); 925 await this.commands.targetConfigurationCommand.updateConfiguration({ 926 customUserAgent: userAgent, 927 }); 928 929 const updatedUserAgent = getConfigurationCustomUserAgent(); 930 return previousCustomUserAgent !== updatedUserAgent; 931 } 932 933 /** 934 * Set or clear touch simulation. When setting to true, this method will 935 * additionally set meta viewport override. 936 * When setting to false, this method will clear all touch simulation and meta viewport 937 * overrides, returning to default behavior for both settings. 938 * 939 * @param {boolean} enabled 940 * @param {boolean} reloadOnTouchSimulationToggle: Set to true to trigger a page reload 941 * if the touch simulation state changes. 942 */ 943 async updateTouchSimulation(enabled, reloadOnTouchSimulationToggle) { 944 await this.commands.targetConfigurationCommand.updateConfiguration({ 945 touchEventsOverride: enabled ? "enabled" : null, 946 reloadOnTouchSimulationToggle, 947 }); 948 } 949 950 /** 951 * Sets the screen orientation values of the simulated device. 952 * 953 * @param {string} type 954 * The orientation type to update the current device screen to. 955 * @param {number} angle 956 * The rotation angle to update the current device screen to. 957 */ 958 async updateScreenOrientation(type, angle) { 959 // We need to call the method on the parent process 960 await this.commands.targetConfigurationCommand.updateConfiguration({ 961 rdmPaneOrientation: { type, angle }, 962 }); 963 } 964 965 /** 966 * Sets whether or not maximum touch points are supported for the simulated device. 967 * 968 * @param {boolean} touchSimulationEnabled 969 * Whether or not touch is enabled for the simulated device. 970 */ 971 async updateMaxTouchPointsEnabled(touchSimulationEnabled) { 972 return this.commands.targetConfigurationCommand.updateConfiguration({ 973 rdmPaneMaxTouchPoints: touchSimulationEnabled ? 1 : 0, 974 }); 975 } 976 977 /** 978 * Sets whether or not the RDM UI should be left-aligned. 979 * 980 * @param {boolean} leftAlignmentEnabled 981 * Whether or not the UI is left-aligned. 982 */ 983 updateUIAlignment(leftAlignmentEnabled) { 984 this.browserContainerEl.classList.toggle( 985 "left-aligned", 986 leftAlignmentEnabled 987 ); 988 } 989 990 /** 991 * Sets the browser element to be the given width and height. 992 * 993 * @param {number} width 994 * The viewport's width. 995 * @param {number} height 996 * The viewport's height. 997 */ 998 updateViewportSize(width, height) { 999 const zoom = this.tab.linkedBrowser.fullZoom; 1000 1001 // Setting this with a variable on the stack instead of directly as width/height 1002 // on the <browser> because we'll need to use this for the alert dialog as well. 1003 this.browserStackEl.style.setProperty("--rdm-width", `${width}px`); 1004 this.browserStackEl.style.setProperty("--rdm-height", `${height}px`); 1005 this.browserStackEl.style.setProperty("--rdm-zoom", zoom); 1006 1007 // This is a bit premature, but we emit a content-resize event here. It 1008 // would be preferrable to wait until the viewport is actually resized, 1009 // but the "resize" event is not triggered by this style change. The 1010 // content-resize message is only used by tests, and if needed those tests 1011 // can use the testing function setViewportSizeAndAwaitReflow to ensure 1012 // the viewport has had time to reach this size. 1013 this.emit("content-resize", { 1014 width, 1015 height, 1016 }); 1017 } 1018 1019 /** 1020 * Helper for tests. Assumes a single viewport for now. 1021 */ 1022 getViewportSize() { 1023 // The getViewportSize function is loaded in index.js, and might not be 1024 // available yet. 1025 if (this.toolWindow.getViewportSize) { 1026 return this.toolWindow.getViewportSize(); 1027 } 1028 1029 return { width: 0, height: 0 }; 1030 } 1031 1032 /** 1033 * Helper for tests, etc. Assumes a single viewport for now. 1034 */ 1035 async setViewportSize(size) { 1036 await this.initialized; 1037 1038 // Ensure that width and height are valid. 1039 let { width, height } = size; 1040 if (!size.width) { 1041 width = this.getViewportSize().width; 1042 } 1043 1044 if (!size.height) { 1045 height = this.getViewportSize().height; 1046 } 1047 1048 this.rdmFrame.contentWindow.setViewportSize({ width, height }); 1049 this.updateViewportSize(width, height); 1050 } 1051 1052 /** 1053 * Helper for tests/reloading the viewport. Assumes a single viewport for now. 1054 */ 1055 getViewportBrowser() { 1056 return this.tab.linkedBrowser; 1057 } 1058 1059 /** 1060 * Helper for contacting the viewport content. Assumes a single viewport for now. 1061 */ 1062 getViewportMessageManager() { 1063 return this.getViewportBrowser().messageManager; 1064 } 1065 1066 /** 1067 * Helper for getting the initial viewport orientation. 1068 */ 1069 getInitialViewportOrientation(viewport) { 1070 return getOrientation(viewport, viewport); 1071 } 1072 1073 /** 1074 * Helper for tests to get the browser's window. 1075 */ 1076 getBrowserWindow() { 1077 return this.browserWindow; 1078 } 1079 1080 clamp(min, max, value) { 1081 return Math.min(Math.max(value, min), max); 1082 } 1083 1084 onContentScrolled(deltaY) { 1085 const currentHeight = parseInt(this.dynamicToolbar.style.height, 10); 1086 const newHeight = this.clamp( 1087 0, 1088 DYNAMIC_TOOLBAR_MAX_HEIGHT, 1089 currentHeight + deltaY 1090 ); 1091 this.dynamicToolbar.style.height = newHeight + "px"; 1092 const offset = newHeight - DYNAMIC_TOOLBAR_MAX_HEIGHT; 1093 InspectorUtils.setVerticalClipping( 1094 this.tab.linkedBrowser.browsingContext, 1095 offset 1096 ); 1097 } 1098 1099 async onTargetAvailable({ targetFront, isTargetSwitching }) { 1100 if (this.destroying) { 1101 return; 1102 } 1103 1104 if (targetFront.isTopLevel) { 1105 await this.restoreActorState(isTargetSwitching); 1106 this.emitForTests("responsive-ui-target-switch-done"); 1107 } 1108 1109 if (Services.prefs.getBoolPref(USE_DYNAMIC_TOOLBAR_PREF)) { 1110 targetFront.on("contentScrolled", this.onContentScrolled); 1111 } 1112 } 1113 1114 async setElementPickerState(state, pickerType) { 1115 this.commands.responsiveCommand.setElementPickerState(state, pickerType); 1116 } 1117 1118 // This just needed to setup watching for network resources, 1119 // to support network throttling. 1120 onNetworkResourceAvailable() {} 1121 1122 /** 1123 * Reload the current tab. 1124 */ 1125 async reloadBrowser() { 1126 await this.commands.targetCommand.reloadTopLevelTarget(); 1127 } 1128 } 1129 1130 module.exports = ResponsiveUI;