ScreenshotsComponentChild.sys.mjs (12385B)
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 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", 9 ScreenshotsOverlay: "resource:///modules/ScreenshotsOverlayChild.sys.mjs", 10 }); 11 12 const SCREENSHOTS_PREVENT_CONTENT_EVENTS_PREF = 13 "screenshots.browser.component.preventContentEvents"; 14 15 export class ScreenshotsComponentChild extends JSWindowActorChild { 16 #resizeTask; 17 #scrollTask; 18 #overlay; 19 #preventableEventsAdded = false; 20 21 static OVERLAY_EVENTS = [ 22 "click", 23 "pointerdown", 24 "pointermove", 25 "pointerup", 26 "keyup", 27 "keydown", 28 ]; 29 30 // The following events are only listened to so we can prevent them from 31 // reaching the content page. The events in OVERLAY_EVENTS are also prevented. 32 static PREVENTABLE_EVENTS = [ 33 "mousemove", 34 "mousedown", 35 "mouseup", 36 "mouseenter", 37 "mouseover", 38 "mouseout", 39 "mouseleave", 40 "touchstart", 41 "touchmove", 42 "touchend", 43 "dblclick", 44 "auxclick", 45 "keypress", 46 "contextmenu", 47 "pointerenter", 48 "pointerover", 49 "pointerout", 50 "pointerleave", 51 ]; 52 53 get overlay() { 54 return this.#overlay; 55 } 56 57 receiveMessage(message) { 58 switch (message.name) { 59 case "Screenshots:ShowOverlay": 60 return this.startScreenshotsOverlay(); 61 case "Screenshots:HideOverlay": 62 return this.endScreenshotsOverlay(message.data); 63 case "Screenshots:isOverlayShowing": 64 return this.overlay?.initialized; 65 case "Screenshots:getFullPageBounds": 66 return this.getFullPageBounds(); 67 case "Screenshots:getVisibleBounds": 68 return this.getVisibleBounds(); 69 case "Screenshots:getDocumentTitle": 70 return this.getDocumentTitle(); 71 case "Screenshots:GetMethodsUsed": 72 return this.getMethodsUsed(); 73 case "Screenshots:RemoveEventListeners": 74 return this.removeEventListeners(); 75 case "Screenshots:AddEventListeners": 76 return this.addEventListeners(); 77 case "Screenshots:MoveFocusToContent": 78 return this.focusOverlay(message.data); 79 case "Screenshots:ClearFocus": 80 Services.focus.clearFocus(this.contentWindow); 81 return null; 82 } 83 return null; 84 } 85 86 handleEvent(event) { 87 if (!event.isTrusted) { 88 return; 89 } 90 91 // Handle overlay events here 92 if ( 93 [ 94 ...ScreenshotsComponentChild.OVERLAY_EVENTS, 95 ...ScreenshotsComponentChild.PREVENTABLE_EVENTS, 96 "selectionchange", 97 ].includes(event.type) 98 ) { 99 if (!this.overlay?.initialized) { 100 return; 101 } 102 103 // Preventing a pointerdown event throws an error in debug builds. 104 // See https://searchfox.org/mozilla-central/rev/b41bb321fe4bd7d03926083698ac498ebec0accf/widget/WidgetEventImpl.cpp#566-572 105 // Don't prevent the default context menu. 106 if (!["contextmenu", "pointerdown"].includes(event.type)) { 107 event.preventDefault(); 108 } 109 110 event.stopImmediatePropagation(); 111 this.overlay.handleEvent(event); 112 return; 113 } 114 115 switch (event.type) { 116 case "beforeunload": 117 this.requestCancelScreenshot("Navigation"); 118 break; 119 case "resize": 120 if (!this.#resizeTask && this.overlay?.initialized) { 121 this.#resizeTask = new lazy.DeferredTask(() => { 122 this.overlay.updateScreenshotsOverlayDimensions("resize"); 123 }, 16); 124 } 125 this.#resizeTask.arm(); 126 break; 127 case "scroll": 128 if (!this.#scrollTask && this.overlay?.initialized) { 129 this.#scrollTask = new lazy.DeferredTask(() => { 130 this.overlay.updateScreenshotsOverlayDimensions("scroll"); 131 }, 16); 132 } 133 this.#scrollTask.arm(); 134 break; 135 case "Screenshots:Close": 136 this.requestCancelScreenshot(event.detail.reason); 137 break; 138 case "Screenshots:Copy": 139 this.requestCopyScreenshot(event.detail.region); 140 break; 141 case "Screenshots:Download": 142 this.requestDownloadScreenshot(event.detail.region); 143 break; 144 case "Screenshots:OverlaySelection": { 145 let { hasSelection, overlayState } = event.detail; 146 this.sendOverlaySelection({ hasSelection, overlayState }); 147 break; 148 } 149 case "Screenshots:RecordEvent": { 150 let { eventName, args } = event.detail; 151 Glean.screenshots[eventName].record(args); 152 break; 153 } 154 case "Screenshots:ShowPanel": 155 this.sendAsyncMessage("Screenshots:ShowPanel"); 156 break; 157 case "Screenshots:HidePanel": 158 this.sendAsyncMessage("Screenshots:HidePanel"); 159 break; 160 case "Screenshots:FocusPanel": 161 this.sendAsyncMessage("Screenshots:MoveFocusToParent", event.detail); 162 break; 163 } 164 } 165 166 /** 167 * Send a request to cancel the screenshot to the parent process 168 */ 169 requestCancelScreenshot(reason) { 170 this.sendAsyncMessage("Screenshots:CancelScreenshot", { 171 closeOverlay: false, 172 reason, 173 }); 174 this.endScreenshotsOverlay(); 175 } 176 177 /** 178 * Send a request to copy the screenshots 179 * 180 * @param {object} region The region dimensions of the screenshot to be copied 181 */ 182 requestCopyScreenshot(region) { 183 region.devicePixelRatio = this.contentWindow.devicePixelRatio; 184 this.sendAsyncMessage("Screenshots:CopyScreenshot", { region }); 185 this.endScreenshotsOverlay({ doNotResetMethods: true }); 186 } 187 188 /** 189 * Send a request to download the screenshots 190 * 191 * @param {object} region The region dimensions of the screenshot to be downloaded 192 */ 193 requestDownloadScreenshot(region) { 194 region.devicePixelRatio = this.contentWindow.devicePixelRatio; 195 this.sendAsyncMessage("Screenshots:DownloadScreenshot", { 196 title: this.getDocumentTitle(), 197 region, 198 }); 199 this.endScreenshotsOverlay({ doNotResetMethods: true }); 200 } 201 202 getDocumentTitle() { 203 return this.document.title; 204 } 205 206 sendOverlaySelection(data) { 207 this.sendAsyncMessage("Screenshots:OverlaySelection", data); 208 } 209 210 getMethodsUsed() { 211 let methodsUsed = this.#overlay.methodsUsed; 212 this.#overlay.resetMethodsUsed(); 213 return methodsUsed; 214 } 215 216 focusOverlay(direction) { 217 this.contentWindow.focus(); 218 this.#overlay.focus(direction); 219 } 220 221 /** 222 * Resolves when the document is ready to have an overlay injected into it. 223 * 224 * @returns {Promise<boolean>} 225 * Resolves to true when document is ready or rejects. 226 */ 227 documentIsReady() { 228 const document = this.document; 229 // Some pages take ages to finish loading - if at all. 230 // We want to respond to enable the screenshots UI as soon that is possible 231 function readyEnough() { 232 return ( 233 document.readyState !== "uninitialized" && document.documentElement 234 ); 235 } 236 237 if (readyEnough()) { 238 return Promise.resolve(); 239 } 240 return new Promise((resolve, reject) => { 241 function onChange(event) { 242 if (event.type === "pagehide") { 243 document.removeEventListener("readystatechange", onChange); 244 this.contentWindow.removeEventListener("pagehide", onChange); 245 reject(new Error("document unloaded before it was ready")); 246 } else if (readyEnough()) { 247 document.removeEventListener("readystatechange", onChange); 248 this.contentWindow.removeEventListener("pagehide", onChange); 249 resolve(); 250 } 251 } 252 document.addEventListener("readystatechange", onChange); 253 this.contentWindow.addEventListener("pagehide", onChange, { once: true }); 254 }); 255 } 256 257 addEventListeners() { 258 this.contentWindow.addEventListener("beforeunload", this); 259 this.contentWindow.addEventListener("resize", this); 260 this.contentWindow.addEventListener("scroll", this); 261 this.addOverlayEventListeners(); 262 } 263 264 addOverlayEventListeners() { 265 let chromeEventHandler = this.docShell.chromeEventHandler; 266 for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) { 267 chromeEventHandler.addEventListener(event, this, true); 268 } 269 270 this.document.addEventListener("selectionchange", this); 271 272 if (Services.prefs.getBoolPref(SCREENSHOTS_PREVENT_CONTENT_EVENTS_PREF)) { 273 for (let event of ScreenshotsComponentChild.PREVENTABLE_EVENTS) { 274 chromeEventHandler.addEventListener(event, this, true); 275 } 276 277 this.#preventableEventsAdded = true; 278 } 279 } 280 281 /** 282 * Wait until the document is ready and then show the screenshots overlay 283 * 284 * @returns {boolean} true when document is ready and the overlay is shown 285 * otherwise false 286 */ 287 async startScreenshotsOverlay() { 288 try { 289 await this.documentIsReady(); 290 } catch (ex) { 291 console.warn(`ScreenshotsComponentChild: ${ex.message}`); 292 return false; 293 } 294 await this.documentIsReady(); 295 let overlay = 296 this.overlay || 297 (this.#overlay = new lazy.ScreenshotsOverlay(this.document)); 298 this.addEventListeners(); 299 300 overlay.initialize(); 301 return true; 302 } 303 304 removeEventListeners() { 305 this.contentWindow.removeEventListener("beforeunload", this); 306 this.contentWindow.removeEventListener("resize", this); 307 this.contentWindow.removeEventListener("scroll", this); 308 this.removeOverlayEventListeners(); 309 } 310 311 removeOverlayEventListeners() { 312 let chromeEventHandler = this.docShell.chromeEventHandler; 313 for (let event of ScreenshotsComponentChild.OVERLAY_EVENTS) { 314 chromeEventHandler.removeEventListener(event, this, true); 315 } 316 317 this.document.removeEventListener("selectionchange", this); 318 319 if (this.#preventableEventsAdded) { 320 for (let event of ScreenshotsComponentChild.PREVENTABLE_EVENTS) { 321 chromeEventHandler.removeEventListener(event, this, true); 322 } 323 } 324 325 this.#preventableEventsAdded = false; 326 } 327 328 /** 329 * Removes event listeners and the screenshots overlay. 330 */ 331 endScreenshotsOverlay(options = {}) { 332 this.removeEventListeners(); 333 334 this.overlay?.tearDown(options); 335 this.#resizeTask?.disarm(); 336 this.#scrollTask?.disarm(); 337 } 338 339 didDestroy() { 340 this.#resizeTask?.disarm(); 341 this.#scrollTask?.disarm(); 342 } 343 344 /** 345 * Gets the full page bounds for a full page screenshot. 346 * 347 * @returns { object } 348 * The device pixel ratio and a DOMRect of the scrollable content bounds. 349 * 350 * devicePixelRatio (float): 351 * The device pixel ratio of the screen 352 * 353 * rect (object): 354 * top (int): 355 * The scroll top position for the content window. 356 * 357 * left (int): 358 * The scroll left position for the content window. 359 * 360 * width (int): 361 * The scroll width of the content window. 362 * 363 * height (int): 364 * The scroll height of the content window. 365 */ 366 getFullPageBounds() { 367 let { 368 scrollMinX, 369 scrollMinY, 370 scrollWidth, 371 scrollHeight, 372 devicePixelRatio, 373 } = this.#overlay.windowDimensions.dimensions; 374 let rect = { 375 left: scrollMinX, 376 top: scrollMinY, 377 right: scrollMinX + scrollWidth, 378 bottom: scrollMinY + scrollHeight, 379 width: scrollWidth, 380 height: scrollHeight, 381 devicePixelRatio, 382 }; 383 return rect; 384 } 385 386 /** 387 * Gets the visible page bounds for a visible screenshot. 388 * 389 * @returns { object } 390 * The device pixel ratio and a DOMRect of the current visible 391 * content bounds. 392 * 393 * devicePixelRatio (float): 394 * The device pixel ratio of the screen 395 * 396 * rect (object): 397 * top (int): 398 * The top position for the content window. 399 * 400 * left (int): 401 * The left position for the content window. 402 * 403 * width (int): 404 * The width of the content window. 405 * 406 * height (int): 407 * The height of the content window. 408 */ 409 getVisibleBounds() { 410 let { 411 pageScrollX, 412 pageScrollY, 413 clientWidth, 414 clientHeight, 415 devicePixelRatio, 416 } = this.#overlay.windowDimensions.dimensions; 417 let rect = { 418 left: pageScrollX, 419 top: pageScrollY, 420 right: pageScrollX + clientWidth, 421 bottom: pageScrollY + clientHeight, 422 width: clientWidth, 423 height: clientHeight, 424 devicePixelRatio, 425 }; 426 return rect; 427 } 428 }