mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
930 lines
34 KiB
JavaScript
930 lines
34 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
"use strict";
|
|
|
|
var SelectionHandler = {
|
|
HANDLE_TYPE_START: "START",
|
|
HANDLE_TYPE_MIDDLE: "MIDDLE",
|
|
HANDLE_TYPE_END: "END",
|
|
|
|
TYPE_NONE: 0,
|
|
TYPE_CURSOR: 1,
|
|
TYPE_SELECTION: 2,
|
|
|
|
SELECT_ALL: 0,
|
|
SELECT_AT_POINT: 1,
|
|
|
|
// Keeps track of data about the dimensions of the selection. Coordinates
|
|
// stored here are relative to the _contentWindow window.
|
|
_cache: null,
|
|
_activeType: 0, // TYPE_NONE
|
|
_ignoreSelectionChanges: false, // True while user drags text selection handles
|
|
_ignoreCompositionChanges: false, // Persist caret during IME composition updates
|
|
|
|
// 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);
|
|
},
|
|
|
|
_isRTL: false,
|
|
|
|
_addObservers: function sh_addObservers() {
|
|
Services.obs.addObserver(this, "Gesture:SingleTap", false);
|
|
Services.obs.addObserver(this, "Tab:Selected", false);
|
|
Services.obs.addObserver(this, "after-viewport-change", 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);
|
|
|
|
BrowserApp.deck.addEventListener("pagehide", this, false);
|
|
BrowserApp.deck.addEventListener("blur", this, true);
|
|
},
|
|
|
|
_removeObservers: function sh_removeObservers() {
|
|
Services.obs.removeObserver(this, "Gesture:SingleTap");
|
|
Services.obs.removeObserver(this, "Tab:Selected");
|
|
Services.obs.removeObserver(this, "after-viewport-change");
|
|
Services.obs.removeObserver(this, "TextSelection:Move");
|
|
Services.obs.removeObserver(this, "TextSelection:Position");
|
|
Services.obs.removeObserver(this, "TextSelection:End");
|
|
Services.obs.removeObserver(this, "TextSelection:Action");
|
|
|
|
BrowserApp.deck.removeEventListener("pagehide", this);
|
|
BrowserApp.deck.removeEventListener("blur", this);
|
|
},
|
|
|
|
observe: function sh_observe(aSubject, aTopic, aData) {
|
|
switch (aTopic) {
|
|
// Update caret position on keyboard activity
|
|
case "TextSelection:UpdateCaretPos":
|
|
// Generated by IME close, autoCorrection / styling
|
|
this._positionHandles();
|
|
break;
|
|
|
|
case "Gesture:SingleTap": {
|
|
if (this._activeType == this.TYPE_SELECTION) {
|
|
let data = JSON.parse(aData);
|
|
if (!this._pointInSelection(data.x, data.y))
|
|
this._closeSelection();
|
|
} else 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":
|
|
case "TextSelection:End":
|
|
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 "after-viewport-change": {
|
|
if (this._activeType == this.TYPE_SELECTION) {
|
|
// Update the cache after the viewport changes (e.g. panning, zooming).
|
|
this._updateCacheForSelection();
|
|
}
|
|
break;
|
|
}
|
|
case "TextSelection:Move": {
|
|
let data = JSON.parse(aData);
|
|
if (this._activeType == this.TYPE_SELECTION) {
|
|
// Ignore selectionChange notifications when handle movement starts
|
|
this._ignoreSelectionChanges = true;
|
|
this._moveSelection(data.handleType == this.HANDLE_TYPE_START, data.x, data.y);
|
|
} else if (this._activeType == this.TYPE_CURSOR) {
|
|
// Ignore IMM composition notifications when caret movement starts
|
|
this._ignoreCompositionChanges = true;
|
|
|
|
// Send a click event to the text box, which positions the caret
|
|
this._sendMouseEvents(data.x, data.y);
|
|
|
|
// Move the handle directly under the caret
|
|
this._positionHandles();
|
|
}
|
|
break;
|
|
}
|
|
case "TextSelection:Position": {
|
|
if (this._activeType == this.TYPE_SELECTION) {
|
|
// Ignore selectionChange notifications when handle movement starts
|
|
this._ignoreSelectionChanges = true;
|
|
// Check to see if the handles should be reversed.
|
|
let isStartHandle = JSON.parse(aData).handleType == this.HANDLE_TYPE_START;
|
|
|
|
try {
|
|
let selectionReversed = this._updateCacheForSelection(isStartHandle);
|
|
if (selectionReversed) {
|
|
// Reverse the anchor and focus to correspond to the new start and end handles.
|
|
let selection = this._getSelection();
|
|
let anchorNode = selection.anchorNode;
|
|
let anchorOffset = selection.anchorOffset;
|
|
selection.collapse(selection.focusNode, selection.focusOffset);
|
|
selection.extend(anchorNode, anchorOffset);
|
|
}
|
|
} catch (e) {
|
|
// User finished handle positioning with one end off the screen
|
|
this._closeSelection();
|
|
break;
|
|
}
|
|
|
|
// Act on selectionChange notifications after handle movement ends
|
|
this._ignoreSelectionChanges = false;
|
|
this._positionHandles();
|
|
|
|
} else if (this._activeType == this.TYPE_CURSOR) {
|
|
// Act on IMM composition notifications after caret movement ends
|
|
this._ignoreCompositionChanges = false;
|
|
this._positionHandles();
|
|
|
|
} else {
|
|
Cu.reportError("Ignored \"TextSelection:Position\" message during invalid selection status");
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case "TextSelection:Get":
|
|
sendMessageToJava({
|
|
type: "TextSelection:Data",
|
|
requestId: aData,
|
|
text: this._getSelectedText()
|
|
});
|
|
break;
|
|
}
|
|
},
|
|
|
|
handleEvent: function sh_handleEvent(aEvent) {
|
|
switch (aEvent.type) {
|
|
case "pagehide":
|
|
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
|
|
};
|
|
},
|
|
|
|
notifySelectionChanged: function sh_notifySelectionChanged(aDocument, aSelection, aReason) {
|
|
// Ignore selectionChange notifications during handle movements
|
|
if (this._ignoreSelectionChanges) {
|
|
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, close
|
|
if (!aSelection.toString()) {
|
|
this._closeSelection();
|
|
}
|
|
},
|
|
|
|
/*
|
|
* 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 }) {
|
|
// Clear out any existing active selection
|
|
this._closeSelection();
|
|
|
|
this._initTargetInfo(aElement);
|
|
|
|
// Clear any existing selection from the document
|
|
this._contentWindow.getSelection().removeAllRanges();
|
|
|
|
// Perform the appropriate selection method, if we can't determine method, or it fails, return
|
|
if (!this._performSelection(aOptions)) {
|
|
this._deactivate();
|
|
return false;
|
|
}
|
|
|
|
// Double check results of successful selection operation
|
|
let selection = this._getSelection();
|
|
if (!selection || selection.rangeCount == 0 || selection.getRangeAt(0).collapsed) {
|
|
this._deactivate();
|
|
return false;
|
|
}
|
|
|
|
// Add a listener to end the selection if it's removed programatically
|
|
selection.QueryInterface(Ci.nsISelectionPrivate).addSelectionListener(this);
|
|
this._activeType = this.TYPE_SELECTION;
|
|
|
|
// Initialize the cache
|
|
this._cache = { start: {}, end: {}};
|
|
this._updateCacheForSelection();
|
|
|
|
let scroll = this._getScrollPos();
|
|
// Figure out the distance between the selection and the click
|
|
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 false;
|
|
}
|
|
|
|
this._positionHandles(positions);
|
|
this._sendMessage("TextSelection:ShowHandles", [this.HANDLE_TYPE_START, this.HANDLE_TYPE_END], aOptions.x, aOptions.y);
|
|
return true;
|
|
},
|
|
|
|
/*
|
|
* 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) {
|
|
return this._domWinUtils.selectAtPoint(aOptions.x, aOptions.y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE);
|
|
}
|
|
|
|
if (aOptions.mode != this.SELECT_ALL) {
|
|
Cu.reportError("SelectionHandler.js: _performSelection() Invalid selection mode " + aOptions.mode);
|
|
return false;
|
|
}
|
|
|
|
// HTMLPreElement is a #text node, SELECT_ALL implies entire paragraph
|
|
if (this._targetElement instanceof HTMLPreElement) {
|
|
return this._domWinUtils.selectAtPoint(1, 1, Ci.nsIDOMWindowUtils.SELECT_PARAGRAPH);
|
|
}
|
|
|
|
// Else default to selectALL Document
|
|
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 true;
|
|
},
|
|
|
|
/* 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];
|
|
},
|
|
|
|
_sendMessage: function(type, handles, aX, aY) {
|
|
let actions = [];
|
|
for (let type in this.actions) {
|
|
let action = this.actions[type];
|
|
if (action.selector.matches(this._targetElement, aX, aY)) {
|
|
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);
|
|
|
|
sendMessageToJava({
|
|
type: type,
|
|
handles: handles,
|
|
actions: actions,
|
|
});
|
|
},
|
|
|
|
_updateMenu: function() {
|
|
this._sendMessage("TextSelection:Update");
|
|
},
|
|
|
|
addAction: function(action) {
|
|
if (!action.id)
|
|
action.id = uuidgen.generateUUID().toString()
|
|
|
|
if (this.actions[action.id])
|
|
throw "Action with id " + action.id + " already added";
|
|
|
|
this.actions[action.id] = action;
|
|
return action.id;
|
|
},
|
|
|
|
removeAction: function(id) {
|
|
delete this.actions[id];
|
|
},
|
|
|
|
actions: {
|
|
SELECT_ALL: {
|
|
label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
|
|
id: "selectall_action",
|
|
icon: "drawable://ab_select_all",
|
|
action: function(aElement) {
|
|
SelectionHandler.selectAll(aElement);
|
|
},
|
|
selector: ClipboardHelper.selectAllContext,
|
|
order: 5,
|
|
},
|
|
|
|
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);
|
|
},
|
|
order: 4,
|
|
selector: ClipboardHelper.cutContext,
|
|
},
|
|
|
|
COPY: {
|
|
label: Strings.browser.GetStringFromName("contextmenu.copy"),
|
|
id: "copy_action",
|
|
icon: "drawable://ab_copy",
|
|
action: function() {
|
|
SelectionHandler.copySelection();
|
|
},
|
|
order: 3,
|
|
selector: ClipboardHelper.getCopyContext(false)
|
|
},
|
|
|
|
PASTE: {
|
|
label: Strings.browser.GetStringFromName("contextmenu.paste"),
|
|
id: "paste_action",
|
|
icon: "drawable://ab_paste",
|
|
action: function(aElement) {
|
|
ClipboardHelper.paste(aElement);
|
|
SelectionHandler._closeSelection();
|
|
},
|
|
order: 2,
|
|
selector: ClipboardHelper.pasteContext,
|
|
},
|
|
|
|
SHARE: {
|
|
label: Strings.browser.GetStringFromName("contextmenu.share"),
|
|
id: "share_action",
|
|
icon: "drawable://ic_menu_share",
|
|
action: function() {
|
|
SelectionHandler.shareSelection();
|
|
},
|
|
selector: ClipboardHelper.shareContext,
|
|
},
|
|
|
|
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();
|
|
},
|
|
order: 1,
|
|
selector: ClipboardHelper.searchWithContext,
|
|
},
|
|
|
|
},
|
|
|
|
/*
|
|
* 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) {
|
|
// See if its an input element, and it isn't disabled, nor handled by Android native dialog
|
|
if (aElement.disabled ||
|
|
InputWidgetHelper.hasInputWidget(aElement) ||
|
|
!((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) ||
|
|
(aElement instanceof HTMLTextAreaElement)))
|
|
return;
|
|
|
|
this._initTargetInfo(aElement);
|
|
|
|
// Caret-specific observer/listeners
|
|
Services.obs.addObserver(this, "TextSelection:UpdateCaretPos", false);
|
|
BrowserApp.deck.addEventListener("keyup", this, false);
|
|
BrowserApp.deck.addEventListener("compositionupdate", this, false);
|
|
BrowserApp.deck.addEventListener("compositionend", this, false);
|
|
|
|
this._activeType = this.TYPE_CURSOR;
|
|
this._positionHandles();
|
|
|
|
this._sendMessage("TextSelection:ShowHandles", [this.HANDLE_TYPE_MIDDLE]);
|
|
},
|
|
|
|
// Target initialization for both TYPE_CURSOR and TYPE_SELECTION
|
|
_initTargetInfo: function sh_initTargetInfo(aElement) {
|
|
this._targetElement = aElement;
|
|
if (aElement instanceof Ci.nsIDOMNSEditableElement) {
|
|
// 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._contentWindow = aElement.ownerDocument.defaultView;
|
|
this._isRTL = (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();
|
|
},
|
|
|
|
_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);
|
|
},
|
|
|
|
selectAll: function sh_selectAll(aElement) {
|
|
this.startSelection(aElement, { mode : this.SELECT_ALL });
|
|
},
|
|
|
|
/*
|
|
* Helper function for moving the selection inside an editable element.
|
|
*
|
|
* @param aAnchorX the stationary handle's x-coordinate in client coordinates
|
|
* @param aX the moved handle's x-coordinate in client coordinates
|
|
* @param aCaretPos the current position of the caret
|
|
*/
|
|
_moveSelectionInEditable: function sh_moveSelectionInEditable(aAnchorX, aX, aCaretPos) {
|
|
let anchorOffset = aX < aAnchorX ? this._targetElement.selectionEnd
|
|
: this._targetElement.selectionStart;
|
|
let newOffset = aCaretPos.offset;
|
|
let [start, end] = anchorOffset <= newOffset ?
|
|
[anchorOffset, newOffset] :
|
|
[newOffset, anchorOffset];
|
|
this._targetElement.setSelectionRange(start, end);
|
|
},
|
|
|
|
/*
|
|
* Moves the selection as the user drags a selection handle.
|
|
*
|
|
* @param aIsStartHandle whether the user is moving the start handle (as opposed to the end handle)
|
|
* @param aX, aY selection point in client coordinates
|
|
*/
|
|
_moveSelection: function sh_moveSelection(aIsStartHandle, aX, aY) {
|
|
// XXX We should be smarter about the coordinates we pass to caretPositionFromPoint, especially
|
|
// in editable targets. We should factor out the logic that's currently in _sendMouseEvents.
|
|
let viewOffset = this._getViewOffset();
|
|
let caretPos = this._contentWindow.document.caretPositionFromPoint(aX - viewOffset.x, aY - viewOffset.y);
|
|
if (!caretPos) {
|
|
// User moves handle offscreen while positioning
|
|
return;
|
|
}
|
|
|
|
// Constrain text selection within editable elements.
|
|
let targetIsEditable = this._targetElement instanceof Ci.nsIDOMNSEditableElement;
|
|
if (targetIsEditable && (caretPos.offsetNode != this._targetElement)) {
|
|
return;
|
|
}
|
|
|
|
// Update the cache as the handle is dragged (keep the cache in client coordinates).
|
|
if (aIsStartHandle) {
|
|
this._cache.start.x = aX;
|
|
this._cache.start.y = aY;
|
|
} else {
|
|
this._cache.end.x = aX;
|
|
this._cache.end.y = aY;
|
|
}
|
|
|
|
let selection = this._getSelection();
|
|
|
|
// The handles work the same on both LTR and RTL pages, but the anchor/focus nodes
|
|
// are reversed, so we need to reverse the logic to extend the selection.
|
|
if ((aIsStartHandle && !this._isRTL) || (!aIsStartHandle && this._isRTL)) {
|
|
if (targetIsEditable) {
|
|
let anchorX = this._isRTL ? this._cache.start.x : this._cache.end.x;
|
|
this._moveSelectionInEditable(anchorX, aX, caretPos);
|
|
} else {
|
|
let focusNode = selection.focusNode;
|
|
let focusOffset = selection.focusOffset;
|
|
selection.collapse(caretPos.offsetNode, caretPos.offset);
|
|
selection.extend(focusNode, focusOffset);
|
|
}
|
|
} else {
|
|
if (targetIsEditable) {
|
|
let anchorX = this._isRTL ? this._cache.end.x : this._cache.start.x;
|
|
this._moveSelectionInEditable(anchorX, aX, caretPos);
|
|
} else {
|
|
selection.extend(caretPos.offsetNode, caretPos.offset);
|
|
}
|
|
}
|
|
},
|
|
|
|
_sendMouseEvents: function sh_sendMouseEvents(aX, aY, useShift) {
|
|
// If we're positioning a cursor in an input field, make sure the handle
|
|
// stays within the bounds of the field
|
|
if (this._activeType == this.TYPE_CURSOR) {
|
|
// Get rect of text inside element
|
|
let range = document.createRange();
|
|
range.selectNodeContents(this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.rootElement);
|
|
let textBounds = range.getBoundingClientRect();
|
|
|
|
// Get rect of editor
|
|
let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0);
|
|
// 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);
|
|
}
|
|
} else if (this._activeType == this.TYPE_SELECTION) {
|
|
// Send mouse event 1px too high to prevent selection from entering the line below where it should be
|
|
aY -= 1;
|
|
}
|
|
|
|
this._domWinUtils.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true);
|
|
this._domWinUtils.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 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, this._contentWindow.document);
|
|
NativeWindow.toast.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), "short");
|
|
}
|
|
this._closeSelection();
|
|
},
|
|
|
|
shareSelection: function sh_shareSelection() {
|
|
let selectedText = this._getSelectedText();
|
|
if (selectedText.length) {
|
|
sendMessageToJava({
|
|
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.isWindowPrivate(parent.browser.contentWindow);
|
|
// 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();
|
|
},
|
|
|
|
/*
|
|
* 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() {
|
|
let selection = this._getSelection();
|
|
if (selection) {
|
|
// Remove our listener before we clear the selection
|
|
selection.QueryInterface(Ci.nsISelectionPrivate).removeSelectionListener(this);
|
|
// Clear selection without clearing the anchorNode or focusNode
|
|
if (selection.rangeCount != 0) {
|
|
selection.collapseToStart();
|
|
}
|
|
}
|
|
},
|
|
|
|
_deactivate: function sh_deactivate() {
|
|
sendMessageToJava({ type: "TextSelection:HideHandles" });
|
|
|
|
this._removeObservers();
|
|
|
|
// Only observed for caret positioning
|
|
if (this._activeType == this.TYPE_CURSOR) {
|
|
Services.obs.removeObserver(this, "TextSelection:UpdateCaretPos");
|
|
BrowserApp.deck.removeEventListener("keyup", this);
|
|
BrowserApp.deck.removeEventListener("compositionupdate", this);
|
|
BrowserApp.deck.removeEventListener("compositionend", this);
|
|
}
|
|
|
|
this._contentWindow = null;
|
|
this._targetElement = null;
|
|
this._isRTL = false;
|
|
this._cache = null;
|
|
this._ignoreSelectionChanges = false;
|
|
this._ignoreCompositionChanges = false;
|
|
|
|
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;
|
|
},
|
|
|
|
_pointInSelection: function sh_pointInSelection(aX, aY) {
|
|
let offset = this._getViewOffset();
|
|
let rangeRect = this._getSelection().getRangeAt(0).getBoundingClientRect();
|
|
let radius = ElementTouchHelper.getTouchRadius();
|
|
return (aX - offset.x > rangeRect.left - radius.left &&
|
|
aX - offset.x < rangeRect.right + radius.right &&
|
|
aY - offset.y > rangeRect.top - radius.top &&
|
|
aY - offset.y < rangeRect.bottom + radius.bottom);
|
|
},
|
|
|
|
// Returns true if the selection has been reversed. Takes optional aIsStartHandle
|
|
// param to decide whether the selection has been reversed.
|
|
_updateCacheForSelection: function sh_updateCacheForSelection(aIsStartHandle) {
|
|
let rects = this._getSelection().getRangeAt(0).getClientRects();
|
|
if (!rects[0]) {
|
|
// nsISelection object exists, but there's nothing actually selected
|
|
throw "Failed to update cache for invalid selection";
|
|
}
|
|
|
|
let start = { x: this._isRTL ? rects[0].right : rects[0].left, y: rects[0].bottom };
|
|
let end = { x: this._isRTL ? rects[rects.length - 1].left : rects[rects.length - 1].right, y: rects[rects.length - 1].bottom };
|
|
|
|
let selectionReversed = false;
|
|
if (this._cache.start) {
|
|
// If the end moved past the old end, but we're dragging the start handle, then that handle should become the end handle (and vice versa)
|
|
selectionReversed = (aIsStartHandle && (end.y > this._cache.end.y || (end.y == this._cache.end.y && end.x > this._cache.end.x))) ||
|
|
(!aIsStartHandle && (start.y < this._cache.start.y || (start.y == this._cache.start.y && start.x < this._cache.start.x)));
|
|
}
|
|
|
|
this._cache.start = start;
|
|
this._cache.end = end;
|
|
|
|
return selectionReversed;
|
|
},
|
|
|
|
_getHandlePositions: function sh_getHandlePositions(scroll) {
|
|
// 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;
|
|
};
|
|
}
|
|
|
|
let positions = null;
|
|
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);
|
|
// 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_MIDDLE,
|
|
left: x + scroll.X,
|
|
top: y + scroll.Y,
|
|
hidden: checkHidden(x, y) }];
|
|
} else {
|
|
let sx = this._cache.start.x;
|
|
let sy = this._cache.start.y;
|
|
let ex = this._cache.end.x;
|
|
let ey = this._cache.end.y;
|
|
|
|
// Translate coordinates to account for selections in sub-frames. We can't cache
|
|
// this because the top-level page may have scrolled since selection started.
|
|
let offset = this._getViewOffset();
|
|
|
|
return [{ handle: this.HANDLE_TYPE_START,
|
|
left: sx + offset.x + scroll.X,
|
|
top: sy + offset.y + scroll.Y,
|
|
hidden: checkHidden(sx, sy) },
|
|
{ handle: this.HANDLE_TYPE_END,
|
|
left: ex + offset.x + scroll.X,
|
|
top: ey + offset.y + scroll.Y,
|
|
hidden: checkHidden(ex, ey) }];
|
|
}
|
|
},
|
|
|
|
// positions is an array of objects with data about handle positions,
|
|
// which we get from _getHandlePositions.
|
|
_positionHandles: function sh_positionHandles(positions) {
|
|
if (!positions) {
|
|
positions = this._getHandlePositions(this._getScrollPos());
|
|
}
|
|
sendMessageToJava({
|
|
type: "TextSelection:PositionHandles",
|
|
positions: positions,
|
|
rtl: this._isRTL
|
|
});
|
|
},
|
|
|
|
subdocumentScrolled: function sh_subdocumentScrolled(aElement) {
|
|
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.
|
|
if (this._activeType == this.TYPE_SELECTION) {
|
|
this._updateCacheForSelection();
|
|
}
|
|
this._positionHandles();
|
|
break;
|
|
}
|
|
if (view == view.parent) {
|
|
break;
|
|
}
|
|
view = view.parent;
|
|
}
|
|
}
|
|
};
|