ContextMenuChild.sys.mjs (40036B)
1 /* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* vim: set ts=2 sw=2 sts=2 et tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", 11 E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", 12 InlineSpellCheckerContent: 13 "resource://gre/modules/InlineSpellCheckerContent.sys.mjs", 14 LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", 15 LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", 16 SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", 17 SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs", 18 }); 19 20 let contextMenus = new WeakMap(); 21 22 export class ContextMenuChild extends JSWindowActorChild { 23 // PUBLIC 24 constructor() { 25 super(); 26 27 this.target = null; 28 this.context = null; 29 this.lastMenuTarget = null; 30 } 31 32 static getTarget(browsingContext, message, key) { 33 let actor = contextMenus.get(browsingContext); 34 if (!actor) { 35 throw new Error( 36 "Can't find ContextMenu actor for browsing context with " + 37 "ID: " + 38 browsingContext.id 39 ); 40 } 41 return actor.getTarget(message, key); 42 } 43 44 static getLastTarget(browsingContext) { 45 let contextMenu = contextMenus.get(browsingContext); 46 return contextMenu && contextMenu.lastMenuTarget; 47 } 48 49 receiveMessage(message) { 50 switch (message.name) { 51 case "ContextMenu:GetFrameTitle": { 52 let target = lazy.ContentDOMReference.resolve( 53 message.data.targetIdentifier 54 ); 55 return Promise.resolve(target.ownerDocument.title); 56 } 57 58 case "ContextMenu:Canvas:ToBlobURL": { 59 let target = lazy.ContentDOMReference.resolve( 60 message.data.targetIdentifier 61 ); 62 return new Promise(resolve => { 63 target.toBlob(blob => { 64 let blobURL = URL.createObjectURL(blob); 65 resolve(blobURL); 66 }); 67 }); 68 } 69 70 case "ContextMenu:Hiding": { 71 this.context = null; 72 this.target = null; 73 break; 74 } 75 76 case "ContextMenu:MediaCommand": { 77 lazy.E10SUtils.wrapHandlingUserInput( 78 this.contentWindow, 79 message.data.handlingUserInput, 80 () => { 81 let media = lazy.ContentDOMReference.resolve( 82 message.data.targetIdentifier 83 ); 84 85 switch (message.data.command) { 86 case "play": 87 media.play(); 88 break; 89 case "pause": 90 media.pause(); 91 break; 92 case "loop": 93 media.loop = !media.loop; 94 break; 95 case "mute": 96 media.muted = true; 97 break; 98 case "unmute": 99 media.muted = false; 100 break; 101 case "playbackRate": 102 media.playbackRate = message.data.data; 103 break; 104 case "hidecontrols": 105 media.removeAttribute("controls"); 106 break; 107 case "showcontrols": 108 media.setAttribute("controls", "true"); 109 break; 110 case "fullscreen": 111 if (this.document.fullscreenEnabled) { 112 media.requestFullscreen(); 113 } 114 break; 115 case "pictureinpicture": { 116 let event = new this.contentWindow.CustomEvent( 117 "MozTogglePictureInPicture", 118 { 119 bubbles: true, 120 detail: { reason: "ContextMenu" }, 121 }, 122 this.contentWindow 123 ); 124 this.contentWindow.windowUtils.dispatchEventToChromeOnly( 125 media, 126 event 127 ); 128 break; 129 } 130 } 131 } 132 ); 133 break; 134 } 135 136 case "ContextMenu:ReloadFrame": { 137 let target = lazy.ContentDOMReference.resolve( 138 message.data.targetIdentifier 139 ); 140 target.ownerDocument.location.reload(message.data.forceReload); 141 break; 142 } 143 144 case "ContextMenu:GetImageText": { 145 let img = lazy.ContentDOMReference.resolve( 146 message.data.targetIdentifier 147 ); 148 const { direction } = this.contentWindow.getComputedStyle(img); 149 150 return img.recognizeCurrentImageText().then(results => { 151 return { results, direction }; 152 }); 153 } 154 155 case "ContextMenu:ToggleRevealPassword": { 156 let target = lazy.ContentDOMReference.resolve( 157 message.data.targetIdentifier 158 ); 159 target.revealPassword = !target.revealPassword; 160 break; 161 } 162 163 case "ContextMenu:UseRelayMask": { 164 const input = lazy.ContentDOMReference.resolve( 165 message.data.targetIdentifier 166 ); 167 input.setUserInput(message.data.emailMask); 168 break; 169 } 170 171 case "ContextMenu:ReloadImage": { 172 let image = lazy.ContentDOMReference.resolve( 173 message.data.targetIdentifier 174 ); 175 176 if (image instanceof Ci.nsIImageLoadingContent) { 177 image.forceReload(); 178 } 179 break; 180 } 181 182 case "ContextMenu:SearchFieldEngineData": { 183 let node = lazy.ContentDOMReference.resolve( 184 message.data.targetIdentifier 185 ); 186 let charset = node.ownerDocument.characterSet; 187 let formBaseURI = Services.io.newURI(node.form.baseURI, charset); 188 let method = node.form.method.toUpperCase(); 189 190 let formData = new FormData(node.form); 191 formData.set(node.name, "{searchTerms}"); 192 193 let url = Services.io.newURI( 194 node.form.getAttribute("action"), 195 charset, 196 formBaseURI 197 ).spec; 198 199 if ( 200 !node.name || 201 (method != "POST" && method != "GET") || 202 node.form.enctype != "application/x-www-form-urlencoded" || 203 formData.values().some(v => typeof v != "string") 204 ) { 205 // This should never happen since these conditions are checked in 206 // `isTargetASearchEngineField`. 207 return Promise.reject("Cannot create search engine from this form."); 208 } 209 210 return Promise.resolve({ url, formData, charset, method }); 211 } 212 213 case "ContextMenu:SaveVideoFrameAsImage": { 214 let video = lazy.ContentDOMReference.resolve( 215 message.data.targetIdentifier 216 ); 217 let canvas = this.document.createElementNS( 218 "http://www.w3.org/1999/xhtml", 219 "canvas" 220 ); 221 canvas.width = video.videoWidth; 222 canvas.height = video.videoHeight; 223 224 let ctxDraw = canvas.getContext("2d"); 225 ctxDraw.drawImage(video, 0, 0); 226 227 // Note: if changing the content type, don't forget to update 228 // consumers that also hardcode this content type. 229 return Promise.resolve(canvas.toDataURL("image/jpeg", "")); 230 } 231 232 case "ContextMenu:SetAsDesktopBackground": { 233 let target = lazy.ContentDOMReference.resolve( 234 message.data.targetIdentifier 235 ); 236 237 // Paranoia: check disableSetDesktopBackground again, in case the 238 // image changed since the context menu was initiated. 239 let disable = this._disableSetDesktopBackground(target); 240 241 if (!disable) { 242 try { 243 Services.scriptSecurityManager.checkLoadURIWithPrincipal( 244 target.ownerDocument.nodePrincipal, 245 target.currentURI 246 ); 247 let canvas = this.document.createElement("canvas"); 248 canvas.width = target.naturalWidth; 249 canvas.height = target.naturalHeight; 250 let ctx = canvas.getContext("2d"); 251 ctx.drawImage(target, 0, 0); 252 let dataURL = canvas.toDataURL(); 253 let url = target.ownerDocument.location; 254 let imageName = url.pathname.substr( 255 url.pathname.lastIndexOf("/") + 1 256 ); 257 return Promise.resolve({ failed: false, dataURL, imageName }); 258 } catch (e) { 259 console.error(e); 260 } 261 } 262 263 return Promise.resolve({ 264 failed: true, 265 dataURL: null, 266 imageName: null, 267 }); 268 } 269 270 case "ContextMenu:GetTextDirective": { 271 const sel = this.contentWindow.getSelection(); 272 const ranges = !sel.isCollapsed 273 ? Array.from({ length: sel.rangeCount }, (_, i) => sel.getRangeAt(i)) 274 : this.document.fragmentDirective.getTextDirectiveRanges(); 275 return ranges 276 ? this.document.fragmentDirective 277 .createTextDirectiveForRanges(ranges) 278 .then(textFragment => { 279 if (textFragment) { 280 let url = URL.fromURI(this.document?.documentURIObject); 281 url.hash += `:~:${textFragment}`; 282 return url.href; 283 } 284 return null; 285 }) 286 : null; 287 } 288 case "ContextMenu:RemoveAllTextFragments": { 289 this.document.fragmentDirective.removeAllTextDirectives(); 290 this.contentWindow.history.replaceState( 291 this.contentWindow.history.state, 292 "", 293 this.contentWindow.location.href 294 ); 295 } 296 } 297 298 return undefined; 299 } 300 301 /** 302 * Returns the event target of the context menu, using a locally stored 303 * reference if possible. If not, and aMessage.objects is defined, 304 * aMessage.objects[aKey] is returned. Otherwise null. 305 * 306 * @param {object} aMessage Message with a objects property 307 * @param {string} aKey Key for the target on aMessage.objects 308 * @return {object} Context menu target 309 */ 310 getTarget(aMessage, aKey = "target") { 311 return this.target || (aMessage.objects && aMessage.objects[aKey]); 312 } 313 314 // PRIVATE 315 _isXULTextLinkLabel(aNode) { 316 const XUL_NS = 317 "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 318 return ( 319 aNode.namespaceURI == XUL_NS && 320 aNode.tagName == "label" && 321 aNode.classList.contains("text-link") && 322 aNode.href 323 ); 324 } 325 326 // Generate fully qualified URL for clicked-on link. 327 _getLinkURL() { 328 let href = this.context.link.href; 329 330 if (href) { 331 // Handle SVG links: 332 if (typeof href == "object" && href.animVal) { 333 return new URL(href.animVal, this.context.link.baseURI).href; 334 } 335 336 return href; 337 } 338 339 href = 340 this.context.link.getAttribute("href") || 341 this.context.link.getAttributeNS("http://www.w3.org/1999/xlink", "href"); 342 343 if (!href || !href.match(/\S/)) { 344 // Without this we try to save as the current doc, 345 // for example, HTML case also throws if empty 346 throw new Error("Empty href"); 347 } 348 349 return new URL(href, this.context.link.baseURI).href; 350 } 351 352 _getLinkURI() { 353 try { 354 return Services.io.newURI(this.context.linkURL); 355 } catch (ex) { 356 // e.g. empty URL string 357 } 358 359 return null; 360 } 361 362 // Get text of link. 363 _getLinkText() { 364 let text = this._gatherTextUnder(this.context.link); 365 366 if (!text || !text.match(/\S/)) { 367 text = this.context.link.getAttribute("title"); 368 if (!text || !text.match(/\S/)) { 369 text = this.context.link.getAttribute("alt"); 370 if (!text || !text.match(/\S/)) { 371 text = this.context.linkURL; 372 } 373 } 374 } 375 376 return text; 377 } 378 379 _getLinkProtocol() { 380 if (this.context.linkURI) { 381 return this.context.linkURI.scheme; // can be |undefined| 382 } 383 384 return null; 385 } 386 387 // Returns true if clicked-on link targets a resource that can be saved. 388 _isLinkSaveable() { 389 // We don't do the Right Thing for news/snews yet, so turn them off 390 // until we do. 391 return ( 392 this.context.linkProtocol && 393 !( 394 this.context.linkProtocol == "mailto" || 395 this.context.linkProtocol == "tel" || 396 this.context.linkProtocol == "javascript" || 397 this.context.linkProtocol == "news" || 398 this.context.linkProtocol == "snews" 399 ) 400 ); 401 } 402 403 // Gather all descendent text under given document node. 404 _gatherTextUnder(root) { 405 let text = ""; 406 let node = root.firstChild; 407 let depth = 1; 408 while (node && depth > 0) { 409 // See if this node is text. 410 if (node.nodeType == node.TEXT_NODE) { 411 // Add this text to our collection. 412 text += " " + node.data; 413 } else if (this.contentWindow.HTMLImageElement.isInstance(node)) { 414 // If it has an "alt" attribute, add that. 415 let altText = node.getAttribute("alt"); 416 if (altText && altText != "") { 417 text += " " + altText; 418 } 419 } 420 // Find next node to test. 421 // First, see if this node has children. 422 if (node.hasChildNodes()) { 423 // Go to first child. 424 node = node.firstChild; 425 depth++; 426 } else { 427 // No children, try next sibling (or parent next sibling). 428 while (depth > 0 && !node.nextSibling) { 429 node = node.parentNode; 430 depth--; 431 } 432 if (node.nextSibling) { 433 node = node.nextSibling; 434 } 435 } 436 } 437 438 // Strip leading and tailing whitespace. 439 text = text.trim(); 440 // Compress remaining whitespace. 441 text = text.replace(/\s+/g, " "); 442 return text; 443 } 444 445 // Returns a "url"-type computed style attribute value, with the url() stripped. 446 _getComputedURL(aElem, aProp) { 447 let urls = aElem.ownerGlobal.getComputedStyle(aElem).getCSSImageURLs(aProp); 448 449 if (!urls.length) { 450 return null; 451 } 452 453 if (urls.length != 1) { 454 throw new Error("found multiple URLs"); 455 } 456 457 return urls[0]; 458 } 459 460 _isProprietaryDRM() { 461 return ( 462 this.context.target.isEncrypted && 463 this.context.target.mediaKeys && 464 this.context.target.mediaKeys.keySystem != "org.w3.clearkey" 465 ); 466 } 467 468 _isMediaURLReusable(aURL) { 469 if (aURL.startsWith("blob:")) { 470 return URL.isBoundToBlob(aURL); 471 } 472 473 return true; 474 } 475 476 _isTargetATextBox(node) { 477 if (this.contentWindow.HTMLInputElement.isInstance(node)) { 478 return node.mozIsTextField(false); 479 } 480 481 return this.contentWindow.HTMLTextAreaElement.isInstance(node); 482 } 483 484 _isSpellCheckEnabled(aNode) { 485 // We can always force-enable spellchecking on textboxes 486 if (this._isTargetATextBox(aNode)) { 487 return true; 488 } 489 490 // We can never spell check something which is not content editable 491 let editable = aNode.isContentEditable; 492 493 if (!editable && aNode.ownerDocument) { 494 editable = aNode.ownerDocument.designMode == "on"; 495 } 496 497 if (!editable) { 498 return false; 499 } 500 501 // Otherwise make sure that nothing in the parent chain disables spellchecking 502 return aNode.spellcheck; 503 } 504 505 _disableSetDesktopBackground(aTarget) { 506 // Disable the Set as Desktop Background menu item if we're still trying 507 // to load the image or the load failed. 508 if (!(aTarget instanceof Ci.nsIImageLoadingContent)) { 509 return true; 510 } 511 512 if ("complete" in aTarget && !aTarget.complete) { 513 return true; 514 } 515 516 if (aTarget.currentURI.schemeIs("javascript")) { 517 return true; 518 } 519 520 let request = aTarget.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST); 521 522 if (!request) { 523 return true; 524 } 525 526 return false; 527 } 528 529 async handleEvent(aEvent) { 530 contextMenus.set(this.browsingContext, this); 531 532 let defaultPrevented = aEvent.defaultPrevented; 533 534 if ( 535 // If the event is not from a chrome-privileged document, and if 536 // `dom.event.contextmenu.enabled` is false, force defaultPrevented=false. 537 !aEvent.composedTarget.nodePrincipal.isSystemPrincipal && 538 !Services.prefs.getBoolPref("dom.event.contextmenu.enabled") 539 ) { 540 defaultPrevented = false; 541 } 542 543 if (defaultPrevented) { 544 return; 545 } 546 547 let doc = aEvent.composedTarget.ownerDocument; 548 if (!doc && Cu.isInAutomation) { 549 // doc has been observed to be null for many years, causing intermittent 550 // test failures all over the place (bug 1478596). The rate of failures 551 // is too low to debug locally, but frequent enough to be a nuisance. 552 // TODO bug 1478596: use these diagnostic logs to resolve the bug. 553 dump( 554 `doc is unexpectedly null (bug 1478596), composedTarget=${aEvent.composedTarget}\n` 555 ); 556 // A potential fix is to fall back to aEvent.target.ownerDocument, per 557 // https://bugzilla.mozilla.org/show_bug.cgi?id=1478596#c1 558 // Let's print potentially viable alternatives to see what we should use. 559 for (let k of ["target", "originalTarget", "explicitOriginalTarget"]) { 560 dump( 561 ` Alternative: ${k}=${aEvent[k]} and its doc=${aEvent[k]?.ownerDocument}\n` 562 ); 563 } 564 } 565 let { 566 mozDocumentURIIfNotForErrorPages: docLocation, 567 characterSet: charSet, 568 baseURI, 569 } = doc; 570 docLocation = docLocation && docLocation.spec; 571 const loginManagerChild = lazy.LoginManagerChild.forWindow(doc.defaultView); 572 const docState = loginManagerChild.stateForDocument(doc); 573 const loginFillInfo = docState.getFieldContext(aEvent.composedTarget); 574 575 let disableSetDesktopBackground = null; 576 577 // Media related cache info parent needs for saving 578 let contentType = null; 579 let contentDisposition = null; 580 if ( 581 aEvent.composedTarget.nodeType == aEvent.composedTarget.ELEMENT_NODE && 582 aEvent.composedTarget instanceof Ci.nsIImageLoadingContent && 583 aEvent.composedTarget.currentURI 584 ) { 585 disableSetDesktopBackground = this._disableSetDesktopBackground( 586 aEvent.composedTarget 587 ); 588 589 try { 590 let imageCache = Cc["@mozilla.org/image/tools;1"] 591 .getService(Ci.imgITools) 592 .getImgCacheForDocument(doc); 593 // The image cache's notion of where this image is located is 594 // the currentURI of the image loading content. 595 let props = imageCache.findEntryProperties( 596 aEvent.composedTarget.currentURI, 597 doc 598 ); 599 600 try { 601 contentType = props.get("type", Ci.nsISupportsCString).data; 602 } catch (e) {} 603 604 try { 605 contentDisposition = props.get( 606 "content-disposition", 607 Ci.nsISupportsCString 608 ).data; 609 } catch (e) {} 610 } catch (e) {} 611 } 612 613 let selectionInfo = lazy.SelectionUtils.getSelectionDetails( 614 this.contentWindow 615 ); 616 617 this._setContext(aEvent); 618 let context = this.context; 619 this.target = context.target; 620 621 let spellInfo = null; 622 let editFlags = null; 623 624 let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( 625 Ci.nsIReferrerInfo 626 ); 627 referrerInfo.initWithElement(aEvent.composedTarget); 628 referrerInfo = lazy.E10SUtils.serializeReferrerInfo(referrerInfo); 629 630 // In the case "onLink" we may have to send link referrerInfo to use in 631 // _openLinkInParameters 632 let linkReferrerInfo = null; 633 if (context.onLink) { 634 linkReferrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( 635 Ci.nsIReferrerInfo 636 ); 637 linkReferrerInfo.initWithElement(context.link); 638 } 639 640 let target = context.target; 641 if (target) { 642 this._cleanContext(); 643 } 644 645 editFlags = lazy.SpellCheckHelper.isEditable( 646 aEvent.composedTarget, 647 this.contentWindow 648 ); 649 650 if (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) { 651 spellInfo = lazy.InlineSpellCheckerContent.initContextMenu( 652 aEvent, 653 editFlags, 654 this 655 ); 656 } 657 658 // Set the event target first as the copy image command needs it to 659 // determine what was context-clicked on. Then, update the state of the 660 // commands on the context menu. 661 this.docShell.docViewer 662 .QueryInterface(Ci.nsIDocumentViewerEdit) 663 .setCommandNode(aEvent.composedTarget); 664 aEvent.composedTarget.ownerGlobal.updateCommands("contentcontextmenu"); 665 666 let data = { 667 context, 668 charSet, 669 baseURI, 670 referrerInfo, 671 editFlags, 672 contentType, 673 docLocation, 674 loginFillInfo, 675 selectionInfo, 676 contentDisposition, 677 disableSetDesktopBackground, 678 }; 679 680 if (context.inFrame && !context.inSrcdocFrame) { 681 data.frameReferrerInfo = lazy.E10SUtils.serializeReferrerInfo( 682 doc.referrerInfo 683 ); 684 } 685 686 if (linkReferrerInfo) { 687 data.linkReferrerInfo = 688 lazy.E10SUtils.serializeReferrerInfo(linkReferrerInfo); 689 } 690 691 // Notify observers (currently only webextensions) of the context menu being 692 // prepared, allowing them to set webExtContextData for us. 693 let prepareContextMenu = { 694 principal: doc.nodePrincipal, 695 setWebExtContextData(webExtContextData) { 696 data.webExtContextData = webExtContextData; 697 }, 698 }; 699 Services.obs.notifyObservers(prepareContextMenu, "on-prepare-contextmenu"); 700 701 // In the event that the content is running in the parent process, we don't 702 // actually want the contextmenu events to reach the parent - we'll dispatch 703 // a new contextmenu event after the async message has reached the parent 704 // instead. 705 aEvent.stopPropagation(); 706 707 data.spellInfo = null; 708 if (!spellInfo) { 709 this.sendAsyncMessage("contextmenu", data); 710 return; 711 } 712 713 try { 714 data.spellInfo = await spellInfo; 715 } catch (ex) {} 716 this.sendAsyncMessage("contextmenu", data); 717 } 718 719 /** 720 * Some things are not serializable, so we either have to only send 721 * their needed data or regenerate them in nsContextMenu.js 722 * - target and target.ownerDocument 723 * - link 724 * - linkURI 725 */ 726 _cleanContext() { 727 const context = this.context; 728 const cleanTarget = Object.create(null); 729 730 cleanTarget.ownerDocument = { 731 // used for nsContextMenu.initLeaveDOMFullScreenItems and 732 // nsContextMenu.initMediaPlayerItems 733 fullscreen: context.target.ownerDocument.fullscreen, 734 735 // used for nsContextMenu.initMiscItems 736 contentType: context.target.ownerDocument.contentType, 737 }; 738 739 // used for nsContextMenu.initMediaPlayerItems 740 Object.assign(cleanTarget, { 741 ended: context.target.ended, 742 muted: context.target.muted, 743 paused: context.target.paused, 744 controls: context.target.controls, 745 duration: context.target.duration, 746 }); 747 748 const onMedia = context.onVideo || context.onAudio; 749 750 if (onMedia) { 751 Object.assign(cleanTarget, { 752 loop: context.target.loop, 753 error: context.target.error, 754 networkState: context.target.networkState, 755 playbackRate: context.target.playbackRate, 756 NETWORK_NO_SOURCE: context.target.NETWORK_NO_SOURCE, 757 }); 758 759 if (context.onVideo) { 760 Object.assign(cleanTarget, { 761 readyState: context.target.readyState, 762 HAVE_CURRENT_DATA: context.target.HAVE_CURRENT_DATA, 763 }); 764 } 765 } 766 767 context.target = cleanTarget; 768 769 if (context.link) { 770 context.link = { href: context.linkURL }; 771 } 772 773 delete context.linkURI; 774 } 775 776 _setContext(aEvent) { 777 this.context = Object.create(null); 778 const context = this.context; 779 780 context.timeStamp = aEvent.timeStamp; 781 context.screenXDevPx = aEvent.screenX * this.contentWindow.devicePixelRatio; 782 context.screenYDevPx = aEvent.screenY * this.contentWindow.devicePixelRatio; 783 context.inputSource = aEvent.inputSource; 784 785 let node = aEvent.composedTarget; 786 787 // Set the node to containing <video>/<audio>/<embed>/<object> if the node 788 // is in the videocontrols UA Widget. 789 if (node.containingShadowRoot?.isUAWidget()) { 790 const host = node.containingShadowRoot.host; 791 if ( 792 this.contentWindow.HTMLMediaElement.isInstance(host) || 793 this.contentWindow.HTMLEmbedElement.isInstance(host) || 794 this.contentWindow.HTMLObjectElement.isInstance(host) 795 ) { 796 node = host; 797 } 798 } 799 800 const XUL_NS = 801 "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; 802 803 context.shouldDisplay = true; 804 805 if ( 806 node.nodeType == node.DOCUMENT_NODE || 807 // Don't display for XUL element unless <label class="text-link"> 808 (node.namespaceURI == XUL_NS && !this._isXULTextLinkLabel(node)) 809 ) { 810 context.shouldDisplay = false; 811 return; 812 } 813 814 const isAboutDevtoolsToolbox = this.document.documentURI.startsWith( 815 "about:devtools-toolbox" 816 ); 817 const editFlags = lazy.SpellCheckHelper.isEditable( 818 node, 819 this.contentWindow 820 ); 821 822 if ( 823 isAboutDevtoolsToolbox && 824 (editFlags & lazy.SpellCheckHelper.TEXTINPUT) === 0 825 ) { 826 // Don't display for about:devtools-toolbox page unless the source was text input. 827 context.shouldDisplay = false; 828 return; 829 } 830 831 // Initialize context to be sent to nsContextMenu 832 // Keep this consistent with the similar code in nsContextMenu's setContext 833 context.bgImageURL = ""; 834 context.imageDescURL = ""; 835 context.imageInfo = null; 836 context.mediaURL = ""; 837 context.webExtBrowserType = ""; 838 839 context.canSpellCheck = false; 840 context.hasBGImage = false; 841 context.hasMultipleBGImages = false; 842 context.isDesignMode = false; 843 context.inFrame = false; 844 context.inPDFViewer = false; 845 context.inSrcdocFrame = false; 846 context.inSyntheticDoc = false; 847 context.inTabBrowser = true; 848 context.inWebExtBrowser = false; 849 850 context.link = null; 851 context.linkDownload = ""; 852 context.linkProtocol = ""; 853 context.linkTextStr = ""; 854 context.linkURL = ""; 855 context.linkURI = null; 856 857 context.onAudio = false; 858 context.onCanvas = false; 859 context.onCompletedImage = false; 860 context.onDRMMedia = false; 861 context.onPiPVideo = false; 862 context.onEditable = false; 863 context.onImage = false; 864 context.onLink = false; 865 context.onLoadedImage = false; 866 context.onMailtoLink = false; 867 context.onTelLink = false; 868 context.onMozExtLink = false; 869 context.onNumeric = false; 870 context.onPassword = false; 871 context.passwordRevealed = false; 872 context.onSaveableLink = false; 873 context.onSpellcheckable = false; 874 context.onTextInput = false; 875 context.onVideo = false; 876 context.inPDFEditor = false; 877 878 const textDirectiveRanges = 879 this.document.fragmentDirective?.getTextDirectiveRanges?.() || []; 880 // .hasTextFragments indicates whether the page will show highlights. 881 context.hasTextFragments = !!textDirectiveRanges.length; 882 883 // Remember the node and its owner document that was clicked 884 // This may be modifed before sending to nsContextMenu 885 context.target = node; 886 context.targetIdentifier = lazy.ContentDOMReference.get(node); 887 888 context.policyContainer = lazy.E10SUtils.serializePolicyContainer( 889 context.target.ownerDocument.policyContainer 890 ); 891 892 // Check if we are in the PDF Viewer. 893 context.inPDFViewer = 894 context.target.ownerDocument.nodePrincipal.originNoSuffix == 895 "resource://pdf.js"; 896 if (context.inPDFViewer) { 897 context.pdfEditorStates = context.target.ownerDocument.editorStates; 898 context.inPDFEditor = !!context.pdfEditorStates?.isEditing; 899 } 900 901 // Check if we are in a synthetic document (stand alone image, video, etc.). 902 context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument; 903 904 context.shouldInitInlineSpellCheckerUINoChildren = false; 905 context.shouldInitInlineSpellCheckerUIWithChildren = false; 906 907 this._setContextForNodesNoChildren(editFlags); 908 this._setContextForNodesWithChildren(editFlags); 909 910 this.lastMenuTarget = { 911 // Remember the node for extensions. 912 targetRef: Cu.getWeakReference(node), 913 // The timestamp is used to verify that the target wasn't changed since the observed menu event. 914 timeStamp: context.timeStamp, 915 }; 916 917 if (isAboutDevtoolsToolbox) { 918 // Setup the menu items on text input in about:devtools-toolbox. 919 context.inAboutDevtoolsToolbox = true; 920 context.canSpellCheck = false; 921 context.inTabBrowser = false; 922 context.inFrame = false; 923 context.inSrcdocFrame = false; 924 context.onSpellcheckable = false; 925 } 926 } 927 928 /** 929 * Sets up the parts of the context menu for when when nodes have no children. 930 * 931 * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper 932 * for the details. 933 */ 934 _setContextForNodesNoChildren(editFlags) { 935 const context = this.context; 936 937 if (context.target.nodeType == context.target.TEXT_NODE) { 938 // For text nodes, look at the parent node to determine the spellcheck attribute. 939 context.canSpellCheck = 940 context.target.parentNode && this._isSpellCheckEnabled(context.target); 941 return; 942 } 943 944 // We only deal with TEXT_NODE and ELEMENT_NODE in this function, so return 945 // early if we don't have one. 946 if (context.target.nodeType != context.target.ELEMENT_NODE) { 947 return; 948 } 949 950 // See if the user clicked on an image. This check mirrors 951 // nsDocumentViewer::GetInImage. Make sure to update both if this is 952 // changed. 953 if ( 954 context.target instanceof Ci.nsIImageLoadingContent && 955 (context.target.currentRequestFinalURI || context.target.currentURI) 956 ) { 957 context.onImage = true; 958 959 context.imageInfo = { 960 currentSrc: context.target.currentSrc, 961 width: context.target.width, 962 height: context.target.height, 963 imageText: this.contentWindow.ImageDocument.isInstance( 964 context.target.ownerDocument 965 ) 966 ? undefined 967 : context.target.title || context.target.alt, 968 }; 969 if (SVGAnimatedLength.isInstance(context.imageInfo.height)) { 970 context.imageInfo.height = context.imageInfo.height.animVal.value; 971 } 972 if (SVGAnimatedLength.isInstance(context.imageInfo.width)) { 973 context.imageInfo.width = context.imageInfo.width.animVal.value; 974 } 975 976 const request = context.target.getRequest( 977 Ci.nsIImageLoadingContent.CURRENT_REQUEST 978 ); 979 980 if (request && request.imageStatus & request.STATUS_SIZE_AVAILABLE) { 981 context.onLoadedImage = true; 982 } 983 984 if ( 985 request && 986 request.imageStatus & request.STATUS_LOAD_COMPLETE && 987 !(request.imageStatus & request.STATUS_ERROR) 988 ) { 989 context.onCompletedImage = true; 990 } 991 992 // The URL of the image before redirects is the currentURI. This is 993 // intended to be used for "Copy Image Link". 994 context.originalMediaURL = (() => { 995 let currentURI = context.target.currentURI?.spec; 996 if (currentURI && this._isMediaURLReusable(currentURI)) { 997 return currentURI; 998 } 999 return ""; 1000 })(); 1001 1002 // The actual URL the image was loaded from (after redirects) is the 1003 // currentRequestFinalURI. We should use that as the URL for purposes of 1004 // deciding on the filename, if it is present. It might not be present 1005 // if images are blocked. 1006 // 1007 // It is important to check both the final and the current URI, as they 1008 // could be different blob URIs, see bug 1625786. 1009 context.mediaURL = (() => { 1010 let finalURI = context.target.currentRequestFinalURI?.spec; 1011 if (finalURI && this._isMediaURLReusable(finalURI)) { 1012 return finalURI; 1013 } 1014 let currentURI = context.target.currentURI?.spec; 1015 if (currentURI && this._isMediaURLReusable(currentURI)) { 1016 return currentURI; 1017 } 1018 return ""; 1019 })(); 1020 1021 const descURL = context.target.getAttribute("longdesc"); 1022 1023 if (descURL) { 1024 context.imageDescURL = new URL( 1025 descURL, 1026 context.target.ownerDocument.body.baseURI 1027 ).href; 1028 } 1029 } else if ( 1030 this.contentWindow.HTMLCanvasElement.isInstance(context.target) 1031 ) { 1032 context.onCanvas = true; 1033 } else if (this.contentWindow.HTMLVideoElement.isInstance(context.target)) { 1034 const mediaURL = context.target.currentSrc || context.target.src; 1035 1036 if (this._isMediaURLReusable(mediaURL)) { 1037 context.mediaURL = mediaURL; 1038 } 1039 1040 if (this._isProprietaryDRM()) { 1041 context.onDRMMedia = true; 1042 } 1043 1044 if (context.target.isCloningElementVisually) { 1045 context.onPiPVideo = true; 1046 } 1047 1048 // Firefox always creates a HTMLVideoElement when loading an ogg file 1049 // directly. If the media is actually audio, be smarter and provide a 1050 // context menu with audio operations. 1051 if ( 1052 context.target.readyState >= context.target.HAVE_METADATA && 1053 (context.target.videoWidth == 0 || context.target.videoHeight == 0) 1054 ) { 1055 context.onAudio = true; 1056 } else { 1057 context.onVideo = true; 1058 } 1059 } else if (this.contentWindow.HTMLAudioElement.isInstance(context.target)) { 1060 context.onAudio = true; 1061 const mediaURL = context.target.currentSrc || context.target.src; 1062 1063 if (this._isMediaURLReusable(mediaURL)) { 1064 context.mediaURL = mediaURL; 1065 } 1066 1067 if (this._isProprietaryDRM()) { 1068 context.onDRMMedia = true; 1069 } 1070 } else if ( 1071 editFlags & 1072 (lazy.SpellCheckHelper.INPUT | lazy.SpellCheckHelper.TEXTAREA) 1073 ) { 1074 context.onTextInput = (editFlags & lazy.SpellCheckHelper.TEXTINPUT) !== 0; 1075 context.onNumeric = (editFlags & lazy.SpellCheckHelper.NUMERIC) !== 0; 1076 context.onEditable = (editFlags & lazy.SpellCheckHelper.EDITABLE) !== 0; 1077 context.onPassword = (editFlags & lazy.SpellCheckHelper.PASSWORD) !== 0; 1078 1079 context.showRelay = 1080 HTMLInputElement.isInstance(context.target) && 1081 !context.target.disabled && 1082 !context.target.readOnly && 1083 (lazy.LoginHelper.isInferredEmailField(context.target) || 1084 lazy.LoginHelper.isInferredUsernameField(context.target)); 1085 context.isDesignMode = 1086 (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) !== 0; 1087 context.passwordRevealed = 1088 context.onPassword && context.target.revealPassword; 1089 context.onSpellcheckable = 1090 (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) !== 0; 1091 1092 // This is guaranteed to be an input or textarea because of the condition above, 1093 // so the no-children flag is always correct. We deal with contenteditable elsewhere. 1094 if (context.onSpellcheckable) { 1095 context.shouldInitInlineSpellCheckerUINoChildren = true; 1096 } 1097 1098 context.onSearchField = editFlags & lazy.SpellCheckHelper.SEARCHENGINE; 1099 } else if (this.contentWindow.HTMLHtmlElement.isInstance(context.target)) { 1100 const bodyElt = context.target.ownerDocument.body; 1101 1102 if (bodyElt) { 1103 let computedURL; 1104 1105 try { 1106 computedURL = this._getComputedURL(bodyElt, "background-image"); 1107 context.hasMultipleBGImages = false; 1108 } catch (e) { 1109 context.hasMultipleBGImages = true; 1110 } 1111 1112 if (computedURL) { 1113 context.hasBGImage = true; 1114 context.bgImageURL = new URL(computedURL, bodyElt.baseURI).href; 1115 } 1116 } 1117 } 1118 1119 context.canSpellCheck = this._isSpellCheckEnabled(context.target); 1120 } 1121 1122 /** 1123 * Sets up the parts of the context menu for when when nodes have children. 1124 * 1125 * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper 1126 * for the details. 1127 */ 1128 _setContextForNodesWithChildren(editFlags) { 1129 const context = this.context; 1130 1131 // Second, bubble out, looking for items of interest that can have childen. 1132 // Always pick the innermost link, background image, etc. 1133 let elem = context.target; 1134 1135 while (elem) { 1136 if (elem.nodeType == elem.ELEMENT_NODE) { 1137 // Link? 1138 const XLINK_NS = "http://www.w3.org/1999/xlink"; 1139 1140 if ( 1141 !context.onLink && 1142 // Be consistent with what hrefAndLinkNodeForClickEvent 1143 // does in browser.js 1144 (this._isXULTextLinkLabel(elem) || 1145 (this.contentWindow.HTMLAnchorElement.isInstance(elem) && 1146 elem.href) || 1147 (this.contentWindow.SVGAElement.isInstance(elem) && 1148 (elem.href || elem.hasAttributeNS(XLINK_NS, "href"))) || 1149 (this.contentWindow.HTMLAreaElement.isInstance(elem) && 1150 elem.href) || 1151 this.contentWindow.HTMLLinkElement.isInstance(elem) || 1152 elem.getAttributeNS(XLINK_NS, "type") == "simple") 1153 ) { 1154 // Target is a link or a descendant of a link. 1155 context.onLink = true; 1156 1157 // Remember corresponding element. 1158 context.link = elem; 1159 context.linkURL = this._getLinkURL(); 1160 context.linkURI = this._getLinkURI(); 1161 context.linkTextStr = this._getLinkText(); 1162 context.linkProtocol = this._getLinkProtocol(); 1163 context.onMailtoLink = context.linkProtocol == "mailto"; 1164 context.onTelLink = context.linkProtocol == "tel"; 1165 context.onMozExtLink = context.linkProtocol == "moz-extension"; 1166 context.onSaveableLink = this._isLinkSaveable(context.link); 1167 1168 context.isSponsoredLink = 1169 (elem.ownerDocument.URL === "about:newtab" || 1170 elem.ownerDocument.URL === "about:home") && 1171 elem.dataset.isSponsoredLink === "true"; 1172 1173 try { 1174 if (elem.download) { 1175 // Ignore download attribute on cross-origin links 1176 context.target.ownerDocument.nodePrincipal.checkMayLoad( 1177 context.linkURI, 1178 true 1179 ); 1180 context.linkDownload = elem.download; 1181 } 1182 } catch (ex) {} 1183 } 1184 1185 // Background image? Don't bother if we've already found a 1186 // background image further down the hierarchy. Otherwise, 1187 // we look for the computed background-image style. 1188 if (!context.hasBGImage && !context.hasMultipleBGImages) { 1189 let bgImgUrl = null; 1190 1191 try { 1192 bgImgUrl = this._getComputedURL(elem, "background-image"); 1193 context.hasMultipleBGImages = false; 1194 } catch (e) { 1195 context.hasMultipleBGImages = true; 1196 } 1197 1198 if (bgImgUrl) { 1199 context.hasBGImage = true; 1200 context.bgImageURL = new URL(bgImgUrl, elem.baseURI).href; 1201 } 1202 } 1203 } 1204 1205 elem = elem.flattenedTreeParentNode; 1206 } 1207 1208 // See if the user clicked in a frame. 1209 const docDefaultView = context.target.ownerGlobal; 1210 1211 if (docDefaultView != docDefaultView.top) { 1212 context.inFrame = true; 1213 1214 if (context.target.ownerDocument.isSrcdocDocument) { 1215 context.inSrcdocFrame = true; 1216 } 1217 } 1218 1219 // if the document is editable, show context menu like in text inputs 1220 if (!context.onEditable) { 1221 if (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) { 1222 // If this.onEditable is false but editFlags is CONTENTEDITABLE, then 1223 // the document itself must be editable. 1224 context.onTextInput = true; 1225 context.onImage = false; 1226 context.onLoadedImage = false; 1227 context.onCompletedImage = false; 1228 context.inFrame = false; 1229 context.inSrcdocFrame = false; 1230 context.hasBGImage = false; 1231 context.isDesignMode = true; 1232 context.onEditable = true; 1233 context.onSpellcheckable = true; 1234 context.shouldInitInlineSpellCheckerUIWithChildren = true; 1235 } 1236 } 1237 } 1238 1239 _destructionObservers = new Set(); 1240 registerDestructionObserver(obj) { 1241 this._destructionObservers.add(obj); 1242 } 1243 1244 unregisterDestructionObserver(obj) { 1245 this._destructionObservers.delete(obj); 1246 } 1247 1248 didDestroy() { 1249 for (let obs of this._destructionObservers) { 1250 obs.actorDestroyed(this); 1251 } 1252 this._destructionObservers = null; 1253 } 1254 }