summaryrefslogtreecommitdiffstats
path: root/mobile/android/actors/SelectionActionDelegateChild.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--mobile/android/actors/SelectionActionDelegateChild.jsm310
1 files changed, 310 insertions, 0 deletions
diff --git a/mobile/android/actors/SelectionActionDelegateChild.jsm b/mobile/android/actors/SelectionActionDelegateChild.jsm
new file mode 100644
index 0000000000..82a7b23a3e
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateChild.jsm
@@ -0,0 +1,310 @@
+/* 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 { GeckoViewActorChild } = ChromeUtils.import(
+ "resource://gre/modules/GeckoViewActorChild.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const EXPORTED_SYMBOLS = ["SelectionActionDelegateChild"];
+
+// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to
+// the GeckoSession on accessible caret changes.
+class SelectionActionDelegateChild extends GeckoViewActorChild {
+ constructor(aModuleName, aMessageManager) {
+ super(aModuleName, aMessageManager);
+
+ this._seqNo = 0;
+ this._isActive = false;
+ this._previousMessage = "";
+ }
+
+ _actions = [
+ {
+ id: "org.mozilla.geckoview.HIDE",
+ predicate: _ => true,
+ perform: _ => this.handleEvent({ type: "pagehide" }),
+ },
+ {
+ id: "org.mozilla.geckoview.CUT",
+ predicate: e =>
+ !e.collapsed && e.selectionEditable && !this._isPasswordField(e),
+ perform: _ => this.docShell.doCommand("cmd_cut"),
+ },
+ {
+ id: "org.mozilla.geckoview.COPY",
+ predicate: e => !e.collapsed && !this._isPasswordField(e),
+ perform: _ => this.docShell.doCommand("cmd_copy"),
+ },
+ {
+ id: "org.mozilla.geckoview.PASTE",
+ predicate: e =>
+ e.selectionEditable &&
+ Services.clipboard.hasDataMatchingFlavors(
+ ["text/unicode"],
+ Ci.nsIClipboard.kGlobalClipboard
+ ),
+ perform: _ => this._performPaste(),
+ },
+ {
+ id: "org.mozilla.geckoview.DELETE",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: _ => this.docShell.doCommand("cmd_delete"),
+ },
+ {
+ id: "org.mozilla.geckoview.COLLAPSE_TO_START",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_moveLeft"),
+ },
+ {
+ id: "org.mozilla.geckoview.COLLAPSE_TO_END",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_moveRight"),
+ },
+ {
+ id: "org.mozilla.geckoview.UNSELECT",
+ predicate: e => !e.collapsed && !e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_selectNone"),
+ },
+ {
+ id: "org.mozilla.geckoview.SELECT_ALL",
+ predicate: e => {
+ if (e.reason === "longpressonemptycontent") {
+ return false;
+ }
+ if (e.selectionEditable && e.target && e.target.activeElement) {
+ const element = e.target.activeElement;
+ let value = "";
+ if (element.value) {
+ value = element.value;
+ } else if (
+ element.isContentEditable ||
+ e.target.designMode === "on"
+ ) {
+ value = element.innerText;
+ }
+ // Do not show SELECT_ALL if the editable is empty
+ // or all the editable text is already selected.
+ return value !== "" && value !== e.selectedTextContent;
+ }
+ return true;
+ },
+ perform: e => this.docShell.doCommand("cmd_selectAll"),
+ },
+ ];
+
+ _performPaste() {
+ this.handleEvent({ type: "pagehide" });
+ this.docShell.doCommand("cmd_paste");
+ }
+
+ _isPasswordField(aEvent) {
+ if (!aEvent.selectionEditable) {
+ return false;
+ }
+
+ const win = aEvent.target.defaultView;
+ const focus = aEvent.target.activeElement;
+ return (
+ win &&
+ win.HTMLInputElement &&
+ focus instanceof win.HTMLInputElement &&
+ !focus.mozIsTextField(/* excludePassword */ true)
+ );
+ }
+
+ _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 frameElement = currentWindow.realFrameElement;
+ currentWindow = frameElement.ownerGlobal;
+
+ // The offset of the iframe window relative to the parent window
+ // includes the iframe's border, and the iframe's origin in its
+ // containing document.
+ const currentRect = frameElement.getBoundingClientRect();
+ const style = currentWindow.getComputedStyle(frameElement);
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
+ const paddingTop = parseFloat(style.paddingTop) || 0;
+
+ offset.left += currentRect.left + borderLeft + paddingLeft;
+ offset.top += currentRect.top + borderTop + paddingTop;
+
+ const targetDocShell = currentWindow.docShell;
+ if (targetDocShell.isMozBrowser) {
+ break;
+ }
+ }
+
+ // Now we have coordinates relative to the root content document's
+ // layout viewport. Subtract the offset of the visual viewport
+ // relative to the layout viewport, to get coordinates relative to
+ // the visual viewport.
+ var offsetX = {};
+ var offsetY = {};
+ currentWindow.windowUtils.getVisualViewportOffsetRelativeToLayoutViewport(
+ offsetX,
+ offsetY
+ );
+ offset.left -= offsetX.value;
+ offset.top -= offsetY.value;
+
+ return offset;
+ }
+
+ /**
+ * Receive and act on AccessibleCarets caret state-change
+ * (mozcaretstatechanged and pagehide) events.
+ */
+ handleEvent(aEvent) {
+ if (aEvent.type === "pagehide" || aEvent.type === "deactivate") {
+ // Hide any selection actions on page hide or deactivate.
+ aEvent = {
+ reason: "visibilitychange",
+ caretVisibile: false,
+ selectionVisible: false,
+ collapsed: true,
+ selectionEditable: false,
+ };
+ }
+
+ let reason = aEvent.reason;
+
+ if (this._isActive && !aEvent.caretVisible) {
+ // For mozcaretstatechanged, "visibilitychange" means the caret is hidden.
+ reason = "visibilitychange";
+ } else if (!aEvent.collapsed && !aEvent.selectionVisible) {
+ reason = "invisibleselection";
+ } else if (
+ !this._isActive &&
+ aEvent.selectionEditable &&
+ aEvent.collapsed &&
+ reason !== "longpressonemptycontent" &&
+ reason !== "taponcaret" &&
+ !Services.prefs.getBoolPref(
+ "geckoview.selection_action.show_on_focus",
+ false
+ )
+ ) {
+ // 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}`;
+
+ if (
+ [
+ "longpressonemptycontent",
+ "releasecaret",
+ "taponcaret",
+ "updateposition",
+ ].includes(reason)
+ ) {
+ const actions = this._actions.filter(action =>
+ action.predicate.call(this, aEvent)
+ );
+
+ const offset = this._getFrameOffset(aEvent);
+ const password = this._isPasswordField(aEvent);
+
+ const msg = {
+ type: "GeckoView:ShowSelectionAction",
+ seqNo: this._seqNo,
+ collapsed: aEvent.collapsed,
+ editable: aEvent.selectionEditable,
+ password,
+ selection: password ? "" : 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);
+
+ this.eventDispatcher.sendRequest(msg, {
+ onSuccess: response => {
+ if (response.seqNo !== this._seqNo) {
+ // Stale action.
+ warn`Stale response ${response.id}`;
+ return;
+ }
+ const action = actions.find(action => action.id === response.id);
+ if (action) {
+ debug`Performing ${response.id}`;
+ action.perform.call(this, aEvent, response);
+ } else {
+ warn`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,
+ });
+ } else {
+ warn`Unknown reason: ${reason}`;
+ }
+ }
+}
+
+const { debug, warn } = SelectionActionDelegateChild.initLogging(
+ "SelectionActionDelegate"
+);