blob: d06fb34739964c728b6ea325b53fc9056ba81ad0 [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Support code for the Contextual Search feature. Given a tap
* location, locates and highlights the word at that location, and retuns
* some contextual data about the selection.
*
*/
/**
* Namespace for this file. Depends on __gCrWeb having already been injected.
*/
__gCrWeb['contextualSearch'] = {};
/* Anyonymizing block */
new function() {
/**
* Utility loggging function.
* @param {string} message Text of log message to be sent to application.
*/
__gCrWeb['contextualSearch'].cxlog = function(message) {
if (Context.debugMode) {
console.log('[CS] ' + message);
}
};
/**
* Enables or disabled the selection change notification forward to the
* controller.
* @param {boolean} enabled whether to turn on (true) or off (false).
*/
__gCrWeb['contextualSearch'].enableSelectionChangeListener = function(enabled) {
if (enabled) {
document.addEventListener('selectionchange',
Context.selectionChanged,
true);
} else {
document.removeEventListener('selectionchange',
Context.selectionChanged,
true);
}
};
__gCrWeb['contextualSearch'].getMutatedElementCount = function() {
return Context.mutationCount;
};
/**
* Enables the DOM mutation event listener.
* @param {number} delay after a mutation event before a tap event can be
* handled.
*/
__gCrWeb['contextualSearch'].setMutationObserverDelay = function(delay) {
Context.DOMMutationTimeoutMillisecond = delay;
// select the target node
var target = document.body;
// create an observer instance
Context.DOMMutationObserver = new MutationObserver(function(mutations) {
// Clear any mutation records older than |DOMMutationTimeoutMillisecond|.
// Only do this every |DOMMutationTimeoutMillisecond|s to avoid thrashing
// on pages with (for example) continuous animations.
var d = new Date();
if ((d.getTime() - Context.lastMutationPrune) >
Context.DOMMutationTimeoutMillisecond) {
Context.processMutations(function(mutationId, mutationTime) {
if ((d.getTime() - mutationTime) <=
Context.DOMMutationTimeoutMillisecond) {
Context.clearMutation(mutationId);
}
return false;
});
Context.lastMutationPrune = d.getTime();
}
mutations.forEach(function(mutation) {
// Don't count mutations with invalid targets.
if (!mutation.target) {
return;
}
// Don't count mutations to invisible elements.
if (mutation.type != 'characterData' && !mutation.target.offsetParent) {
return;
}
// Don't count attribute mutations where the attribute's current and
// old values are the same. Also don't count attribute mutation where
// the current and old values are both "false-ish" (so changing a null to
// an empty string has no effect).
if (mutation.type == 'attributes') {
if (mutation.target.getAttribute(mutation.attributeName) ==
mutation.oldValue) {
return;
}
if (!mutation.target.getAttribute(mutation.attributeName) &&
!mutation.oldValue) {
return;
}
// Don't count the attribute mutation from setting an ID for tracking or
// if it is unsetting the ID.
if (mutation.attributeName == 'id' &&
(mutation.target.id.match(/__CTXSM___\d__\d+/) ||
mutation.target.id == '')) {
return;
}
}
if (mutation.type == 'characterData' &&
mutation.target.textContent == mutation.oldValue) {
return;
}
// If this is the first mutation after tap, and the mutation target
// intersects with the highlighted elements, forward it to the Chrome
// application to check if CS must be dismissed.
if (Context.highlightRange &&
!Context.mutationEventForwarded &&
Context.highlightRange.intersectsNode(mutation.target)) {
Context.mutationEventForwarded = true;
__gCrWeb.message.invokeOnHost(
{'command' : 'contextualSearch.mutationEvent'});
}
Context.lastDOMMutationMillisecond = d.getTime();
// If the mutated item isn't an element, find its parent.
// If the element doesn't have an ID, assign one to it.
var idContainer = mutation.target;
if (mutation.type == 'characterData') {
idContainer = idContainer.parentElement;
}
var id = idContainer.id;
if (!id) {
id = Context.newID(Context.lastDOMMutationMillisecond);
idContainer.id = id;
}
Context.recordMutation(id, Context.lastDOMMutationMillisecond);
});
});
// configuration of the observer:
var config = {
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
};
// pass in the target node, as well as the observer options
Context.DOMMutationObserver.observe(target, config);
};
/**
* Enables the body touch end event listener. This will catch touch events that
* don't call preventDefault.
* @param {number} delay after a mutation event before a tap event can be
* handled.
*/
__gCrWeb['contextualSearch'].setBodyTouchListenerDelay = function(delay) {
Context.touchEventTimeoutMillisecond = delay;
Context.bodyTouchEndEventListener = function(event) {
if (!event.defaultPrevented) {
var d = new Date();
Context.lastTouchEventMillisecond = d.getTime();
} else {
__gCrWeb['contextualSearch'].cxlog('Touch default prevented');
}
};
document.body.addEventListener('touchend', Context.bodyTouchEndEventListener,
false);
};
/**
* Disables the DOM mutation listener.
*/
__gCrWeb['contextualSearch'].disableMutationObserver = function() {
Context.DOMMutationObserver.disconnect();
Context.DOMMutationObserver = null;
Context.lastDOMMutationMillisecond = 0;
};
/**
* Disables the body touchend listener.
*/
__gCrWeb['contextualSearch'].disableBodyTouchListener = function() {
document.body.removeEventListener('touchend',
Context.bodyTouchEndEventListener, false);
Context.lastTouchEventMillisecond = 0;
};
/**
* Expands the highlight to [startOffset, endOffset] in the surrounding range.
* @param {number} startOffset first character to include in the range.
* @param {number} endOffset last character to include in the range.
* @return {JSON} new highlighted rects.
*/
__gCrWeb['contextualSearch'].expandHighlight =
function(startOffset, endOffset) {
if ((startOffset == Context.surroundingRange.highlightStartOffset &&
endOffset == Context.surroundingRange.highlightEndOffset) ||
startOffset > endOffset) {
return;
}
var range = Context.createSurroundingRange(startOffset, endOffset);
Context.highlightRange = range;
return __gCrWeb['contextualSearch'].highlightRects();
};
/**
* Returns the rects to draw the current highlight.
* @return {JSON} hilighted rects.
*/
__gCrWeb['contextualSearch'].highlightRects = function() {
return __gCrWeb.stringify(
{'rects' : Context.getHighlightRects(),
'size': {'width' : document.documentElement.scrollWidth,
'height' : document.documentElement.scrollHeight
}});
};
/**
* Clears the current highlight.
*/
__gCrWeb['contextualSearch'].clearHighlight = function() {
Context.highlightRange = null;
};
/**
* Retrieve the currently highlighted string.
* This is used for test purposes only.
* @return {string} the currently highlighted string.
*/
__gCrWeb['contextualSearch'].retrieveHighlighted = function() {
return Context.rangeToString(Context.highlightRange);
};
/**
* Prepares Contextual Search for a given point in the window. This method
* will find which word is located at the given point, extract the context
* data for that word, and (if a word was located), pass the context data
* back to the calling application.
* @param {number} x The point's x coordinate as a ratio of page width.
* @param {number} y The point's y coordinate as a ratio of page height.
* @return {object} Empty if no word was found, or the context, x and y if
* a word was found.
*/
__gCrWeb['contextualSearch'].handleTapAtPoint = function(x, y) {
var tapResults;
if (window.getSelection().toString()) {
tapResults = new ContextData();
tapResults.error = 'Failed: selection is not empty.';
return __gCrWeb.stringify({'context' : tapResults.returnContext()});
}
var d = new Date();
var lastTouchDelta = d.getTime() - Context.lastTouchEventMillisecond;
if (Context.touchEventTimeoutMillisecond &&
lastTouchDelta > Context.touchEventTimeoutMillisecond) {
tapResults = new ContextData();
tapResults.error = 'Failed: last touch was ' + lastTouchDelta +
'ms ago (>' + Context.touchEventTimeoutMillisecond + 'ms timeout)';
} else {
var absoluteX =
x * document.documentElement.scrollWidth - document.body.scrollLeft;
var absoluteY =
y * document.documentElement.scrollHeight - document.body.scrollTop;
tapResults = Context.getContextDataFromPoint(absoluteX, absoluteY);
if (!tapResults.error) {
var range = tapResults.range;
if (!range) {
tapResults.error = 'Failed: context data range was empty';
} else if (tapResults.surroundingText.length < tapResults.offsetEnd) {
tapResults.error =
'Failed: surrounding text is shorter than text offset';
} else if (tapResults.getSelectedText() != tapResults.selectedText) {
tapResults.error = 'Failed: offsets do not match selected text: (' +
tapResults.getSelectedText() + ') vs. (' +
tapResults.selectedText + ')';
}
}
}
Context.mutationEventForwarded = false;
return __gCrWeb.stringify({'context' : tapResults.returnContext()});
};
//------------------------------------------------------------------------------
// ContextData
//------------------------------------------------------------------------------
/**
* @constructor
*/
var ContextData = function() {};
/**
* An error message, if any, associated with the context.
* @type {?string}
*/
ContextData.prototype.error = null;
/**
* The range containing the selected text.
* @type {?Range}
*/
ContextData.prototype.range = null;
/**
* The URL from where the context was extracted.
* @type {?string}
*/
ContextData.prototype.url = null;
/**
* The selected text.
* @type {?string}
*/
ContextData.prototype.selectedText = null;
/**
* The surrounding text.
* @type {?string}
*/
ContextData.prototype.surroundingText = null;
/**
* The start position of the selected text relative to the surrounding text.
* @type {?number}
*/
ContextData.prototype.offsetStart = null;
/**
* The end position of the selected text relative to the surrounding text.
* @type {?number}
*/
ContextData.prototype.offsetEnd = null;
/**
* The rewritten query.
* @type {?string}
*/
ContextData.prototype.rewrittenQuery = null;
/**
* Gets the search query for the context.
* @return {?string} The search query.
*/
ContextData.prototype.getQuery = function() {
return this.rewrittenQuery || this.selectedText;
};
/**
* @return {string} The part of the surrounding text before the selected text.
*/
ContextData.prototype.getTextBefore = function() {
var selectedText = this.selectedText;
var surroundingText = this.surroundingText;
var result = '';
if (!this.rewrittenQuery && surroundingText) {
result = surroundingText.substring(0, this.offsetStart);
}
return result;
};
/**
* @return {string} The part of the surrounding text after the selected text.
*/
ContextData.prototype.getTextAfter = function() {
var selectedText = this.selectedText;
var surroundingText = this.surroundingText;
var result = '';
if (!this.rewrittenQuery && selectedText && surroundingText) {
result = surroundingText.substring(this.offsetEnd);
}
return result;
};
/**
* @return {string} The selected text as indicated by offsetStart and
* offsetEnd. This should be the same as selectedText
*/
ContextData.prototype.getSelectedText = function() {
var surroundingText = this.surroundingText;
var result = '';
if (!this.rewrittenQuery && surroundingText) {
result = surroundingText.substring(this.offsetStart, this.offsetEnd);
}
return result;
};
/**
* @return {JSONDictionary} Context data assembeld for return to native app.
*/
ContextData.prototype.returnContext = function() {
var context = {'url' : this.url,
'selectedText' : this.selectedText,
'surroundingText' : this.surroundingText,
'offsetStart' : this.offsetStart,
'offsetEnd' : this.offsetEnd,
'rects': Context.getHighlightRects()
};
if (this.error) {
context['error'] = this.error;
}
return context;
};
//------------------------------------------------------------------------------
// Context
//------------------------------------------------------------------------------
var Context = {};
/**
* Whether to send log output to the host.
* @type {bool}
*/
Context.debugMode = false;
/**
* The maximium amount of time that should be spent searching for a text range,
* in milliseconds. If the search does not finish within the specified value,
* it should terminate without returning a result.
* @const {number}
*/
Context.GET_RANGE_TIMEOUT_MS = 50;
/**
* Number of surrounding sentences when calculating the surrounding text.
* @const {number}
*/
Context.NUMBER_OF_CHARS_IN_SURROUNDING_SENTENCES = 1500;
/**
* Maximum number of chars for a selection to trigger the search.
* @const {number}
*/
Context.MAX_NUMBER_OF_CHARS_IN_SELECTION = 100;
/**
* Last range returned by Context.extractSurroundingDataFromRange.
* @type {JSONObject} Contains startContainer, endContainer, startOffset,
* endOffset of the surrounding range and relative position of the tapped word.
*/
Context.surroundingRange = null;
/**
* The range that is curently highlighted.
* @type {Range}
*/
Context.highlightRange = null;
/**
* A boolean to check if a mutation event has been forwarded after the latest
* tap.
* @type {boolean}
*/
Context.mutationEventForwarded = false;
/**
* A Regular Expression that matches Unicode word characters.
* @type {RegExp}
*/
Context.reWordCharacter_ =
/[\u00C0-\u1FFF\u2C00-\uD7FF\w]/;
/**
* An observer of the DOM mutation.
* @type {MutationObserver}
*/
Context.DOMMutationObserver = null;
/**
* The date of the last DOM mutation (in ms).
* @type {number}
*/
Context.lastDOMMutationMillisecond = 0;
/**
* A hash of timestamps keyed by element-id.
* @type {Object}
*/
Context.mutatedElements = {};
/**
* A running count of tracked mutated objects.
* @type {number}
*/
Context.mutationCount = 0;
/**
* Date of the last time the mutation list was pruned of old entries (in ms).
* @type {number}
*/
Context.lastMutationPrune = 0;
/**
* An incrementing integer for generating temporary element ids when needed.
* @type {number}
*/
Context.mutationIdCounter = 0;
/**
* A snapshot of the previous text selection (if any), used to determine if a
* selection change is a new selection or not. previousSelection stores the
* anchor and focus nodes and offsets of the previously-reported selection.
* @type {Object}
*/
Context.previousSelection = null;
/**
* Generates a string suitable for use as a temporary element id.
* @param {string} nonce A string that varies based on the current time.
* @return {string} A string to be used as an element id.
*/
Context.newID = function(nonce) {
return '__CTXSM___' + (Context.mutationIdCounter++) + '__' + nonce;
};
/**
* The timeout of DOM mutation after which a contextual search can be triggered
* (in ms)
* @type {number}
*/
Context.DOMMutationTimeoutMillisecond = 200;
/**
* An observer of body to catch unhandled touch events.
* @type {EventListener}
*/
Context.bodyTouchEndEventListener = null;
/**
* The date of the last unhandled touch event (in ms).
* @type {number}
*/
Context.lastTouchEventMillisecond = 0;
/**
* The timeout of DOM mutation after which a contextual search can be triggered
* (in ms)
* @type {number}
*/
Context.touchEventTimeoutMillisecond = 0;
/**
* List of node types whose contents should not be parsed by Contextual Search.
* @type {Array.<string>}
*/
Context['invalidElements_'] = [
'A',
'APPLET',
'AREA',
'AUDIO',
'BUTTON',
'CANVAS',
'EMBED',
'FRAME',
'FRAMESET',
'IFRAME',
'IMG',
'INPUT',
'KEYGEN',
'LABEL',
'MAP',
'OBJECT',
'OPTGROUP',
'OPTION',
'PROGRESS',
'SCRIPT',
'SELECT',
'TEXTAREA',
'VIDEO'
];
/**
* List of ARIA roles that define widgets.
* For more info, see: http://www.w3.org/TR/wai-aria/roles#widget_roles
* @type {Array.<string>}
*/
Context['widgetRoles_'] = [
'alert',
'alertdialog',
'button',
'checkbox',
'dialog',
'gridcell',
'link',
'log',
'marquee',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'progressbar',
'radio',
'scrollbar',
'slider',
'spinbutton',
'status',
'tab',
'tabpanel',
'textbox',
'timer',
'tooltip',
'treeitem'
];
/**
* List of ARIA roles that define composite widgets.
* For more info, see: http://www.w3.org/TR/wai-aria/roles#widget_roles
* @type {Array.<string>}
*/
Context['compositeWidgetRoles_'] = [
'combobox',
'grid',
'listbox',
'menu',
'menubar',
'radiogroup',
'tablist',
'tree',
'treegrid'
];
/**
* Mutation record handling
*/
/**
* Records a DOM mutation.
* @param {string} mutationId The id of the mutated DOM element.
* @param {number} mutationTime The time of the mutation.
*/
Context.recordMutation = function(mutationId, mutationTime) {
Context.mutatedElements[mutationId] = mutationTime;
Context.mutationCount += 1;
};
/**
* Clears the record of a DOM mutation.
* @param {string} mutationId The id of the mutated DOM element.
*/
Context.clearMutation = function(mutationId) {
delete Context.mutatedElements[mutationId];
Context.mutationCount -= 1;
};
/**
* Performs some operation on all recorded mutations, passing the mutated node
* id and mutation time into func.
* @param {function} func The function to apply to the recorded mutations.
*/
Context.processMutations = function(func) {
for (mutationId in Context.mutatedElements) {
var mutationTime = Context.mutatedElements[mutationId];
if (func(mutationId, mutationTime)) {
break;
}
}
};
/**
* Returns whether the selection is valid to trigger a contextual search.
* An invalid selection is a selection that is either too long or contains a
* single latin character (there are some site that use x's or o's as crosses or
* circles), or is included or contains invalid elements.
* @param {selection} selection The current selection to test.
* @return {boolean} Whether selection should trigger contextual search.
*/
Context.isSelectionValid = function(selection) {
var selectionText = selection.toString();
var length = selectionText.length;
if (length > Context.MAX_NUMBER_OF_CHARS_IN_SELECTION) {
return false;
}
if (length == 1 && selectionText.codePointAt(0) < 256) {
return false;
}
var rangeCount = selection.rangeCount;
for (var rangeIndex = 0; rangeIndex < rangeCount; rangeIndex++) {
// Test if the selection is inside an invalid element.
var range = window.getSelection().getRangeAt(rangeIndex);
var element = range.commonAncestorContainer;
while (element) {
if (element.nodeType == element.ELEMENT_NODE &&
!Context.isValidElement(element)) {
return false;
}
element = element.parentElement;
}
// Test if the selection contains an invalid element.
var startNode = range.startContainer.childNodes[range.startOffset] ||
range.startContainer;
var endNode = range.endContainer.childNodes[range.endOffset] ||
range.endContainer;
element = startNode;
while (element) {
if (element.nodeType == element.ELEMENT_NODE &&
!Context.isValidElement(element)) {
return false;
}
element = Context.getNextNode(element, endNode, false);
}
}
return true;
};
/**
* Forwards the selection changed notification to the controller class.
*/
Context.selectionChanged = function() {
var newSelection = window.getSelection();
if (!newSelection.toString()) {
Context.previousSelection = null;
return;
}
var updated = false;
if (Context.previousSelection) {
updated =
(Context.previousSelection.anchorNode == newSelection.anchorNode &&
Context.previousSelection.anchorOffset == newSelection.anchorOffset) ||
(Context.previousSelection.focusNode == newSelection.focusNode &&
Context.previousSelection.focusOffset == newSelection.focusOffset);
}
var selectionText = newSelection.toString();
var valid = true;
if (!Context.isSelectionValid(newSelection)) {
// Mark selection as invalid.
selectionText = '';
valid = false;
}
__gCrWeb.message.invokeOnHost(
{'command' : 'contextualSearch.selectionChanged',
'text' : selectionText,
'updated' : updated,
'valid' : valid
});
// Snapshot the selection for comparison.
Context.previousSelection = {
'anchorNode' : newSelection.anchorNode,
'anchorOffset' : newSelection.anchorOffset,
'focusNode' : newSelection.focusNode,
'focusOffset' : newSelection.focusOffset
};
};
/**
* Gets the data necessary to create a Contextual Search from a given point
* in the window.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {ContextData} The object describing the context.
*/
Context.getContextDataFromPoint = function(x, y) {
var contextData = Context.contextFromPoint(x, y);
if (contextData.error) {
return contextData;
}
var range = contextData.range = Context.getWordRangeFromPoint(x, y);
if (range) {
contextData.selectedText = range.toString();
contextData.url = location.href;
Context.extractSurroundingDataFromRange(contextData, range);
Context.highlightRange = range;
}
return contextData;
};
/**
* Checks whether the context in a given point is valid. A context will be
* valid when the element at the given point is not interactive or editable.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {ContextData} Context data from the point, an error set if invalid.
* @private
*/
Context.contextFromPoint = function(x, y) {
// TODO(crbug.com/711350): Evaluate whether this should use context_menu.js's
// elementFromPoint_() instead?
var contextData = new ContextData();
var element = document.elementFromPoint(x, y);
if (!element) {
contextData.error = "Failed: Couldn't locate an element at " + x + ', ' + y;
return contextData;
}
var d = new Date();
var lastDOM = d.getTime() - Context.lastDOMMutationMillisecond;
if (lastDOM <= Context.DOMMutationTimeoutMillisecond) {
Context.processMutations(function(mutationId, mutationTime) {
var mutatedElement = document.getElementById(mutationId);
if (!mutatedElement) {
Context.clearMutation(mutationId);
} else {
var lastElementMutation = d.getTime() - mutationTime;
if (lastElementMutation < 0 ||
(lastElementMutation > Context.DOMMutationTimeoutMillisecond)) {
return false; // mutation expired, continue.
}
if (element.contains(mutatedElement) ||
mutatedElement.contains(element)) {
contextData.error = 'Failed: Tap was in element mutated ' +
lastElementMutation + 'ms ago (<' +
Context.DOMMutationTimeoutMillisecond + 'ms interval)';
return true; // break from processing mutations
}
}
return false; // continue processing mutations
});
if (contextData.error)
return contextData;
}
while (element) {
if (element.nodeType == element.ELEMENT_NODE &&
!Context.isValidElement(element, false)) {
contextData.error =
'Failed: Tap was in an invalid (' + element.nodeName + ') element';
return contextData;
}
element = element.parentElement;
}
return contextData;
};
/**
* Checks whether the given element can be used as a touch target.
* @see Context.isValidContextFromPoint_
* @param {Element} element The element in question.
* @param {boolean} forDisplay Whether we are testing if an element is valid
* for tap handling (false) or for display (true).
* @return {boolean} Whether the element is a valid context.
* @private
*/
Context.isValidElement = function(element, forDisplay) {
if (element.nodeName == 'A') {
return forDisplay;
}
if (Context.invalidElements_.indexOf(element.nodeName) != -1) {
__gCrWeb['contextualSearch'].cxlog(
'Failed: ' + element.nodeName + ' element was invalid');
return false;
}
if (element.getAttribute('contenteditable')) {
__gCrWeb['contextualSearch'].cxlog(
'Failed: ' + element.nodeName + ' element was editable');
return false;
}
var role = element.getAttribute('role');
if (Context.widgetRoles_.indexOf(role) != -1 ||
Context.compositeWidgetRoles_.indexOf(role) != -1) {
__gCrWeb['contextualSearch'].cxlog(
'Failed: ' + element.nodeName + ' role ' + role + ' was invalid');
return false;
}
if (forDisplay) {
var style = window.getComputedStyle(element);
if (style.display === 'none') {
__gCrWeb['contextualSearch'].cxlog(
'Failed: ' + element.nodeName + ' hidden');
return false;
}
}
return true;
};
/**
* Gets the word range located at a given point. This method will find the
* word whose bounding rectangle contains the given point.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {Range} The word range at the given point.
* @private
*/
Context.getWordRangeFromPoint = function(x, y) {
var element = document.elementFromPoint(x, y);
var range = null;
try {
range = Context.findWordRangeFromPointRecursive(element, x, y);
} catch (e) {
__gCrWeb['contextualSearch'].cxlog(
'Recursive word find failed: ' + e.message);
}
return range;
};
/**
* Recursively gets the word range located at a given point.
* @see Context.getWordRangeFromPoint_
* @param {Node} node The node being inspected.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {Range} The word range at the given point.
* @private
*/
Context.findWordRangeFromPointRecursive = function(node, x, y) {
if (!node) {
return null;
}
if (node.nodeType == node.TEXT_NODE) {
var position = Context.findCharacterPositionInTextFromPoint(node, x, y);
if (position == -1) {
return null;
}
var range = node.ownerDocument.createRange();
range.setStart(node, position);
range.setEnd(node, position);
range.expand('word');
if (Context.rangeContainsPoint(range, x, y)) {
return range;
}
if (range) {
range.detach();
}
} else if (node.nodeType == node.ELEMENT_NODE) {
var childNodes = node.childNodes;
var childNodesLength = childNodes.length;
for (var i = 0, length = childNodesLength; i < length; i++) {
var childNode = childNodes[i];
var range = childNode.ownerDocument.createRange();
range.selectNodeContents(childNode);
if (Context.rangeContainsPoint(range, x, y)) {
range.detach();
return Context.findWordRangeFromPointRecursive(childNode, x, y);
} else {
range.detach();
}
}
}
return null;
};
/**
* Gets the position of the character range located at a given point. This
* method will find the single character whose bounding rectangle contains
* the given point and return the position of that character in the text
* node string. If not character is found this method returns -1.
* @see Context.findWordRangeFromPointRecursive_
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @param {Text} node The text node being inspected.
* @return {number} The position of the character in the text node string.
* @private
*/
Context.findCharacterPositionInTextFromPoint = function(node, x, y) {
var startTime = new Date().getTime();
var start = 0;
var end = node.textContent.length - 1;
// Performs a binary search to find a single character whose bouding
// rectangle contains the given point.
var range = document.createRange();
while (!found || (end - start + 1) > 1) {
if ((new Date().getTime() - startTime) > Context.GET_RANGE_TIMEOUT_MS) {
__gCrWeb['contextualSearch'].cxlog('Timed out!');
break;
}
var middle = Math.floor((start + end) / 2);
range.setStart(node, start);
range.setEnd(node, middle + 1); // + 1 because end point is non-inclusive.
var found = Context.rangeContainsPoint(range, x, y);
if (found) {
end = middle;
} else {
start = middle + 1;
}
}
if (found) {
var text = range.toString();
// Tests if the character is actually a word character (a letter, digit,
// underscore, or any Unicode letter). If the character is not a word
// character it means the given point is in a whitespace, punctuation or
// other non-relevant characters, and in this case it should not be
// considered a successful finding.
if (!Context.reWordCharacter_.test(text)) {
found = false;
}
}
range.detach();
return found ? start : -1;
};
/**
* Gets the surrounding data from a given range.
* @param {ContextData} contextData Object where the data will be written.
* @param {Range} range A text range.
* @private
*/
Context.extractSurroundingDataFromRange = function(contextData, range) {
var surroundingRange = range.cloneRange();
var length = surroundingRange.toString().length;
while (length < Context.NUMBER_OF_CHARS_IN_SURROUNDING_SENTENCES) {
surroundingRange.expand('sentence');
var oldLength = length;
length = surroundingRange.toString().length;
if (oldLength == length) {
break;
}
}
var textNodeType = range.startContainer.TEXT_NODE;
var selectionStartOffset = 0;
var selectionStartNode = range.startContainer;
if (range.startContainer.nodeType == textNodeType) {
selectionStartOffset = range.startOffset;
} else {
selectionStartNode = range.startContainer.childNodes[range.startOffset];
}
var surroundingStartOffset = 0;
if (surroundingRange.startContainer.nodeType == textNodeType) {
surroundingStartOffset = surroundingRange.startOffset;
}
var surroundingRemoveAtEnd = 0;
if (surroundingRange.endContainer.nodeType == textNodeType) {
surroundingRemoveAtEnd = surroundingRange.endContainer.textContent.length -
surroundingRange.endOffset;
}
// It is possible that invalid nodes are present inside the surrounding range.
// Extract text to make sure this is not the case.
var textNodes = Context.textNodesFromRange(surroundingRange);
var offset = 0;
var index = 0;
var surroundingString = '';
var foundStart = false;
for (index = 0; index < textNodes.length; index++) {
if (textNodes[index] === ' ') {
surroundingString += ' ';
if (!foundStart) {
offset += 1;
}
continue;
}
if (textNodes[index] == selectionStartNode) {
foundStart = true;
}
if (!foundStart) {
offset += textNodes[index].textContent.length;
}
surroundingString += textNodes[index].textContent;
}
offset += selectionStartOffset - surroundingStartOffset;
surroundingString = surroundingString.substring(surroundingStartOffset,
surroundingString.length - surroundingRemoveAtEnd);
contextData.surroundingText = surroundingString;
contextData.offsetStart = offset;
contextData.offsetEnd = offset + range.toString().length;
Context.surroundingRange = {
startContainer: surroundingRange.startContainer,
startOffset: surroundingRange.startOffset,
endContainer: surroundingRange.endContainer,
endOffset: surroundingRange.endOffset,
highlightStartOffset: contextData.offsetStart,
highlightEndOffset: contextData.offsetEnd
};
surroundingRange.detach();
};
/**
* Returns whether character is a whitespace.
* @param {string} character The character to test.
* @return {boolean} Whether |character| is a whitespace.
*/
Context.isCharSpace = function(character) {
return character.trim().length == 0;
};
/**
* Find next node in a DFS order. A parent is returned before its children.
* @param {Node} node The current node.
* @param {Node} endNode The right limit of the DFS.
* @param {boolean} onlyValid Whether invalid node should be skipped in DFS.
* @return {Node} The node coming after |node|. Null is endNode is reached.
*/
Context.getNextNode = function(node, endNode, onlyValid) {
if (!node)
return null;
if (node.childNodes.length > 0) {
node = node.childNodes[0];
if (node.nodeType == node.TEXT_NODE ||
(node.nodeType == node.ELEMENT_NODE &&
(!onlyValid || Context.isValidElement(node, true)))) {
return node;
}
if (node.nodeType == node.ELEMENT_NODE && node.contains(endNode)) {
return null;
}
}
while (node != null) {
if (!node.nextSibling) {
node = node.parentNode;
continue;
}
if (node.contains(endNode))
return null;
node = node.nextSibling;
if (node.nodeType == node.TEXT_NODE ||
(node.nodeType == node.ELEMENT_NODE &&
(!onlyValid || Context.isValidElement(node, true)))) {
return node;
}
}
return null;
};
/**
* Creates the list of text nodes that are part of range. The list will contain
* whitespace strings to replace block elements.
* @param {Range} range The range containing the text information.
* @return {Array.<Node>} The array of text nodes in the range.
*/
Context.textNodesFromRange = function(range) {
var blockStack = [];
var startNode = range.startContainer.childNodes[range.startOffset] ||
range.startContainer;
var endNode = range.endContainer.childNodes[range.endOffset] ||
range.endContainer;
if (startNode == endNode && startNode.childNodes.length === 0) {
return [startNode];
}
var textNodes = [];
var node = startNode;
// Do not add a space as first node.
var addSpace = false;
var lastNodeAddedHasSpace = true;
do {
if (node.nodeType == node.TEXT_NODE && node.textContent.length > 0) {
while (blockStack.length && !blockStack[0].contains(node)) {
addSpace = true;
blockStack.shift();
}
if (addSpace && !lastNodeAddedHasSpace &&
!Context.isCharSpace(node.textContent[0])) {
textNodes.push(' ');
addSpace = false;
}
textNodes.push(node);
lastNodeAddedHasSpace = Context.isCharSpace(
node.textContent[node.textContent.length - 1]);
} else if (node.nodeType == node.ELEMENT_NODE) {
var style = window.getComputedStyle(node);
if (style && style.display != 'inline') {
addSpace = true;
blockStack.unshift(node);
}
}
node = Context.getNextNode(node, endNode, true);
} while (node && node != endNode);
if (node == endNode && node.nodeType == node.TEXT_NODE) {
if (addSpace && !lastNodeAddedHasSpace &&
!Context.isCharSpace(node.textContent[0])) {
textNodes.push(' ');
}
textNodes.push(node);
}
return textNodes;
};
/**
* Checks if a particular range contains a given point.
* @param {Range} range A text range.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {boolean} whether the range contains the given point.
*/
Context.rangeContainsPoint = function(range, x, y) {
var rects = range.getClientRects();
var rectsLength = rects.length;
for (var i = 0, length = rectsLength; i < length; i++) {
var rect = rects[i];
var contains = Context.rectContainsPoint(rect, x, y);
if (contains) {
return true;
}
}
return false;
};
/**
* Checks if a particular rectangle contains a given point.
* @param {Rect} rect A rectangle.
* @param {number} x The point's x coordinate.
* @param {number} y The point's y coordinate.
* @return {boolean} whether the rectangle contains the given point.
* @private
*/
Context.rectContainsPoint = function(rect, x, y) {
if (x >= rect.left && x <= rect.right &&
y >= rect.top && y <= rect.bottom) {
return true;
}
return false;
};
/**
* Create a new range to the [startOffset, endOffset] in the surrounding
* range returned by Context.extractSurroundingDataFromRange.
* @param {number} startOffset first character to include in the range.
* @param {number} endOffset last character to include in the range.
* @return {Range} the range including [startOffset, endOffset].
* @private
*/
Context.createSurroundingRange = function(startOffset, endOffset) {
if ((startOffset == Context.surroundingRange.highlightStartOffset &&
endOffset == Context.surroundingRange.highlightEndOffset) ||
startOffset >= endOffset) {
return;
}
var range = document.createRange();
range.setStart(Context.surroundingRange.startContainer,
Context.surroundingRange.startOffset);
range.setEnd(Context.surroundingRange.endContainer,
Context.surroundingRange.endOffset);
var textNodes = Context.textNodesFromRange(range);
var highlightRange = document.createRange();
var length = textNodes.length;
var offset = 0;
if (length > 0 && Context.surroundingRange.startContainer == textNodes[0]) {
// Ignore the text inside |startContainer| before the range.
offset -= Context.surroundingRange.startOffset;
}
var startSet = false;
for (var index = 0; index < length; index++) {
var node = textNodes[index];
if (node === ' ') {
offset += 1;
continue;
}
if (!startSet && offset + node.textContent.length > startOffset) {
startSet = true;
highlightRange.setStart(node, startOffset - offset);
}
if (offset + node.textContent.length >= endOffset) {
highlightRange.setEnd(node, endOffset - offset);
break;
}
offset += node.textContent.length;
}
return highlightRange;
};
/**
* Returns the string contained in the parameter |range|. Text rules are the
* same as textNodesFromRange.
* @param {Range} range the range to convert to string.
* @return {string} the string contained in range.
* @private
*/
Context.rangeToString = function(range) {
var textNodes = Context.textNodesFromRange(range);
var string = '';
var length = textNodes.length;
for (var index = 0; index < length; index++) {
var node = textNodes[index];
if (node === ' ') {
string += ' ';
continue;
}
var nodeString = node.textContent;
if (node == range.endNode) {
nodeString = nodeString.substring(0, range.endOffset);
}
if (node == range.startNode) {
nodeString.substring(range.startOffset, nodeString.length);
}
string += nodeString;
}
return string;
};
/**
* Create the text client rects contained in the current |highlightRange|.
* @return {string} A string containing a comma separated list of rects in
* the format 'top bottom left right'. Coordinates are page based (not screen
* based).
* @private
*/
Context.getHighlightRects = function() {
if (Context.highlightRange == null) {
return '';
}
var rectsArray = new Array();
var rects = Context.highlightRange.getClientRects();
var rectsLength = rects.length;
for (var i = 0, length = rectsLength; i < length; i++) {
var top = rects[i].top + document.body.scrollTop;
var bottom = rects[i].bottom + document.body.scrollTop;
var left = rects[i].left + document.body.scrollLeft;
var right = rects[i].right + document.body.scrollLeft;
rectsArray[i] = '' + top + ' ' + bottom + ' ' + left + ' ' + right;
}
return rectsArray.join(',');
};
/* Anyonymizing block end */
}