diff options
Diffstat (limited to 'browser/actors/ContextMenuChild.sys.mjs')
-rw-r--r-- | browser/actors/ContextMenuChild.sys.mjs | 1239 |
1 files changed, 1239 insertions, 0 deletions
diff --git a/browser/actors/ContextMenuChild.sys.mjs b/browser/actors/ContextMenuChild.sys.mjs new file mode 100644 index 0000000000..235a0d268a --- /dev/null +++ b/browser/actors/ContextMenuChild.sys.mjs @@ -0,0 +1,1239 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + InlineSpellCheckerContent: + "resource://gre/modules/InlineSpellCheckerContent.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", + SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", + SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs", +}); + +let contextMenus = new WeakMap(); + +export class ContextMenuChild extends JSWindowActorChild { + // PUBLIC + constructor() { + super(); + + this.target = null; + this.context = null; + this.lastMenuTarget = null; + } + + static getTarget(browsingContext, message, key) { + let actor = contextMenus.get(browsingContext); + if (!actor) { + throw new Error( + "Can't find ContextMenu actor for browsing context with " + + "ID: " + + browsingContext.id + ); + } + return actor.getTarget(message, key); + } + + static getLastTarget(browsingContext) { + let contextMenu = contextMenus.get(browsingContext); + return contextMenu && contextMenu.lastMenuTarget; + } + + receiveMessage(message) { + switch (message.name) { + case "ContextMenu:GetFrameTitle": { + let target = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + return Promise.resolve(target.ownerDocument.title); + } + + case "ContextMenu:Canvas:ToBlobURL": { + let target = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + return new Promise(resolve => { + target.toBlob(blob => { + let blobURL = URL.createObjectURL(blob); + resolve(blobURL); + }); + }); + } + + case "ContextMenu:Hiding": { + this.context = null; + this.target = null; + break; + } + + case "ContextMenu:MediaCommand": { + lazy.E10SUtils.wrapHandlingUserInput( + this.contentWindow, + message.data.handlingUserInput, + () => { + let media = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + + switch (message.data.command) { + case "play": + media.play(); + break; + case "pause": + media.pause(); + break; + case "loop": + media.loop = !media.loop; + break; + case "mute": + media.muted = true; + break; + case "unmute": + media.muted = false; + break; + case "playbackRate": + media.playbackRate = message.data.data; + break; + case "hidecontrols": + media.removeAttribute("controls"); + break; + case "showcontrols": + media.setAttribute("controls", "true"); + break; + case "fullscreen": + if (this.document.fullscreenEnabled) { + media.requestFullscreen(); + } + break; + case "pictureinpicture": + if (!media.isCloningElementVisually) { + Services.telemetry.keyedScalarAdd( + "pictureinpicture.opened_method", + "contextmenu", + 1 + ); + } + let event = new this.contentWindow.CustomEvent( + "MozTogglePictureInPicture", + { + bubbles: true, + detail: { reason: "contextMenu" }, + }, + this.contentWindow + ); + media.dispatchEvent(event); + break; + } + } + ); + break; + } + + case "ContextMenu:ReloadFrame": { + let target = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + target.ownerDocument.location.reload(message.data.forceReload); + break; + } + + case "ContextMenu:GetImageText": { + let img = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + const { direction } = this.contentWindow.getComputedStyle(img); + + return img.recognizeCurrentImageText().then(results => { + return { results, direction }; + }); + } + + case "ContextMenu:ToggleRevealPassword": { + let target = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + target.revealPassword = !target.revealPassword; + break; + } + + case "ContextMenu:UseRelayMask": { + const input = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + input.setUserInput(message.data.emailMask); + break; + } + + case "ContextMenu:ReloadImage": { + let image = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + + if (image instanceof Ci.nsIImageLoadingContent) { + image.forceReload(); + } + break; + } + + case "ContextMenu:SearchFieldBookmarkData": { + let node = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + let charset = node.ownerDocument.characterSet; + let formBaseURI = Services.io.newURI(node.form.baseURI, charset); + let formURI = Services.io.newURI( + node.form.getAttribute("action"), + charset, + formBaseURI + ); + let spec = formURI.spec; + let isURLEncoded = + node.form.method.toUpperCase() == "POST" && + (node.form.enctype == "application/x-www-form-urlencoded" || + node.form.enctype == ""); + let title = node.ownerDocument.title; + + function escapeNameValuePair([aName, aValue]) { + if (isURLEncoded) { + return escape(aName + "=" + aValue); + } + + return escape(aName) + "=" + escape(aValue); + } + let formData = new this.contentWindow.FormData(node.form); + formData.delete(node.name); + formData = Array.from(formData).map(escapeNameValuePair); + formData.push( + escape(node.name) + (isURLEncoded ? escape("=%s") : "=%s") + ); + + let postData; + + if (isURLEncoded) { + postData = formData.join("&"); + } else { + let separator = spec.includes("?") ? "&" : "?"; + spec += separator + formData.join("&"); + } + + return Promise.resolve({ spec, title, postData, charset }); + } + + case "ContextMenu:SaveVideoFrameAsImage": { + let video = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + let canvas = this.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + let ctxDraw = canvas.getContext("2d"); + ctxDraw.drawImage(video, 0, 0); + + // Note: if changing the content type, don't forget to update + // consumers that also hardcode this content type. + return Promise.resolve(canvas.toDataURL("image/jpeg", "")); + } + + case "ContextMenu:SetAsDesktopBackground": { + let target = lazy.ContentDOMReference.resolve( + message.data.targetIdentifier + ); + + // Paranoia: check disableSetDesktopBackground again, in case the + // image changed since the context menu was initiated. + let disable = this._disableSetDesktopBackground(target); + + if (!disable) { + try { + Services.scriptSecurityManager.checkLoadURIWithPrincipal( + target.ownerDocument.nodePrincipal, + target.currentURI + ); + let canvas = this.document.createElement("canvas"); + canvas.width = target.naturalWidth; + canvas.height = target.naturalHeight; + let ctx = canvas.getContext("2d"); + ctx.drawImage(target, 0, 0); + let dataURL = canvas.toDataURL(); + let url = new URL(target.ownerDocument.location.href).pathname; + let imageName = url.substr(url.lastIndexOf("/") + 1); + return Promise.resolve({ failed: false, dataURL, imageName }); + } catch (e) { + console.error(e); + } + } + + return Promise.resolve({ + failed: true, + dataURL: null, + imageName: null, + }); + } + } + + return undefined; + } + + /** + * Returns the event target of the context menu, using a locally stored + * reference if possible. If not, and aMessage.objects is defined, + * aMessage.objects[aKey] is returned. Otherwise null. + * @param {Object} aMessage Message with a objects property + * @param {String} aKey Key for the target on aMessage.objects + * @return {Object} Context menu target + */ + getTarget(aMessage, aKey = "target") { + return this.target || (aMessage.objects && aMessage.objects[aKey]); + } + + // PRIVATE + _isXULTextLinkLabel(aNode) { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return ( + aNode.namespaceURI == XUL_NS && + aNode.tagName == "label" && + aNode.classList.contains("text-link") && + aNode.href + ); + } + + // Generate fully qualified URL for clicked-on link. + _getLinkURL() { + let href = this.context.link.href; + + if (href) { + // Handle SVG links: + if (typeof href == "object" && href.animVal) { + return this._makeURLAbsolute(this.context.link.baseURI, href.animVal); + } + + return href; + } + + href = + this.context.link.getAttribute("href") || + this.context.link.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + + if (!href || !href.match(/\S/)) { + // Without this we try to save as the current doc, + // for example, HTML case also throws if empty + throw new Error("Empty href"); + } + + return this._makeURLAbsolute(this.context.link.baseURI, href); + } + + _getLinkURI() { + try { + return Services.io.newURI(this.context.linkURL); + } catch (ex) { + // e.g. empty URL string + } + + return null; + } + + // Get text of link. + _getLinkText() { + let text = this._gatherTextUnder(this.context.link); + + if (!text || !text.match(/\S/)) { + text = this.context.link.getAttribute("title"); + if (!text || !text.match(/\S/)) { + text = this.context.link.getAttribute("alt"); + if (!text || !text.match(/\S/)) { + text = this.context.linkURL; + } + } + } + + return text; + } + + _getLinkProtocol() { + if (this.context.linkURI) { + return this.context.linkURI.scheme; // can be |undefined| + } + + return null; + } + + // Returns true if clicked-on link targets a resource that can be saved. + _isLinkSaveable(aLink) { + // We don't do the Right Thing for news/snews yet, so turn them off + // until we do. + return ( + this.context.linkProtocol && + !( + this.context.linkProtocol == "mailto" || + this.context.linkProtocol == "tel" || + this.context.linkProtocol == "javascript" || + this.context.linkProtocol == "news" || + this.context.linkProtocol == "snews" + ) + ); + } + + // Gather all descendent text under given document node. + _gatherTextUnder(root) { + let text = ""; + let node = root.firstChild; + let depth = 1; + while (node && depth > 0) { + // See if this node is text. + if (node.nodeType == node.TEXT_NODE) { + // Add this text to our collection. + text += " " + node.data; + } else if (this.contentWindow.HTMLImageElement.isInstance(node)) { + // If it has an "alt" attribute, add that. + let altText = node.getAttribute("alt"); + if (altText && altText != "") { + text += " " + altText; + } + } + // Find next node to test. + // First, see if this node has children. + if (node.hasChildNodes()) { + // Go to first child. + node = node.firstChild; + depth++; + } else { + // No children, try next sibling (or parent next sibling). + while (depth > 0 && !node.nextSibling) { + node = node.parentNode; + depth--; + } + if (node.nextSibling) { + node = node.nextSibling; + } + } + } + + // Strip leading and tailing whitespace. + text = text.trim(); + // Compress remaining whitespace. + text = text.replace(/\s+/g, " "); + return text; + } + + // Returns a "url"-type computed style attribute value, with the url() stripped. + _getComputedURL(aElem, aProp) { + let urls = aElem.ownerGlobal.getComputedStyle(aElem).getCSSImageURLs(aProp); + + if (!urls.length) { + return null; + } + + if (urls.length != 1) { + throw new Error("found multiple URLs"); + } + + return urls[0]; + } + + _makeURLAbsolute(aBase, aUrl) { + return Services.io.newURI(aUrl, null, Services.io.newURI(aBase)).spec; + } + + _isProprietaryDRM() { + return ( + this.context.target.isEncrypted && + this.context.target.mediaKeys && + this.context.target.mediaKeys.keySystem != "org.w3.clearkey" + ); + } + + _isMediaURLReusable(aURL) { + if (aURL.startsWith("blob:")) { + return URL.isValidObjectURL(aURL); + } + + return true; + } + + _isTargetATextBox(node) { + if (this.contentWindow.HTMLInputElement.isInstance(node)) { + return node.mozIsTextField(false); + } + + return this.contentWindow.HTMLTextAreaElement.isInstance(node); + } + + /** + * Check if we are in the parent process and the current iframe is the RDM iframe. + */ + _isTargetRDMFrame(node) { + return ( + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT && + node.tagName === "iframe" && + node.hasAttribute("mozbrowser") + ); + } + + _isSpellCheckEnabled(aNode) { + // We can always force-enable spellchecking on textboxes + if (this._isTargetATextBox(aNode)) { + return true; + } + + // We can never spell check something which is not content editable + let editable = aNode.isContentEditable; + + if (!editable && aNode.ownerDocument) { + editable = aNode.ownerDocument.designMode == "on"; + } + + if (!editable) { + return false; + } + + // Otherwise make sure that nothing in the parent chain disables spellchecking + return aNode.spellcheck; + } + + _disableSetDesktopBackground(aTarget) { + // Disable the Set as Desktop Background menu item if we're still trying + // to load the image or the load failed. + if (!(aTarget instanceof Ci.nsIImageLoadingContent)) { + return true; + } + + if ("complete" in aTarget && !aTarget.complete) { + return true; + } + + if (aTarget.currentURI.schemeIs("javascript")) { + return true; + } + + let request = aTarget.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST); + + if (!request) { + return true; + } + + return false; + } + + async handleEvent(aEvent) { + contextMenus.set(this.browsingContext, this); + + let defaultPrevented = aEvent.defaultPrevented; + + if ( + // If the event is not from a chrome-privileged document, and if + // `dom.event.contextmenu.enabled` is false, force defaultPrevented=false. + !aEvent.composedTarget.nodePrincipal.isSystemPrincipal && + !Services.prefs.getBoolPref("dom.event.contextmenu.enabled") + ) { + defaultPrevented = false; + } + + if (defaultPrevented) { + return; + } + + if (this._isTargetRDMFrame(aEvent.composedTarget)) { + // The target is in the DevTools RDM iframe, a proper context menu event + // will be created from the RDM browser. + return; + } + + let doc = aEvent.composedTarget.ownerDocument; + let { + mozDocumentURIIfNotForErrorPages: docLocation, + characterSet: charSet, + baseURI, + } = doc; + docLocation = docLocation && docLocation.spec; + const loginManagerChild = lazy.LoginManagerChild.forWindow(doc.defaultView); + const docState = loginManagerChild.stateForDocument(doc); + const loginFillInfo = docState.getFieldContext(aEvent.composedTarget); + + let disableSetDesktopBackground = null; + + // Media related cache info parent needs for saving + let contentType = null; + let contentDisposition = null; + if ( + aEvent.composedTarget.nodeType == aEvent.composedTarget.ELEMENT_NODE && + aEvent.composedTarget instanceof Ci.nsIImageLoadingContent && + aEvent.composedTarget.currentURI + ) { + disableSetDesktopBackground = this._disableSetDesktopBackground( + aEvent.composedTarget + ); + + try { + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(doc); + // The image cache's notion of where this image is located is + // the currentURI of the image loading content. + let props = imageCache.findEntryProperties( + aEvent.composedTarget.currentURI, + doc + ); + + try { + contentType = props.get("type", Ci.nsISupportsCString).data; + } catch (e) {} + + try { + contentDisposition = props.get( + "content-disposition", + Ci.nsISupportsCString + ).data; + } catch (e) {} + } catch (e) {} + } + + let selectionInfo = lazy.SelectionUtils.getSelectionDetails( + this.contentWindow + ); + + this._setContext(aEvent); + let context = this.context; + this.target = context.target; + + let spellInfo = null; + let editFlags = null; + + let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( + Ci.nsIReferrerInfo + ); + referrerInfo.initWithElement(aEvent.composedTarget); + referrerInfo = lazy.E10SUtils.serializeReferrerInfo(referrerInfo); + + // In the case "onLink" we may have to send link referrerInfo to use in + // _openLinkInParameters + let linkReferrerInfo = null; + if (context.onLink) { + linkReferrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( + Ci.nsIReferrerInfo + ); + linkReferrerInfo.initWithElement(context.link); + } + + let target = context.target; + if (target) { + this._cleanContext(); + } + + editFlags = lazy.SpellCheckHelper.isEditable( + aEvent.composedTarget, + this.contentWindow + ); + + if (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) { + spellInfo = lazy.InlineSpellCheckerContent.initContextMenu( + aEvent, + editFlags, + this + ); + } + + // Set the event target first as the copy image command needs it to + // determine what was context-clicked on. Then, update the state of the + // commands on the context menu. + this.docShell.contentViewer + .QueryInterface(Ci.nsIContentViewerEdit) + .setCommandNode(aEvent.composedTarget); + aEvent.composedTarget.ownerGlobal.updateCommands("contentcontextmenu"); + + let data = { + context, + charSet, + baseURI, + referrerInfo, + editFlags, + contentType, + docLocation, + loginFillInfo, + selectionInfo, + contentDisposition, + disableSetDesktopBackground, + }; + + if (context.inFrame && !context.inSrcdocFrame) { + data.frameReferrerInfo = lazy.E10SUtils.serializeReferrerInfo( + doc.referrerInfo + ); + } + + if (linkReferrerInfo) { + data.linkReferrerInfo = + lazy.E10SUtils.serializeReferrerInfo(linkReferrerInfo); + } + + // Notify observers (currently only webextensions) of the context menu being + // prepared, allowing them to set webExtContextData for us. + let prepareContextMenu = { + principal: doc.nodePrincipal, + setWebExtContextData(webExtContextData) { + data.webExtContextData = webExtContextData; + }, + }; + Services.obs.notifyObservers(prepareContextMenu, "on-prepare-contextmenu"); + + // In the event that the content is running in the parent process, we don't + // actually want the contextmenu events to reach the parent - we'll dispatch + // a new contextmenu event after the async message has reached the parent + // instead. + aEvent.stopPropagation(); + + data.spellInfo = null; + if (!spellInfo) { + this.sendAsyncMessage("contextmenu", data); + return; + } + + try { + data.spellInfo = await spellInfo; + } catch (ex) {} + this.sendAsyncMessage("contextmenu", data); + } + + /** + * Some things are not serializable, so we either have to only send + * their needed data or regenerate them in nsContextMenu.js + * - target and target.ownerDocument + * - link + * - linkURI + */ + _cleanContext(aEvent) { + const context = this.context; + const cleanTarget = Object.create(null); + + cleanTarget.ownerDocument = { + // used for nsContextMenu.initLeaveDOMFullScreenItems and + // nsContextMenu.initMediaPlayerItems + fullscreen: context.target.ownerDocument.fullscreen, + + // used for nsContextMenu.initMiscItems + contentType: context.target.ownerDocument.contentType, + }; + + // used for nsContextMenu.initMediaPlayerItems + Object.assign(cleanTarget, { + ended: context.target.ended, + muted: context.target.muted, + paused: context.target.paused, + controls: context.target.controls, + duration: context.target.duration, + }); + + const onMedia = context.onVideo || context.onAudio; + + if (onMedia) { + Object.assign(cleanTarget, { + loop: context.target.loop, + error: context.target.error, + networkState: context.target.networkState, + playbackRate: context.target.playbackRate, + NETWORK_NO_SOURCE: context.target.NETWORK_NO_SOURCE, + }); + + if (context.onVideo) { + Object.assign(cleanTarget, { + readyState: context.target.readyState, + HAVE_CURRENT_DATA: context.target.HAVE_CURRENT_DATA, + }); + } + } + + context.target = cleanTarget; + + if (context.link) { + context.link = { href: context.linkURL }; + } + + delete context.linkURI; + } + + _setContext(aEvent) { + this.context = Object.create(null); + const context = this.context; + + context.timeStamp = aEvent.timeStamp; + context.screenXDevPx = aEvent.screenX * this.contentWindow.devicePixelRatio; + context.screenYDevPx = aEvent.screenY * this.contentWindow.devicePixelRatio; + context.mozInputSource = aEvent.mozInputSource; + + let node = aEvent.composedTarget; + + // Set the node to containing <video>/<audio>/<embed>/<object> if the node + // is in the videocontrols UA Widget. + if (node.containingShadowRoot?.isUAWidget()) { + const host = node.containingShadowRoot.host; + if ( + this.contentWindow.HTMLMediaElement.isInstance(host) || + this.contentWindow.HTMLEmbedElement.isInstance(host) || + this.contentWindow.HTMLObjectElement.isInstance(host) + ) { + node = host; + } + } + + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + context.shouldDisplay = true; + + if ( + node.nodeType == node.DOCUMENT_NODE || + // Don't display for XUL element unless <label class="text-link"> + (node.namespaceURI == XUL_NS && !this._isXULTextLinkLabel(node)) + ) { + context.shouldDisplay = false; + return; + } + + const isAboutDevtoolsToolbox = this.document.documentURI.startsWith( + "about:devtools-toolbox" + ); + const editFlags = lazy.SpellCheckHelper.isEditable( + node, + this.contentWindow + ); + + if ( + isAboutDevtoolsToolbox && + (editFlags & lazy.SpellCheckHelper.TEXTINPUT) === 0 + ) { + // Don't display for about:devtools-toolbox page unless the source was text input. + context.shouldDisplay = false; + return; + } + + // Initialize context to be sent to nsContextMenu + // Keep this consistent with the similar code in nsContextMenu's setContext + context.bgImageURL = ""; + context.imageDescURL = ""; + context.imageInfo = null; + context.mediaURL = ""; + context.webExtBrowserType = ""; + + context.canSpellCheck = false; + context.hasBGImage = false; + context.hasMultipleBGImages = false; + context.isDesignMode = false; + context.inFrame = false; + context.inPDFViewer = false; + context.inSrcdocFrame = false; + context.inSyntheticDoc = false; + context.inTabBrowser = true; + context.inWebExtBrowser = false; + + context.link = null; + context.linkDownload = ""; + context.linkProtocol = ""; + context.linkTextStr = ""; + context.linkURL = ""; + context.linkURI = null; + + context.onAudio = false; + context.onCanvas = false; + context.onCompletedImage = false; + context.onDRMMedia = false; + context.onPiPVideo = false; + context.onEditable = false; + context.onImage = false; + context.onKeywordField = false; + context.onLink = false; + context.onLoadedImage = false; + context.onMailtoLink = false; + context.onTelLink = false; + context.onMozExtLink = false; + context.onNumeric = false; + context.onPassword = false; + context.passwordRevealed = false; + context.onSaveableLink = false; + context.onSpellcheckable = false; + context.onTextInput = false; + context.onVideo = false; + context.inPDFEditor = false; + + // Remember the node and its owner document that was clicked + // This may be modifed before sending to nsContextMenu + context.target = node; + context.targetIdentifier = lazy.ContentDOMReference.get(node); + + context.csp = lazy.E10SUtils.serializeCSP(context.target.ownerDocument.csp); + + // Check if we are in the PDF Viewer. + context.inPDFViewer = + context.target.ownerDocument.nodePrincipal.originNoSuffix == + "resource://pdf.js"; + if (context.inPDFViewer) { + context.pdfEditorStates = context.target.ownerDocument.editorStates; + context.inPDFEditor = !!context.pdfEditorStates?.isEditing; + } + + // Check if we are in a synthetic document (stand alone image, video, etc.). + context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument; + + context.shouldInitInlineSpellCheckerUINoChildren = false; + context.shouldInitInlineSpellCheckerUIWithChildren = false; + + this._setContextForNodesNoChildren(editFlags); + this._setContextForNodesWithChildren(editFlags); + + this.lastMenuTarget = { + // Remember the node for extensions. + targetRef: Cu.getWeakReference(node), + // The timestamp is used to verify that the target wasn't changed since the observed menu event. + timeStamp: context.timeStamp, + }; + + if (isAboutDevtoolsToolbox) { + // Setup the menu items on text input in about:devtools-toolbox. + context.inAboutDevtoolsToolbox = true; + context.canSpellCheck = false; + context.inTabBrowser = false; + context.inFrame = false; + context.inSrcdocFrame = false; + context.onSpellcheckable = false; + } + } + + /** + * Sets up the parts of the context menu for when when nodes have no children. + * + * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper + * for the details. + */ + _setContextForNodesNoChildren(editFlags) { + const context = this.context; + + if (context.target.nodeType == context.target.TEXT_NODE) { + // For text nodes, look at the parent node to determine the spellcheck attribute. + context.canSpellCheck = + context.target.parentNode && this._isSpellCheckEnabled(context.target); + return; + } + + // We only deal with TEXT_NODE and ELEMENT_NODE in this function, so return + // early if we don't have one. + if (context.target.nodeType != context.target.ELEMENT_NODE) { + return; + } + + // See if the user clicked on an image. This check mirrors + // nsDocumentViewer::GetInImage. Make sure to update both if this is + // changed. + if ( + context.target instanceof Ci.nsIImageLoadingContent && + (context.target.currentRequestFinalURI || context.target.currentURI) + ) { + context.onImage = true; + + context.imageInfo = { + currentSrc: context.target.currentSrc, + width: context.target.width, + height: context.target.height, + imageText: context.target.title || context.target.alt, + }; + const { SVGAnimatedLength } = context.target.ownerGlobal; + if (SVGAnimatedLength.isInstance(context.imageInfo.height)) { + context.imageInfo.height = context.imageInfo.height.animVal.value; + } + if (SVGAnimatedLength.isInstance(context.imageInfo.width)) { + context.imageInfo.width = context.imageInfo.width.animVal.value; + } + + const request = context.target.getRequest( + Ci.nsIImageLoadingContent.CURRENT_REQUEST + ); + + if (request && request.imageStatus & request.STATUS_SIZE_AVAILABLE) { + context.onLoadedImage = true; + } + + if ( + request && + request.imageStatus & request.STATUS_LOAD_COMPLETE && + !(request.imageStatus & request.STATUS_ERROR) + ) { + context.onCompletedImage = true; + } + + // The URL of the image before redirects is the currentURI. This is + // intended to be used for "Copy Image Link". + context.originalMediaURL = (() => { + let currentURI = context.target.currentURI?.spec; + if (currentURI && this._isMediaURLReusable(currentURI)) { + return currentURI; + } + return ""; + })(); + + // The actual URL the image was loaded from (after redirects) is the + // currentRequestFinalURI. We should use that as the URL for purposes of + // deciding on the filename, if it is present. It might not be present + // if images are blocked. + // + // It is important to check both the final and the current URI, as they + // could be different blob URIs, see bug 1625786. + context.mediaURL = (() => { + let finalURI = context.target.currentRequestFinalURI?.spec; + if (finalURI && this._isMediaURLReusable(finalURI)) { + return finalURI; + } + let currentURI = context.target.currentURI?.spec; + if (currentURI && this._isMediaURLReusable(currentURI)) { + return currentURI; + } + return ""; + })(); + + const descURL = context.target.getAttribute("longdesc"); + + if (descURL) { + context.imageDescURL = this._makeURLAbsolute( + context.target.ownerDocument.body.baseURI, + descURL + ); + } + } else if ( + this.contentWindow.HTMLCanvasElement.isInstance(context.target) + ) { + context.onCanvas = true; + } else if (this.contentWindow.HTMLVideoElement.isInstance(context.target)) { + const mediaURL = context.target.currentSrc || context.target.src; + + if (this._isMediaURLReusable(mediaURL)) { + context.mediaURL = mediaURL; + } + + if (this._isProprietaryDRM()) { + context.onDRMMedia = true; + } + + if (context.target.isCloningElementVisually) { + context.onPiPVideo = true; + } + + // Firefox always creates a HTMLVideoElement when loading an ogg file + // directly. If the media is actually audio, be smarter and provide a + // context menu with audio operations. + if ( + context.target.readyState >= context.target.HAVE_METADATA && + (context.target.videoWidth == 0 || context.target.videoHeight == 0) + ) { + context.onAudio = true; + } else { + context.onVideo = true; + } + } else if (this.contentWindow.HTMLAudioElement.isInstance(context.target)) { + context.onAudio = true; + const mediaURL = context.target.currentSrc || context.target.src; + + if (this._isMediaURLReusable(mediaURL)) { + context.mediaURL = mediaURL; + } + + if (this._isProprietaryDRM()) { + context.onDRMMedia = true; + } + } else if ( + editFlags & + (lazy.SpellCheckHelper.INPUT | lazy.SpellCheckHelper.TEXTAREA) + ) { + context.onTextInput = (editFlags & lazy.SpellCheckHelper.TEXTINPUT) !== 0; + context.onNumeric = (editFlags & lazy.SpellCheckHelper.NUMERIC) !== 0; + context.onEditable = (editFlags & lazy.SpellCheckHelper.EDITABLE) !== 0; + context.onPassword = (editFlags & lazy.SpellCheckHelper.PASSWORD) !== 0; + + context.showRelay = + HTMLInputElement.isInstance(context.target) && + !context.target.disabled && + !context.target.readOnly && + (lazy.LoginHelper.isInferredEmailField(context.target) || + lazy.LoginHelper.isInferredUsernameField(context.target)); + context.isDesignMode = + (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) !== 0; + context.passwordRevealed = + context.onPassword && context.target.revealPassword; + context.onSpellcheckable = + (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) !== 0; + + // This is guaranteed to be an input or textarea because of the condition above, + // so the no-children flag is always correct. We deal with contenteditable elsewhere. + if (context.onSpellcheckable) { + context.shouldInitInlineSpellCheckerUINoChildren = true; + } + + context.onKeywordField = editFlags & lazy.SpellCheckHelper.KEYWORD; + } else if (this.contentWindow.HTMLHtmlElement.isInstance(context.target)) { + const bodyElt = context.target.ownerDocument.body; + + if (bodyElt) { + let computedURL; + + try { + computedURL = this._getComputedURL(bodyElt, "background-image"); + context.hasMultipleBGImages = false; + } catch (e) { + context.hasMultipleBGImages = true; + } + + if (computedURL) { + context.hasBGImage = true; + context.bgImageURL = this._makeURLAbsolute( + bodyElt.baseURI, + computedURL + ); + } + } + } + + context.canSpellCheck = this._isSpellCheckEnabled(context.target); + } + + /** + * Sets up the parts of the context menu for when when nodes have children. + * + * @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper + * for the details. + */ + _setContextForNodesWithChildren(editFlags) { + const context = this.context; + + // Second, bubble out, looking for items of interest that can have childen. + // Always pick the innermost link, background image, etc. + let elem = context.target; + + while (elem) { + if (elem.nodeType == elem.ELEMENT_NODE) { + // Link? + const XLINK_NS = "http://www.w3.org/1999/xlink"; + + if ( + !context.onLink && + // Be consistent with what hrefAndLinkNodeForClickEvent + // does in browser.js + (this._isXULTextLinkLabel(elem) || + (this.contentWindow.HTMLAnchorElement.isInstance(elem) && + elem.href) || + (this.contentWindow.SVGAElement.isInstance(elem) && + (elem.href || elem.hasAttributeNS(XLINK_NS, "href"))) || + (this.contentWindow.HTMLAreaElement.isInstance(elem) && + elem.href) || + this.contentWindow.HTMLLinkElement.isInstance(elem) || + elem.getAttributeNS(XLINK_NS, "type") == "simple") + ) { + // Target is a link or a descendant of a link. + context.onLink = true; + + // Remember corresponding element. + context.link = elem; + context.linkURL = this._getLinkURL(); + context.linkURI = this._getLinkURI(); + context.linkTextStr = this._getLinkText(); + context.linkProtocol = this._getLinkProtocol(); + context.onMailtoLink = context.linkProtocol == "mailto"; + context.onTelLink = context.linkProtocol == "tel"; + context.onMozExtLink = context.linkProtocol == "moz-extension"; + context.onSaveableLink = this._isLinkSaveable(context.link); + + context.isSponsoredLink = + (elem.ownerDocument.URL === "about:newtab" || + elem.ownerDocument.URL === "about:home") && + elem.dataset.isSponsoredLink === "true"; + + try { + if (elem.download) { + // Ignore download attribute on cross-origin links + context.target.ownerDocument.nodePrincipal.checkMayLoad( + context.linkURI, + true + ); + context.linkDownload = elem.download; + } + } catch (ex) {} + } + + // Background image? Don't bother if we've already found a + // background image further down the hierarchy. Otherwise, + // we look for the computed background-image style. + if (!context.hasBGImage && !context.hasMultipleBGImages) { + let bgImgUrl = null; + + try { + bgImgUrl = this._getComputedURL(elem, "background-image"); + context.hasMultipleBGImages = false; + } catch (e) { + context.hasMultipleBGImages = true; + } + + if (bgImgUrl) { + context.hasBGImage = true; + context.bgImageURL = this._makeURLAbsolute(elem.baseURI, bgImgUrl); + } + } + } + + elem = elem.flattenedTreeParentNode; + } + + // See if the user clicked in a frame. + const docDefaultView = context.target.ownerGlobal; + + if (docDefaultView != docDefaultView.top) { + context.inFrame = true; + + if (context.target.ownerDocument.isSrcdocDocument) { + context.inSrcdocFrame = true; + } + } + + // if the document is editable, show context menu like in text inputs + if (!context.onEditable) { + if (editFlags & lazy.SpellCheckHelper.CONTENTEDITABLE) { + // If this.onEditable is false but editFlags is CONTENTEDITABLE, then + // the document itself must be editable. + context.onTextInput = true; + context.onKeywordField = false; + context.onImage = false; + context.onLoadedImage = false; + context.onCompletedImage = false; + context.inFrame = false; + context.inSrcdocFrame = false; + context.hasBGImage = false; + context.isDesignMode = true; + context.onEditable = true; + context.onSpellcheckable = true; + context.shouldInitInlineSpellCheckerUIWithChildren = true; + } + } + } + + _destructionObservers = new Set(); + registerDestructionObserver(obj) { + this._destructionObservers.add(obj); + } + + unregisterDestructionObserver(obj) { + this._destructionObservers.delete(obj); + } + + didDestroy() { + for (let obs of this._destructionObservers) { + obs.actorDestroyed(this); + } + this._destructionObservers = null; + } +} |