Bug 1441279 - 2. Add selection action JS modules; r?esawin draft
authorJim Chen <nchen@mozilla.com>
Mon, 02 Apr 2018 17:13:45 -0400
changeset 776311 63d496ee0e4c82e83e0c04f15325a7c0e353698b
parent 776310 853f501fad4bcd57fcef8ce814d89f6c3fca7f34
child 776312 af0562409fdc0954e05a9435fe319d07f8c0c237
push id104840
push userbmo:nchen@mozilla.com
push dateMon, 02 Apr 2018 21:15:36 +0000
reviewersesawin
bugs1441279
milestone61.0a1
Bug 1441279 - 2. Add selection action JS modules; r?esawin Add JS modules for listening to accessible caret events, and relaying those events to Java. MozReview-Commit-ID: JPLTMzK7Nzn
mobile/android/chrome/geckoview/GeckoViewSelectionActionContent.js
mobile/android/chrome/geckoview/geckoview.js
mobile/android/chrome/geckoview/jar.mn
mobile/android/modules/geckoview/GeckoViewSelectionAction.jsm
mobile/android/modules/geckoview/moz.build
new file mode 100644
--- /dev/null
+++ b/mobile/android/chrome/geckoview/GeckoViewSelectionActionContent.js
@@ -0,0 +1,249 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+ChromeUtils.import("resource://gre/modules/GeckoViewContentModule.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "dump", () =>
+    ChromeUtils.import("resource://gre/modules/AndroidLog.jsm",
+                       {}).AndroidLog.d.bind(null, "ViewSelectionActionContent"));
+
+function debug(aMsg) {
+  // dump(aMsg);
+}
+
+// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to
+// the GeckoSession on accessible caret changes.
+class GeckoViewSelectionActionContent extends GeckoViewContentModule {
+  constructor(aModuleName, aMessageManager) {
+    super(aModuleName, aMessageManager);
+
+    this._seqNo = 0;
+    this._isActive = false;
+    this._previousMessage = {};
+
+    this._actions = [{
+      id: "org.mozilla.geckoview.CUT",
+      predicate: e => !e.collapsed && e.selectionEditable,
+      perform: _ => this._domWindowUtils.sendContentCommandEvent("cut"),
+    }, {
+      id: "org.mozilla.geckoview.COPY",
+      predicate: e => !e.collapsed,
+      perform: _ => this._domWindowUtils.sendContentCommandEvent("copy"),
+    }, {
+      id: "org.mozilla.geckoview.PASTE",
+      predicate: e => e.selectionEditable &&
+                      Services.clipboard.hasDataMatchingFlavors(
+                          ["text/unicode"], 1, Ci.nsIClipboard.kGlobalClipboard),
+      perform: _ => this._domWindowUtils.sendContentCommandEvent("paste"),
+    }, {
+      id: "org.mozilla.geckoview.DELETE",
+      predicate: e => !e.collapsed && e.selectionEditable,
+      perform: _ => this._domWindowUtils.sendContentCommandEvent("delete"),
+    }, {
+      id: "org.mozilla.geckoview.COLLAPSE_TO_START",
+      predicate: e => !e.collapsed && e.selectionEditable,
+      perform: e => this._getSelection(e).collapseToStart(),
+    }, {
+      id: "org.mozilla.geckoview.COLLAPSE_TO_END",
+      predicate: e => !e.collapsed && e.selectionEditable,
+      perform: e => this._getSelection(e).collapseToEnd(),
+    }, {
+      id: "org.mozilla.geckoview.UNSELECT",
+      predicate: e => !e.collapsed && !e.selectionEditable,
+      perform: e => this._getSelection(e).removeAllRanges(),
+    }, {
+      id: "org.mozilla.geckoview.SELECT_ALL",
+      predicate: e => e.reason !== "longpressonemptycontent",
+      perform: e => this._getSelectionController(e).selectAll(),
+    }];
+  }
+
+  get _domWindowUtils() {
+    return content.QueryInterface(Ci.nsIInterfaceRequestor)
+                  .getInterface(Ci.nsIDOMWindowUtils);
+  }
+
+  _getSelectionController(aEvent) {
+    if (aEvent.selectionEditable) {
+      const focus = aEvent.target.activeElement;
+      if (focus instanceof Ci.nsIDOMNSEditableElement && focus.editor) {
+        return focus.editor.selectionController;
+      }
+    }
+
+    return aEvent.target.defaultView
+                 .QueryInterface(Ci.nsIInterfaceRequestor)
+                 .getInterface(Ci.nsIDocShell)
+                 .QueryInterface(Ci.nsIInterfaceRequestor)
+                 .getInterface(Ci.nsISelectionDisplay)
+                 .QueryInterface(Ci.nsISelectionController);
+  }
+
+  _getSelection(aEvent) {
+    return this._getSelectionController(aEvent)
+               .getSelection(Ci.nsISelectionController.SELECTION_NORMAL);
+  }
+
+  _getFrameOffset(aEvent) {
+    // Get correct offset in case of nested iframe.
+    const offset = {
+      left: 0,
+      top: 0,
+    };
+
+    let currentWindow = aEvent.target.defaultView;
+    while (currentWindow.realFrameElement) {
+      const currentRect = currentWindow.realFrameElement.getBoundingClientRect();
+      currentWindow = currentWindow.realFrameElement.ownerGlobal;
+
+      offset.left += currentRect.left;
+      offset.top += currentRect.top;
+
+      let targetDocShell = currentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                                        .getInterface(Ci.nsIWebNavigation);
+      if (targetDocShell.isMozBrowser) {
+        break;
+      }
+    }
+
+    return offset;
+  }
+
+  register() {
+    debug("register");
+    addEventListener("mozcaretstatechanged", this);
+  }
+
+  unregister() {
+    debug("unregister");
+    removeEventListener("mozcaretstatechanged", this);
+  }
+
+  /**
+   * Receive and act on AccessibleCarets caret state-change
+   * (mozcaretstatechanged) events.
+   */
+  handleEvent(aEvent) {
+    let reason = aEvent.reason;
+
+    if (this._isActive && !aEvent.caretVisible) {
+      // For mozcaretstatechanged, "visibilitychange" means the caret is hidden.
+      reason = "visibilitychange";
+    } else if (this._isActive &&
+               !aEvent.collapsed &&
+               !aEvent.selectionVisible) {
+      reason = "invisibleselection";
+    } else if (aEvent.selectionEditable &&
+               aEvent.collapsed &&
+               reason !== "longpressonemptycontent" &&
+               reason !== "taponcaret") {
+      // Don't show selection actions when merely focusing on an editor or
+      // repositioning the cursor. Wait until long press or the caret is tapped
+      // in order to match Android behavior.
+      reason = "visibilitychange";
+    }
+
+    debug("handleEvent " + reason + " " + aEvent);
+
+    if (["longpressonemptycontent",
+         "releasecaret",
+         "taponcaret",
+         "updateposition"].includes(reason)) {
+
+      const actions = this._actions.filter(
+          action => action.predicate.call(this, aEvent));
+
+      const offset = this._getFrameOffset(aEvent);
+
+      const msg = {
+        type: "GeckoView:ShowSelectionAction",
+        seqNo: this._seqNo,
+        collapsed: aEvent.collapsed,
+        editable: aEvent.selectionEditable,
+        selection: aEvent.selectedTextContent,
+        clientRect: !aEvent.boundingClientRect ? null : {
+          left: aEvent.boundingClientRect.left + offset.left,
+          top: aEvent.boundingClientRect.top + offset.top,
+          right: aEvent.boundingClientRect.right + offset.left,
+          bottom: aEvent.boundingClientRect.bottom + offset.top,
+        },
+        actions: actions.map(action => action.id),
+      };
+
+      try {
+        if (msg.clientRect) {
+          msg.clientRect.bottom += parseFloat(Services.prefs.getCharPref(
+              "layout.accessiblecaret.height", "0"));
+        }
+      } catch (e) {
+      }
+
+      if (this._isActive && JSON.stringify(msg) === this._previousMessage) {
+        // Don't call again if we're already active and things haven't changed.
+        return;
+      }
+
+      msg.seqNo = ++this._seqNo;
+      this._isActive = true;
+      this._previousMessage = JSON.stringify(msg);
+
+      debug("onShowSelectionAction " + JSON.stringify(msg));
+
+      // This event goes to GeckoViewSelectionAction.jsm, where the data is
+      // further transformed and then sent to GeckoSession.
+      this.eventDispatcher.sendRequest(msg, {
+        onSuccess: response => {
+          if (response.seqNo !== this._seqNo) {
+            // Stale action.
+            return;
+          }
+          let action = actions.find(action => action.id === response.id);
+          if (action) {
+            action.perform.call(this, aEvent, response);
+          } else {
+            dump("Invalid action " + response.id);
+          }
+        },
+        onError: _ => {
+          // Do nothing; we can get here if the delegate was just unregistered.
+        },
+      });
+
+    } else if (["invisibleselection",
+                "presscaret",
+                "scroll",
+                "visibilitychange"].includes(reason)) {
+
+      if (!this._isActive) {
+        return;
+      }
+
+      this._isActive = false;
+
+      // Mark previous actions as stale. Don't do this for "invisibleselection"
+      // or "scroll" because previous actions should still be valid even after
+      // these events occur.
+      if (reason !== "invisibleselection" && reason !== "scroll") {
+        this._seqNo++;
+      }
+
+      this.eventDispatcher.sendRequest({
+        type: "GeckoView:HideSelectionAction",
+        reason: reason,
+      });
+
+    } else {
+      dump("Unknown reason: " + reason);
+    }
+  }
+}
+
+var selectionActionListener =
+    new GeckoViewSelectionActionContent("GeckoViewSelectionAction", this);
--- a/mobile/android/chrome/geckoview/geckoview.js
+++ b/mobile/android/chrome/geckoview/geckoview.js
@@ -79,13 +79,15 @@ function startup() {
   ModuleManager.add("resource://gre/modules/GeckoViewScroll.jsm",
                     "GeckoViewScroll");
   ModuleManager.add("resource://gre/modules/GeckoViewTab.jsm",
                     "GeckoViewTab");
   ModuleManager.add("resource://gre/modules/GeckoViewRemoteDebugger.jsm",
                     "GeckoViewRemoteDebugger");
   ModuleManager.add("resource://gre/modules/GeckoViewTrackingProtection.jsm",
                     "GeckoViewTrackingProtection");
+  ModuleManager.add("resource://gre/modules/GeckoViewSelectionAction.jsm",
+                    "GeckoViewSelectionAction");
 
   // Move focus to the content window at the end of startup,
   // so things like text selection can work properly.
   browser.focus();
 }
