mirror of
https://github.com/classilla/tenfourfox.git
synced 2024-08-18 07:29:28 +00:00
1450 lines
51 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
};
|