summaryrefslogtreecommitdiffstats
path: root/mobile/android/chrome/content/ActionBarHandler.js
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/chrome/content/ActionBarHandler.js')
-rw-r--r--mobile/android/chrome/content/ActionBarHandler.js731
1 files changed, 731 insertions, 0 deletions
diff --git a/mobile/android/chrome/content/ActionBarHandler.js b/mobile/android/chrome/content/ActionBarHandler.js
new file mode 100644
index 000000000..190021043
--- /dev/null
+++ b/mobile/android/chrome/content/ActionBarHandler.js
@@ -0,0 +1,731 @@
+// -*- 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/. */
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");
+
+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.
+ */
+ caretStateChangedHandler: function(e) {
+ // 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.
+ */
+ observe: function(subject, topic, data) {
+ switch (topic) {
+ // 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) {
+ action.action(this._targetElement, this._contentWindow);
+ break;
+ }
+ }
+ break;
+ }
+
+ // Provide selected text to FindInPageBar on request.
+ case "TextSelection:Get": {
+ Messaging.sendRequest({
+ type: "TextSelection:Data",
+ requestId: data,
+ text: this._getSelectedText(),
+ });
+
+ this._uninit();
+ break;
+ }
+
+ // User closed ActionBar by clicking "checkmark" button.
+ case "TextSelection:End": {
+ // End the requested selection only.
+ if (this._selectionID == JSON.parse(data).selectionID) {
+ this._uninit();
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Called when Gecko AccessibleCaret becomes visible.
+ */
+ _init: function(boundingClientRect) {
+ let [element, win] = this._getSelectionTargets();
+ if (!win) {
+ 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.
+ Messaging.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() {
+ Messaging.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 (((element instanceof HTMLInputElement) && element.mozIsTextField(false)) ||
+ (element instanceof 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;
+ }
+
+ // Close the ActionBar.
+ Messaging.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 imeSupport = this._getEditor(element, win).QueryInterface(Ci.nsIEditorIMESupport);
+ if (imeSupport.composing) {
+ imeSupport.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;
+ });
+
+ if (sendAlways || !actionsMatch) {
+ Messaging.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 imeSupport = ActionBarHandler._getEditor(element, win).
+ QueryInterface(Ci.nsIEditorIMESupport);
+ if (imeSupport.composing) {
+ element.blur();
+ element.focus();
+ }
+ }
+
+ // Close ActionBarHandler, then selectAll, and display handles.
+ ActionBarHandler._getSelectAllController(element, win).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 instanceof Ci.nsIDOMHTMLInputElement &&
+ !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) {
+ // First copy the selection text to the clipboard.
+ let selectedText = ActionBarHandler._getSelectedText();
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].
+ getService(Ci.nsIClipboardHelper);
+ clipboard.copyString(selectedText);
+
+ let msg = Strings.browser.GetStringFromName("selectionHelper.textCopied");
+ Snackbars.show(msg, Snackbars.LENGTH_LONG);
+
+ // Then cut the selection text.
+ ActionBarHandler._getSelection(element, win).deleteFromDocument();
+
+ 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 instanceof Ci.nsIDOMHTMLInputElement &&
+ !element.mozIsTextField(true)) {
+ return false;
+ }
+ // Allow if selected text exists.
+ return (ActionBarHandler._getSelectedText().length > 0);
+ },
+ },
+
+ action: function(element, win) {
+ let selectedText = ActionBarHandler._getSelectedText();
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].
+ getService(Ci.nsIClipboardHelper);
+ clipboard.copyString(selectedText);
+
+ 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, flavors.length,
+ 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) {
+ BrowserApp.loadURI("tel:" +
+ ActionBarHandler._getSelectedPhoneNumber());
+
+ ActionBarHandler._uninit();
+ UITelemetry.addEvent("action.1", "actionbar", null, "call");
+ },
+ },
+
+ SEARCH: {
+ id: "search_action",
+ label: () => Strings.browser.formatStringFromName("contextmenu.search",
+ [Services.search.defaultEngine.name], 1),
+ 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 = 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 parent = BrowserApp.selectedTab;
+ let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser);
+ BrowserApp.addTab(searchSubmission.uri.spec,
+ { parentId: parent.id,
+ selected: true,
+ isPrivate: isPrivate,
+ }
+ );
+
+ 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) {
+ if(!(element instanceof 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 (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.
+ 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) {
+ Messaging.sendRequest({
+ type: "Share:Text",
+ text: ActionBarHandler._getSelectedText(),
+ });
+
+ 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");
+ },
+
+ /**
+ * 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 instanceof Ci.nsIDOMHTMLTextAreaElement) {
+ let flags = Ci.nsIDocumentEncoder.OutputPreformatted |
+ Ci.nsIDocumentEncoder.OutputRaw;
+ return selection.QueryInterface(Ci.nsISelectionPrivate).
+ toStringWithFormat("text/plain", flags, 0);
+ }
+
+ // Return explicitly selected text.
+ return selection.toString();
+ },
+
+ /**
+ * Provides the nsISelection for either an editor, or from the
+ * default window.
+ */
+ _getSelection: function(element = this._targetElement, win = this._contentWindow) {
+ return (element instanceof Ci.nsIDOMNSEditableElement) ?
+ this._getEditor(element).selection :
+ win.getSelection();
+ },
+
+ /**
+ * Returns an nsEditor or nsHTMLEditor.
+ */
+ _getEditor: function(element = this._targetElement, win = this._contentWindow) {
+ if (element instanceof Ci.nsIDOMNSEditableElement) {
+ return element.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
+ }
+
+ return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation).
+ QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIEditingSession).
+ getEditorForWindow(win);
+ },
+
+ /**
+ * Returns a selection controller.
+ */
+ _getSelectionController: function(element = this._targetElement, win = this._contentWindow) {
+ if (element instanceof Ci.nsIDOMNSEditableElement) {
+ return this._getEditor(element, win).selectionController;
+ }
+
+ return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation).
+ QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay).
+ QueryInterface(Ci.nsISelectionController);
+ },
+
+ /**
+ * For selectAll(), provides the editor, or the default window selection Controller.
+ */
+ _getSelectAllController: function(element = this._targetElement, win = this._contentWindow) {
+ let editor = this._getEditor(element, win);
+ return (editor) ?
+ editor : this._getSelectionController(element, 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));
+ },
+};