--- a/mobile/android/chrome/geckoview/jar.mn
+++ b/mobile/android/chrome/geckoview/jar.mn
@@ -7,8 +7,9 @@ geckoview.jar:
 
   content/ErrorPageEventHandler.js
   content/geckoview.xul
   content/geckoview.js
   content/GeckoViewContent.js
   content/GeckoViewContentSettings.js
   content/GeckoViewNavigationContent.js
   content/GeckoViewScrollContent.js
+  content/GeckoViewSelectionActionContent.js
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewSelectionAction.jsm
@@ -0,0 +1,38 @@
+/* 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/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["GeckoViewSelectionAction"];
+
+ChromeUtils.import("resource://gre/modules/GeckoViewModule.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "dump", () =>
+    ChromeUtils.import("resource://gre/modules/AndroidLog.jsm",
+                       {}).AndroidLog.d.bind(null, "ViewSelectionAction"));
+
+function debug(aMsg) {
+  // dump(aMsg);
+}
+
+// Handles inter-op between accessible carets and GeckoSession.
+class GeckoViewSelectionAction extends GeckoViewModule {
+  init() {
+  }
+
+  register() {
+    debug("register");
+    this.registerContent("chrome://geckoview/content/GeckoViewSelectionActionContent.js");
+  }
+
+  unregister() {
+    debug("unregister");
+  }
+
+  // Message manager event handler.
+  receiveMessage(aMsg) {
+    debug("receiveMessage " + aMsg.name);
+  }
+}
--- a/mobile/android/modules/geckoview/moz.build
+++ b/mobile/android/modules/geckoview/moz.build
@@ -8,15 +8,16 @@ EXTRA_JS_MODULES += [
     'AndroidLog.jsm',
     'GeckoViewContent.jsm',
     'GeckoViewContentModule.jsm',
     'GeckoViewModule.jsm',
     'GeckoViewNavigation.jsm',
     'GeckoViewProgress.jsm',
     'GeckoViewRemoteDebugger.jsm',
     'GeckoViewScroll.jsm',
+    'GeckoViewSelectionAction.jsm',
     'GeckoViewSettings.jsm',
     'GeckoViewTab.jsm',
     'GeckoViewTrackingProtection.jsm',
     'GeckoViewUtils.jsm',
     'LoadURIDelegate.jsm',
     'Messaging.jsm',
 ]