gecko/mobile/android/chrome/content/SelectionHandler.js

588 lines
21 KiB
JavaScript
Raw Normal View History

/* 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,
// Keeps track of data about the dimensions of the selection. Coordinates
// stored here are relative to the _view window.
cache: null,
_activeType: 0, // TYPE_NONE
// The window that holds the selection (can be a sub-frame)
get _view() {
if (this._viewRef)
return this._viewRef.get();
return null;
},
set _view(aView) {
this._viewRef = Cu.getWeakReference(aView);
},
// The target can be a window or an input element
get _target() {
if (this._targetRef)
return this._targetRef.get();
return null;
},
set _target(aTarget) {
this._targetRef = Cu.getWeakReference(aTarget);
},
get _cwu() {
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, "Window:Resize", 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);
BrowserApp.deck.addEventListener("compositionend", this, false);
},
_removeObservers: function sh_removeObservers() {
Services.obs.removeObserver(this, "Gesture:SingleTap");
Services.obs.removeObserver(this, "Window:Resize");
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");
BrowserApp.deck.removeEventListener("compositionend", this);
},
observe: function sh_observe(aSubject, aTopic, aData) {
switch (aTopic) {
case "Gesture:SingleTap": {
if (this._activeType == this.TYPE_SELECTION) {
let data = JSON.parse(aData);
this.endSelection(data.x, data.y);
}
break;
}
case "Tab:Selected":
if (this._activeType == this.TYPE_CURSOR) {
this.hideThumb();
}
// fall through
case "Window:Resize": {
if (this._activeType == this.TYPE_SELECTION) {
// Knowing when the page is done drawing is hard, so let's just cancel
// the selection when the window changes. We should fix this later.
this.endSelection();
}
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)
this.moveSelection(data.handleType == this.HANDLE_TYPE_START, data.x, data.y);
else if (this._activeType == this.TYPE_CURSOR) {
// 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) {
let data = JSON.parse(aData);
// Reverse the handles if necessary.
let selectionReversed = this.updateCacheForSelection(data.handleType == this.HANDLE_TYPE_START);
if (selectionReversed) {
// Re-send mouse events to update the selection corresponding to the new handles.
if (this._isRTL) {
this._sendMouseEvents(this.cache.end.x, this.cache.end.y, false);
this._sendMouseEvents(this.cache.start.x, this.cache.start.y, true);
} else {
this._sendMouseEvents(this.cache.start.x, this.cache.start.y, false);
this._sendMouseEvents(this.cache.end.x, this.cache.end.y, true);
}
}
// Position the handles to align with the edges of the selection.
this.positionHandles();
} else if (this._activeType == this.TYPE_CURSOR) {
this.positionHandles();
}
break;
}
}
},
handleEvent: function sh_handleEvent(aEvent) {
switch (aEvent.type) {
case "pagehide":
if (this._activeType == this.TYPE_SELECTION)
this.endSelection();
else
this.hideThumb();
break;
case "keydown":
case "blur":
if (this._activeType == this.TYPE_CURSOR)
this.hideThumb();
break;
case "compositionend":
// If the handles are displayed during user input, hide them.
if (this._activeType == this.TYPE_CURSOR) {
this.hideThumb();
}
break;
}
},
_ignoreCollapsedSelection: false,
notifySelectionChanged: function sh_notifySelectionChanged(aDoc, aSel, aReason) {
if (aSel.isCollapsed) {
// Bail if we're ignoring events for a collapsed selection.
if (this._ignoreCollapsedSelection)
return;
// If the selection is collapsed because of one of the mouse events we
// sent while moving the handle, don't get rid of the selection handles.
if (aReason & Ci.nsISelectionListener.MOUSEDOWN_REASON) {
this._ignoreCollapsedSelection = true;
return;
}
// Otherwise, we do want to end the selection.
this.endSelection();
}
this._ignoreCollapsedSelection = false;
},
/** 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';
},
// aX/aY are in top-level window browser coordinates
startSelection: function sh_startSelection(aElement, aX, aY) {
// Clear out any existing selection
if (this._activeType == this.TYPE_SELECTION) {
this.endSelection();
} else if (this._activeType == this.TYPE_CURSOR) {
// Hide the cursor handles.
this.hideThumb();
}
// Get the element's view
this._view = aElement.ownerDocument.defaultView;
if (aElement instanceof Ci.nsIDOMNSEditableElement)
this._target = aElement;
else
this._target = this._view;
this._addObservers();
this._view.addEventListener("pagehide", this, false);
this._isRTL = (this._view.getComputedStyle(aElement, "").direction == "rtl");
// Remove any previous selected or created ranges. Tapping anywhere on a
// page will create an empty range.
let selection = this.getSelection();
selection.removeAllRanges();
// Position the caret using a fake mouse click sent to the top-level window
this._sendMouseEvents(aX, aY, false);
try {
let selectionController = this.getSelectionController();
// Select the word nearest the caret
selectionController.wordMove(false, false);
// Move forward in LTR, backward in RTL
selectionController.wordMove(!this._isRTL, true);
} catch(e) {
// If we couldn't select the word at the given point, bail
this._cleanUp();
return;
}
// If there isn't an appropriate selection, bail
if (!selection.rangeCount || !selection.getRangeAt(0) || !selection.toString().trim().length) {
selection.collapseToStart();
this._cleanUp();
return;
}
// Add a listener to end the selection if it's removed programatically
selection.QueryInterface(Ci.nsISelectionPrivate).addSelectionListener(this);
// Initialize the cache
this.cache = { start: {}, end: {}};
this.updateCacheForSelection();
this._activeType = this.TYPE_SELECTION;
this.positionHandles();
sendMessageToJava({
type: "TextSelection:ShowHandles",
handles: [this.HANDLE_TYPE_START, this.HANDLE_TYPE_END]
});
if (aElement instanceof Ci.nsIDOMNSEditableElement)
aElement.focus();
},
getSelection: function sh_getSelection() {
if (this._target instanceof Ci.nsIDOMNSEditableElement)
return this._target.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selection;
else
return this._target.getSelection();
},
getSelectionController: function sh_getSelectionController() {
if (this._target instanceof Ci.nsIDOMNSEditableElement)
return this._target.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selectionController;
else
return this._target.QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsIWebNavigation).
QueryInterface(Ci.nsIInterfaceRequestor).
getInterface(Ci.nsISelectionDisplay).
QueryInterface(Ci.nsISelectionController);
},
// Used by the contextmenu "matches" functions in ClipboardHelper
shouldShowContextMenu: function sh_shouldShowContextMenu(aX, aY) {
return (this._activeType == this.TYPE_SELECTION) && this._pointInSelection(aX, aY);
},
selectAll: function sh_selectAll(aElement, aX, aY) {
if (this._activeType != this.TYPE_SELECTION)
this.startSelection(aElement, aX, aY);
let selectionController = this.getSelectionController();
selectionController.selectAll();
this.updateCacheForSelection();
this.positionHandles();
},
// Moves the ends of the selection in the page. aX/aY are in top-level window
// browser coordinates.
moveSelection: function sh_moveSelection(aIsStartHandle, aX, aY) {
// Update the handle position as it's dragged.
if (aIsStartHandle) {
this.cache.start.x = aX;
this.cache.start.y = aY;
} else {
this.cache.end.x = aX;
this.cache.end.y = aY;
}
// The handles work the same on both LTR and RTL pages, but the underlying selection
// works differently, so we need to reverse how we send mouse events on RTL pages.
if (this._isRTL) {
// Position the caret at the end handle using a fake mouse click
if (!aIsStartHandle)
this._sendMouseEvents(this.cache.end.x, this.cache.end.y, false);
// Selects text between the carat and the start handle using a fake shift+click
this._sendMouseEvents(this.cache.start.x, this.cache.start.y, true);
} else {
// Position the caret at the start handle using a fake mouse click
if (aIsStartHandle)
this._sendMouseEvents(this.cache.start.x, this.cache.start.y, false);
// Selects text between the carat and the end handle using a fake shift+click
this._sendMouseEvents( this.cache.end.x, this.cache.end.y, true);
}
},
_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._target.QueryInterface(Ci.nsIDOMNSEditableElement).editor.rootElement);
let textBounds = range.getBoundingClientRect();
// Get rect of editor
let editorBounds = this._cwu.sendQueryContentEvent(this._cwu.QUERY_EDITOR_RECT, 0, 0, 0, 0);
let editorRect = new Rect(editorBounds.left, editorBounds.top, editorBounds.width, editorBounds.height);
// 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._cwu.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true);
this._cwu.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true);
},
// aX/aY are in top-level window browser coordinates
endSelection: function sh_endSelection(aX, aY) {
if (this._activeType != this.TYPE_SELECTION)
return "";
this._activeType = this.TYPE_NONE;
sendMessageToJava({
type: "TextSelection:HideHandles",
handles: [this.HANDLE_TYPE_START, this.HANDLE_TYPE_END]
});
let selectedText = "";
let pointInSelection = false;
if (this._view) {
let selection = this.getSelection();
if (selection) {
// Get the text before we clear the selection!
selectedText = selection.toString().trim();
// Also figure out if the point is in the selection before we clear it.
if (arguments.length == 2 && this._pointInSelection(aX, aY))
pointInSelection = true;
selection.removeAllRanges();
selection.QueryInterface(Ci.nsISelectionPrivate).removeSelectionListener(this);
}
}
// Only try copying text if there's text to copy!
if (pointInSelection && selectedText.length) {
let element = ElementTouchHelper.anyElementFromPoint(aX, aY);
// Only try copying text if the tap happens in the same view
if (element.ownerDocument.defaultView == this._view) {
let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
clipboard.copyString(selectedText, element.ownerDocument);
NativeWindow.toast.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), "short");
}
}
this._cleanUp();
return selectedText;
},
_cleanUp: function sh_cleanUp() {
this._removeObservers();
this._view.removeEventListener("pagehide", this, false);
this._view.removeEventListener("keydown", this, false);
this._view.removeEventListener("blur", this, true);
this._activeType = this.TYPE_NONE;
this._view = null;
this._target = null;
this._isRTL = false;
this.cache = null;
},
_getViewOffset: function sh_getViewOffset() {
let offset = { x: 0, y: 0 };
let win = this._view;
// 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 selection = this.getSelection();
let rects = selection.getRangeAt(0).getClientRects();
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;
},
showThumb: function sh_showThumb(aElement) {
if (!aElement)
return;
// Get the element's view
this._view = aElement.ownerDocument.defaultView;
this._target = aElement;
this._addObservers();
this._view.addEventListener("pagehide", this, false);
this._view.addEventListener("keydown", this, false);
this._view.addEventListener("blur", this, true);
this._activeType = this.TYPE_CURSOR;
this.positionHandles();
sendMessageToJava({
type: "TextSelection:ShowHandles",
handles: [this.HANDLE_TYPE_MIDDLE]
});
},
hideThumb: function sh_hideThumb() {
this._activeType = this.TYPE_NONE;
this._cleanUp();
sendMessageToJava({
type: "TextSelection:HideHandles",
handles: [this.HANDLE_TYPE_MIDDLE]
});
},
positionHandles: function sh_positionHandles() {
let scrollX = {}, scrollY = {};
this._view.top.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).getScrollXY(false, scrollX, scrollY);
// 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._view.frameElement) {
let bounds = this._view.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._cwu.sendQueryContentEvent(this._cwu.QUERY_CARET_RECT, this._target.selectionEnd, 0, 0, 0);
let x = cursor.left;
let y = cursor.top + cursor.height;
positions = [{ handle: this.HANDLE_TYPE_MIDDLE,
left: x + scrollX.value,
top: y + scrollY.value,
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();
positions = [{ handle: this.HANDLE_TYPE_START,
left: sx + offset.x + scrollX.value,
top: sy + offset.y + scrollY.value,
hidden: checkHidden(sx, sy) },
{ handle: this.HANDLE_TYPE_END,
left: ex + offset.x + scrollX.value,
top: ey + offset.y + scrollY.value,
hidden: checkHidden(ex, ey) }];
}
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._view;
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;
}
}
};