/* 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/. */ import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs", SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs", }); const MAX_TEXT_LENGTH = 4096; export class ContentDelegateChild extends GeckoViewActorChild { notifyParentOfViewportFit() { if (this.triggerViewportFitChange) { this.contentWindow.cancelIdleCallback(this.triggerViewportFitChange); } this.triggerViewportFitChange = this.contentWindow.requestIdleCallback( () => { this.triggerViewportFitChange = null; const viewportFit = this.contentWindow.windowUtils.getViewportFitInfo(); if (this.lastViewportFit === viewportFit) { return; } this.lastViewportFit = viewportFit; this.eventDispatcher.sendRequest({ type: "GeckoView:DOMMetaViewportFit", viewportfit: viewportFit, }); } ); } #getSelection(aElement, aEditFlags) { if (aEditFlags & lazy.SpellCheckHelper.TEXTINPUT) { return aElement?.editor?.selection; } return this.contentWindow.getSelection(); } #getSelectionBoundingRect(aFocusedElement, aEditFlags) { const selection = this.#getSelection(aFocusedElement, aEditFlags); if (!selection || selection.isCollapsed || selection.rangeCount != 1) { return null; } const range = selection.getRangeAt(0); return range.getBoundingClientRect(); } /** * Show action menu if contextmenu is by mouse and we have selected text */ #showActionMenu(aEvent) { if (aEvent.pointerType !== "mouse") { return false; } const win = this.contentWindow; const selectionInfo = lazy.SelectionUtils.getSelectionDetails(win); if (!selectionInfo.text.length) { // Don't show action menu by contextmenu event if no selection return false; } // The selection isn't collapsed and has a text. We try to show action menu const focusedElement = Services.focus.focusedElement || aEvent.composedTarget; const editFlags = lazy.SpellCheckHelper.isEditable(focusedElement, win); const selectionEditable = !!( editFlags & (lazy.SpellCheckHelper.EDITABLE | lazy.SpellCheckHelper.CONTENTEDITABLE) ); const boundingClientRect = this.#getSelectionBoundingRect( focusedElement, editFlags ); const caretEvent = new CaretStateChangedEvent("mozcaretstatechanged", { bubbles: true, collapsed: selectionInfo.docSelectionIsCollapsed, boundingClientRect, reason: "taponcaret", caretVisible: true, selectionVisible: true, selectionEditable, selectedTextContent: selectionInfo.text, }); win.dispatchEvent(caretEvent); // If selection is changed, or focus is changed, dismiss action menu const eventTarget = (() => { if (editFlags & lazy.SpellCheckHelper.TEXTINPUT) { return focusedElement; } return this.contentWindow.document; })(); function dismissHandler() { const dismissEvent = new CaretStateChangedEvent("mozcaretstatechanged", { bubbles: true, reason: "visibilitychange", }); win.dispatchEvent(dismissEvent); eventTarget.removeEventListener("selectionchange", dismissHandler); win.removeEventListener("focusin", dismissHandler); win.removeEventListener("focusout", dismissHandler); win.removeEventListener("blur", dismissHandler); } eventTarget.addEventListener("selectionchange", dismissHandler); win.addEventListener("focusin", dismissHandler); win.addEventListener("focusout", dismissHandler); win.addEventListener("blur", dismissHandler); return true; } // eslint-disable-next-line complexity handleEvent(aEvent) { debug`handleEvent: ${aEvent.type}`; switch (aEvent.type) { case "contextmenu": { if (aEvent.defaultPrevented) { return; } if (this.#showActionMenu(aEvent)) { aEvent.preventDefault(); return; } function nearestParentAttribute(aNode, aAttribute) { while ( aNode && aNode.hasAttribute && !aNode.hasAttribute(aAttribute) ) { aNode = aNode.parentNode; } return aNode && aNode.getAttribute && aNode.getAttribute(aAttribute); } function createAbsoluteUri(aBaseUri, aUri) { if (!aUri || !aBaseUri || !aBaseUri.displaySpec) { return null; } return Services.io.newURI(aUri, null, aBaseUri).displaySpec; } const node = aEvent.composedTarget; const baseUri = node.ownerDocument.baseURIObject; const uri = createAbsoluteUri( baseUri, nearestParentAttribute(node, "href") ); const title = nearestParentAttribute(node, "title"); const alt = nearestParentAttribute(node, "alt"); const elementType = ChromeUtils.getClassName(node); const isImage = elementType === "HTMLImageElement"; const isMedia = elementType === "HTMLVideoElement" || elementType === "HTMLAudioElement"; let elementSrc = (isImage || isMedia) && (node.currentSrc || node.src); if (elementSrc) { const isBlob = elementSrc.startsWith("blob:"); if (isBlob && !URL.isBoundToBlob(elementSrc)) { elementSrc = null; } } if (uri || isImage || isMedia) { const msg = { type: "GeckoView:ContextMenu", // We don't have full zoom on Android, so using CSS coordinates // here is fine, since the CSS coordinate spaces match between the // child and parent processes. // // TODO(m_kato): // title, alt and textContent should consider surrogate pair and grapheme cluster? screenX: aEvent.screenX, screenY: aEvent.screenY, baseUri: (baseUri && baseUri.displaySpec) || null, uri, title: (title && title.substring(0, MAX_TEXT_LENGTH)) || null, alt: (alt && alt.substring(0, MAX_TEXT_LENGTH)) || null, elementType, elementSrc: elementSrc || null, textContent: (node.textContent && node.textContent.substring(0, MAX_TEXT_LENGTH)) || null, }; this.eventDispatcher.sendRequest(msg); aEvent.preventDefault(); } break; } case "MozDOMFullscreen:Request": { this.sendAsyncMessage("GeckoView:DOMFullscreenRequest", {}); break; } case "MozDOMFullscreen:Entered": case "MozDOMFullscreen:Exited": // Content may change fullscreen state by itself, and we should ensure // that the parent always exits fullscreen when content has left // full screen mode. if (this.contentWindow?.document.fullscreenElement) { break; } // fall-through case "MozDOMFullscreen:Exit": this.sendAsyncMessage("GeckoView:DOMFullscreenExit", {}); break; case "DOMMetaViewportFitChanged": if (aEvent.originalTarget.ownerGlobal == this.contentWindow) { this.notifyParentOfViewportFit(); } break; case "DOMContentLoaded": { if (aEvent.originalTarget.ownerGlobal == this.contentWindow) { // If loaded content doesn't have viewport-fit, parent still // uses old value of previous content. this.notifyParentOfViewportFit(); } if (this.contentWindow !== this.contentWindow?.top) { // Only check WebApp manifest on the top level window. return; } this.contentWindow.requestIdleCallback(async () => { const manifest = await lazy.ManifestObtainer.contentObtainManifest( this.contentWindow ); if (manifest) { this.eventDispatcher.sendRequest({ type: "GeckoView:WebAppManifest", manifest, }); } }); break; } case "MozFirstContentfulPaint": { this.eventDispatcher.sendRequest({ type: "GeckoView:FirstContentfulPaint", }); break; } case "MozPaintStatusReset": { this.eventDispatcher.sendRequest({ type: "GeckoView:PaintStatusReset", }); break; } } } } const { debug, warn } = ContentDelegateChild.initLogging( "ContentDelegateChild" );