tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ContentDelegateChild.sys.mjs (8909B)


      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 { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs",
     11  SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
     12  SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs",
     13 });
     14 
     15 const MAX_TEXT_LENGTH = 4096;
     16 
     17 export class ContentDelegateChild extends GeckoViewActorChild {
     18  notifyParentOfViewportFit() {
     19    if (this.triggerViewportFitChange) {
     20      this.contentWindow.cancelIdleCallback(this.triggerViewportFitChange);
     21    }
     22    this.triggerViewportFitChange = this.contentWindow.requestIdleCallback(
     23      () => {
     24        this.triggerViewportFitChange = null;
     25        const viewportFit = this.contentWindow.windowUtils.getViewportFitInfo();
     26        if (this.lastViewportFit === viewportFit) {
     27          return;
     28        }
     29        this.lastViewportFit = viewportFit;
     30        this.sendAsyncMessage("GeckoView:DOMMetaViewportFit", viewportFit);
     31      }
     32    );
     33  }
     34 
     35  #getSelection(aElement, aEditFlags) {
     36    if (aEditFlags & lazy.SpellCheckHelper.TEXTINPUT) {
     37      return aElement?.editor?.selection;
     38    }
     39 
     40    return this.contentWindow.getSelection();
     41  }
     42 
     43  #getSelectionBoundingRect(aFocusedElement, aEditFlags) {
     44    const selection = this.#getSelection(aFocusedElement, aEditFlags);
     45    if (!selection || selection.isCollapsed || selection.rangeCount != 1) {
     46      return null;
     47    }
     48    const range = selection.getRangeAt(0);
     49    return range.getBoundingClientRect();
     50  }
     51 
     52  /**
     53   * Show action menu if contextmenu is by mouse and we have selected text
     54   */
     55  #showActionMenu(aEvent) {
     56    if (aEvent.pointerType !== "mouse") {
     57      return false;
     58    }
     59 
     60    const win = this.contentWindow;
     61    const selectionInfo = lazy.SelectionUtils.getSelectionDetails(win);
     62    if (!selectionInfo.text.length) {
     63      // Don't show action menu by contextmenu event if no selection
     64      return false;
     65    }
     66 
     67    // The selection isn't collapsed and has a text.  We try to show action menu
     68    const focusedElement =
     69      Services.focus.focusedElement || aEvent.composedTarget;
     70 
     71    const editFlags = lazy.SpellCheckHelper.isEditable(focusedElement, win);
     72    const selectionEditable = !!(
     73      editFlags &
     74      (lazy.SpellCheckHelper.EDITABLE | lazy.SpellCheckHelper.CONTENTEDITABLE)
     75    );
     76    const boundingClientRect = this.#getSelectionBoundingRect(
     77      focusedElement,
     78      editFlags
     79    );
     80 
     81    const caretEvent = new CaretStateChangedEvent("mozcaretstatechanged", {
     82      bubbles: true,
     83      collapsed: selectionInfo.docSelectionIsCollapsed,
     84      boundingClientRect,
     85      reason: "taponcaret",
     86      caretVisible: true,
     87      selectionVisible: true,
     88      selectionEditable,
     89      selectedTextContent: selectionInfo.text,
     90    });
     91 
     92    win.dispatchEvent(caretEvent);
     93 
     94    // If selection is changed, or focus is changed, dismiss action menu
     95    const eventTarget = (() => {
     96      if (editFlags & lazy.SpellCheckHelper.TEXTINPUT) {
     97        return focusedElement;
     98      }
     99      return this.contentWindow.document;
    100    })();
    101 
    102    function dismissHandler() {
    103      const dismissEvent = new CaretStateChangedEvent("mozcaretstatechanged", {
    104        bubbles: true,
    105        reason: "visibilitychange",
    106      });
    107      win.dispatchEvent(dismissEvent);
    108 
    109      eventTarget.removeEventListener("selectionchange", dismissHandler);
    110      win.removeEventListener("focusin", dismissHandler);
    111      win.removeEventListener("focusout", dismissHandler);
    112      win.removeEventListener("blur", dismissHandler);
    113    }
    114 
    115    eventTarget.addEventListener("selectionchange", dismissHandler);
    116    win.addEventListener("focusin", dismissHandler);
    117    win.addEventListener("focusout", dismissHandler);
    118    win.addEventListener("blur", dismissHandler);
    119 
    120    return true;
    121  }
    122 
    123  // eslint-disable-next-line complexity
    124  handleEvent(aEvent) {
    125    debug`handleEvent: ${aEvent.type}`;
    126 
    127    switch (aEvent.type) {
    128      case "contextmenu": {
    129        if (aEvent.defaultPrevented) {
    130          return;
    131        }
    132 
    133        if (this.#showActionMenu(aEvent)) {
    134          aEvent.preventDefault();
    135          return;
    136        }
    137 
    138        function nearestParentAttribute(aNode, aAttribute) {
    139          while (
    140            aNode &&
    141            aNode.hasAttribute &&
    142            !aNode.hasAttribute(aAttribute)
    143          ) {
    144            aNode = aNode.parentNode;
    145          }
    146          return aNode && aNode.getAttribute && aNode.getAttribute(aAttribute);
    147        }
    148 
    149        function createAbsoluteUri(aBaseUri, aUri) {
    150          if (!aUri || !aBaseUri || !aBaseUri.displaySpec) {
    151            return null;
    152          }
    153          return Services.io.newURI(aUri, null, aBaseUri).displaySpec;
    154        }
    155 
    156        const node = aEvent.composedTarget;
    157        const baseUri = node.ownerDocument.baseURIObject;
    158        const uri = createAbsoluteUri(
    159          baseUri,
    160          nearestParentAttribute(node, "href")
    161        );
    162        const title = nearestParentAttribute(node, "title");
    163        const alt = nearestParentAttribute(node, "alt");
    164        const elementType = ChromeUtils.getClassName(node);
    165        const isImage = elementType === "HTMLImageElement";
    166        const isMedia =
    167          elementType === "HTMLVideoElement" ||
    168          elementType === "HTMLAudioElement";
    169        let elementSrc = (isImage || isMedia) && (node.currentSrc || node.src);
    170        if (elementSrc) {
    171          const isBlob = elementSrc.startsWith("blob:");
    172          if (isBlob && !URL.isBoundToBlob(elementSrc)) {
    173            elementSrc = null;
    174          }
    175        }
    176 
    177        if (uri || isImage || isMedia) {
    178          const msg = {
    179            // We don't have full zoom on Android, so using CSS coordinates
    180            // here is fine, since the CSS coordinate spaces match between the
    181            // child and parent processes.
    182            //
    183            // TODO(m_kato):
    184            // title, alt and textContent should consider surrogate pair and grapheme cluster?
    185            screenX: aEvent.screenX,
    186            screenY: aEvent.screenY,
    187            baseUri: (baseUri && baseUri.displaySpec) || null,
    188            uri,
    189            title: (title && title.substring(0, MAX_TEXT_LENGTH)) || null,
    190            alt: (alt && alt.substring(0, MAX_TEXT_LENGTH)) || null,
    191            elementType,
    192            elementSrc: elementSrc || null,
    193            textContent:
    194              (node.textContent &&
    195                node.textContent.substring(0, MAX_TEXT_LENGTH)) ||
    196              null,
    197            linkText:
    198              (node.innerText &&
    199                node.innerText.substring(0, MAX_TEXT_LENGTH)) ||
    200              null,
    201          };
    202 
    203          this.sendAsyncMessage("GeckoView:ContextMenu", msg);
    204          aEvent.preventDefault();
    205        }
    206        break;
    207      }
    208      case "MozDOMFullscreen:Request": {
    209        this.sendAsyncMessage("GeckoView:DOMFullscreenRequest");
    210        break;
    211      }
    212      case "MozDOMFullscreen:Entered":
    213      case "MozDOMFullscreen:Exited":
    214        // Content may change fullscreen state by itself, and we should ensure
    215        // that the parent always exits fullscreen when content has left
    216        // full screen mode.
    217        if (this.contentWindow?.document.fullscreenElement) {
    218          break;
    219        }
    220      // fall-through
    221      case "MozDOMFullscreen:Exit":
    222        this.sendAsyncMessage("GeckoView:DOMFullscreenExit");
    223        break;
    224      case "DOMMetaViewportFitChanged":
    225        if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
    226          this.notifyParentOfViewportFit();
    227        }
    228        break;
    229      case "DOMContentLoaded": {
    230        if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
    231          // If loaded content doesn't have viewport-fit, parent still
    232          // uses old value of previous content.
    233          this.notifyParentOfViewportFit();
    234        }
    235        if (this.contentWindow !== this.contentWindow?.top) {
    236          // Only check WebApp manifest on the top level window.
    237          return;
    238        }
    239        this.contentWindow.requestIdleCallback(async () => {
    240          const manifest = await lazy.ManifestObtainer.contentObtainManifest(
    241            this.contentWindow
    242          );
    243          if (manifest) {
    244            this.sendAsyncMessage("GeckoView:WebAppManifest", manifest);
    245          }
    246        });
    247        break;
    248      }
    249      case "MozFirstContentfulPaint": {
    250        this.sendAsyncMessage("GeckoView:FirstContentfulPaint");
    251        break;
    252      }
    253      case "MozPaintStatusReset": {
    254        this.sendAsyncMessage("GeckoView:PaintStatusReset");
    255        break;
    256      }
    257    }
    258  }
    259 }
    260 
    261 const { debug, warn } = ContentDelegateChild.initLogging(
    262  "ContentDelegateChild"
    263 );