diff options
Diffstat (limited to 'mobile/android/chrome/content/ActionBarHandler.js')
-rw-r--r-- | mobile/android/chrome/content/ActionBarHandler.js | 731 |
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)); + }, +}; |