gecko-dev/mobile/android/modules/ActionBarHandler.jsm
Boris Zbarsky 9de72a3ac6 Bug 1557793 part 2. Stop using [array] in nsIStringBundle. r=Pike
Differential Revision: https://phabricator.services.mozilla.com/D34196

--HG--
extra : moz-landing-system : lando
2019-06-11 15:51:51 +00:00

813 lines
25 KiB
JavaScript

// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
/* 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 = ["ActionBarHandler"];
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
EventDispatcher: "resource://gre/modules/Messaging.jsm",
GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
Services: "resource://gre/modules/Services.jsm",
Snackbars: "resource://gre/modules/Snackbars.jsm",
UITelemetry: "resource://gre/modules/UITelemetry.jsm",
});
XPCOMUtils.defineLazyServiceGetter(this, "ParentalControls",
"@mozilla.org/parental-controls-service;1", "nsIParentalControlsService");
var Strings = {};
XPCOMUtils.defineLazyGetter(Strings, "browser", _ =>
Services.strings.createBundle("chrome://browser/locale/browser.properties"));
const PHONE_REGEX = /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/; // Are we a phone #?
/**
* ActionBarHandler Object and methods. Interface between Gecko Text Selection code
* (AccessibleCaret, etc) and the Mobile ActionBar UI.
*/
var ActionBarHandler = {
// Error codes returned from _init().
START_TOUCH_ERROR: {
NO_CONTENT_WINDOW: "No valid content Window found.",
NONE: "",
},
_nextSelectionID: 1, // Next available.
_selectionID: null, // Unique Selection ID, assigned each time we _init().
_boundingClientRect: null, // Current selections boundingClientRect.
_actionBarActions: null, // Most-recent set of actions sent to ActionBar.
/**
* Receive and act on AccessibleCarets caret state-change
* (mozcaretstatechanged) events.
*/
handleEvent: function(e) {
e.stopImmediatePropagation();
if (e.reason === "presscaret" || e.reason === "releasecaret") {
const dispatcher = this._getDispatcher(Services.focus.focusedWindow);
if (dispatcher) {
dispatcher.sendRequest({
type: "GeckoView:PinOnScreen",
pinned: e.reason === "presscaret",
});
}
}
// Close an open ActionBar, if carets no longer logically visible.
if (this._selectionID && !e.caretVisible) {
this._uninit(false);
return;
}
if (!this._selectionID && e.collapsed) {
switch (e.reason) {
case "longpressonemptycontent":
case "taponcaret":
// Show ActionBar when long pressing on an empty input or single
// tapping on the caret.
this._init(e.boundingClientRect);
break;
case "updateposition":
// Do not show ActionBar when single tapping on an non-empty editable
// input.
break;
default:
break;
}
return;
}
// Open a closed ActionBar if carets actually visible.
if (!this._selectionID && e.caretVisuallyVisible) {
this._init(e.boundingClientRect);
return;
}
// Else, update an open ActionBar.
if (this._selectionID) {
if (!this._selectionHasChanged()) {
// Still the same active selection.
if (e.reason == "presscaret" || e.reason == "scroll") {
// boundingClientRect doesn't matter since we are hiding the floating
// toolbar.
this._updateVisibility();
} else {
// Selection changes update boundingClientRect.
this._boundingClientRect = e.boundingClientRect;
let forceUpdate = e.reason == "updateposition" || e.reason == "releasecaret";
this._sendActionBarActions(forceUpdate);
}
} else {
// We've started a new selection entirely.
this._uninit(false);
this._init(e.boundingClientRect);
}
}
},
/**
* ActionBarHandler notification observers.
*/
onEvent: function(event, data, callback) {
switch (event) {
// User click an ActionBar button.
case "TextSelection:Action": {
if (!this._selectionID) {
break;
}
for (let type in this.actions) {
let action = this.actions[type];
if (action.id == data.id) {
action.action(this._targetElement, this._contentWindow);
break;
}
}
break;
}
// Provide selected text to FindInPageBar on request.
case "TextSelection:Get": {
try {
callback.onSuccess(this._getSelectedText());
} catch (e) {
callback.onError(e.toString());
}
this._uninit();
break;
}
// User closed ActionBar by clicking "checkmark" button.
case "TextSelection:End": {
// End the requested selection only.
if (this._selectionID == data.selectionID) {
this._uninit();
}
break;
}
}
},
_getDispatcher: function(win) {
try {
return GeckoViewUtils.getDispatcherForWindow(win);
} catch (e) {
return null;
}
},
/**
* Called when Gecko AccessibleCaret becomes visible.
*/
_init: function(boundingClientRect) {
let [element, win] = this._getSelectionTargets();
let dispatcher = this._getDispatcher(win);
if (!win || !dispatcher) {
return this.START_TOUCH_ERROR.NO_CONTENT_WINDOW;
}
// Hold the ActionBar ID provided by Gecko.
this._selectionID = this._nextSelectionID++;
[this._targetElement, this._contentWindow] = [element, win];
this._boundingClientRect = boundingClientRect;
// Open the ActionBar, send it's actions list.
dispatcher.sendRequest({
type: "TextSelection:ActionbarInit",
selectionID: this._selectionID,
});
this._sendActionBarActions(true);
return this.START_TOUCH_ERROR.NONE;
},
/**
* Called when content is scrolled and handles are hidden.
*/
_updateVisibility: function() {
let win = this._contentWindow;
let dispatcher = this._getDispatcher(win);
if (!dispatcher) {
return;
}
dispatcher.sendRequest({
type: "TextSelection:Visibility",
selectionID: this._selectionID,
});
},
/**
* Determines the window containing the selection, and its
* editable element if present.
*/
_getSelectionTargets: function() {
let [element, win] = [Services.focus.focusedElement, Services.focus.focusedWindow];
if (!element) {
// No focused editable.
return [null, win];
}
// Return focused editable text element and its window.
if (((ChromeUtils.getClassName(element) === "HTMLInputElement") && element.mozIsTextField(false)) ||
(ChromeUtils.getClassName(element) === "HTMLTextAreaElement") ||
element.isContentEditable) {
return [element, win];
}
// Focused element can't contain text.
return [null, win];
},
/**
* The active Selection has changed, if the current focused element / win,
* pair, or state of the win's designMode changes.
*/
_selectionHasChanged: function() {
let [element, win] = this._getSelectionTargets();
return (this._targetElement !== element ||
this._contentWindow !== win ||
this._isInDesignMode(this._contentWindow) !== this._isInDesignMode(win));
},
/**
* Called when Gecko AccessibleCaret becomes hidden,
* ActionBar is closed by user "close" request, or as a result of object
* methods such as SELECT_ALL, PASTE, etc.
*/
_uninit: function(clearSelection = true) {
// Bail if there's no active selection.
if (!this._selectionID) {
return;
}
let win = this._contentWindow;
let dispatcher = this._getDispatcher(win);
if (dispatcher) {
// Close the ActionBar.
dispatcher.sendRequest({
type: "TextSelection:ActionbarUninit",
});
}
// Clear the selection ID to complete the uninit(), but leave our reference
// to selectionTargets (_targetElement, _contentWindow) in case we need
// a final clearSelection().
this._selectionID = null;
this._boundingClientRect = null;
// Clear selection required if triggered by self, or TextSelection icon
// actions. If called by Gecko CaretStateChangedEvent,
// visibility state is already correct.
if (clearSelection) {
this._clearSelection();
}
},
/**
* Final UI cleanup when Actionbar is closed by icon click, or where
* we terminate selection state after before/after actionbar actions
* (Cut, Copy, Paste, Search, Share, Call).
*/
_clearSelection: function(element = this._targetElement, win = this._contentWindow) {
// Commit edit compositions, and clear focus from editables.
if (element) {
let editor = this._getEditor(element, win);
if (editor.composing) {
editor.forceCompositionEnd();
}
element.blur();
}
// Remove Selection from non-editables and now-unfocused contentEditables.
if (!element || element.isContentEditable) {
this._getSelection().removeAllRanges();
}
},
/**
* Called to determine current ActionBar actions and send to TextSelection
* handler. By default we only send if current action state differs from
* the previous.
* @param By default we only send an ActionBarStatus update message if
* there is a change from the previous state. sendAlways can be
* set by init() for example, where we want to always send the
* current state.
*/
_sendActionBarActions: function(sendAlways) {
let actions = this._getActionBarActions();
let actionCountUnchanged = this._actionBarActions &&
actions.length === this._actionBarActions.length;
let actionsMatch = actionCountUnchanged &&
this._actionBarActions.every((e, i) => {
return e.id === actions[i].id;
});
let win = this._contentWindow;
let dispatcher = this._getDispatcher(win);
if (!dispatcher) {
return;
}
if (sendAlways || !actionsMatch) {
dispatcher.sendRequest({
type: "TextSelection:ActionbarStatus",
selectionID: this._selectionID,
actions: actions,
x: this._boundingClientRect.x,
y: this._boundingClientRect.y,
width: this._boundingClientRect.width,
height: this._boundingClientRect.height,
});
}
this._actionBarActions = actions;
},
/**
* Determine and return current ActionBar state.
*/
_getActionBarActions: function(element = this._targetElement, win = this._contentWindow) {
let actions = [];
for (let type in this.actions) {
let action = this.actions[type];
if (action.selector.matches(element, win)) {
let a = {
id: action.id,
label: this._getActionValue(action, "label", "", element),
icon: this._getActionValue(action, "icon", "drawable://ic_status_logo", element),
order: this._getActionValue(action, "order", 0, element),
floatingOrder: this._getActionValue(action, "floatingOrder", 9, element),
showAsAction: this._getActionValue(action, "showAsAction", true, element),
};
actions.push(a);
}
}
actions.sort((a, b) => b.order - a.order);
return actions;
},
/**
* Provides a value from an action. If the action defines the value as a function,
* we return the result of calling the function. Otherwise, we return the value
* itself. If the value isn't defined for this action, will return a default.
*/
_getActionValue: function(obj, name, defaultValue, element) {
if (!(name in obj))
return defaultValue;
if (typeof obj[name] == "function")
return obj[name](element);
return obj[name];
},
/**
* Actionbar callback methods.
*/
actions: {
SELECT_ALL: {
id: "selectall_action",
label: () => Strings.browser.GetStringFromName("contextmenu.selectAll"),
icon: "drawable://ab_select_all",
order: 5,
floatingOrder: 5,
selector: {
matches: function(element, win) {
// For editable, check its length. For default contentWindow, assume
// true, else there'd been nothing to long-press to open ActionBar.
return (element) ? element.textLength != 0 : true;
},
},
action: function(element, win) {
// Some Mobile keyboards such as SwiftKeyboard, provide auto-suggest
// style highlights via composition selections in editables.
if (element) {
// If we have an active composition string, commit it, and
// ensure proper element focus.
let editor = ActionBarHandler._getEditor(element, win);
if (editor.composing) {
element.blur();
element.focus();
}
}
// Close ActionBarHandler, then selectAll, and display handles.
ActionBarHandler._getDocShell(win).doCommand("cmd_selectAll");
UITelemetry.addEvent("action.1", "actionbar", null, "select_all");
},
},
CUT: {
id: "cut_action",
label: () => Strings.browser.GetStringFromName("contextmenu.cut"),
icon: "drawable://ab_cut",
order: 4,
floatingOrder: 1,
selector: {
matches: function(element, win) {
// Can cut from editable, or design-mode document.
if (!element && !ActionBarHandler._isInDesignMode(win)) {
return false;
}
// Don't allow "cut" from password fields.
if (element &&
ChromeUtils.getClassName(element) === "HTMLInputElement" &&
!element.mozIsTextField(true)) {
return false;
}
// Don't allow "cut" from disabled/readonly fields.
if (element && (element.disabled || element.readOnly)) {
return false;
}
// Allow if selected text exists.
return (ActionBarHandler._getSelectedText().length > 0);
},
},
action: function(element, win) {
ActionBarHandler._getEditor(element, win).cut();
let msg = Strings.browser.GetStringFromName("selectionHelper.textCopied");
Snackbars.show(msg, Snackbars.LENGTH_LONG);
ActionBarHandler._uninit();
UITelemetry.addEvent("action.1", "actionbar", null, "cut");
},
},
COPY: {
id: "copy_action",
label: () => Strings.browser.GetStringFromName("contextmenu.copy"),
icon: "drawable://ab_copy",
order: 3,
floatingOrder: 2,
selector: {
matches: function(element, win) {
// Don't allow "copy" from password fields.
if (element &&
ChromeUtils.getClassName(element) === "HTMLInputElement" &&
!element.mozIsTextField(true)) {
return false;
}
// Allow if selected text exists.
return (ActionBarHandler._getSelectedText().length > 0);
},
},
action: function(element, win) {
ActionBarHandler._getDocShell(win).doCommand("cmd_copy");
let msg = Strings.browser.GetStringFromName("selectionHelper.textCopied");
Snackbars.show(msg, Snackbars.LENGTH_LONG);
ActionBarHandler._uninit();
UITelemetry.addEvent("action.1", "actionbar", null, "copy");
},
},
PASTE: {
id: "paste_action",
label: () => Strings.browser.GetStringFromName("contextmenu.paste"),
icon: "drawable://ab_paste",
order: 2,
floatingOrder: 3,
selector: {
matches: function(element, win) {
// Can paste to editable, or design-mode document.
if (!element && !ActionBarHandler._isInDesignMode(win)) {
return false;
}
// Can't paste into disabled/readonly fields.
if (element && (element.disabled || element.readOnly)) {
return false;
}
// Can't paste if Clipboard empty.
let flavors = ["text/unicode"];
return Services.clipboard.hasDataMatchingFlavors(flavors,
Ci.nsIClipboard.kGlobalClipboard);
},
},
action: function(element, win) {
// Paste the clipboard, then close the ActionBarHandler and ActionBar.
ActionBarHandler._getEditor(element, win).
paste(Ci.nsIClipboard.kGlobalClipboard);
ActionBarHandler._uninit();
UITelemetry.addEvent("action.1", "actionbar", null, "paste");
},
},
CALL: {
id: "call_action",
label: () => Strings.browser.GetStringFromName("contextmenu.call"),
icon: "drawable://phone",
order: 1,
floatingOrder: 0,
selector: {
matches: function(element, win) {
return (ActionBarHandler._getSelectedPhoneNumber() != null);
},
},
action: function(element, win) {
let uri = "tel:" + ActionBarHandler._getSelectedPhoneNumber();
let chrome = GeckoViewUtils.getChromeWindow(win);
if (chrome.BrowserApp && chrome.BrowserApp.loadURI) {
chrome.BrowserApp.loadURI(uri);
} else {
let bwin = chrome.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow;
if (bwin) {
bwin.openURI(Services.io.newURI(uri), win,
Ci.nsIBrowserDOMWindow.OPEN_NEWTAB,
Ci.nsIBrowserDOMWindow.OPEN_NEW,
win.document.nodePrincipal);
}
}
ActionBarHandler._uninit();
UITelemetry.addEvent("action.1", "actionbar", null, "call");
},
},
SEARCH: {
id: "search_action",
label: () => Strings.browser.formatStringFromName("contextmenu.search",
[Services.search.defaultEngine.name]),
icon: "drawable://ab_search",
order: 1,
floatingOrder: 6,
selector: {
matches: function(element, win) {
// Allow if selected text exists.
return (ActionBarHandler._getSelectedText().length > 0);
},
},
action: function(element, win) {
let selectedText = BrowserUtils.trimSelection(ActionBarHandler._getSelectedText());
ActionBarHandler._uninit();
// Set current tab as parent of new tab,
// and set new tab as private if the parent is.
let searchSubmission = Services.search.defaultEngine.getSubmission(selectedText);
let chrome = GeckoViewUtils.getChromeWindow(win);
if (chrome.BrowserApp && chrome.BrowserApp.selectedTab && chrome.BrowserApp.addTab) {
let parent = chrome.BrowserApp.selectedTab;
let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser);
chrome.BrowserApp.addTab(searchSubmission.uri.spec,
{ parentId: parent.id,
selected: true,
isPrivate: isPrivate,
}
);
} else {
let bwin = chrome.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow;
if (bwin) {
bwin.openURI(searchSubmission.uri, win,
Ci.nsIBrowserDOMWindow.OPEN_NEWTAB,
Ci.nsIBrowserDOMWindow.OPEN_NEW,
win.document.nodePrincipal);
}
}
UITelemetry.addEvent("action.1", "actionbar", null, "search");
},
},
SEARCH_ADD: {
id: "search_add_action",
label: () => Strings.browser.GetStringFromName("contextmenu.addSearchEngine3"),
icon: "drawable://ab_add_search_engine",
order: 0,
floatingOrder: 8,
selector: {
matches: function(element, win) {
let chrome = GeckoViewUtils.getChromeWindow(win);
if (!chrome.SearchEngines) {
return false;
}
if (!element || ChromeUtils.getClassName(element) !== "HTMLInputElement") {
return false;
}
let form = element.form;
if (!form || element.type == "password") {
return false;
}
let method = form.method.toUpperCase();
let canAddEngine = (method == "GET") ||
(method == "POST" && (form.enctype != "text/plain" && form.enctype != "multipart/form-data"));
if (!canAddEngine) {
return false;
}
// If SearchEngine query finds it, then we don't want action to add displayed.
if (chrome.SearchEngines.visibleEngineExists(element)) {
return false;
}
return true;
},
},
action: function(element, win) {
UITelemetry.addEvent("action.1", "actionbar", null, "add_search_engine");
// Engines are added asynch. If required, update SelectionUI on callback.
let chrome = GeckoViewUtils.getChromeWindow(win);
chrome.SearchEngines.addEngine(element, (result) => {
if (result) {
ActionBarHandler._sendActionBarActions(true);
}
});
},
},
SHARE: {
id: "share_action",
label: () => Strings.browser.GetStringFromName("contextmenu.share"),
icon: "drawable://ic_menu_share",
order: 0,
floatingOrder: 4,
selector: {
matches: function(element, win) {
if (!ParentalControls.isAllowed(ParentalControls.SHARE)) {
return false;
}
// Allow if selected text exists.
return (ActionBarHandler._getSelectedText().length > 0);
},
},
action: function(element, win) {
let title = win.document.title;
if (title && title.length > 200) {
let ellipsis = "\u2026";
try {
ellipsis = Services.prefs.getComplexValue(
"intl.ellipsis", Ci.nsIPrefLocalizedString).data;
} catch (e) {
}
title = title.slice(0, 200) + ellipsis; // Add ellipsis.
} else if (!title) {
title = win.location.href;
}
EventDispatcher.instance.sendRequest({
type: "Share:Text",
text: ActionBarHandler._getSelectedText(),
title: title,
});
ActionBarHandler._uninit();
UITelemetry.addEvent("action.1", "actionbar", null, "share");
},
},
},
/**
* Provides UUID service for generating action ID's.
*/
get _idService() {
delete this._idService;
return this._idService = Cc["@mozilla.org/uuid-generator;1"].
getService(Ci.nsIUUIDGenerator);
},
/**
* The targetElement holds an editable element containing a
* selection or a caret.
*/
get _targetElement() {
if (this._targetElementRef)
return this._targetElementRef.get();
return null;
},
set _targetElement(element) {
this._targetElementRef = Cu.getWeakReference(element);
},
/**
* The contentWindow holds the selection, or the targetElement
* if it's an editable.
*/
get _contentWindow() {
if (this._contentWindowRef)
return this._contentWindowRef.get();
return null;
},
set _contentWindow(aContentWindow) {
this._contentWindowRef = Cu.getWeakReference(aContentWindow);
},
/**
* If we have an active selection, is it part of a designMode document?
*/
_isInDesignMode: function(win) {
return this._selectionID && (win.document.designMode === "on");
},
/**
* Get current DocShell object.
*/
_getDocShell: function(win) {
return win.docShell;
},
/**
* Provides the currently selected text, for either an editable,
* or for the default contentWindow.
*/
_getSelectedText: function() {
// Can be called from FindInPageBar "TextSelection:Get", when there
// is no active selection.
if (!this._selectionID) {
return "";
}
let selection = this._getSelection();
// Textarea can contain LF, etc.
if (this._targetElement &&
ChromeUtils.getClassName(this._targetElement) === "HTMLTextAreaElement") {
let flags = Ci.nsIDocumentEncoder.OutputPreformatted |
Ci.nsIDocumentEncoder.OutputRaw;
return selection.toStringWithFormat("text/plain", flags, 0);
}
// Return explicitly selected text.
return selection.toString();
},
/**
* Tests whether a given element is editable.
*/
_isElementEditable: function(element) {
if (!element) {
return false;
}
let elementClass = ChromeUtils.getClassName(element);
return elementClass === "HTMLInputElement" ||
elementClass === "HTMLTextAreaElement";
},
/**
* Provides the Selection for either an editor, or from the
* default window.
*/
_getSelection: function(element = this._targetElement, win = this._contentWindow) {
return this._isElementEditable(element) ?
this._getEditor(element).selection :
win.getSelection();
},
/**
* Returns an nsEditor or nsHTMLEditor.
*/
_getEditor: function(element = this._targetElement, win = this._contentWindow) {
if (this._isElementEditable(element)) {
return element.editor;
}
return win.docShell.editingSession.getEditorForWindow(win);
},
/**
* Call / Phone Helper methods.
*/
_getSelectedPhoneNumber: function() {
let selectedText = this._getSelectedText().trim();
return this._isPhoneNumber(selectedText) ?
selectedText : null;
},
_isPhoneNumber: function(selectedText) {
return (PHONE_REGEX.test(selectedText));
},
};