tor-browser

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

SelectionActionDelegateChild.sys.mjs (13352B)


      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  LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
     11 });
     12 
     13 const MAGNIFIER_PREF = "layout.accessiblecaret.magnifier.enabled";
     14 const ACCESSIBLECARET_HEIGHT_PREF = "layout.accessiblecaret.height";
     15 const PREFS = [MAGNIFIER_PREF, ACCESSIBLECARET_HEIGHT_PREF];
     16 
     17 // Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to
     18 // the GeckoSession on accessible caret changes.
     19 export class SelectionActionDelegateChild extends GeckoViewActorChild {
     20  constructor(aModuleName, aMessageManager) {
     21    super(aModuleName, aMessageManager);
     22 
     23    this._actionCallback = () => {};
     24    this._isActive = false;
     25    this._previousMessage = "";
     26 
     27    // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
     28    // directly, so we create a new function here instead to act as our
     29    // nsIObserver, which forwards the notification to the observe method.
     30    this._observerFunction = (subject, topic, data) => {
     31      this.observe(subject, topic, data);
     32    };
     33    for (const pref of PREFS) {
     34      Services.prefs.addObserver(pref, this._observerFunction);
     35    }
     36 
     37    this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
     38    this._accessiblecaretHeight = parseFloat(
     39      Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
     40    );
     41  }
     42 
     43  didDestroy() {
     44    for (const pref of PREFS) {
     45      Services.prefs.removeObserver(pref, this._observerFunction);
     46    }
     47  }
     48 
     49  _actions = [
     50    {
     51      id: "org.mozilla.geckoview.HIDE",
     52      predicate: _ => true,
     53      perform: _ => this.handleEvent({ type: "pagehide" }),
     54    },
     55    {
     56      id: "org.mozilla.geckoview.CUT",
     57      predicate: e =>
     58        !e.collapsed && e.selectionEditable && !this._isPasswordField(e),
     59      perform: _ => this.docShell.doCommand("cmd_cut"),
     60    },
     61    {
     62      id: "org.mozilla.geckoview.COPY",
     63      predicate: e => !e.collapsed && !this._isPasswordField(e),
     64      perform: _ => this.docShell.doCommand("cmd_copy"),
     65    },
     66    {
     67      id: "org.mozilla.geckoview.PASTE",
     68      predicate: e =>
     69        (this._isContentHtmlEditable(e) &&
     70          Services.clipboard.hasDataMatchingFlavors(
     71            /* The following image types are considered by editor */
     72            ["image/gif", "image/jpeg", "image/png"],
     73            Ci.nsIClipboard.kGlobalClipboard
     74          )) ||
     75        (e.selectionEditable &&
     76          Services.clipboard.hasDataMatchingFlavors(
     77            ["text/plain"],
     78            Ci.nsIClipboard.kGlobalClipboard
     79          )),
     80      perform: _ => this._performPaste(),
     81    },
     82    {
     83      id: "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT",
     84      predicate: e =>
     85        this._isContentHtmlEditable(e) &&
     86        Services.clipboard.hasDataMatchingFlavors(
     87          ["text/html"],
     88          Ci.nsIClipboard.kGlobalClipboard
     89        ),
     90      perform: _ => this._performPasteAsPlainText(),
     91    },
     92    {
     93      id: "org.mozilla.geckoview.DELETE",
     94      predicate: e => !e.collapsed && e.selectionEditable,
     95      perform: _ => this.docShell.doCommand("cmd_delete"),
     96    },
     97    {
     98      id: "org.mozilla.geckoview.COLLAPSE_TO_START",
     99      predicate: e => !e.collapsed && e.selectionEditable,
    100      perform: () => this.docShell.doCommand("cmd_moveLeft"),
    101    },
    102    {
    103      id: "org.mozilla.geckoview.COLLAPSE_TO_END",
    104      predicate: e => !e.collapsed && e.selectionEditable,
    105      perform: () => this.docShell.doCommand("cmd_moveRight"),
    106    },
    107    {
    108      id: "org.mozilla.geckoview.UNSELECT",
    109      predicate: e => !e.collapsed && !e.selectionEditable,
    110      perform: () => this.docShell.doCommand("cmd_selectNone"),
    111    },
    112    {
    113      id: "org.mozilla.geckoview.SELECT_ALL",
    114      predicate: e => {
    115        if (e.reason === "longpressonemptycontent") {
    116          return false;
    117        }
    118        // When on design mode, focusedElement will be null.
    119        const element =
    120          Services.focus.focusedElement || e.target?.activeElement;
    121        if (e.selectionEditable && e.target && element) {
    122          let value = "";
    123          if (element.value) {
    124            value = element.value;
    125          } else if (
    126            element.isContentEditable ||
    127            e.target.designMode === "on"
    128          ) {
    129            value = element.innerText;
    130          }
    131          // Do not show SELECT_ALL if the editable is empty
    132          // or all the editable text is already selected.
    133          return value !== "" && value !== e.selectedTextContent;
    134        }
    135        return true;
    136      },
    137      perform: () => this.docShell.doCommand("cmd_selectAll"),
    138    },
    139  ];
    140 
    141  receiveMessage({ name, data }) {
    142    debug`receiveMessage ${name}`;
    143 
    144    switch (name) {
    145      case "ExecuteSelectionAction": {
    146        this._actionCallback(data);
    147      }
    148    }
    149  }
    150 
    151  _performPaste() {
    152    this.handleEvent({ type: "pagehide" });
    153    this.docShell.doCommand("cmd_paste");
    154  }
    155 
    156  _performPasteAsPlainText() {
    157    this.handleEvent({ type: "pagehide" });
    158    this.docShell.doCommand("cmd_pasteNoFormatting");
    159  }
    160 
    161  _isPasswordField(aEvent) {
    162    if (!aEvent.selectionEditable) {
    163      return false;
    164    }
    165 
    166    const win = aEvent.target.defaultView;
    167    const focus = aEvent.target.activeElement;
    168    return (
    169      win &&
    170      win.HTMLInputElement &&
    171      win.HTMLInputElement.isInstance(focus) &&
    172      !focus.mozIsTextField(/* excludePassword */ true)
    173    );
    174  }
    175 
    176  _isContentHtmlEditable(aEvent) {
    177    if (!aEvent.selectionEditable) {
    178      return false;
    179    }
    180 
    181    if (aEvent.target.designMode == "on") {
    182      return true;
    183    }
    184 
    185    // focused element isn't <input> nor <textarea>
    186    const win = aEvent.target.defaultView;
    187    const focus = Services.focus.focusedElement;
    188    return (
    189      win &&
    190      win.HTMLInputElement &&
    191      win.HTMLTextAreaElement &&
    192      !win.HTMLInputElement.isInstance(focus) &&
    193      !win.HTMLTextAreaElement.isInstance(focus)
    194    );
    195  }
    196 
    197  _getDefaultMagnifierPoint(aEvent) {
    198    const rect = lazy.LayoutUtils.rectToScreenRect(aEvent.target.ownerGlobal, {
    199      left: aEvent.clientX,
    200      top: aEvent.clientY - this._accessiblecaretHeight,
    201      width: 0,
    202      height: 0,
    203    });
    204    return { x: rect.left, y: rect.top };
    205  }
    206 
    207  _getBetterMagnifierPoint(aEvent) {
    208    const win = aEvent.target.defaultView;
    209    if (!win) {
    210      return this._getDefaultMagnifierPoint(aEvent);
    211    }
    212 
    213    const focus = aEvent.target.activeElement;
    214    if (
    215      win.HTMLInputElement?.isInstance(focus) &&
    216      focus.mozIsTextField(false)
    217    ) {
    218      // <input> element. Use vertical center position of input element.
    219      const bounds = focus.getBoundingClientRect();
    220      const rect = lazy.LayoutUtils.rectToScreenRect(
    221        aEvent.target.ownerGlobal,
    222        {
    223          left: aEvent.clientX,
    224          top: bounds.top,
    225          width: 0,
    226          height: bounds.height,
    227        }
    228      );
    229      return { x: rect.left, y: rect.top + rect.height / 2 };
    230    }
    231 
    232    if (win.HTMLTextAreaElement?.isInstance(focus)) {
    233      // TODO:
    234      // <textarea> element. How to get better selection bounds?
    235      return this._getDefaultMagnifierPoint(aEvent);
    236    }
    237 
    238    const selection = win.getSelection();
    239    if (selection.rangeCount != 1) {
    240      // When selecting text using accessible caret, selection count will be 1.
    241      // This situation means that current selection isn't into text.
    242      return this._getDefaultMagnifierPoint(aEvent);
    243    }
    244 
    245    // We are looking for better selection bounds, then use it.
    246    const bounds = (() => {
    247      const range = selection.getRangeAt(0);
    248      let distance = Number.MAX_SAFE_INTEGER;
    249      let y = aEvent.clientY;
    250      const rectList = range.getClientRects();
    251      for (const rect of rectList) {
    252        const newDistance = Math.abs(aEvent.clientY - rect.bottom);
    253        if (distance > newDistance) {
    254          y = rect.top + rect.height / 2;
    255          distance = newDistance;
    256        }
    257      }
    258      return { left: aEvent.clientX, top: y, width: 0, height: 0 };
    259    })();
    260 
    261    const rect = lazy.LayoutUtils.rectToScreenRect(
    262      aEvent.target.ownerGlobal,
    263      bounds
    264    );
    265    return { x: rect.left, y: rect.top };
    266  }
    267 
    268  _handleMagnifier(aEvent) {
    269    if (["presscaret", "dragcaret"].includes(aEvent.reason)) {
    270      debug`_handleMagnifier: ${aEvent.reason}`;
    271      const screenPoint = this._getBetterMagnifierPoint(aEvent);
    272      this.sendAsyncMessage("GeckoView:ShowMagnifier", { screenPoint });
    273    } else if (aEvent.reason == "releasecaret") {
    274      debug`_handleMagnifier: ${aEvent.reason}`;
    275      this.sendAsyncMessage("GeckoView:HideMagnifier");
    276    }
    277  }
    278 
    279  /**
    280   * Receive and act on AccessibleCarets caret state-change
    281   * (mozcaretstatechanged and pagehide) events.
    282   */
    283  handleEvent(aEvent) {
    284    if (aEvent.type === "pagehide" || aEvent.type === "deactivate") {
    285      // Hide any selection actions on page hide or deactivate.
    286      aEvent = {
    287        reason: "visibilitychange",
    288        caretVisibile: false,
    289        selectionVisible: false,
    290        collapsed: true,
    291        selectionEditable: false,
    292      };
    293    }
    294 
    295    let reason = aEvent.reason;
    296 
    297    if (this._isActive && !aEvent.caretVisible) {
    298      // For mozcaretstatechanged, "visibilitychange" means the caret is hidden.
    299      reason = "visibilitychange";
    300    } else if (!aEvent.collapsed && !aEvent.selectionVisible) {
    301      reason = "invisibleselection";
    302    } else if (
    303      !this._isActive &&
    304      aEvent.selectionEditable &&
    305      aEvent.collapsed &&
    306      reason !== "longpressonemptycontent" &&
    307      reason !== "taponcaret" &&
    308      !Services.prefs.getBoolPref(
    309        "geckoview.selection_action.show_on_focus",
    310        false
    311      )
    312    ) {
    313      // Don't show selection actions when merely focusing on an editor or
    314      // repositioning the cursor. Wait until long press or the caret is tapped
    315      // in order to match Android behavior.
    316      reason = "visibilitychange";
    317    }
    318 
    319    debug`handleEvent: ${reason}`;
    320 
    321    if (this._magnifierEnabled) {
    322      this._handleMagnifier(aEvent);
    323    }
    324 
    325    if (
    326      [
    327        "longpressonemptycontent",
    328        "releasecaret",
    329        "taponcaret",
    330        "updateposition",
    331      ].includes(reason)
    332    ) {
    333      const actions = this._actions.filter(action =>
    334        action.predicate.call(this, aEvent)
    335      );
    336 
    337      const screenRect = (() => {
    338        const boundingRect = aEvent.boundingClientRect;
    339        if (!boundingRect) {
    340          return null;
    341        }
    342        const rect = lazy.LayoutUtils.rectToScreenRect(
    343          aEvent.target.ownerGlobal,
    344          boundingRect
    345        );
    346        return {
    347          left: rect.left,
    348          top: rect.top,
    349          right: rect.right,
    350          bottom: rect.bottom + this._accessiblecaretHeight,
    351        };
    352      })();
    353 
    354      const password = this._isPasswordField(aEvent);
    355 
    356      const msg = {
    357        collapsed: aEvent.collapsed,
    358        editable: aEvent.selectionEditable,
    359        password,
    360        selection: password ? "" : aEvent.selectedTextContent,
    361        screenRect,
    362        actions: actions.map(action => action.id),
    363      };
    364 
    365      if (this._isActive && JSON.stringify(msg) === this._previousMessage) {
    366        // Don't call again if we're already active and things haven't changed.
    367        return;
    368      }
    369 
    370      this._isActive = true;
    371      this._previousMessage = JSON.stringify(msg);
    372 
    373      // We can't just listen to the response of the message because we accept
    374      // multiple callbacks.
    375      this._actionCallback = data => {
    376        const action = actions.find(action => action.id === data.id);
    377        if (action) {
    378          debug`Performing ${data.id}`;
    379          action.perform.call(this, aEvent);
    380        } else {
    381          warn`Invalid action ${data.id}`;
    382        }
    383      };
    384      this.sendAsyncMessage("ShowSelectionAction", msg);
    385    } else if (
    386      [
    387        "invisibleselection",
    388        "presscaret",
    389        "scroll",
    390        "visibilitychange",
    391      ].includes(reason)
    392    ) {
    393      if (!this._isActive) {
    394        return;
    395      }
    396      this._isActive = false;
    397 
    398      // Mark previous actions as stale. Don't do this for "invisibleselection"
    399      // or "scroll" because previous actions should still be valid even after
    400      // these events occur.
    401      if (reason !== "invisibleselection" && reason !== "scroll") {
    402        this._seqNo++;
    403      }
    404 
    405      this.sendAsyncMessage("HideSelectionAction", { reason });
    406    } else if (reason == "dragcaret") {
    407      // nothing for selection action
    408    } else {
    409      warn`Unknown reason: ${reason}`;
    410    }
    411  }
    412 
    413  observe(aSubject, aTopic, aData) {
    414    if (aTopic != "nsPref:changed") {
    415      return;
    416    }
    417 
    418    switch (aData) {
    419      case ACCESSIBLECARET_HEIGHT_PREF:
    420        this._accessiblecaretHeight = parseFloat(
    421          Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
    422        );
    423        break;
    424      case MAGNIFIER_PREF:
    425        this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
    426        break;
    427    }
    428    // Reset magnifier
    429    this.sendAsyncMessage("GeckoView:HideMagnifier");
    430  }
    431 }
    432 
    433 const { debug, warn } = SelectionActionDelegateChild.initLogging(
    434  "SelectionActionDelegate"
    435 );