ScreenshotsUtils.sys.mjs (44081B)
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 import { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 const SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF = 9 "screenshots.browser.component.last-screenshot-method"; 10 const SCREENSHOTS_LAST_SAVED_METHOD_PREF = 11 "screenshots.browser.component.last-saved-method"; 12 const SCREENSHOTS_ENABLED_PREF = "screenshots.browser.component.enabled"; 13 14 const lazy = {}; 15 16 ChromeUtils.defineESModuleGetters(lazy, { 17 CustomizableUI: 18 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 19 Downloads: "resource://gre/modules/Downloads.sys.mjs", 20 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 21 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 22 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 23 }); 24 25 XPCOMUtils.defineLazyServiceGetters(lazy, { 26 AlertsService: ["@mozilla.org/alerts-service;1", Ci.nsIAlertsService], 27 }); 28 29 XPCOMUtils.defineLazyPreferenceGetter( 30 lazy, 31 "SCREENSHOTS_LAST_SAVED_METHOD", 32 SCREENSHOTS_LAST_SAVED_METHOD_PREF, 33 "download" 34 ); 35 36 XPCOMUtils.defineLazyPreferenceGetter( 37 lazy, 38 "SCREENSHOTS_LAST_SCREENSHOT_METHOD", 39 SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF, 40 "visible" 41 ); 42 43 XPCOMUtils.defineLazyPreferenceGetter( 44 lazy, 45 "SCREENSHOTS_ENABLED", 46 SCREENSHOTS_ENABLED_PREF, 47 true, 48 () => ScreenshotsUtils.monitorScreenshotsPref() 49 ); 50 51 ChromeUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => { 52 return new Localization(["browser/screenshots.ftl"], true); 53 }); 54 55 const AlertNotification = Components.Constructor( 56 "@mozilla.org/alert-notification;1", 57 "nsIAlertNotification", 58 "initWithObject" 59 ); 60 61 // The max dimension for a canvas is 32,767 https://searchfox.org/mozilla-central/rev/f40d29a11f2eb4685256b59934e637012ea6fb78/gfx/cairo/cairo/src/cairo-image-surface.c#62. 62 // The max number of pixels for a canvas is 472,907,776 pixels (i.e., 22,528 x 20,992) https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size 63 // We have to limit screenshots to these dimensions otherwise it will cause an error. 64 export const MAX_CAPTURE_DIMENSION = 32766; 65 export const MAX_CAPTURE_AREA = 472907776; 66 export const MAX_SNAPSHOT_DIMENSION = 1024; 67 68 export class ScreenshotsComponentParent extends JSWindowActorParent { 69 async receiveMessage(message) { 70 let region, title; 71 let browser = message.target.browsingContext.topFrameElement; 72 // ignore message from child actors with no associated browser element 73 if (!browser) { 74 return; 75 } 76 if ( 77 ScreenshotsUtils.getUIPhase(browser) == UIPhases.CLOSED && 78 !ScreenshotsUtils.browserToScreenshotsState.has(browser) 79 ) { 80 // We've already exited or never opened and there's no UI or state that could 81 // handle this message. We additionally check for screenshot-state to ensure we 82 // don't ignore an overlay message when there is no current selection - which 83 // otherwise looks like the UIPhases.CLOSED state. 84 return; 85 } 86 87 switch (message.name) { 88 case "Screenshots:CancelScreenshot": { 89 let { reason } = message.data; 90 ScreenshotsUtils.cancel(browser, reason); 91 break; 92 } 93 case "Screenshots:CopyScreenshot": 94 ScreenshotsUtils.closePanel(browser); 95 ({ region } = message.data); 96 await ScreenshotsUtils.copyScreenshotFromRegion(region, browser); 97 ScreenshotsUtils.exit(browser); 98 break; 99 case "Screenshots:DownloadScreenshot": 100 ScreenshotsUtils.closePanel(browser); 101 ({ title, region } = message.data); 102 await ScreenshotsUtils.downloadScreenshotFromRegion( 103 title, 104 region, 105 browser 106 ); 107 ScreenshotsUtils.exit(browser); 108 break; 109 case "Screenshots:OverlaySelection": 110 ScreenshotsUtils.setPerBrowserState(browser, { 111 hasOverlaySelection: message.data.hasSelection, 112 overlayState: message.data.overlayState, 113 }); 114 break; 115 case "Screenshots:ShowPanel": 116 ScreenshotsUtils.openPanel(browser); 117 break; 118 case "Screenshots:HidePanel": 119 ScreenshotsUtils.closePanel(browser); 120 break; 121 case "Screenshots:MoveFocusToParent": 122 ScreenshotsUtils.focusPanel(browser, message.data); 123 break; 124 } 125 } 126 127 didDestroy() { 128 // When restoring a crashed tab the browser is null 129 let browser = this.browsingContext.topFrameElement; 130 if (browser) { 131 ScreenshotsUtils.exit(browser); 132 } 133 } 134 } 135 136 export class ScreenshotsHelperParent extends JSWindowActorParent { 137 receiveMessage(message) { 138 switch (message.name) { 139 case "ScreenshotsHelper:GetElementRectFromPoint": { 140 let cxt = BrowsingContext.get(message.data.bcId); 141 return cxt.currentWindowGlobal 142 .getActor("ScreenshotsHelper") 143 .sendQuery("ScreenshotsHelper:GetElementRectFromPoint", message.data); 144 } 145 } 146 return null; 147 } 148 } 149 150 export const UIPhases = { 151 CLOSED: 0, // nothing showing 152 INITIAL: 1, // panel and overlay showing 153 OVERLAYSELECTION: 2, // something selected in the overlay 154 PREVIEW: 3, // preview dialog showing 155 }; 156 157 export var ScreenshotsUtils = { 158 browserToScreenshotsState: new WeakMap(), 159 initialized: false, 160 methodsUsed: {}, 161 162 /** 163 * Figures out which of various states the screenshots UI is in, for the given browser. 164 * 165 * @param browser The selected browser 166 * @returns One of the `UIPhases` constants 167 */ 168 getUIPhase(browser) { 169 let perBrowserState = this.browserToScreenshotsState.get(browser); 170 if (perBrowserState?.previewDialog) { 171 return UIPhases.PREVIEW; 172 } 173 const buttonsPanel = this.panelForBrowser(browser); 174 if (buttonsPanel && !buttonsPanel.hidden) { 175 return UIPhases.INITIAL; 176 } 177 if (perBrowserState?.hasOverlaySelection) { 178 return UIPhases.OVERLAYSELECTION; 179 } 180 return UIPhases.CLOSED; 181 }, 182 183 resetMethodsUsed() { 184 this.methodsUsed = { fullpage: 0, visible: 0 }; 185 }, 186 187 monitorScreenshotsPref() { 188 if (lazy.SCREENSHOTS_ENABLED) { 189 this.initialize(); 190 } else { 191 this.uninitialize(); 192 } 193 194 this.screenshotsEnabled = lazy.SCREENSHOTS_ENABLED; 195 }, 196 197 initialize() { 198 if (!this.initialized) { 199 if (!lazy.SCREENSHOTS_ENABLED) { 200 return; 201 } 202 ScreenshotsCustomizableWidget.init(); 203 this.resetMethodsUsed(); 204 Services.obs.addObserver(this, "menuitem-screenshot"); 205 this.initialized = true; 206 if (Cu.isInAutomation) { 207 Services.obs.notifyObservers(null, "screenshots-component-initialized"); 208 } 209 } 210 }, 211 212 uninitialize() { 213 if (this.initialized) { 214 ScreenshotsCustomizableWidget.uninit(); 215 Services.obs.removeObserver(this, "menuitem-screenshot"); 216 for (let browser of ChromeUtils.nondeterministicGetWeakMapKeys( 217 this.browserToScreenshotsState 218 )) { 219 this.exit(browser); 220 } 221 this.initialized = false; 222 if (Cu.isInAutomation) { 223 Services.obs.notifyObservers( 224 null, 225 "screenshots-component-uninitialized" 226 ); 227 } 228 } 229 }, 230 231 handleEvent(event) { 232 switch (event.type) { 233 case "keydown": 234 this.handleKeyDownEvent(event); 235 break; 236 case "TabSelect": 237 this.handleTabSelect(event); 238 break; 239 case "SwapDocShells": 240 this.handleDocShellSwapEvent(event); 241 break; 242 case "EndSwapDocShells": 243 this.handleEndDocShellSwapEvent(event); 244 break; 245 } 246 }, 247 248 handleKeyDownEvent(event) { 249 let browser = 250 event.view.browsingContext.topChromeWindow.gBrowser.selectedBrowser; 251 if (!browser) { 252 return; 253 } 254 255 switch (event.key) { 256 case "Escape": 257 // The chromeEventHandler in the child actor will handle events that 258 // don't match this 259 if (event.target.parentElement === this.panelForBrowser(browser)) { 260 this.cancel(browser, "Escape"); 261 } 262 break; 263 case "ArrowLeft": 264 case "ArrowUp": 265 case "ArrowRight": 266 case "ArrowDown": 267 this.handleArrowKeyDown(event, browser); 268 break; 269 case "Tab": 270 this.maybeLockFocus(event); 271 break; 272 } 273 }, 274 275 /** 276 * When we swap docshells for a given screenshots browser, we need to update 277 * the browserToScreenshotsState WeakMap to the correct browser. If the old 278 * browser is in a state other than OVERLAYSELECTION, we will close 279 * screenshots. 280 * 281 * @param {Event} event The SwapDocShells event 282 */ 283 handleDocShellSwapEvent(event) { 284 let oldBrowser = event.target; 285 let newBrowser = event.detail; 286 287 const currentUIPhase = this.getUIPhase(oldBrowser); 288 if (currentUIPhase === UIPhases.OVERLAYSELECTION) { 289 newBrowser.addEventListener("SwapDocShells", this); 290 newBrowser.addEventListener("EndSwapDocShells", this); 291 oldBrowser.removeEventListener("SwapDocShells", this); 292 293 let perBrowserState = 294 this.browserToScreenshotsState.get(oldBrowser) || {}; 295 this.browserToScreenshotsState.set(newBrowser, perBrowserState); 296 this.browserToScreenshotsState.delete(oldBrowser); 297 298 this.getActor(oldBrowser).sendAsyncMessage( 299 "Screenshots:RemoveEventListeners" 300 ); 301 } else { 302 this.cancel(oldBrowser, "Navigation"); 303 } 304 }, 305 306 /** 307 * When we swap docshells for a given screenshots browser, we need to add the 308 * event listeners to the new browser because we removed event listeners in 309 * handleDocShellSwapEvent. 310 * 311 * We attach the overlay event listeners to this.docShell.chromeEventHandler 312 * in ScreenshotsComponentChild.sys.mjs which is the browser when the page is 313 * loaded via the parent process (about:config, about:robots, etc) and when 314 * this is the case, we lose the event listeners on the original browser. 315 * To fix this, we remove the event listeners on the old browser and add the 316 * event listeners to the new browser when a SwapDocShells occurs. 317 * 318 * @param {Event} event The EndSwapDocShells event 319 */ 320 handleEndDocShellSwapEvent(event) { 321 let browser = event.target; 322 this.getActor(browser).sendAsyncMessage("Screenshots:AddEventListeners"); 323 browser.removeEventListener("EndSwapDocShells", this); 324 }, 325 326 /** 327 * When we receive a TabSelect event, we will close screenshots in the 328 * previous tab if the previous tab was in the initial state. 329 * 330 * @param {Event} event The TabSelect event 331 */ 332 handleTabSelect(event) { 333 let previousTab = event.detail.previousTab; 334 if (this.getUIPhase(previousTab.linkedBrowser) === UIPhases.INITIAL) { 335 this.cancel(previousTab.linkedBrowser, "Navigation"); 336 } 337 }, 338 339 /** 340 * If the overlay state is crosshairs or dragging, move the native cursor 341 * respective to the arrow key pressed. 342 * 343 * @param {Event} event A keydown event 344 * @param {Browser} browser The selected browser 345 * @returns 346 */ 347 handleArrowKeyDown(event, browser) { 348 // Wayland doesn't support `sendNativeMouseEvent` so just return 349 if (Services.appinfo.isWayland) { 350 return; 351 } 352 353 let { overlayState } = this.browserToScreenshotsState.get(browser); 354 355 if (!["crosshairs", "dragging"].includes(overlayState)) { 356 return; 357 } 358 359 let left = 0; 360 let top = 0; 361 let exponent = event.shiftKey ? 1 : 0; 362 switch (event.key) { 363 case "ArrowLeft": 364 left -= 10 ** exponent; 365 break; 366 case "ArrowUp": 367 top -= 10 ** exponent; 368 break; 369 case "ArrowRight": 370 left += 10 ** exponent; 371 break; 372 case "ArrowDown": 373 top += 10 ** exponent; 374 break; 375 default: 376 return; 377 } 378 379 // Clear and move focus to browser so the child actor can capture events 380 this.clearContentFocus(browser); 381 Services.focus.clearFocus(browser.ownerGlobal); 382 Services.focus.setFocus(browser, 0); 383 384 let x = {}; 385 let y = {}; 386 let win = browser.ownerGlobal; 387 win.windowUtils.getLastOverWindowPointerLocationInCSSPixels(x, y); 388 389 this.moveCursor( 390 { 391 left: (x.value + left) * win.devicePixelRatio, 392 top: (y.value + top) * win.devicePixelRatio, 393 }, 394 browser 395 ); 396 }, 397 398 /** 399 * Move the native cursor to the given position. Clamp the position to the 400 * window just in case. 401 * 402 * @param {object} position An object containing the left and top position 403 * @param {Browser} browser The selected browser 404 */ 405 moveCursor(position, browser) { 406 let { left, top } = position; 407 let win = browser.ownerGlobal; 408 409 const windowLeft = win.mozInnerScreenX * win.devicePixelRatio; 410 const windowTop = win.mozInnerScreenY * win.devicePixelRatio; 411 const contentTop = 412 (win.mozInnerScreenY + (win.innerHeight - browser.clientHeight)) * 413 win.devicePixelRatio; 414 const windowRight = 415 (win.mozInnerScreenX + win.innerWidth) * win.devicePixelRatio; 416 const windowBottom = 417 (win.mozInnerScreenY + win.innerHeight) * win.devicePixelRatio; 418 419 left += windowLeft; 420 top += windowTop; 421 422 // Clamp left and top to content dimensions 423 let parsedLeft = Math.round( 424 Math.min(Math.max(left, windowLeft), windowRight) 425 ); 426 let parsedTop = Math.round( 427 Math.min(Math.max(top, contentTop), windowBottom) 428 ); 429 430 win.windowUtils.sendNativeMouseEvent( 431 parsedLeft, 432 parsedTop, 433 win.windowUtils.NATIVE_MOUSE_MESSAGE_MOVE, 434 0, 435 0, 436 win.document.documentElement 437 ); 438 }, 439 440 observe(subj, topic, data) { 441 let { gBrowser } = subj; 442 let browser = gBrowser.selectedBrowser; 443 444 switch (topic) { 445 case "menuitem-screenshot": { 446 const uiPhase = this.getUIPhase(browser); 447 if (uiPhase !== UIPhases.CLOSED) { 448 // toggle from already-open to closed 449 this.cancel(browser, data); 450 return; 451 } 452 this.start(browser, data); 453 break; 454 } 455 } 456 }, 457 458 /** 459 * Notify screenshots when screenshot command is used. 460 * 461 * @param window The current window the screenshot command was used. 462 * @param type The type of screenshot taken. Used for telemetry. 463 */ 464 notify(window, type) { 465 Services.obs.notifyObservers( 466 window.event.currentTarget.ownerGlobal, 467 "menuitem-screenshot", 468 type 469 ); 470 }, 471 472 /** 473 * Creates/gets and returns a Screenshots actor. 474 * 475 * @param browser The current browser. 476 * @returns JSWindowActor The screenshot actor. 477 */ 478 getActor(browser) { 479 let actor = browser.browsingContext.currentWindowGlobal.getActor( 480 "ScreenshotsComponent" 481 ); 482 return actor; 483 }, 484 485 /** 486 * Show the Screenshots UI and start the capture flow 487 * 488 * @param browser The current browser. 489 * @param reason [string] Optional reason string passed along when recording telemetry events 490 */ 491 start(browser, reason = "") { 492 const uiPhase = this.getUIPhase(browser); 493 switch (uiPhase) { 494 case UIPhases.CLOSED: { 495 this.captureFocusedElement(browser, "previousFocusRef"); 496 this.showPanelAndOverlay(browser, reason); 497 browser.addEventListener("SwapDocShells", this); 498 let gBrowser = browser.getTabBrowser(); 499 gBrowser.tabContainer.addEventListener("TabSelect", this); 500 browser.ownerDocument.addEventListener("keydown", this); 501 break; 502 } 503 case UIPhases.INITIAL: 504 // nothing to do, panel & overlay are already open 505 break; 506 case UIPhases.PREVIEW: { 507 this.closeDialogBox(browser); 508 this.showPanelAndOverlay(browser, reason); 509 break; 510 } 511 } 512 }, 513 514 /** 515 * Exit the Screenshots UI for the given browser 516 * Closes any of open UI elements (preview dialog, panel, overlay) and cleans up internal state. 517 * 518 * @param browser The current browser. 519 */ 520 exit(browser) { 521 this.captureFocusedElement(browser, "currentFocusRef"); 522 this.closeDialogBox(browser); 523 this.closePanel(browser); 524 this.closeOverlay(browser); 525 this.resetMethodsUsed(); 526 this.attemptToRestoreFocus(browser); 527 528 this.revokeBlobURL(browser); 529 530 browser.removeEventListener("SwapDocShells", this); 531 const gBrowser = browser.getTabBrowser(); 532 gBrowser.tabContainer.removeEventListener("TabSelect", this); 533 browser.ownerDocument.removeEventListener("keydown", this); 534 535 this.browserToScreenshotsState.delete(browser); 536 if (Cu.isInAutomation) { 537 Services.obs.notifyObservers(null, "screenshots-exit"); 538 } 539 }, 540 541 /** 542 * Cancel/abort the screenshots operation for the given browser 543 * 544 * @param browser The current browser. 545 */ 546 cancel(browser, reason) { 547 this.recordTelemetryEvent("canceled" + reason); 548 this.exit(browser); 549 }, 550 551 /** 552 * Update internal UI state associated with the given browser 553 * 554 * @param browser The current browser. 555 * @param nameValues {object} An object with one or more named property values 556 */ 557 setPerBrowserState(browser, nameValues = {}) { 558 if (!this.browserToScreenshotsState.has(browser)) { 559 // we should really have this state already, created when the preview dialog was opened 560 this.browserToScreenshotsState.set(browser, {}); 561 } 562 let perBrowserState = this.browserToScreenshotsState.get(browser); 563 Object.assign(perBrowserState, nameValues); 564 }, 565 566 maybeLockFocus(event) { 567 let browser = event.view.gBrowser.selectedBrowser; 568 569 if (!Services.focus.focusedElement) { 570 event.preventDefault(); 571 this.focusPanel(browser); 572 return; 573 } 574 575 let target = event.explicitOriginalTarget; 576 577 if (!target.closest("moz-button-group")) { 578 return; 579 } 580 581 let isElementFirst = !!target.nextElementSibling; 582 583 if (isElementFirst && event.shiftKey) { 584 event.preventDefault(); 585 this.moveFocusToContent(browser, "backward"); 586 } else if (!isElementFirst && !event.shiftKey) { 587 event.preventDefault(); 588 this.moveFocusToContent(browser); 589 } 590 }, 591 592 focusPanel(browser, { direction } = {}) { 593 let buttonsPanel = this.panelForBrowser(browser); 594 if (direction) { 595 buttonsPanel 596 .querySelector("screenshots-buttons") 597 .focusButton(direction === "forward" ? "first" : "last"); 598 } else { 599 buttonsPanel 600 .querySelector("screenshots-buttons") 601 .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); 602 } 603 }, 604 605 moveFocusToContent(browser, direction = "forward") { 606 this.getActor(browser).sendAsyncMessage( 607 "Screenshots:MoveFocusToContent", 608 direction 609 ); 610 }, 611 612 clearContentFocus(browser) { 613 this.getActor(browser).sendAsyncMessage("Screenshots:ClearFocus"); 614 }, 615 616 /** 617 * Attempt to place focus on the element that had focus before screenshots UI was shown 618 * 619 * @param browser The current browser. 620 */ 621 attemptToRestoreFocus(browser) { 622 const document = browser.ownerDocument; 623 const window = browser.ownerGlobal; 624 625 const doFocus = () => { 626 // Move focus it back to where it was previously. 627 prevFocus.setAttribute("refocused-by-panel", true); 628 try { 629 let fm = Services.focus; 630 fm.setFocus(prevFocus, fm.FLAG_NOSCROLL); 631 } catch (e) { 632 prevFocus.focus(); 633 } 634 prevFocus.removeAttribute("refocused-by-panel"); 635 let focusedElement; 636 try { 637 focusedElement = document.commandDispatcher.focusedElement; 638 if (!focusedElement) { 639 focusedElement = document.activeElement; 640 } 641 } catch (ex) { 642 focusedElement = document.activeElement; 643 } 644 }; 645 646 let perBrowserState = this.browserToScreenshotsState.get(browser) || {}; 647 let prevFocus = perBrowserState.previousFocusRef?.get(); 648 let currentFocus = perBrowserState.currentFocusRef?.get(); 649 delete perBrowserState.currentFocusRef; 650 651 // Avoid changing focus if focus changed during exit - perhaps exit was caused 652 // by a user action which resulted in focus moving 653 let nowFocus; 654 try { 655 nowFocus = document.commandDispatcher.focusedElement; 656 } catch (e) { 657 nowFocus = document.activeElement; 658 } 659 if (nowFocus && nowFocus != currentFocus) { 660 return; 661 } 662 663 let dialog = this.getDialog(browser); 664 let panel = this.panelForBrowser(browser); 665 666 if (prevFocus) { 667 // Try to restore focus 668 try { 669 if (document.commandDispatcher.focusedWindow != window) { 670 // Focus has already been set to a different window 671 return; 672 } 673 } catch (ex) {} 674 675 if (!currentFocus) { 676 doFocus(); 677 return; 678 } 679 while (currentFocus) { 680 if ( 681 (dialog && currentFocus == dialog) || 682 (panel && currentFocus == panel) || 683 currentFocus == browser 684 ) { 685 doFocus(); 686 return; 687 } 688 currentFocus = currentFocus.parentNode; 689 if ( 690 currentFocus && 691 currentFocus.nodeType == currentFocus.DOCUMENT_FRAGMENT_NODE && 692 currentFocus.host 693 ) { 694 // focus was in a shadowRoot, we'll try the host", 695 currentFocus = currentFocus.host; 696 } 697 } 698 doFocus(); 699 } 700 }, 701 702 /** 703 * Set a flag so we don't try to exit when preview dialog next closes. 704 * 705 * @param browser The current browser. 706 * @param reason [string] Optional reason string passed along when recording telemetry events 707 */ 708 scheduleRetry(browser, reason) { 709 let perBrowserState = this.browserToScreenshotsState.get(browser); 710 if (!perBrowserState?.closedPromise) { 711 console.warn( 712 "Expected perBrowserState with a closedPromise for the preview dialog" 713 ); 714 return; 715 } 716 this.setPerBrowserState(browser, { exitOnPreviewClose: false }); 717 perBrowserState?.closedPromise.then(() => { 718 this.start(browser, reason); 719 }); 720 }, 721 722 /** 723 * Open the tab dialog for preview 724 * 725 * @param browser The current browser 726 */ 727 async openPreviewDialog(browser) { 728 let dialogBox = browser.ownerGlobal.gBrowser.getTabDialogBox(browser); 729 let { dialog, closedPromise } = await dialogBox.open( 730 `chrome://browser/content/screenshots/screenshots-preview.html?browsingContextId=${browser.browsingContext.id}`, 731 { 732 features: "resizable=no", 733 sizeTo: "available", 734 allowDuplicateDialogs: false, 735 }, 736 browser 737 ); 738 739 this.setPerBrowserState(browser, { 740 previewDialog: dialog, 741 exitOnPreviewClose: true, 742 closedPromise: closedPromise.finally(() => { 743 this.onDialogClose(browser); 744 }), 745 }); 746 return dialog; 747 }, 748 749 /** 750 * Take a weak-reference to whatever element currently has focus and associate it with 751 * the UI state for this browser. 752 * 753 * @param browser The current browser. 754 * @param {string} stateRefName The property name for this element reference. 755 */ 756 captureFocusedElement(browser, stateRefName) { 757 let document = browser.ownerDocument; 758 let focusedElement; 759 try { 760 focusedElement = document.commandDispatcher.focusedElement; 761 if (!focusedElement) { 762 focusedElement = document.activeElement; 763 } 764 } catch (ex) { 765 focusedElement = document.activeElement; 766 } 767 this.setPerBrowserState(browser, { 768 [stateRefName]: Cu.getWeakReference(focusedElement), 769 }); 770 }, 771 772 /** 773 * Returns the buttons panel for the given browser if the panel exists. 774 * Otherwise creates the buttons panel and returns the buttons panel. 775 * 776 * @param browser The current browser 777 * @returns The buttons panel 778 */ 779 panelForBrowser(browser) { 780 let buttonsPanel = browser.ownerDocument.getElementById( 781 "screenshotsPagePanel" 782 ); 783 if (!buttonsPanel) { 784 let doc = browser.ownerDocument; 785 let template = doc.getElementById("screenshotsPagePanelTemplate"); 786 let fragmentClone = template.content.cloneNode(true); 787 buttonsPanel = fragmentClone.firstElementChild; 788 template.replaceWith(buttonsPanel); 789 browser.closest("#tabbrowser-tabbox").prepend(buttonsPanel); 790 } 791 792 return ( 793 buttonsPanel ?? 794 browser.ownerDocument.getElementById("screenshotsPagePanel") 795 ); 796 }, 797 798 /** 799 * Open the buttons panel. 800 * 801 * @param browser The current browser 802 */ 803 openPanel(browser) { 804 let buttonsPanel = this.panelForBrowser(browser); 805 if (!buttonsPanel.hidden) { 806 return null; 807 } 808 buttonsPanel.hidden = false; 809 810 return new Promise(resolve => { 811 browser.ownerGlobal.requestAnimationFrame(() => { 812 buttonsPanel 813 .querySelector("screenshots-buttons") 814 .focusButton(lazy.SCREENSHOTS_LAST_SCREENSHOT_METHOD); 815 resolve(); 816 }); 817 }); 818 }, 819 820 /** 821 * Close the panel 822 * 823 * @param browser The current browser 824 */ 825 closePanel(browser) { 826 let buttonsPanel = this.panelForBrowser(browser); 827 if (!buttonsPanel) { 828 return; 829 } 830 buttonsPanel.hidden = true; 831 }, 832 833 /** 834 * If the buttons panel exists and is open we will hide both the panel 835 * and the overlay. If the overlay is showing, we will hide the overlay. 836 * Otherwise create or display the buttons. 837 * 838 * @param browser The current browser. 839 */ 840 async showPanelAndOverlay(browser, data) { 841 let actor = this.getActor(browser); 842 actor.sendAsyncMessage("Screenshots:ShowOverlay"); 843 this.recordTelemetryEvent("started" + data); 844 this.openPanel(browser); 845 }, 846 847 /** 848 * Close the overlay UI, and clear out internal state if there was an overlay selection 849 * The overlay lives in the child document; so although closing is actually async, we assume success. 850 * 851 * @param browser The current browser. 852 */ 853 closeOverlay(browser, options = {}) { 854 // If the actor has been unregistered (e.g. if the component enabled pref is flipped false) 855 // its possible getActor will throw an exception. That's ok. 856 let actor; 857 try { 858 actor = this.getActor(browser); 859 } catch (ex) {} 860 actor?.sendAsyncMessage("Screenshots:HideOverlay", options); 861 862 if (this.browserToScreenshotsState.has(browser)) { 863 this.setPerBrowserState(browser, { 864 hasOverlaySelection: false, 865 }); 866 } 867 }, 868 869 /** 870 * Gets the screenshots dialog box 871 * 872 * @param browser The selected browser 873 * @returns Screenshots dialog box if it exists otherwise null 874 */ 875 getDialog(browser) { 876 let currTabDialogBox = browser.tabDialogBox; 877 let browserContextId = browser.browsingContext.id; 878 if (currTabDialogBox) { 879 let manager = currTabDialogBox.getTabDialogManager(); 880 let dialogs = manager.hasDialogs && manager.dialogs; 881 if (dialogs.length) { 882 for (let dialog of dialogs) { 883 if ( 884 dialog._openedURL.endsWith( 885 `browsingContextId=${browserContextId}` 886 ) && 887 dialog._openedURL.includes("screenshots-preview.html") 888 ) { 889 return dialog; 890 } 891 } 892 } 893 } 894 return null; 895 }, 896 897 /** 898 * Closes the dialog box it it exists 899 * 900 * @param browser The selected browser 901 */ 902 closeDialogBox(browser) { 903 let perBrowserState = this.browserToScreenshotsState.get(browser); 904 if (perBrowserState?.previewDialog) { 905 perBrowserState.previewDialog.close(); 906 return true; 907 } 908 return false; 909 }, 910 911 /** 912 * Callback fired when the preview dialog window closes 913 * Will exit the screenshots UI if the `exitOnPreviewClose` flag is set for this browser 914 * 915 * @param browser The associated browser 916 */ 917 onDialogClose(browser) { 918 let perBrowserState = this.browserToScreenshotsState.get(browser); 919 if (!perBrowserState) { 920 return; 921 } 922 delete perBrowserState.previewDialog; 923 if (perBrowserState?.exitOnPreviewClose) { 924 this.exit(browser); 925 } 926 }, 927 928 /** 929 * Gets the screenshots button if it is visible, otherwise it will get the 930 * element that the screenshots button is nested under. If the screenshots 931 * button doesn't exist then we will default to the navigator toolbox. 932 * 933 * @param browser The selected browser 934 * @returns The anchor element for the ConfirmationHint 935 */ 936 getWidgetAnchor(browser) { 937 let window = browser.ownerGlobal; 938 let widgetGroup = window.CustomizableUI.getWidget("screenshot-button"); 939 let widget = widgetGroup?.forWindow(window); 940 let anchor = widget?.anchor; 941 942 // Check if the anchor exists and is visible 943 if ( 944 !anchor || 945 !anchor.isConnected || 946 !window.isElementVisible(anchor.parentNode) 947 ) { 948 // Use the hamburger button if the screenshots button isn't available 949 anchor = browser.ownerDocument.getElementById("PanelUI-menu-button"); 950 } 951 return anchor; 952 }, 953 954 /** 955 * Indicate that the screenshot has been copied via ConfirmationHint. 956 * 957 * @param browser The selected browser 958 */ 959 showCopiedConfirmationHint(browser) { 960 let anchor = this.getWidgetAnchor(browser); 961 962 browser.ownerGlobal.ConfirmationHint.show( 963 anchor, 964 "confirmation-hint-screenshot-copied" 965 ); 966 }, 967 968 /** 969 * Gets the full page bounds from the screenshots child actor. 970 * 971 * @param browser The current browser. 972 * @returns { object } 973 * Contains the full page bounds from the screenshots child actor. 974 */ 975 fetchFullPageBounds(browser) { 976 let actor = this.getActor(browser); 977 return actor.sendQuery("Screenshots:getFullPageBounds"); 978 }, 979 980 /** 981 * Gets the visible bounds from the screenshots child actor. 982 * 983 * @param browser The current browser. 984 * @returns { object } 985 * Contains the visible bounds from the screenshots child actor. 986 */ 987 fetchVisibleBounds(browser) { 988 let actor = this.getActor(browser); 989 return actor.sendQuery("Screenshots:getVisibleBounds"); 990 }, 991 992 showAlertMessage(title, message) { 993 lazy.AlertsService.showAlert( 994 new AlertNotification({ title, text: message }) 995 ); 996 }, 997 998 /** 999 * Revoke the object url of the current browsers screenshot. 1000 * 1001 * @param {browser} browser The current browser 1002 */ 1003 revokeBlobURL(browser) { 1004 let browserState = this.browserToScreenshotsState.get(browser); 1005 if (browserState?.blobURL) { 1006 URL.revokeObjectURL(browserState.blobURL); 1007 } 1008 }, 1009 1010 /** 1011 * Set the blob url on the browser state so we can revoke on exit. 1012 * 1013 * @param {browser} browser The current browser 1014 * @param {string} blobURL The object url for the screenshot 1015 */ 1016 setBlobURL(browser, blobURL) { 1017 // We shouldn't already have a blob URL on the browser 1018 // but let's revoke just in case. 1019 this.revokeBlobURL(browser); 1020 1021 this.setPerBrowserState(browser, { blobURL }); 1022 }, 1023 1024 /** 1025 * The max dimension of any side of a canvas is 32767 and the max canvas area is 1026 * 124925329. If the width or height is greater or equal to 32766 we will crop the 1027 * screenshot to the max width. If the area is still too large for the canvas 1028 * we will adjust the height so we can successfully capture the screenshot. 1029 * 1030 * @param {object} rect The dimensions of the screenshot. The rect will be 1031 * modified in place 1032 */ 1033 cropScreenshotRectIfNeeded(rect) { 1034 let cropped = false; 1035 let width = rect.width * rect.devicePixelRatio; 1036 let height = rect.height * rect.devicePixelRatio; 1037 1038 if (width > MAX_CAPTURE_DIMENSION) { 1039 width = MAX_CAPTURE_DIMENSION; 1040 cropped = true; 1041 } 1042 if (height > MAX_CAPTURE_DIMENSION) { 1043 height = MAX_CAPTURE_DIMENSION; 1044 cropped = true; 1045 } 1046 if (width * height > MAX_CAPTURE_AREA) { 1047 height = Math.floor(MAX_CAPTURE_AREA / width); 1048 cropped = true; 1049 } 1050 1051 rect.width = Math.floor(width / rect.devicePixelRatio); 1052 rect.height = Math.floor(height / rect.devicePixelRatio); 1053 rect.right = rect.left + rect.width; 1054 rect.bottom = rect.top + rect.height; 1055 1056 if (cropped) { 1057 let [errorTitle, errorMessage] = 1058 lazy.screenshotsLocalization.formatMessagesSync([ 1059 { id: "screenshots-too-large-error-title" }, 1060 { id: "screenshots-too-large-error-details" }, 1061 ]); 1062 this.showAlertMessage(errorTitle.value, errorMessage.value); 1063 this.recordTelemetryEvent("failedScreenshotTooLarge"); 1064 } 1065 }, 1066 1067 /** 1068 * Take the screenshot, then open and add the screenshot-ui element to the 1069 * dialog box. 1070 * 1071 * @param browser The current browser. 1072 * @param type The type of screenshot taken. 1073 */ 1074 async takeScreenshot(browser, type) { 1075 this.closePanel(browser); 1076 this.closeOverlay(browser, { 1077 doNotResetMethods: true, 1078 highlightRegions: true, 1079 }); 1080 1081 Services.focus.setFocus(browser, 0); 1082 1083 let rect; 1084 let lastUsedMethod; 1085 if (type === "FullPage") { 1086 rect = await this.fetchFullPageBounds(browser); 1087 lastUsedMethod = "fullpage"; 1088 } else { 1089 rect = await this.fetchVisibleBounds(browser); 1090 lastUsedMethod = "visible"; 1091 } 1092 1093 let canvas = await this.createCanvas(rect, browser); 1094 let blob = await canvas.convertToBlob(); 1095 1096 let dialog = await this.openPreviewDialog(browser); 1097 await dialog._dialogReady; 1098 let screenshotsPreviewEl = dialog._frame.contentDocument.querySelector( 1099 "screenshots-preview" 1100 ); 1101 1102 let blobURL = URL.createObjectURL(blob); 1103 this.setBlobURL(browser, blobURL); 1104 screenshotsPreviewEl.previewImg.src = blobURL; 1105 1106 screenshotsPreviewEl.focusButton(lazy.SCREENSHOTS_LAST_SAVED_METHOD); 1107 1108 Services.prefs.setStringPref( 1109 SCREENSHOTS_LAST_SCREENSHOT_METHOD_PREF, 1110 lastUsedMethod 1111 ); 1112 this.methodsUsed[lastUsedMethod] += 1; 1113 this.recordTelemetryEvent("selected" + type); 1114 1115 if (Cu.isInAutomation) { 1116 Services.obs.notifyObservers(null, "screenshots-preview-ready"); 1117 } 1118 }, 1119 1120 /** 1121 * Creates a canvas and draws a snapshot of the screenshot on the canvas 1122 * 1123 * @param region The bounds of screenshots 1124 * @param browser The current browser 1125 * @returns The canvas 1126 */ 1127 async createCanvas(region, browser) { 1128 region.left = Math.round(region.left); 1129 region.right = Math.round(region.right); 1130 region.top = Math.round(region.top); 1131 region.bottom = Math.round(region.bottom); 1132 region.width = Math.round(region.right - region.left); 1133 region.height = Math.round(region.bottom - region.top); 1134 1135 this.cropScreenshotRectIfNeeded(region); 1136 1137 let { devicePixelRatio } = region; 1138 1139 let browsingContext = BrowsingContext.get(browser.browsingContext.id); 1140 1141 let canvas = new OffscreenCanvas( 1142 region.width * devicePixelRatio, 1143 region.height * devicePixelRatio 1144 ); 1145 let context = canvas.getContext("2d"); 1146 1147 const snapshotSize = Math.floor(MAX_SNAPSHOT_DIMENSION * devicePixelRatio); 1148 1149 for ( 1150 let startLeft = region.left; 1151 startLeft < region.right; 1152 startLeft += MAX_SNAPSHOT_DIMENSION 1153 ) { 1154 for ( 1155 let startTop = region.top; 1156 startTop < region.bottom; 1157 startTop += MAX_SNAPSHOT_DIMENSION 1158 ) { 1159 let height = 1160 startTop + MAX_SNAPSHOT_DIMENSION > region.bottom 1161 ? region.bottom - startTop 1162 : MAX_SNAPSHOT_DIMENSION; 1163 let width = 1164 startLeft + MAX_SNAPSHOT_DIMENSION > region.right 1165 ? region.right - startLeft 1166 : MAX_SNAPSHOT_DIMENSION; 1167 let rect = new DOMRect(startLeft, startTop, width, height); 1168 1169 let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( 1170 rect, 1171 devicePixelRatio, 1172 "rgb(255,255,255)" 1173 ); 1174 1175 // The `left` and `top` need to be a multiple of the `snapshotSize` to 1176 // prevent gaps/lines from appearing in the screenshot. 1177 // If devicePixelRatio is 0.3, snapshotSize would be 307 after flooring 1178 // from 307.2. Therefore every fifth snapshot would have a start of 1179 // 307.2 * 5 or 1536 which is not a multiple of 307 and would cause a 1180 // gap/line in the snapshot. 1181 let left = Math.floor((startLeft - region.left) * devicePixelRatio); 1182 let top = Math.floor((startTop - region.top) * devicePixelRatio); 1183 context.drawImage( 1184 snapshot, 1185 left - (left % snapshotSize), 1186 top - (top % snapshotSize), 1187 Math.floor(width * devicePixelRatio), 1188 Math.floor(height * devicePixelRatio) 1189 ); 1190 1191 snapshot.close(); 1192 } 1193 } 1194 1195 return canvas; 1196 }, 1197 1198 /** 1199 * Copy the screenshot 1200 * 1201 * @param region The bounds of the screenshots 1202 * @param browser The current browser 1203 */ 1204 async copyScreenshotFromRegion(region, browser) { 1205 let canvas = await this.createCanvas(region, browser); 1206 let blob = await canvas.convertToBlob(); 1207 1208 await this.copyScreenshot(blob, browser, "OverlayCopy"); 1209 }, 1210 1211 async copyScreenshotFromBlobURL(blobURL, browser, eventName) { 1212 let blob = await fetch(blobURL).then(r => r.blob()); 1213 await this.copyScreenshot(blob, browser, eventName); 1214 }, 1215 1216 /** 1217 * Copy the image to the clipboard 1218 * This is called from the preview dialog 1219 * 1220 * @param blob The image data 1221 * @param browser The current browser 1222 * @param eventName For telemetry 1223 */ 1224 async copyScreenshot(blob, browser, eventName) { 1225 // Guard against missing image data. 1226 if (!blob) { 1227 return; 1228 } 1229 1230 const imageTools = Cc["@mozilla.org/image/tools;1"].getService( 1231 Ci.imgITools 1232 ); 1233 1234 let buffer = await blob.arrayBuffer(); 1235 const imgDecoded = imageTools.decodeImageFromArrayBuffer( 1236 buffer, 1237 "image/png" 1238 ); 1239 1240 const transferable = Cc[ 1241 "@mozilla.org/widget/transferable;1" 1242 ].createInstance(Ci.nsITransferable); 1243 transferable.init(null); 1244 // Internal consumers expect the image data to be stored as a 1245 // nsIInputStream. On Linux and Windows, pasted data is directly 1246 // retrieved from the system's native clipboard, and made available 1247 // as a nsIInputStream. 1248 // 1249 // On macOS, nsClipboard::GetNativeClipboardData (nsClipboard.mm) uses 1250 // a cached copy of nsITransferable if available, e.g. when the copy 1251 // was initiated by the same browser instance. To make sure that a 1252 // nsIInputStream is returned instead of the cached imgIContainer, 1253 // the image is exported as as `kNativeImageMime`. Data associated 1254 // with this type is converted to a platform-specific image format 1255 // when written to the clipboard. The type is not used when images 1256 // are read from the clipboard (on all platforms, not just macOS). 1257 // This forces nsClipboard::GetNativeClipboardData to fall back to 1258 // the native clipboard, and return the image as a nsITransferable. 1259 transferable.addDataFlavor("application/x-moz-nativeimage"); 1260 transferable.setTransferData("application/x-moz-nativeimage", imgDecoded); 1261 1262 Services.clipboard.setData( 1263 transferable, 1264 null, 1265 Services.clipboard.kGlobalClipboard 1266 ); 1267 1268 this.showCopiedConfirmationHint(browser); 1269 1270 let extra = await this.getActor(browser).sendQuery( 1271 "Screenshots:GetMethodsUsed" 1272 ); 1273 this.recordTelemetryEvent("copy" + eventName, { 1274 ...extra, 1275 ...this.methodsUsed, 1276 }); 1277 this.resetMethodsUsed(); 1278 1279 Services.prefs.setStringPref(SCREENSHOTS_LAST_SAVED_METHOD_PREF, "copy"); 1280 }, 1281 1282 /** 1283 * Download the screenshot 1284 * 1285 * @param title The title of the current page 1286 * @param region The bounds of the screenshot 1287 * @param browser The current browser 1288 */ 1289 async downloadScreenshotFromRegion(title, region, browser) { 1290 let canvas = await this.createCanvas(region, browser); 1291 let blob = await canvas.convertToBlob(); 1292 let blobURL = URL.createObjectURL(blob); 1293 this.setBlobURL(browser, blobURL); 1294 1295 await this.downloadScreenshot(title, blobURL, browser, "OverlayDownload"); 1296 }, 1297 1298 /** 1299 * Download the screenshot 1300 * This is called from the preview dialog 1301 * 1302 * @param title The title of the current page or null and getFilename will get the title 1303 * @param blobURL The image data 1304 * @param browser The current browser 1305 * @param eventName For telemetry 1306 * @returns true if the download succeeds, otherwise false 1307 */ 1308 async downloadScreenshot(title, blobURL, browser, eventName) { 1309 // Guard against missing image data. 1310 if (!blobURL) { 1311 return false; 1312 } 1313 1314 let { filename, accepted } = await getFilename(title, browser); 1315 if (!accepted) { 1316 return false; 1317 } 1318 1319 const targetFile = new lazy.FileUtils.File(filename); 1320 1321 // Create download and track its progress. 1322 try { 1323 const download = await lazy.Downloads.createDownload({ 1324 source: blobURL, 1325 target: targetFile, 1326 }); 1327 1328 let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate( 1329 browser.ownerGlobal 1330 ); 1331 const list = await lazy.Downloads.getList( 1332 isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC 1333 ); 1334 // add the download to the download list in the Downloads list in the Browser UI 1335 list.add(download); 1336 1337 // Await successful completion of the save via the download manager 1338 await download.start(); 1339 } catch (ex) { 1340 console.error( 1341 `Failed to create download using filename: ${filename} (length: ${ 1342 new Blob([filename]).size 1343 })` 1344 ); 1345 1346 return false; 1347 } 1348 1349 let extra = await this.getActor(browser).sendQuery( 1350 "Screenshots:GetMethodsUsed" 1351 ); 1352 this.recordTelemetryEvent("download" + eventName, { 1353 ...extra, 1354 ...this.methodsUsed, 1355 }); 1356 this.resetMethodsUsed(); 1357 1358 Services.prefs.setStringPref( 1359 SCREENSHOTS_LAST_SAVED_METHOD_PREF, 1360 "download" 1361 ); 1362 1363 return true; 1364 }, 1365 1366 recordTelemetryEvent(name, args) { 1367 Glean.screenshots[name].record(args); 1368 }, 1369 }; 1370 1371 export const ScreenshotsCustomizableWidget = { 1372 init() { 1373 // In testing, we might call init more than once 1374 const widgetId = "screenshot-button"; 1375 lazy.CustomizableUI.createWidget({ 1376 id: widgetId, 1377 shortcutId: "key_screenshot", 1378 l10nId: "screenshot-toolbar-button", 1379 onCommand(aEvent) { 1380 Services.obs.notifyObservers( 1381 aEvent.currentTarget.ownerGlobal, 1382 "menuitem-screenshot", 1383 "ToolbarButton" 1384 ); 1385 }, 1386 }); 1387 const maybePlaceToolbarButton = () => { 1388 // If Nimbus tells us the widget should be placed and visible by default, first check we 1389 // didn't already handle this 1390 const buttonPlacedByNimbus = Services.prefs.getBoolPref( 1391 "screenshots.browser.component.buttonOnToolbarByDefault.handled", 1392 false 1393 ); 1394 if ( 1395 !buttonPlacedByNimbus && 1396 !lazy.CustomizableUI.getPlacementOfWidget(widgetId)?.area && 1397 lazy.NimbusFeatures.screenshots.getVariable("buttonOnToolbarByDefault") 1398 ) { 1399 // We'll place the button after the urlbar if its in the nav-bar 1400 let buttonPosition = 0; 1401 const AREA_NAVBAR = lazy.CustomizableUI.AREA_NAVBAR; 1402 const urlbarPlacement = 1403 lazy.CustomizableUI.getPlacementOfWidget("urlbar-container"); 1404 if (urlbarPlacement?.area == AREA_NAVBAR) { 1405 buttonPosition = urlbarPlacement.position + 1; 1406 const widgetIds = lazy.CustomizableUI.getWidgetIdsInArea(AREA_NAVBAR); 1407 // we want to go after the spring widget when there's one directly after the urlbar 1408 if (widgetIds[buttonPosition].includes("special-spring")) { 1409 buttonPosition++; 1410 } 1411 } 1412 lazy.CustomizableUI.addWidgetToArea( 1413 widgetId, 1414 AREA_NAVBAR, 1415 buttonPosition 1416 ); 1417 Services.prefs.setBoolPref( 1418 "screenshots.browser.component.buttonOnToolbarByDefault.handled", 1419 true 1420 ); 1421 } 1422 }; 1423 // Check now and handle future Nimbus updates 1424 maybePlaceToolbarButton(); 1425 1426 lazy.NimbusFeatures.screenshots.onUpdate(() => { 1427 const enrollment = 1428 lazy.NimbusFeatures.screenshots.getEnrollmentMetadata(); 1429 if (!enrollment) { 1430 return; 1431 } 1432 maybePlaceToolbarButton(); 1433 }); 1434 }, 1435 1436 uninit() { 1437 lazy.CustomizableUI.destroyWidget("screenshot-button"); 1438 }, 1439 };