/* Copyright (c) 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 Caret browsing content script, runs in each frame. * * The behavior is based on Mozilla's spec whenever possible: * http://www.mozilla.org/access/keyboard/proposal * * The one exception is that Esc is used to escape out of a form control, * rather than their proposed key (which doesn't seem to work in the * latest Firefox anyway). * * Some details about how Chrome selection works, which will help in * understanding the code: * * The Selection object (window.getSelection()) has four components that * completely describe the state of the caret or selection: * * base and anchor: this is the start of the selection, the fixed point. * extent and focus: this is the end of the selection, the part that * moves when you hold down shift and press the left or right arrows. * * When the selection is a cursor, the base, anchor, extent, and focus are * all the same. * * There's only one time when the base and anchor are not the same, or the * extent and focus are not the same, and that's when the selection is in * an ambiguous state - i.e. it's not clear which edge is the focus and which * is the anchor. As an example, if you double-click to select a word, then * the behavior is dependent on your next action. If you press Shift+Right, * the right edge becomes the focus. But if you press Shift+Left, the left * edge becomes the focus. * * When the selection is in an ambiguous state, the base and extent are set * to the position where the mouse clicked, and the anchor and focus are set * to the boundaries of the selection. * * The only way to set the selection and give it direction is to use * the non-standard Selection.setBaseAndExtent method. If you try to use * Selection.addRange(), the anchor will always be on the left and the focus * will always be on the right, making it impossible to manipulate * selections that move from right to left. * * Finally, Chrome will throw an exception if you try to set an invalid * selection - a selection where the left and right edges are not the same, * but it doesn't span any visible characters. A common example is that * there are often many whitespace characters in the DOM that are not * visible on the page; trying to select them will fail. Another example is * any node that's invisible or not displayed. * * While there are probably many possible methods to determine what is * selectable, this code uses the method of determining if there's a valid * bounding box for the range or not - keep moving the cursor forwards until * the range from the previous position and candidate next position has a * valid bounding box. */ /** * Return whether a node is focusable. This includes nodes whose tabindex * attribute is set to "-1" explicitly - these nodes are not in the tab * order, but they should still be focused if the user navigates to them * using linear or smart DOM navigation. * * Note that when the tabIndex property of an Element is -1, that doesn't * tell us whether the tabIndex attribute is missing or set to "-1" explicitly, * so we have to check the attribute. * * @param {Object} targetNode The node to check if it's focusable. * @return {boolean} True if the node is focusable. */ function isFocusable(targetNode) { if (!targetNode || typeof(targetNode.tabIndex) != 'number') { return false; } if (targetNode.tabIndex >= 0) { return true; } if (targetNode.hasAttribute && targetNode.hasAttribute('tabindex') && targetNode.getAttribute('tabindex') == '-1') { return true; } return false; } /** * Determines whether or not a node is or is the descendant of another node. * * @param {Object} node The node to be checked. * @param {Object} ancestor The node to see if it's a descendant of. * @return {boolean} True if the node is ancestor or is a descendant of it. */ function isDescendantOfNode(node, ancestor) { while (node && ancestor) { if (node.isSameNode(ancestor)) { return true; } node = node.parentNode; } return false; } /** * The class handling the Caret Browsing implementation in the page. * Installs a keydown listener that always responds to the F7 key, * sets up communication with the background page, and then when caret * browsing is enabled, response to various key events to move the caret * or selection within the text content of the document. Uses the native * Chrome selection wherever possible, but displays its own flashing * caret using a DIV because there's no native caret available. * @constructor */ var CaretBrowsing = function() {}; /** * Is caret browsing enabled? * @type {boolean} */ CaretBrowsing.isEnabled = false; /** * Keep it enabled even when flipped off (for the options page)? * @type {boolean} */ CaretBrowsing.forceEnabled = false; /** * What to do when the caret appears? * @type {string} */ CaretBrowsing.onEnable; /** * What to do when the caret jumps? * @type {string} */ CaretBrowsing.onJump; /** * Is this window / iframe focused? We won't show the caret if not, * especially so that carets aren't shown in two iframes of the same * tab. * @type {boolean} */ CaretBrowsing.isWindowFocused = false; /** * Is the caret actually visible? This is true only if isEnabled and * isWindowFocused are both true. * @type {boolean} */ CaretBrowsing.isCaretVisible = false; /** * The actual caret element, an absolute-positioned flashing line. * @type {Element} */ CaretBrowsing.caretElement; /** * The x-position of the caret, in absolute pixels. * @type {number} */ CaretBrowsing.caretX = 0; /** * The y-position of the caret, in absolute pixels. * @type {number} */ CaretBrowsing.caretY = 0; /** * The width of the caret in pixels. * @type {number} */ CaretBrowsing.caretWidth = 0; /** * The height of the caret in pixels. * @type {number} */ CaretBrowsing.caretHeight = 0; /** * The foregroundc color. * @type {string} */ CaretBrowsing.caretForeground = '#000'; /** * The backgroundc color. * @type {string} */ CaretBrowsing.caretBackground = '#fff'; /** * Is the selection collapsed, i.e. are the start and end locations * the same? If so, our blinking caret image is shown; otherwise * the Chrome selection is shown. * @type {boolean} */ CaretBrowsing.isSelectionCollapsed = false; /** * The id returned by window.setInterval for our blink function, so * we can cancel it when caret browsing is disabled. * @type {number?} */ CaretBrowsing.blinkFunctionId = null; /** * The desired x-coordinate to match when moving the caret up and down. * To match the behavior as documented in Mozilla's caret browsing spec * (http://www.mozilla.org/access/keyboard/proposal), we keep track of the * initial x position when the user starts moving the caret up and down, * so that the x position doesn't drift as you move throughout lines, but * stays as close as possible to the initial position. This is reset when * moving left or right or clicking. * @type {number?} */ CaretBrowsing.targetX = null; /** * A flag that flips on or off as the caret blinks. * @type {boolean} */ CaretBrowsing.blinkFlag = true; /** * Whether or not we're on a Mac - affects modifier keys. * @type {boolean} */ CaretBrowsing.isMac = (navigator.appVersion.indexOf("Mac") != -1); /** * Check if a node is a control that normally allows the user to interact * with it using arrow keys. We won't override the arrow keys when such a * control has focus, the user must press Escape to do caret browsing outside * that control. * @param {Node} node A node to check. * @return {boolean} True if this node is a control that the user can * interact with using arrow keys. */ CaretBrowsing.isControlThatNeedsArrowKeys = function(node) { if (!node) { return false; } if (node == document.body || node != document.activeElement) { return false; } if (node.constructor == HTMLSelectElement) { return true; } if (node.constructor == HTMLInputElement) { switch (node.type) { case 'email': case 'number': case 'password': case 'search': case 'text': case 'tel': case 'url': case '': return true; // All of these are text boxes. case 'datetime': case 'datetime-local': case 'date': case 'month': case 'radio': case 'range': case 'week': return true; // These are other input elements that use arrows. } } // Handle focusable ARIA controls. if (node.getAttribute && isFocusable(node)) { var role = node.getAttribute('role'); switch (role) { case 'combobox': case 'grid': case 'gridcell': case 'listbox': case 'menu': case 'menubar': case 'menuitem': case 'menuitemcheckbox': case 'menuitemradio': case 'option': case 'radiogroup': case 'scrollbar': case 'slider': case 'spinbutton': case 'tab': case 'tablist': case 'textbox': case 'tree': case 'treegrid': case 'treeitem': return true; } } return false; }; /** * If there's no initial selection, set the cursor just before the * first text character in the document. */ CaretBrowsing.setInitialCursor = function() { var sel = window.getSelection(); if (sel.rangeCount > 0) { return; } var start = new Cursor(document.body, 0, ''); var end = new Cursor(document.body, 0, ''); var nodesCrossed = []; var result = TraverseUtil.getNextChar(start, end, nodesCrossed, true); if (result == null) { return; } CaretBrowsing.setAndValidateSelection(start, start); }; /** * Set focus to a node if it's focusable. If it's an input element, * select the text, otherwise it doesn't appear focused to the user. * Every other control behaves normally if you just call focus() on it. * @param {Node} node The node to focus. * @return {boolean} True if the node was focused. */ CaretBrowsing.setFocusToNode = function(node) { while (node && node != document.body) { if (isFocusable(node) && node.constructor != HTMLIFrameElement) { node.focus(); if (node.constructor == HTMLInputElement && node.select) { node.select(); } return true; } node = node.parentNode; } return false; }; /** * Set focus to the first focusable node in the given list. * select the text, otherwise it doesn't appear focused to the user. * Every other control behaves normally if you just call focus() on it. * @param {Array} nodeList An array of nodes to focus. * @return {boolean} True if the node was focused. */ CaretBrowsing.setFocusToFirstFocusable = function(nodeList) { for (var i = 0; i < nodeList.length; i++) { if (CaretBrowsing.setFocusToNode(nodeList[i])) { return true; } } return false; }; /** * Set the caret element's normal style, i.e. not when animating. */ CaretBrowsing.setCaretElementNormalStyle = function() { var element = CaretBrowsing.caretElement; element.className = 'CaretBrowsing_Caret'; element.style.opacity = CaretBrowsing.isSelectionCollapsed ? '1.0' : '0.0'; element.style.left = CaretBrowsing.caretX + 'px'; element.style.top = CaretBrowsing.caretY + 'px'; element.style.width = CaretBrowsing.caretWidth + 'px'; element.style.height = CaretBrowsing.caretHeight + 'px'; element.style.color = CaretBrowsing.caretForeground; }; /** * Animate the caret element into the normal style. */ CaretBrowsing.animateCaretElement = function() { var element = CaretBrowsing.caretElement; element.style.left = (CaretBrowsing.caretX - 50) + 'px'; element.style.top = (CaretBrowsing.caretY - 100) + 'px'; element.style.width = (CaretBrowsing.caretWidth + 100) + 'px'; element.style.height = (CaretBrowsing.caretHeight + 200) + 'px'; element.className = 'CaretBrowsing_AnimateCaret'; // Start the animation. The setTimeout is so that the old values will get // applied first, so we can animate to the new values. window.setTimeout(function() { if (!CaretBrowsing.caretElement) { return; } CaretBrowsing.setCaretElementNormalStyle(); element.style['transition'] = 'all 0.8s ease-in'; function listener() { element.removeEventListener( 'transitionend', listener, false); element.style['transition'] = 'none'; } element.addEventListener( 'transitionend', listener, false); }, 0); }; /** * Quick flash and then show the normal caret style. */ CaretBrowsing.flashCaretElement = function() { var x = CaretBrowsing.caretX; var y = CaretBrowsing.caretY; var height = CaretBrowsing.caretHeight; var vert = document.createElement('div'); vert.className = 'CaretBrowsing_FlashVert'; vert.style.left = (x - 6) + 'px'; vert.style.top = (y - 100) + 'px'; vert.style.width = '11px'; vert.style.height = (200) + 'px'; document.body.appendChild(vert); window.setTimeout(function() { document.body.removeChild(vert); if (CaretBrowsing.caretElement) { CaretBrowsing.setCaretElementNormalStyle(); } }, 250); }; /** * Create the caret element. This assumes that caretX, caretY, * caretWidth, and caretHeight have all been set. The caret is * animated in so the user can find it when it first appears. */ CaretBrowsing.createCaretElement = function() { var element = document.createElement('div'); element.className = 'CaretBrowsing_Caret'; document.body.appendChild(element); CaretBrowsing.caretElement = element; if (CaretBrowsing.onEnable == 'anim') { CaretBrowsing.animateCaretElement(); } else if (CaretBrowsing.onEnable == 'flash') { CaretBrowsing.flashCaretElement(); } else { CaretBrowsing.setCaretElementNormalStyle(); } }; /** * Recreate the caret element, triggering any intro animation. */ CaretBrowsing.recreateCaretElement = function() { if (CaretBrowsing.caretElement) { window.clearInterval(CaretBrowsing.blinkFunctionId); CaretBrowsing.caretElement.parentElement.removeChild( CaretBrowsing.caretElement); CaretBrowsing.caretElement = null; CaretBrowsing.updateIsCaretVisible(); } }; /** * Get the rectangle for a cursor position. This is tricky because * you can't get the bounding rectangle of an empty range, so this function * computes the rect by trying a range including one character earlier or * later than the cursor position. * @param {Cursor} cursor A single cursor position. * @return {{left: number, top: number, width: number, height: number}} * The bounding rectangle of the cursor. */ CaretBrowsing.getCursorRect = function(cursor) { var node = cursor.node; var index = cursor.index; var rect = { left: 0, top: 0, width: 1, height: 0 }; if (node.constructor == Text) { var left = index; var right = index; var max = node.data.length; var newRange = document.createRange(); while (left > 0 || right < max) { if (left > 0) { left--; newRange.setStart(node, left); newRange.setEnd(node, index); var rangeRect = newRange.getBoundingClientRect(); if (rangeRect && rangeRect.width && rangeRect.height) { rect.left = rangeRect.right; rect.top = rangeRect.top; rect.height = rangeRect.height; break; } } if (right < max) { right++; newRange.setStart(node, index); newRange.setEnd(node, right); var rangeRect = newRange.getBoundingClientRect(); if (rangeRect && rangeRect.width && rangeRect.height) { rect.left = rangeRect.left; rect.top = rangeRect.top; rect.height = rangeRect.height; break; } } } } else { rect.height = node.offsetHeight; while (node !== null) { rect.left += node.offsetLeft; rect.top += node.offsetTop; node = node.offsetParent; } } rect.left += window.pageXOffset; rect.top += window.pageYOffset; return rect; }; /** * Compute the new location of the caret or selection and update * the element as needed. * @param {boolean} scrollToSelection If true, will also scroll the page * to the caret / selection location. */ CaretBrowsing.updateCaretOrSelection = function(scrollToSelection) { var previousX = CaretBrowsing.caretX; var previousY = CaretBrowsing.caretY; var sel = window.getSelection(); if (sel.rangeCount == 0) { if (CaretBrowsing.caretElement) { CaretBrowsing.isSelectionCollapsed = false; CaretBrowsing.caretElement.style.opacity = '0.0'; } return; } var range = sel.getRangeAt(0); if (!range) { if (CaretBrowsing.caretElement) { CaretBrowsing.isSelectionCollapsed = false; CaretBrowsing.caretElement.style.opacity = '0.0'; } return; } if (CaretBrowsing.isControlThatNeedsArrowKeys(document.activeElement)) { var node = document.activeElement; CaretBrowsing.caretWidth = node.offsetWidth; CaretBrowsing.caretHeight = node.offsetHeight; CaretBrowsing.caretX = 0; CaretBrowsing.caretY = 0; while (node.offsetParent) { CaretBrowsing.caretX += node.offsetLeft; CaretBrowsing.caretY += node.offsetTop; node = node.offsetParent; } CaretBrowsing.isSelectionCollapsed = false; } else if (range.startOffset != range.endOffset || range.startContainer != range.endContainer) { var rect = range.getBoundingClientRect(); if (!rect) { return; } CaretBrowsing.caretX = rect.left + window.pageXOffset; CaretBrowsing.caretY = rect.top + window.pageYOffset; CaretBrowsing.caretWidth = rect.width; CaretBrowsing.caretHeight = rect.height; CaretBrowsing.isSelectionCollapsed = false; } else { var rect = CaretBrowsing.getCursorRect( new Cursor(range.startContainer, range.startOffset, TraverseUtil.getNodeText(range.startContainer))); CaretBrowsing.caretX = rect.left; CaretBrowsing.caretY = rect.top; CaretBrowsing.caretWidth = rect.width; CaretBrowsing.caretHeight = rect.height; CaretBrowsing.isSelectionCollapsed = true; } if (!CaretBrowsing.caretElement) { CaretBrowsing.createCaretElement(); } else { var element = CaretBrowsing.caretElement; if (CaretBrowsing.isSelectionCollapsed) { element.style.opacity = '1.0'; element.style.left = CaretBrowsing.caretX + 'px'; element.style.top = CaretBrowsing.caretY + 'px'; element.style.width = CaretBrowsing.caretWidth + 'px'; element.style.height = CaretBrowsing.caretHeight + 'px'; } else { element.style.opacity = '0.0'; } } var elem = range.startContainer; if (elem.constructor == Text) elem = elem.parentElement; var style = window.getComputedStyle(elem); var bg = axs.utils.getBgColor(style, elem); var fg = axs.utils.getFgColor(style, elem, bg); CaretBrowsing.caretBackground = axs.color.colorToString(bg); CaretBrowsing.caretForeground = axs.color.colorToString(fg); if (scrollToSelection) { // Scroll just to the "focus" position of the selection, // the part the user is manipulating. var rect = CaretBrowsing.getCursorRect( new Cursor(sel.focusNode, sel.focusOffset, TraverseUtil.getNodeText(sel.focusNode))); var yscroll = window.pageYOffset; var pageHeight = window.innerHeight; var caretY = rect.top; var caretHeight = Math.min(rect.height, 30); if (yscroll + pageHeight < caretY + caretHeight) { window.scroll(0, (caretY + caretHeight - pageHeight + 100)); } else if (caretY < yscroll) { window.scroll(0, (caretY - 100)); } } if (Math.abs(previousX - CaretBrowsing.caretX) > 500 || Math.abs(previousY - CaretBrowsing.caretY) > 100) { if (CaretBrowsing.onJump == 'anim') { CaretBrowsing.animateCaretElement(); } else if (CaretBrowsing.onJump == 'flash') { CaretBrowsing.flashCaretElement(); } } }; /** * Return true if the selection directionality is ambiguous, which happens * if, for example, the user double-clicks in the middle of a word to select * it. In that case, the selection should extend by the right edge if the * user presses right, and by the left edge if the user presses left. * @param {Selection} sel The selection. * @return {boolean} True if the selection directionality is ambiguous. */ CaretBrowsing.isAmbiguous = function(sel) { return (sel.anchorNode != sel.baseNode || sel.anchorOffset != sel.baseOffset || sel.focusNode != sel.extentNode || sel.focusOffset != sel.extentOffset); }; /** * Create a Cursor from the anchor position of the selection, the * part that doesn't normally move. * @param {Selection} sel The selection. * @return {Cursor} A cursor pointing to the selection's anchor location. */ CaretBrowsing.makeAnchorCursor = function(sel) { return new Cursor(sel.anchorNode, sel.anchorOffset, TraverseUtil.getNodeText(sel.anchorNode)); }; /** * Create a Cursor from the focus position of the selection. * @param {Selection} sel The selection. * @return {Cursor} A cursor pointing to the selection's focus location. */ CaretBrowsing.makeFocusCursor = function(sel) { return new Cursor(sel.focusNode, sel.focusOffset, TraverseUtil.getNodeText(sel.focusNode)); }; /** * Create a Cursor from the left boundary of the selection - the boundary * closer to the start of the document. * @param {Selection} sel The selection. * @return {Cursor} A cursor pointing to the selection's left boundary. */ CaretBrowsing.makeLeftCursor = function(sel) { var range = sel.rangeCount == 1 ? sel.getRangeAt(0) : null; if (range && range.endContainer == sel.anchorNode && range.endOffset == sel.anchorOffset) { return CaretBrowsing.makeFocusCursor(sel); } else { return CaretBrowsing.makeAnchorCursor(sel); } }; /** * Create a Cursor from the right boundary of the selection - the boundary * closer to the end of the document. * @param {Selection} sel The selection. * @return {Cursor} A cursor pointing to the selection's right boundary. */ CaretBrowsing.makeRightCursor = function(sel) { var range = sel.rangeCount == 1 ? sel.getRangeAt(0) : null; if (range && range.endContainer == sel.anchorNode && range.endOffset == sel.anchorOffset) { return CaretBrowsing.makeAnchorCursor(sel); } else { return CaretBrowsing.makeFocusCursor(sel); } }; /** * Try to set the window's selection to be between the given start and end * cursors, and return whether or not it was successful. * @param {Cursor} start The start position. * @param {Cursor} end The end position. * @return {boolean} True if the selection was successfully set. */ CaretBrowsing.setAndValidateSelection = function(start, end) { var sel = window.getSelection(); sel.setBaseAndExtent(start.node, start.index, end.node, end.index); if (sel.rangeCount != 1) { return false; } return (sel.anchorNode == start.node && sel.anchorOffset == start.index && sel.focusNode == end.node && sel.focusOffset == end.index); }; /** * Note: the built-in function by the same name is unreliable. * @param {Selection} sel The selection. * @return {boolean} True if the start and end positions are the same. */ CaretBrowsing.isCollapsed = function(sel) { return (sel.anchorOffset == sel.focusOffset && sel.anchorNode == sel.focusNode); }; /** * Determines if the modifier key is held down that should cause * the cursor to move by word rather than by character. * @param {Event} evt A keyboard event. * @return {boolean} True if the cursor should move by word. */ CaretBrowsing.isMoveByWordEvent = function(evt) { if (CaretBrowsing.isMac) { return evt.altKey; } else { return evt.ctrlKey; } }; /** * Moves the cursor forwards to the next valid position. * @param {Cursor} cursor The current cursor location. * On exit, the cursor will be at the next position. * @param {Array} nodesCrossed Any HTML nodes crossed between the * initial and final cursor position will be pushed onto this array. * @return {?string} The character reached, or null if the bottom of the * document has been reached. */ CaretBrowsing.forwards = function(cursor, nodesCrossed) { var previousCursor = cursor.clone(); var result = TraverseUtil.forwardsChar(cursor, nodesCrossed); // Work around the fact that TraverseUtil.forwardsChar returns once per // char in a block of text, rather than once per possible selection // position in a block of text. if (result && cursor.node != previousCursor.node && cursor.index > 0) { cursor.index = 0; } return result; }; /** * Moves the cursor backwards to the previous valid position. * @param {Cursor} cursor The current cursor location. * On exit, the cursor will be at the previous position. * @param {Array} nodesCrossed Any HTML nodes crossed between the * initial and final cursor position will be pushed onto this array. * @return {?string} The character reached, or null if the top of the * document has been reached. */ CaretBrowsing.backwards = function(cursor, nodesCrossed) { var previousCursor = cursor.clone(); var result = TraverseUtil.backwardsChar(cursor, nodesCrossed); // Work around the fact that TraverseUtil.backwardsChar returns once per // char in a block of text, rather than once per possible selection // position in a block of text. if (result && cursor.node != previousCursor.node && cursor.index < cursor.text.length) { cursor.index = cursor.text.length; } return result; }; /** * Called when the user presses the right arrow. If there's a selection, * moves the cursor to the end of the selection range. If it's a cursor, * moves past one character. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.moveRight = function(evt) { CaretBrowsing.targetX = null; var sel = window.getSelection(); if (!evt.shiftKey && !CaretBrowsing.isCollapsed(sel)) { var right = CaretBrowsing.makeRightCursor(sel); CaretBrowsing.setAndValidateSelection(right, right); return false; } var start = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeLeftCursor(sel) : CaretBrowsing.makeAnchorCursor(sel); var end = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeRightCursor(sel) : CaretBrowsing.makeFocusCursor(sel); var previousEnd = end.clone(); var nodesCrossed = []; while (true) { var result; if (CaretBrowsing.isMoveByWordEvent(evt)) { result = TraverseUtil.getNextWord(previousEnd, end, nodesCrossed); } else { previousEnd = end.clone(); result = CaretBrowsing.forwards(end, nodesCrossed); } if (result === null) { return CaretBrowsing.moveLeft(evt); } if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? start : end, end)) { break; } } if (!evt.shiftKey) { nodesCrossed.push(end.node); CaretBrowsing.setFocusToFirstFocusable(nodesCrossed); } return false; }; /** * Called when the user presses the left arrow. If there's a selection, * moves the cursor to the start of the selection range. If it's a cursor, * moves backwards past one character. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.moveLeft = function(evt) { CaretBrowsing.targetX = null; var sel = window.getSelection(); if (!evt.shiftKey && !CaretBrowsing.isCollapsed(sel)) { var left = CaretBrowsing.makeLeftCursor(sel); CaretBrowsing.setAndValidateSelection(left, left); return false; } var start = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeLeftCursor(sel) : CaretBrowsing.makeFocusCursor(sel); var end = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeRightCursor(sel) : CaretBrowsing.makeAnchorCursor(sel); var previousStart = start.clone(); var nodesCrossed = []; while (true) { var result; if (CaretBrowsing.isMoveByWordEvent(evt)) { result = TraverseUtil.getPreviousWord( start, previousStart, nodesCrossed); } else { previousStart = start.clone(); result = CaretBrowsing.backwards(start, nodesCrossed); } if (result === null) { break; } if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? end : start, start)) { break; } } if (!evt.shiftKey) { nodesCrossed.push(start.node); CaretBrowsing.setFocusToFirstFocusable(nodesCrossed); } return false; }; /** * Called when the user presses the down arrow. If there's a selection, * moves the cursor to the end of the selection range. If it's a cursor, * attempts to move to the equivalent horizontal pixel position in the * subsequent line of text. If this is impossible, go to the first character * of the next line. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.moveDown = function(evt) { var sel = window.getSelection(); if (!evt.shiftKey && !CaretBrowsing.isCollapsed(sel)) { var right = CaretBrowsing.makeRightCursor(sel); CaretBrowsing.setAndValidateSelection(right, right); return false; } var start = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeLeftCursor(sel) : CaretBrowsing.makeAnchorCursor(sel); var end = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeRightCursor(sel) : CaretBrowsing.makeFocusCursor(sel); var endRect = CaretBrowsing.getCursorRect(end); if (CaretBrowsing.targetX === null) { CaretBrowsing.targetX = endRect.left; } var previousEnd = end.clone(); var leftPos = end.clone(); var rightPos = end.clone(); var bestPos = null; var bestY = null; var bestDelta = null; var bestHeight = null; var nodesCrossed = []; var y = -1; while (true) { if (null === CaretBrowsing.forwards(rightPos, nodesCrossed)) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? start : leftPos, leftPos)) { break; } else { return CaretBrowsing.moveLeft(evt); } break; } var range = document.createRange(); range.setStart(leftPos.node, leftPos.index); range.setEnd(rightPos.node, rightPos.index); var rect = range.getBoundingClientRect(); if (rect && rect.width < rect.height) { y = rect.top + window.pageYOffset; // Return the best match so far if we get half a line past the best. if (bestY != null && y > bestY + bestHeight / 2) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? start : bestPos, bestPos)) { break; } else { bestY = null; } } // Stop here if we're an entire line the wrong direction // (for example, we reached the top of the next column). if (y < endRect.top - endRect.height) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? start : leftPos, leftPos)) { break; } } // Otherwise look to see if this current position is on the // next line and better than the previous best match, if any. if (y >= endRect.top + endRect.height) { var deltaLeft = Math.abs(CaretBrowsing.targetX - rect.left); if ((bestDelta == null || deltaLeft < bestDelta) && (leftPos.node != end.node || leftPos.index != end.index)) { bestPos = leftPos.clone(); bestY = y; bestDelta = deltaLeft; bestHeight = rect.height; } var deltaRight = Math.abs(CaretBrowsing.targetX - rect.right); if (bestDelta == null || deltaRight < bestDelta) { bestPos = rightPos.clone(); bestY = y; bestDelta = deltaRight; bestHeight = rect.height; } // Return the best match so far if the deltas are getting worse, // not better. if (bestDelta != null && deltaLeft > bestDelta && deltaRight > bestDelta) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? start : bestPos, bestPos)) { break; } else { bestY = null; } } } } leftPos = rightPos.clone(); } if (!evt.shiftKey) { CaretBrowsing.setFocusToNode(leftPos.node); } return false; }; /** * Called when the user presses the up arrow. If there's a selection, * moves the cursor to the start of the selection range. If it's a cursor, * attempts to move to the equivalent horizontal pixel position in the * previous line of text. If this is impossible, go to the last character * of the previous line. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.moveUp = function(evt) { var sel = window.getSelection(); if (!evt.shiftKey && !CaretBrowsing.isCollapsed(sel)) { var left = CaretBrowsing.makeLeftCursor(sel); CaretBrowsing.setAndValidateSelection(left, left); return false; } var start = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeLeftCursor(sel) : CaretBrowsing.makeFocusCursor(sel); var end = CaretBrowsing.isAmbiguous(sel) ? CaretBrowsing.makeRightCursor(sel) : CaretBrowsing.makeAnchorCursor(sel); var startRect = CaretBrowsing.getCursorRect(start); if (CaretBrowsing.targetX === null) { CaretBrowsing.targetX = startRect.left; } var previousStart = start.clone(); var leftPos = start.clone(); var rightPos = start.clone(); var bestPos = null; var bestY = null; var bestDelta = null; var bestHeight = null; var nodesCrossed = []; var y = 999999; while (true) { if (null === CaretBrowsing.backwards(leftPos, nodesCrossed)) { CaretBrowsing.setAndValidateSelection( evt.shiftKey ? end : rightPos, rightPos); break; } var range = document.createRange(); range.setStart(leftPos.node, leftPos.index); range.setEnd(rightPos.node, rightPos.index); var rect = range.getBoundingClientRect(); if (rect && rect.width < rect.height) { y = rect.top + window.pageYOffset; // Return the best match so far if we get half a line past the best. if (bestY != null && y < bestY - bestHeight / 2) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? end : bestPos, bestPos)) { break; } else { bestY = null; } } // Exit if we're an entire line the wrong direction // (for example, we reached the bottom of the previous column.) if (y > startRect.top + startRect.height) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? end : rightPos, rightPos)) { break; } } // Otherwise look to see if this current position is on the // next line and better than the previous best match, if any. if (y <= startRect.top - startRect.height) { var deltaLeft = Math.abs(CaretBrowsing.targetX - rect.left); if (bestDelta == null || deltaLeft < bestDelta) { bestPos = leftPos.clone(); bestY = y; bestDelta = deltaLeft; bestHeight = rect.height; } var deltaRight = Math.abs(CaretBrowsing.targetX - rect.right); if ((bestDelta == null || deltaRight < bestDelta) && (rightPos.node != start.node || rightPos.index != start.index)) { bestPos = rightPos.clone(); bestY = y; bestDelta = deltaRight; bestHeight = rect.height; } // Return the best match so far if the deltas are getting worse, // not better. if (bestDelta != null && deltaLeft > bestDelta && deltaRight > bestDelta) { if (CaretBrowsing.setAndValidateSelection( evt.shiftKey ? end : bestPos, bestPos)) { break; } else { bestY = null; } } } } rightPos = leftPos.clone(); } if (!evt.shiftKey) { CaretBrowsing.setFocusToNode(rightPos.node); } return false; }; /** * Set the document's selection to surround a control, so that the next * arrow key they press will allow them to explore the content before * or after a given control. * @param {Node} control The control to escape from. */ CaretBrowsing.escapeFromControl = function(control) { control.blur(); var start = new Cursor(control, 0, ''); var previousStart = start.clone(); var end = new Cursor(control, 0, ''); var previousEnd = end.clone(); var nodesCrossed = []; while (true) { if (null === CaretBrowsing.backwards(start, nodesCrossed)) { break; } var r = document.createRange(); r.setStart(start.node, start.index); r.setEnd(previousStart.node, previousStart.index); if (r.getBoundingClientRect()) { break; } previousStart = start.clone(); } while (true) { if (null === CaretBrowsing.forwards(end, nodesCrossed)) { break; } if (isDescendantOfNode(end.node, control)) { previousEnd = end.clone(); continue; } var r = document.createRange(); r.setStart(previousEnd.node, previousEnd.index); r.setEnd(end.node, end.index); if (r.getBoundingClientRect()) { break; } } if (!isDescendantOfNode(previousStart.node, control)) { start = previousStart.clone(); } if (!isDescendantOfNode(previousEnd.node, control)) { end = previousEnd.clone(); } CaretBrowsing.setAndValidateSelection(start, end); window.setTimeout(function() { CaretBrowsing.updateCaretOrSelection(true); }, 0); }; /** * Toggle whether caret browsing is enabled or not. */ CaretBrowsing.toggle = function() { if (CaretBrowsing.forceEnabled) { CaretBrowsing.recreateCaretElement(); return; } CaretBrowsing.isEnabled = !CaretBrowsing.isEnabled; var obj = {}; obj['enabled'] = CaretBrowsing.isEnabled; chrome.storage.sync.set(obj); CaretBrowsing.updateIsCaretVisible(); }; /** * Event handler, called when a key is pressed. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.onKeyDown = function(evt) { if (evt.defaultPrevented) { return; } if (evt.keyCode == 118) { // F7 CaretBrowsing.toggle(); } if (!CaretBrowsing.isEnabled) { return true; } if (evt.target && CaretBrowsing.isControlThatNeedsArrowKeys( /** @type (Node) */(evt.target))) { if (evt.keyCode == 27) { CaretBrowsing.escapeFromControl(/** @type {Node} */(evt.target)); evt.preventDefault(); evt.stopPropagation(); return false; } else { return true; } } // If the current selection doesn't have a range, try to escape out of // the current control. If that fails, return so we don't fail whe // trying to move the cursor or selection. var sel = window.getSelection(); if (sel.rangeCount == 0) { if (document.activeElement) { CaretBrowsing.escapeFromControl(document.activeElement); sel = window.getSelection(); } if (sel.rangeCount == 0) { return true; } } if (CaretBrowsing.caretElement) { CaretBrowsing.caretElement.style.visibility = 'visible'; CaretBrowsing.blinkFlag = true; } var result = true; switch (evt.keyCode) { case 37: result = CaretBrowsing.moveLeft(evt); break; case 38: result = CaretBrowsing.moveUp(evt); break; case 39: result = CaretBrowsing.moveRight(evt); break; case 40: result = CaretBrowsing.moveDown(evt); break; } if (result == false) { evt.preventDefault(); evt.stopPropagation(); } window.setTimeout(function() { CaretBrowsing.updateCaretOrSelection(result == false); }, 0); return result; }; /** * Event handler, called when the mouse is clicked. Chrome already * sets the selection when the mouse is clicked, all we need to do is * update our cursor. * @param {Event} evt The DOM event. * @return {boolean} True if the default action should be performed. */ CaretBrowsing.onClick = function(evt) { if (!CaretBrowsing.isEnabled) { return true; } window.setTimeout(function() { CaretBrowsing.targetX = null; CaretBrowsing.updateCaretOrSelection(false); }, 0); return true; }; /** * Called at a regular interval. Blink the cursor by changing its visibility. */ CaretBrowsing.caretBlinkFunction = function() { if (CaretBrowsing.caretElement) { if (CaretBrowsing.blinkFlag) { CaretBrowsing.caretElement.style.backgroundColor = CaretBrowsing.caretForeground; CaretBrowsing.blinkFlag = false; } else { CaretBrowsing.caretElement.style.backgroundColor = CaretBrowsing.caretBackground; CaretBrowsing.blinkFlag = true; } } }; /** * Update whether or not the caret is visible, based on whether caret browsing * is enabled and whether this window / iframe has focus. */ CaretBrowsing.updateIsCaretVisible = function() { CaretBrowsing.isCaretVisible = (CaretBrowsing.isEnabled && CaretBrowsing.isWindowFocused); if (CaretBrowsing.isCaretVisible && !CaretBrowsing.caretElement) { CaretBrowsing.setInitialCursor(); CaretBrowsing.updateCaretOrSelection(true); if (CaretBrowsing.caretElement) { CaretBrowsing.blinkFunctionId = window.setInterval( CaretBrowsing.caretBlinkFunction, 500); } } else if (!CaretBrowsing.isCaretVisible && CaretBrowsing.caretElement) { window.clearInterval(CaretBrowsing.blinkFunctionId); if (CaretBrowsing.caretElement) { CaretBrowsing.isSelectionCollapsed = false; CaretBrowsing.caretElement.parentElement.removeChild( CaretBrowsing.caretElement); CaretBrowsing.caretElement = null; } } }; /** * Called when the prefs get updated. */ CaretBrowsing.onPrefsUpdated = function() { chrome.storage.sync.get(null, function(result) { if (!CaretBrowsing.forceEnabled) { CaretBrowsing.isEnabled = result['enabled']; } CaretBrowsing.onEnable = result['onenable']; CaretBrowsing.onJump = result['onjump']; CaretBrowsing.recreateCaretElement(); }); }; /** * Called when this window / iframe gains focus. */ CaretBrowsing.onWindowFocus = function() { CaretBrowsing.isWindowFocused = true; CaretBrowsing.updateIsCaretVisible(); }; /** * Called when this window / iframe loses focus. */ CaretBrowsing.onWindowBlur = function() { CaretBrowsing.isWindowFocused = false; CaretBrowsing.updateIsCaretVisible(); }; /** * Initializes caret browsing by adding event listeners and extension * message listeners. */ CaretBrowsing.init = function() { CaretBrowsing.isWindowFocused = document.hasFocus(); document.addEventListener('keydown', CaretBrowsing.onKeyDown, false); document.addEventListener('click', CaretBrowsing.onClick, false); window.addEventListener('focus', CaretBrowsing.onWindowFocus, false); window.addEventListener('blur', CaretBrowsing.onWindowBlur, false); }; window.setTimeout(function() { // Make sure the script only loads once. if (!window['caretBrowsingLoaded']) { window['caretBrowsingLoaded'] = true; CaretBrowsing.init(); if (document.body.getAttribute('caretbrowsing') == 'on') { CaretBrowsing.forceEnabled = true; CaretBrowsing.isEnabled = true; CaretBrowsing.updateIsCaretVisible(); } chrome.storage.onChanged.addListener(function() { CaretBrowsing.onPrefsUpdated(); }); CaretBrowsing.onPrefsUpdated(); } }, 0);