tenfourfox/mobile/android/chrome/content/SelectionHandler.js
Cameron Kaiser c9b2922b70 hello FPR
2017-04-19 00:56:45 -07:00

1450 lines
51 KiB
JavaScript

// -*- 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";
// Define elements that bound phone number containers.
const PHONE_NUMBER_CONTAINERS = "td,div";
const DEFER_CLOSE_TRIGGER_MS = 125; // Grace period delay before deferred _closeSelection()
// Gecko AccessibleCaret pref names.
const PREF_GECKO_ACCESSIBLECARET_ENABLED = "layout.accessiblecaret.enabled";
var SelectionHandler = {
// Successful startSelection() or attachCaret().
ERROR_NONE: "",
// Error codes returned during startSelection().
START_ERROR_INVALID_MODE: "Invalid selection mode requested.",
START_ERROR_NONTEXT_INPUT: "Target element by definition contains no text.",
START_ERROR_NO_WORD_SELECTED: "No word selected at point.",
START_ERROR_SELECT_WORD_FAILED: "Word selection at point failed.",
START_ERROR_SELECT_ALL_PARAGRAPH_FAILED: "Select-All Paragraph failed.",
START_ERROR_NO_SELECTION: "Selection performed, but nothing resulted.",
START_ERROR_PROXIMITY: "Selection target and result seem unrelated.",
START_ERROR_SELECTIONCARETS_ENABLED: "Native selectionCarets requested while Gecko enabled.",
// Error codes returned during attachCaret().
ATTACH_ERROR_INCOMPATIBLE: "Element disabled, handled natively, or not editable.",
ATTACH_ERROR_TOUCHCARET_ENABLED: "Native touchCaret requested while Gecko enabled.",
HANDLE_TYPE_ANCHOR: "ANCHOR",
HANDLE_TYPE_CARET: "CARET",
HANDLE_TYPE_FOCUS: "FOCUS",
TYPE_NONE: 0,
TYPE_CURSOR: 1,
TYPE_SELECTION: 2,
SELECT_ALL: 0,
SELECT_AT_POINT: 1,
// Gecko TouchCaret/SelectionCaret pref values.
_accessibleCaretEnabledValue: null,
_selectionCaretEnabledValue: null,
// Keeps track of data about the dimensions of the selection. Coordinates
// stored here are relative to the _contentWindow window.
_cache: { anchorPt: {}, focusPt: {} },
_targetIsRTL: false,
_anchorIsRTL: false,
_focusIsRTL: false,
_activeType: 0, // TYPE_NONE
_selectionPrivate: null, // private selection reference
_selectionID: null, // Unique Selection ID
_draggingHandles: false, // True while user drags text selection handles
_dragStartAnchorOffset: null, // Editables need initial pos during HandleMove events
_dragStartFocusOffset: null, // Editables need initial pos during HandleMove events
_ignoreCompositionChanges: false, // Persist caret during IME composition updates
_prevHandlePositions: [], // Avoid issuing duplicate "TextSelection:Position" messages
_deferCloseTimer: null, // Used to defer _closeSelection() actions during programmatic changes
// TargetElement changes (text <--> no text) trigger actionbar UI update
_prevTargetElementHasText: null,
// The window that holds the selection (can be a sub-frame)
get _contentWindow() {
if (this._contentWindowRef)
return this._contentWindowRef.get();
return null;
},
set _contentWindow(aContentWindow) {
this._contentWindowRef = Cu.getWeakReference(aContentWindow);
},
get _targetElement() {
if (this._targetElementRef)
return this._targetElementRef.get();
return null;
},
set _targetElement(aTargetElement) {
this._targetElementRef = Cu.getWeakReference(aTargetElement);
},
get _domWinUtils() {
return BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIDOMWindowUtils);
},
// Provides UUID service for selection ID's.
get _idService() {
delete this._idService;
return this._idService = Cc["@mozilla.org/uuid-generator;1"].
getService(Ci.nsIUUIDGenerator);
},
// Are we supporting Accessible-core or native-Java carets?
get _accessibleCaretEnabled() {
if (this._accessibleCaretEnabledValue == null) {
try {
this._accessibleCaretEnabledValue = Services.prefs.getBoolPref(PREF_GECKO_ACCESSIBLECARET_ENABLED);
} catch (unused) { }
Services.prefs.addObserver(PREF_GECKO_ACCESSIBLECARET_ENABLED, function() {
SelectionHandler._accessibleCaretEnabledValue =
Services.prefs.getBoolPref(PREF_GECKO_ACCESSIBLECARET_ENABLED);
}, false);
}
return this._accessibleCaretEnabledValue;
},
_addObservers: function sh_addObservers() {
Services.obs.addObserver(this, "Gesture:SingleTap", false);
Services.obs.addObserver(this, "Tab:Selected", false);
Services.obs.addObserver(this, "TextSelection:Move", false);
Services.obs.addObserver(this, "TextSelection:Position", false);
Services.obs.addObserver(this, "TextSelection:End", false);
Services.obs.addObserver(this, "TextSelection:Action", false);
Services.obs.addObserver(this, "TextSelection:LayerReflow", false);
BrowserApp.deck.addEventListener("pagehide", this, false);
BrowserApp.deck.addEventListener("blur", this, true);
BrowserApp.deck.addEventListener("scroll", this, true);
},
_removeObservers: function sh_removeObservers() {
Services.obs.removeObserver(this, "Gesture:SingleTap");
Services.obs.removeObserver(this, "Tab:Selected");
Services.obs.removeObserver(this, "TextSelection:Move");
Services.obs.removeObserver(this, "TextSelection:Position");
Services.obs.removeObserver(this, "TextSelection:End");
Services.obs.removeObserver(this, "TextSelection:Action");
Services.obs.removeObserver(this, "TextSelection:LayerReflow");
BrowserApp.deck.removeEventListener("pagehide", this, false);
BrowserApp.deck.removeEventListener("blur", this, true);
BrowserApp.deck.removeEventListener("scroll", this, true);
},
observe: function sh_observe(aSubject, aTopic, aData) {
// Ignore all but selectionListener notifications during deferred _closeSelection().
if (this._deferCloseTimer) {
return;
}
switch (aTopic) {
// Update selectionListener and handle/caret positions, on page reflow
// (keyboard open/close, dynamic DOM changes, orientation updates, etc).
case "TextSelection:LayerReflow": {
if (this._activeType == this.TYPE_SELECTION) {
this._updateSelectionListener();
}
if (this._activeType != this.TYPE_NONE) {
this._positionHandlesOnChange();
}
break;
}
case "Gesture:SingleTap": {
if (this._activeType == this.TYPE_CURSOR) {
// attachCaret() is called in the "Gesture:SingleTap" handler in BrowserEventHandler
// We're guaranteed to call this first, because this observer was added last
this._deactivate();
}
break;
}
case "Tab:Selected":
this._closeSelection();
break;
case "TextSelection:End":
let data = JSON.parse(aData);
// End the requested selection only.
if (this._selectionID === data.selectionID) {
this._closeSelection();
}
break;
case "TextSelection:Action":
for (let type in this.actions) {
if (this.actions[type].id == aData) {
this.actions[type].action(this._targetElement);
break;
}
}
break;
case "TextSelection:Move": {
let data = JSON.parse(aData);
if (this._activeType == this.TYPE_SELECTION) {
this._startDraggingHandles();
this._moveSelection(data.handleType, new Point(data.x, data.y));
} else if (this._activeType == this.TYPE_CURSOR) {
this._startDraggingHandles();
// Ignore IMM composition notifications when caret movement starts
this._ignoreCompositionChanges = true;
this._moveCaret(data.x, data.y);
// Move the handle directly under the caret
this._positionHandles();
}
break;
}
case "TextSelection:Position": {
if (this._activeType == this.TYPE_SELECTION) {
this._startDraggingHandles();
this._ensureSelectionDirection();
this._stopDraggingHandles();
this._positionHandles();
// Changes to handle position can affect selection context and actionbar display
this._updateMenu();
} else if (this._activeType == this.TYPE_CURSOR) {
// Act on IMM composition notifications after caret movement ends
this._ignoreCompositionChanges = false;
this._stopDraggingHandles();
this._positionHandles();
} else {
Cu.reportError("Ignored \"TextSelection:Position\" message during invalid selection status");
}
break;
}
case "TextSelection:Get":
Messaging.sendRequest({
type: "TextSelection:Data",
requestId: aData,
text: this._getSelectedText()
});
break;
}
},
// Ignore selectionChange notifications during handle dragging, disable dynamic
// IME text compositions (autoSuggest, autoCorrect, etc)
_startDraggingHandles: function sh_startDraggingHandles() {
if (!this._draggingHandles) {
this._draggingHandles = true;
let selection = this._getSelection();
this._dragStartAnchorOffset = selection.anchorOffset;
this._dragStartFocusOffset = selection.focusOffset;
Messaging.sendRequest({ type: "TextSelection:DraggingHandle", dragging: true });
}
},
// Act on selectionChange notifications when not dragging handles, allow dynamic
// IME text compositions (autoSuggest, autoCorrect, etc)
_stopDraggingHandles: function sh_stopDraggingHandles() {
if (this._draggingHandles) {
this._draggingHandles = false;
this._dragStartAnchorOffset = null;
this._dragStartFocusOffset = null;
Messaging.sendRequest({ type: "TextSelection:DraggingHandle", dragging: false });
}
},
handleEvent: function sh_handleEvent(aEvent) {
// Ignore all but selectionListener notifications during deferred _closeSelection().
if (this._deferCloseTimer) {
return;
}
switch (aEvent.type) {
case "scroll":
// Maintain position when top-level document is scrolled
this._positionHandlesOnChange();
break;
case "pagehide": {
// We only care about events on the selected tab.
let tab = BrowserApp.getTabForWindow(aEvent.originalTarget.defaultView);
if (tab == BrowserApp.selectedTab) {
this._closeSelection();
}
break;
}
case "blur":
this._closeSelection();
break;
// Update caret position on keyboard activity
case "keyup":
// Not generated by Swiftkeyboard
case "compositionupdate":
case "compositionend":
// Generated by SwiftKeyboard, et. al.
if (!this._ignoreCompositionChanges) {
this._positionHandles();
}
break;
}
},
/** Returns true if the provided element can be selected in text selection, false otherwise. */
canSelect: function sh_canSelect(aElement) {
return !(aElement instanceof Ci.nsIDOMHTMLButtonElement ||
aElement instanceof Ci.nsIDOMHTMLEmbedElement ||
aElement instanceof Ci.nsIDOMHTMLImageElement ||
aElement instanceof Ci.nsIDOMHTMLMediaElement) &&
aElement.style.MozUserSelect != 'none';
},
_getScrollPos: function sh_getScrollPos() {
// Get the current display position
let scrollX = {}, scrollY = {};
this._contentWindow.top.QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIDOMWindowUtils).getScrollXY(false, scrollX, scrollY);
return {
X: scrollX.value,
Y: scrollY.value
};
},
/**
* Add a selection listener to monitor for external selection changes.
*/
_addSelectionListener: function(selection) {
this._selectionPrivate = selection.QueryInterface(Ci.nsISelectionPrivate);
this._selectionPrivate.addSelectionListener(this);
},
/**
* The nsISelection object for an editable can change during DOM mutations,
* causing us to stop receiving selectionChange notifications.
*
* We can detect that after a layer-reflow event, and dynamically update the
* listener.
*/
_updateSelectionListener: function() {
if (!(this._targetElement instanceof Ci.nsIDOMNSEditableElement)) {
return;
}
let selection = this._getSelection();
if (this._selectionPrivate != selection.QueryInterface(Ci.nsISelectionPrivate)) {
this._removeSelectionListener();
this._addSelectionListener(selection);
}
},
/**
* Remove the selection listener.
*/
_removeSelectionListener: function() {
this._selectionPrivate.removeSelectionListener(this);
this._selectionPrivate = null;
},
/**
* Observe and react to programmatic SelectionChange notifications.
*/
notifySelectionChanged: function sh_notifySelectionChanged(aDocument, aSelection, aReason) {
// Cancel any in-progress / deferred _closeSelection() action.
this._cancelDeferredCloseSelection();
// Ignore selectionChange notifications during handle movements
if (this._draggingHandles) {
return;
}
// If the selection was collapsed to Start or to End, always close it
if ((aReason & Ci.nsISelectionListener.COLLAPSETOSTART_REASON) ||
(aReason & Ci.nsISelectionListener.COLLAPSETOEND_REASON)) {
this._closeSelection();
return;
}
// If selected text no longer exists, schedule a deferred close action.
if (!aSelection.toString()) {
this._deferCloseSelection();
return;
}
// Update the selection handle positions.
this._positionHandles();
},
/*
* Called from browser.js when the user long taps on text or chooses
* the "Select Word" context menu item. Initializes SelectionHandler,
* starts a selection, and positions the text selection handles.
*
* @param aOptions list of options describing how to start selection
* Options include:
* mode - SELECT_ALL to select everything in the target
* element, or SELECT_AT_POINT to select a word.
* x - The x-coordinate for SELECT_AT_POINT.
* y - The y-coordinate for SELECT_AT_POINT.
*/
startSelection: function sh_startSelection(aElement, aOptions = { mode: SelectionHandler.SELECT_ALL }) {
// Disable Native touchCarets if Gecko AccessibleCaret enabled.
if (this._accessibleCaretEnabled) {
return this.START_ERROR_SELECTIONCARETS_ENABLED;
}
// Clear out any existing active selection
this._closeSelection();
if (this._isNonTextInputElement(aElement)) {
return this.START_ERROR_NONTEXT_INPUT;
}
const focus = Services.focus.focusedWindow;
if (focus) {
// Make sure any previous focus is cleared.
Services.focus.clearFocus(focus);
}
this._initTargetInfo(aElement, this.TYPE_SELECTION);
// Perform the appropriate selection method, if we can't determine method, or it fails, return
let selectionResult = this._performSelection(aOptions);
if (selectionResult !== this.ERROR_NONE) {
this._deactivate();
return selectionResult;
}
// Double check results of successful selection operation
let selection = this._getSelection();
if (!selection ||
selection.rangeCount == 0 ||
selection.getRangeAt(0).collapsed ||
this._getSelectedText().length == 0) {
this._deactivate();
return this.START_ERROR_NO_SELECTION;
}
// Add a listener to end the selection if it's removed programatically
this._addSelectionListener(selection);
this._activeType = this.TYPE_SELECTION;
// Figure out the distance between the selection and the click
let scroll = this._getScrollPos();
let positions = this._getHandlePositions(scroll);
if (aOptions.mode == this.SELECT_AT_POINT &&
!this._selectionNearClick(scroll.X + aOptions.x, scroll.Y + aOptions.y, positions)) {
this._closeSelection();
return this.START_ERROR_PROXIMITY;
}
// Determine position and show handles, open actionbar
this._positionHandles(positions);
Messaging.sendRequest({
selectionID: this._selectionID,
type: "TextSelection:ShowHandles",
handles: [this.HANDLE_TYPE_ANCHOR, this.HANDLE_TYPE_FOCUS]
});
this._updateMenu();
return this.ERROR_NONE;
},
/*
* Called to perform a selection operation, given a target element, selection method, starting point etc.
*/
_performSelection: function sh_performSelection(aOptions) {
if (aOptions.mode == this.SELECT_AT_POINT) {
// Clear any ranges selected outside SelectionHandler, by code such as Find-In-Page.
this._contentWindow.getSelection().removeAllRanges();
try {
if (!this._domWinUtils.selectAtPoint(aOptions.x, aOptions.y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE)) {
return this.START_ERROR_NO_WORD_SELECTED;
}
} catch (e) {
return this.START_ERROR_SELECT_WORD_FAILED;
}
// Perform additional phone-number "smart selection".
if (this._isPhoneNumber(this._getSelection().toString())) {
this._selectSmartPhoneNumber();
}
return this.ERROR_NONE;
}
// Only selectAll() assumed from this point.
if (aOptions.mode != this.SELECT_ALL) {
return this.START_ERROR_INVALID_MODE;
}
// HTMLPreElement is a #text node, SELECT_ALL implies entire paragraph
if (this._targetElement instanceof HTMLPreElement) {
try {
this._domWinUtils.selectAtPoint(1, 1, Ci.nsIDOMWindowUtils.SELECT_PARAGRAPH);
return this.ERROR_NONE;
} catch (e) {
return this.START_ERROR_SELECT_ALL_PARAGRAPH_FAILED;
}
}
// Else default to selectALL Document
let editor = this._getEditor();
if (editor) {
editor.selectAll();
} else {
this._getSelectionController().selectAll();
}
// Selection is entire HTMLHtmlElement, remove any trailing document whitespace
let selection = this._getSelection();
let lastNode = selection.focusNode;
while (lastNode && lastNode.lastChild) {
lastNode = lastNode.lastChild;
}
if (lastNode instanceof Text) {
try {
selection.extend(lastNode, lastNode.length);
} catch (e) {
Cu.reportError("SelectionHandler.js: _performSelection() whitespace trim fails: lastNode[" + lastNode +
"] lastNode.length[" + lastNode.length + "]");
}
}
return this.ERROR_NONE;
},
/*
* Called to expand a selection that appears to represent a phone number. This enhances the basic
* SELECT_WORDNOSPACE logic employed in performSelection() in response to long-tap / selecting text.
*/
_selectSmartPhoneNumber: function() {
this._extendPhoneNumberSelection("forward");
this._reversePhoneNumberSelectionDir();
this._extendPhoneNumberSelection("backward");
this._reversePhoneNumberSelectionDir();
},
/*
* Extend the current phone number selection in the requested direction.
*/
_extendPhoneNumberSelection: function(direction) {
let selection = this._getSelection();
// Extend the phone number selection until we find a boundry.
while (true) {
// Save current focus position, and extend the selection.
let focusNode = selection.focusNode;
let focusOffset = selection.focusOffset;
selection.modify("extend", direction, "character");
// If the selection doesn't change, (can't extend further), we're done.
if (selection.focusNode == focusNode && selection.focusOffset == focusOffset) {
return;
}
// Don't extend past a valid phone number.
if (!this._isPhoneNumber(selection.toString().trim())) {
// Backout the undesired selection extend, and we're done.
selection.collapse(selection.anchorNode, selection.anchorOffset);
selection.extend(focusNode, focusOffset);
return;
}
// Don't extend the selection into a new container.
if (selection.focusNode != focusNode) {
let nextContainer = (selection.focusNode instanceof Text) ?
selection.focusNode.parentNode : selection.focusNode;
if (nextContainer.matches &&
nextContainer.matches(PHONE_NUMBER_CONTAINERS)) {
// Backout the undesired selection extend, and we're done.
selection.collapse(selection.anchorNode, selection.anchorOffset);
selection.extend(focusNode, focusOffset);
return
}
}
}
},
/*
* Reverse the the selection direction, swapping anchorNode <-+-> focusNode.
*/
_reversePhoneNumberSelectionDir: function(direction) {
let selection = this._getSelection();
let anchorNode = selection.anchorNode;
let anchorOffset = selection.anchorOffset;
selection.collapse(selection.focusNode, selection.focusOffset);
selection.extend(anchorNode, anchorOffset);
},
/* Return true if the current selection (given by aPositions) is near to where the coordinates passed in */
_selectionNearClick: function(aX, aY, aPositions) {
let distance = 0;
// Check if the click was in the bounding box of the selection handles
if (aPositions[0].left < aX && aX < aPositions[1].left
&& aPositions[0].top < aY && aY < aPositions[1].top) {
distance = 0;
} else {
// If it was outside, check the distance to the center of the selection
let selectposX = (aPositions[0].left + aPositions[1].left) / 2;
let selectposY = (aPositions[0].top + aPositions[1].top) / 2;
let dx = Math.abs(selectposX - aX);
let dy = Math.abs(selectposY - aY);
distance = dx + dy;
}
let maxSelectionDistance = Services.prefs.getIntPref("browser.ui.selection.distance");
return (distance < maxSelectionDistance);
},
/* Reads a value from an action. If the action defines the value as a function, will return the result of calling
the function. Otherwise, will return the value itself. If the value isn't defined for this action, will return a default */
_getValue: function(obj, name, defaultValue) {
if (!(name in obj))
return defaultValue;
if (typeof obj[name] == "function")
return obj[name](this._targetElement);
return obj[name];
},
addAction: function(action) {
if (!action.id)
action.id = uuidgen.generateUUID().toString()
if (this.actions[action.id])
throw "Action with id " + action.id + " already added";
// Update actions list and actionbar UI if active.
this.actions[action.id] = action;
this._updateMenu();
return action.id;
},
removeAction: function(id) {
// Update actions list and actionbar UI if active.
delete this.actions[id];
this._updateMenu();
},
_updateMenu: function() {
if (this._activeType == this.TYPE_NONE) {
return;
}
// Update actionbar UI.
let actions = [];
for (let type in this.actions) {
let action = this.actions[type];
if (action.selector.matches(this._targetElement)) {
let a = {
id: action.id,
label: this._getValue(action, "label", ""),
icon: this._getValue(action, "icon", "drawable://ic_status_logo"),
showAsAction: this._getValue(action, "showAsAction", true),
order: this._getValue(action, "order", 0)
};
actions.push(a);
}
}
actions.sort((a, b) => b.order - a.order);
Messaging.sendRequest({
type: "TextSelection:Update",
actions: actions
});
},
/*
* Actionbar methods.
*/
actions: {
SELECT_ALL: {
label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
id: "selectall_action",
icon: "drawable://ab_select_all",
action: function(aElement) {
SelectionHandler.startSelection(aElement);
UITelemetry.addEvent("action.1", "actionbar", null, "select_all");
},
order: 5,
selector: {
matches: function(aElement) {
return (aElement.textLength != 0);
}
}
},
CUT: {
label: Strings.browser.GetStringFromName("contextmenu.cut"),
id: "cut_action",
icon: "drawable://ab_cut",
action: function(aElement) {
let start = aElement.selectionStart;
let end = aElement.selectionEnd;
SelectionHandler.copySelection();
aElement.value = aElement.value.substring(0, start) + aElement.value.substring(end)
// copySelection closes the selection. Show a caret where we just cut the text.
SelectionHandler.attachCaret(aElement);
UITelemetry.addEvent("action.1", "actionbar", null, "cut");
},
order: 4,
selector: {
matches: function(aElement) {
// Disallow cut for contentEditable elements (until Bug 1112276 is fixed).
return !aElement.isContentEditable && SelectionHandler.isElementEditableText(aElement) ?
SelectionHandler.isSelectionActive() : false;
}
}
},
COPY: {
label: Strings.browser.GetStringFromName("contextmenu.copy"),
id: "copy_action",
icon: "drawable://ab_copy",
action: function() {
SelectionHandler.copySelection();
UITelemetry.addEvent("action.1", "actionbar", null, "copy");
},
order: 3,
selector: {
matches: function(aElement) {
// Don't include "copy" for password fields.
// mozIsTextField(true) tests for only non-password fields.
if (aElement instanceof Ci.nsIDOMHTMLInputElement && !aElement.mozIsTextField(true)) {
return false;
}
return SelectionHandler.isSelectionActive();
}
}
},
PASTE: {
label: Strings.browser.GetStringFromName("contextmenu.paste"),
id: "paste_action",
icon: "drawable://ab_paste",
action: function(aElement) {
if (aElement) {
let target = SelectionHandler._getEditor();
aElement.focus();
target.paste(Ci.nsIClipboard.kGlobalClipboard);
SelectionHandler._closeSelection();
UITelemetry.addEvent("action.1", "actionbar", null, "paste");
}
},
order: 2,
selector: {
matches: function(aElement) {
if (SelectionHandler.isElementEditableText(aElement)) {
let flavors = ["text/unicode"];
return Services.clipboard.hasDataMatchingFlavors(flavors, flavors.length, Ci.nsIClipboard.kGlobalClipboard);
}
return false;
}
}
},
SHARE: {
label: Strings.browser.GetStringFromName("contextmenu.share"),
id: "share_action",
icon: "drawable://ic_menu_share",
action: function() {
SelectionHandler.shareSelection();
UITelemetry.addEvent("action.1", "actionbar", null, "share");
},
selector: {
matches: function() {
if (!ParentalControls.isAllowed(ParentalControls.SHARE)) {
return false;
}
return SelectionHandler.isSelectionActive();
}
}
},
SEARCH_ADD: {
id: "search_add_action",
label: Strings.browser.GetStringFromName("contextmenu.addSearchEngine2"),
icon: "drawable://ab_add_search_engine",
selector: {
matches: function(element) {
if(!(element instanceof HTMLInputElement)) {
return false;
}
let form = element.form;
if (!form || element.type == "password") {
return false;
}
// These are the following types of forms we can create keywords for:
//
// method encoding type can create keyword
// GET * YES
// * YES
// POST * YES
// POST application/x-www-form-urlencoded YES
// POST text/plain NO ( a little tricky to do)
// POST multipart/form-data NO
// POST everything else YES
let method = form.method.toUpperCase();
return (method == "GET" || method == "") ||
(form.enctype != "text/plain") && (form.enctype != "multipart/form-data");
},
},
action: function(element) {
UITelemetry.addEvent("action.1", "actionbar", null, "add_search_engine");
SearchEngines.addEngine(element);
},
},
SEARCH: {
label: function() {
return Strings.browser.formatStringFromName("contextmenu.search", [Services.search.defaultEngine.name], 1);
},
id: "search_action",
icon: "drawable://ab_search",
action: function() {
SelectionHandler.searchSelection();
SelectionHandler._closeSelection();
UITelemetry.addEvent("action.1", "actionbar", null, "search");
},
order: 1,
selector: {
matches: function() {
return SelectionHandler.isSelectionActive();
}
}
},
CALL: {
label: Strings.browser.GetStringFromName("contextmenu.call"),
id: "call_action",
icon: "drawable://phone",
action: function() {
SelectionHandler.callSelection();
UITelemetry.addEvent("action.1", "actionbar", null, "call");
},
order: 1,
selector: {
matches: function () {
return SelectionHandler._getSelectedPhoneNumber() != null;
}
}
}
},
/*
* Called by BrowserEventHandler when the user taps in a form input.
* Initializes SelectionHandler and positions the caret handle.
*
* @param aX, aY tap location in client coordinates.
*/
attachCaret: function sh_attachCaret(aElement) {
// Disable Native touchCarets if Gecko AccessibleCaret enabled.
if (this._accessibleCaretEnabled) {
return this.ATTACH_ERROR_TOUCHCARET_ENABLED;
}
// Clear out any existing active selection
this._closeSelection();
// Ensure it isn't disabled, isn't handled by Android native dialog, and is editable text element
if (aElement.disabled || InputWidgetHelper.hasInputWidget(aElement) || !this.isElementEditableText(aElement)) {
return this.ATTACH_ERROR_INCOMPATIBLE;
}
this._initTargetInfo(aElement, this.TYPE_CURSOR);
// Caret-specific observer/listeners
BrowserApp.deck.addEventListener("keyup", this, false);
BrowserApp.deck.addEventListener("compositionupdate", this, false);
BrowserApp.deck.addEventListener("compositionend", this, false);
this._activeType = this.TYPE_CURSOR;
// Determine position and show caret, open actionbar
this._positionHandles();
Messaging.sendRequest({
selectionID: this._selectionID,
type: "TextSelection:ShowHandles",
handles: [this.HANDLE_TYPE_CARET]
});
this._updateMenu();
return this.ERROR_NONE;
},
// Target initialization for both TYPE_CURSOR and TYPE_SELECTION
_initTargetInfo: function sh_initTargetInfo(aElement, aSelectionType) {
this._targetElement = aElement;
if (aElement instanceof Ci.nsIDOMNSEditableElement) {
if (aSelectionType === this.TYPE_SELECTION) {
// Blur the targetElement to force IME code to undo previous style compositions
// (visible underlines / etc generated by autoCorrection, autoSuggestion)
aElement.blur();
}
// Ensure targetElement is now focused normally
aElement.focus();
}
this._selectionID = this._idService.generateUUID().toString();
this._stopDraggingHandles();
this._contentWindow = aElement.ownerDocument.defaultView;
this._targetIsRTL = (this._contentWindow.getComputedStyle(aElement, "").direction == "rtl");
this._addObservers();
},
_getSelection: function sh_getSelection() {
if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selection;
else
return this._contentWindow.getSelection();
},
_getSelectedText: function sh_getSelectedText() {
if (!this._contentWindow)
return "";
let selection = this._getSelection();
if (!selection)
return "";
if (this._targetElement instanceof Ci.nsIDOMHTMLTextAreaElement) {
return selection.QueryInterface(Ci.nsISelectionPrivate).
toStringWithFormat("text/plain", Ci.nsIDocumentEncoder.OutputPreformatted | Ci.nsIDocumentEncoder.OutputRaw, 0);
}
return selection.toString().trim();
},
_getEditor: function sh_getEditor() {
if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) {
return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
}
return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIEditingSession)
.getEditorForWindow(this._contentWindow);
},
_getSelectionController: function sh_getSelectionController() {
if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selectionController;
else
return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIWebNavigation).
QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsISelectionDisplay).
QueryInterface(Ci.nsISelectionController);
},
// Used by the contextmenu "matches" functions in ClipboardHelper
isSelectionActive: function sh_isSelectionActive() {
return (this._activeType == this.TYPE_SELECTION);
},
isElementEditableText: function (aElement) {
return (((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) ||
(aElement instanceof HTMLTextAreaElement)) && !aElement.readOnly) ||
aElement.isContentEditable;
},
_isNonTextInputElement: function(aElement) {
return (aElement instanceof HTMLInputElement && !aElement.mozIsTextField(false));
},
/*
* Moves the selection as the user drags a handle.
* @param handleType: Specifies either the anchor or the focus handle.
* @param handlePt: selection point in client coordinates.
*/
_moveSelection: function sh_moveSelection(handleType, handlePt) {
let isAnchorHandle = (handleType == this.HANDLE_TYPE_ANCHOR);
// Determine new caret position from handlePt, exit if user
// moved it offscreen.
let viewOffset = this._getViewOffset();
let ptX = handlePt.x - viewOffset.x;
let ptY = handlePt.y - viewOffset.y;
let cwd = this._contentWindow.document;
let caretPos = cwd.caretPositionFromPoint(ptX, ptY);
if (!caretPos) {
return;
}
// Constrain text selection within editable elements.
let targetIsEditable = this._targetElement instanceof Ci.nsIDOMNSEditableElement;
if (targetIsEditable && (caretPos.offsetNode != this._targetElement)) {
return;
}
// Update the Selection for editable elements. Selection Change
// logic is the same, regardless of RTL/LTR. Selection direction is
// maintained always forward (startOffset <= endOffset).
if (targetIsEditable) {
let start = this._dragStartAnchorOffset;
let end = this._dragStartFocusOffset;
if (isAnchorHandle) {
start = caretPos.offset;
} else {
end = caretPos.offset;
}
if (start > end) {
[start, end] = [end, start];
}
this._targetElement.setSelectionRange(start, end);
return;
}
// Update the Selection for non-editable elements. Selection Change
// logic is the same, regardless of RTL/LTR. Selection direction internally
// can finish reversed by user drag. ie: Forward is (a,o ---> f,o),
// and reversed is (a,o <--- f,o).
let selection = this._getSelection();
if (isAnchorHandle) {
let focusNode = selection.focusNode;
let focusOffset = selection.focusOffset;
selection.collapse(caretPos.offsetNode, caretPos.offset);
selection.extend(focusNode, focusOffset);
} else {
selection.extend(caretPos.offsetNode, caretPos.offset);
}
},
_moveCaret: function sh_moveCaret(aX, aY) {
// Get rect of text inside element
let range = document.createRange();
range.selectNodeContents(this._getEditor().rootElement);
let textBounds = range.getBoundingClientRect();
// Get rect of editor
let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0,
this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
// the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
// divide by the pixel ratio
let editorRect = new Rect(editorBounds.left / window.devicePixelRatio,
editorBounds.top / window.devicePixelRatio,
editorBounds.width / window.devicePixelRatio,
editorBounds.height / window.devicePixelRatio);
// Use intersection of the text rect and the editor rect
let rect = new Rect(textBounds.left, textBounds.top, textBounds.width, textBounds.height);
rect.restrictTo(editorRect);
// Clamp vertically and scroll if handle is at bounds. The top and bottom
// must be restricted by an additional pixel since clicking on the top
// edge of an input field moves the cursor to the beginning of that
// field's text (and clicking the bottom moves the cursor to the end).
if (aY < rect.y + 1) {
aY = rect.y + 1;
this._getSelectionController().scrollLine(false);
} else if (aY > rect.y + rect.height - 1) {
aY = rect.y + rect.height - 1;
this._getSelectionController().scrollLine(true);
}
// Clamp horizontally and scroll if handle is at bounds
if (aX < rect.x) {
aX = rect.x;
this._getSelectionController().scrollCharacter(false);
} else if (aX > rect.x + rect.width) {
aX = rect.x + rect.width;
this._getSelectionController().scrollCharacter(true);
}
this._domWinUtils.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, 0, true);
this._domWinUtils.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, 0, true);
},
copySelection: function sh_copySelection() {
let selectedText = this._getSelectedText();
if (selectedText.length) {
let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
clipboard.copyString(selectedText);
NativeWindow.toast.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), "long");
}
this._closeSelection();
},
shareSelection: function sh_shareSelection() {
let selectedText = this._getSelectedText();
if (selectedText.length) {
Messaging.sendRequest({
type: "Share:Text",
text: selectedText
});
}
this._closeSelection();
},
searchSelection: function sh_searchSelection() {
let selectedText = this._getSelectedText();
if (selectedText.length) {
let req = Services.search.defaultEngine.getSubmission(selectedText);
let parent = BrowserApp.selectedTab;
let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser);
// Set current tab as parent of new tab, and set new tab as private if the parent is.
BrowserApp.addTab(req.uri.spec, {parentId: parent.id,
selected: true,
isPrivate: isPrivate});
}
this._closeSelection();
},
_phoneRegex: /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/,
_getSelectedPhoneNumber: function sh_getSelectedPhoneNumber() {
return this._isPhoneNumber(this._getSelectedText().trim());
},
_isPhoneNumber: function sh_isPhoneNumber(selectedText) {
return (this._phoneRegex.test(selectedText) ? selectedText : null);
},
callSelection: function sh_callSelection() {
let selectedText = this._getSelectedPhoneNumber();
if (selectedText) {
BrowserApp.loadURI("tel:" + selectedText);
}
this._closeSelection();
},
/**
* Deferred _closeSelection() actions allow for brief periods where programmatic
* selection changes have effectively closed the selection, but we anticipate further
* activity that may restore it.
*
* At this point, we hide the UI handles, and stop responding to messages until
* either the final _closeSelection() is triggered, or until our Gecko selectionListener
* notices a subsequent programmatic selection that results in a new selection.
*/
_deferCloseSelection: function() {
// Schedule the deferred _closeSelection() action.
this._deferCloseTimer = setTimeout((function() {
// Time is up! Close the selection.
this._deferCloseTimer = null;
this._closeSelection();
}).bind(this), DEFER_CLOSE_TRIGGER_MS);
// Hide any handles while deferClosed.
if (this._prevHandlePositions.length) {
let positions = this._prevHandlePositions;
for (let i in positions) {
positions[i].hidden = true;
}
Messaging.sendRequest({
type: "TextSelection:PositionHandles",
positions: positions,
});
}
},
/**
* Cancel any current deferred _closeSelection() action.
*/
_cancelDeferredCloseSelection: function() {
if (this._deferCloseTimer) {
clearTimeout(this._deferCloseTimer);
this._deferCloseTimer = null;
}
},
/*
* Shuts SelectionHandler down.
*/
_closeSelection: function sh_closeSelection() {
// Bail if there's no active selection
if (this._activeType == this.TYPE_NONE)
return;
if (this._activeType == this.TYPE_SELECTION)
this._clearSelection();
this._deactivate();
},
_clearSelection: function sh_clearSelection() {
// Cancel any in-progress / deferred _closeSelection() process.
this._cancelDeferredCloseSelection();
let selection = this._getSelection();
if (selection) {
// Remove our listener before we clear the selection
this._removeSelectionListener();
// Remove the selection. For editables, we clear selection without losing
// element focus. For non-editables, just clear all.
if (selection.rangeCount != 0) {
if (this.isElementEditableText(this._targetElement)) {
selection.collapseToStart();
} else {
selection.removeAllRanges();
}
}
}
},
_deactivate: function sh_deactivate() {
this._stopDraggingHandles();
// Hide handle/caret, close actionbar
Messaging.sendRequest({ type: "TextSelection:HideHandles" });
this._removeObservers();
// Only observed for caret positioning
if (this._activeType == this.TYPE_CURSOR) {
BrowserApp.deck.removeEventListener("keyup", this);
BrowserApp.deck.removeEventListener("compositionupdate", this);
BrowserApp.deck.removeEventListener("compositionend", this);
}
this._contentWindow = null;
this._targetElement = null;
this._targetIsRTL = false;
this._ignoreCompositionChanges = false;
this._prevHandlePositions = [];
this._prevTargetElementHasText = null;
this._activeType = this.TYPE_NONE;
},
_getViewOffset: function sh_getViewOffset() {
let offset = { x: 0, y: 0 };
let win = this._contentWindow;
// Recursively look through frames to compute the total position offset.
while (win.frameElement) {
let rect = win.frameElement.getBoundingClientRect();
offset.x += rect.left;
offset.y += rect.top;
win = win.parent;
}
return offset;
},
/*
* The direction of the Selection is ensured for editables while the user drags
* the handles (per "TextSelection:Move" event). For non-editables, we just let
* the user change direction, but fix it up at the end of handle movement (final
* "TextSelection:Position" event).
*/
_ensureSelectionDirection: function() {
// Never needed at this time.
if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) {
return;
}
// Nothing needed if not reversed.
let qcEventResult = this._domWinUtils.sendQueryContentEvent(
this._domWinUtils.QUERY_SELECTED_TEXT, 0, 0, 0, 0);
if (!qcEventResult.reversed) {
return;
}
// Reverse the Selection.
let selection = this._getSelection();
let newFocusNode = selection.anchorNode;
let newFocusOffset = selection.anchorOffset;
selection.collapse(selection.focusNode, selection.focusOffset);
selection.extend(newFocusNode, newFocusOffset);
},
/*
* Updates the TYPE_SELECTION cache, with the handle anchor/focus point values
* of the current selection. Passed to Java for UI positioning only.
*
* Note that the anchor handle and focus handle can reference text in nodes
* with mixed direction. (ie a.direction = "rtl" while f.direction = "ltr").
*/
_updateCacheForSelection: function() {
let selection = this._getSelection();
let rects = selection.getRangeAt(0).getClientRects();
if (rects.length == 0) {
// nsISelection object exists, but there's nothing actually selected
throw "Failed to update cache for invalid selection";
}
// Right-to-Left (ie: Hebrew) anchorPt is on right,
// Left-to-Right (ie: English) anchorPt is on left.
this._anchorIsRTL = this._isNodeRTL(selection.anchorNode);
let anchorIdx = 0;
this._cache.anchorPt = (this._anchorIsRTL) ?
new Point(rects[anchorIdx].right, rects[anchorIdx].bottom) :
new Point(rects[anchorIdx].left, rects[anchorIdx].bottom);
// Right-to-Left (ie: Hebrew) focusPt is on left,
// Left-to-Right (ie: English) focusPt is on right.
this._focusIsRTL = this._isNodeRTL(selection.focusNode);
let focusIdx = rects.length - 1;
this._cache.focusPt = (this._focusIsRTL) ?
new Point(rects[focusIdx].left, rects[focusIdx].bottom) :
new Point(rects[focusIdx].right, rects[focusIdx].bottom);
},
/*
* Return true if text associated with a node is RTL.
*/
_isNodeRTL: function(node) {
// Find containing node that supports .direction attribute (needed
// when target node is #text for example).
while (node && !(node instanceof Element)) {
node = node.parentNode;
}
// Worst case, use original direction from _targetElement.
if (!node) {
return this._targetIsRTL;
}
let nodeWin = node.ownerDocument.defaultView;
let nodeStyle = nodeWin.getComputedStyle(node, "");
return (nodeStyle.direction == "rtl");
},
_getHandlePositions: function(scroll = this._getScrollPos()) {
// the checkHidden function tests to see if the given point is hidden inside an
// iframe/subdocument. this is so that if we select some text inside an iframe and
// scroll the iframe so the selection is out of view, we hide the handles rather
// than having them float on top of the main page content.
let checkHidden = function(x, y) {
return false;
};
if (this._contentWindow.frameElement) {
let bounds = this._contentWindow.frameElement.getBoundingClientRect();
checkHidden = function(x, y) {
return x < 0 || y < 0 || x > bounds.width || y > bounds.height;
};
}
if (this._activeType == this.TYPE_CURSOR) {
// The left and top properties returned are relative to the client area
// of the window, so we don't need to account for a sub-frame offset.
let cursor = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_CARET_RECT, this._targetElement.selectionEnd, 0, 0, 0,
this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
// the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
// divide by the pixel ratio
let x = cursor.left / window.devicePixelRatio;
let y = (cursor.top + cursor.height) / window.devicePixelRatio;
return [{ handle: this.HANDLE_TYPE_CARET,
left: x + scroll.X,
top: y + scroll.Y,
rtl: this._targetIsRTL,
hidden: checkHidden(x, y) }];
}
// Determine the handle screen coords
this._updateCacheForSelection();
let offset = this._getViewOffset();
return [{ handle: this.HANDLE_TYPE_ANCHOR,
left: this._cache.anchorPt.x + offset.x + scroll.X,
top: this._cache.anchorPt.y + offset.y + scroll.Y,
rtl: this._anchorIsRTL,
hidden: checkHidden(this._cache.anchorPt.x, this._cache.anchorPt.y) },
{ handle: this.HANDLE_TYPE_FOCUS,
left: this._cache.focusPt.x + offset.x + scroll.X,
top: this._cache.focusPt.y + offset.y + scroll.Y,
rtl: this._focusIsRTL,
hidden: checkHidden(this._cache.focusPt.x, this._cache.focusPt.y) }];
},
// Position handles, but avoid superfluous re-positioning (helps during
// "TextSelection:LayerReflow", "scroll" of top-level document, etc).
_positionHandlesOnChange: function() {
// Helper function to compare position messages
let samePositions = function(aPrev, aCurr) {
if (aPrev.length != aCurr.length) {
return false;
}
for (let i = 0; i < aPrev.length; i++) {
if (aPrev[i].left != aCurr[i].left ||
aPrev[i].top != aCurr[i].top ||
aPrev[i].rtl != aCurr[i].rtl ||
aPrev[i].hidden != aCurr[i].hidden) {
return false;
}
}
return true;
}
let positions = this._getHandlePositions();
if (!samePositions(this._prevHandlePositions, positions)) {
this._positionHandles(positions);
}
},
// Position handles, allow for re-position, in case user drags handle
// to invalid position, then releases, we can put it back where it started
// positions is an array of objects with data about handle positions,
// which we get from _getHandlePositions.
_positionHandles: function(positions = this._getHandlePositions()) {
Messaging.sendRequest({
type: "TextSelection:PositionHandles",
positions: positions,
});
this._prevHandlePositions = positions;
// Text state transitions (text <--> no text) will affect selection context and actionbar display
let currTargetElementHasText = (this._targetElement.textLength > 0);
if (currTargetElementHasText != this._prevTargetElementHasText) {
this._prevTargetElementHasText = currTargetElementHasText;
this._updateMenu();
}
},
subdocumentScrolled: function sh_subdocumentScrolled(aElement) {
// Ignore all but selectionListener notifications during deferred _closeSelection().
if (this._deferCloseTimer) {
return;
}
if (this._activeType == this.TYPE_NONE) {
return;
}
let scrollView = aElement.ownerDocument.defaultView;
let view = this._contentWindow;
while (true) {
if (view == scrollView) {
// The selection is in a view (or sub-view) of the view that scrolled.
// So we need to reposition the handles.
this._positionHandles();
break;
}
if (view == view.parent) {
break;
}
view = view.parent;
}
}
};