summaryrefslogtreecommitdiffstats
path: root/chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js
diff options
context:
space:
mode:
Diffstat (limited to 'chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js')
-rw-r--r--chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js993
1 files changed, 993 insertions, 0 deletions
diff --git a/chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js b/chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js
new file mode 100644
index 00000000000..78b1eff2e2a
--- /dev/null
+++ b/chromium/chrome/browser/resources/chromeos/chromevox/chromevox/injected/active_indicator.js
@@ -0,0 +1,993 @@
+// 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 Draws and animates the graphical indicator around the active
+ * object or text range, and handles animation when the indicator is moving.
+ */
+
+
+goog.provide('cvox.ActiveIndicator');
+
+goog.require('cvox.Cursor');
+goog.require('cvox.DomUtil');
+
+
+/**
+ * Constructs and ActiveIndicator, a glowing outline around whatever
+ * node or text range is currently active. Initially it won't display
+ * anything; call syncToNode, syncToRange, or syncToCursorSelection to
+ * make it animate and move. It only displays when this window/iframe
+ * has focus.
+ *
+ * @constructor
+ */
+cvox.ActiveIndicator = function() {
+ /**
+ * The time when the indicator was most recently moved.
+ * @type {number}
+ * @private
+ */
+ this.lastMoveTime_ = 0;
+
+ /**
+ * An estimate of the current zoom factor of the webpage. This is
+ * needed in order to accurately line up the different pieces of the
+ * indicator border and avoid rounding errors.
+ * @type {number}
+ * @private
+ */
+ this.zoom_ = 1;
+
+ /**
+ * The parent element of the indicator.
+ * @type {?Element}
+ * @private
+ */
+ this.container_ = null;
+
+ /**
+ * The current indicator rects.
+ * @type {Array.<ClientRect>}
+ * @private
+ */
+ this.rects_ = null;
+
+ /**
+ * The most recent target of a call to syncToNode, syncToRange, or
+ * syncToCursorSelection.
+ * @type {Array.<Node>|Range}
+ * @private
+ */
+ this.lastSyncTarget_ = null;
+
+ /**
+ * The most recent client rects for the active indicator, so we
+ * can tell when it moved.
+ * @type {ClientRectList|Array.<ClientRect>}
+ * @private
+ */
+ this.lastClientRects_ = null;
+
+ /**
+ * The id from window.setTimeout when updating the indicator if needed.
+ * @type {?number}
+ * @private
+ */
+ this.updateIndicatorTimeoutId_ = null;
+
+ /**
+ * True if this window is blurred and we shouldn't show the indicator.
+ * @type {boolean}
+ * @private
+ */
+ this.blurred_ = false;
+
+ /**
+ * A cached value of window height.
+ * @type {number|undefined}
+ * @private
+ */
+ this.innerHeight_;
+
+ /**
+ * A cached value of window width.
+ * @type {number|undefined}
+ * @private
+ */
+ this.innerWidth_;
+
+ // Hide the indicator when the window doesn't have focus.
+ window.addEventListener('focus', goog.bind(function() {
+ this.blurred_ = false;
+ if (this.container_) {
+ this.container_.classList.remove('cvox_indicator_window_not_focused');
+ }
+ }, this), false);
+ window.addEventListener('blur', goog.bind(function() {
+ this.blurred_ = true;
+ if (this.container_) {
+ this.container_.classList.add('cvox_indicator_window_not_focused');
+ }
+ }, this), false);
+};
+
+/**
+ * CSS for the active indicator. The basic hierarchy looks like this:
+ *
+ * container (pulsing) (animate_normal, animate_quick)
+ * region (visible)
+ * top
+ * middle_nw
+ * middle_ne
+ * middle_sw
+ * middle_se
+ * bottom
+ * region (visible)
+ * top
+ * middle_nw
+ * middle_ne
+ * middle_sw
+ * middle_se
+ * bottom
+ *
+ * @type {string}
+ * @const
+ */
+cvox.ActiveIndicator.STYLE =
+ '.cvox_indicator_container {' +
+ ' position: absolute !important;' +
+ ' left: 0 !important;' +
+ ' top: 0 !important;' +
+ ' z-index: 2147483647 !important;' +
+ ' pointer-events: none !important;' +
+ ' margin: 0px !important;' +
+ ' padding: 0px !important;' +
+ '}' +
+ '.cvox_indicator_window_not_focused {' +
+ ' visibility: hidden !important;' +
+ '}' +
+ '.cvox_indicator_pulsing {' +
+ ' -webkit-animation: ' +
+ // NOTE(deboer): This animation is 0 seconds long to work around
+ // http://crbug.com/128993. Revert it to 2s when the bug is fixed.
+ ' cvox_indicator_pulsing_animation 0s 2 alternate !important;' +
+ ' -webkit-animation-timing-function: ease-in-out !important;' +
+ '}' +
+ '.cvox_indicator_region {' +
+ ' opacity: 0 !important;' +
+ ' -webkit-transition: opacity 1s !important;' +
+ '}' +
+ '.cvox_indicator_visible {' +
+ ' opacity: 1 !important;' +
+ '}' +
+ '.cvox_indicator_container .cvox_indicator_region * {' +
+ ' position:absolute !important;' +
+ ' box-shadow: 0 0 4px 4px #f7983a !important;' +
+ ' border-radius: 6px !important;' +
+ ' margin: 0px !important;' +
+ ' padding: 0px !important;' +
+ ' -webkit-transition: none !important;' +
+ '}' +
+ '.cvox_indicator_animate_normal .cvox_indicator_region * {' +
+ ' -webkit-transition: all 0.3s !important;' +
+ '}' +
+ '.cvox_indicator_animate_quick .cvox_indicator_region * {' +
+ ' -webkit-transition: all 0.1s !important;' +
+ '}' +
+ '.cvox_indicator_top {' +
+ ' border-radius: inherit inherit 0 0 !important;' +
+ '}' +
+ '.cvox_indicator_middle_nw {' +
+ ' border-radius: inherit 0 0 0 !important;' +
+ '}' +
+ '.cvox_indicator_middle_ne {' +
+ ' border-radius: 0 inherit 0 0 !important;' +
+ '}' +
+ '.cvox_indicator_middle_se {' +
+ ' border-radius: 0 0 inherit 0 !important;' +
+ '}' +
+ '.cvox_indicator_middle_sw {' +
+ ' border-radius: 0 0 0 inherit !important;' +
+ '}' +
+ '.cvox_indicator_bottom {' +
+ ' border-radius: 0 0 inherit inherit !important;' +
+ '}' +
+ '@-webkit-keyframes cvox_indicator_pulsing_animation {' +
+ ' 0% {opacity: 1.0}' +
+ ' 50% {opacity: 0.5}' +
+ ' 100% {opacity: 1.0}' +
+ '}';
+
+/**
+ * The minimum number of milliseconds that must have elapsed
+ * since the last navigation for a quick animation to be allowed.
+ * @type {number}
+ * @const
+ */
+cvox.ActiveIndicator.QUICK_ANIM_DELAY_MS = 100;
+
+/**
+ * The minimum number of milliseconds that must have elapsed
+ * since the last navigation for a normal (slower) animation
+ * to be allowed.
+ * @type {number}
+ * @const
+ */
+cvox.ActiveIndicator.NORMAL_ANIM_DELAY_MS = 300;
+
+/**
+ * Margin between the active object's rect and the indicator border.
+ * @type {number}
+ * @const
+ */
+cvox.ActiveIndicator.MARGIN = 8;
+
+/**
+ * Remove the indicator from the DOM.
+ */
+cvox.ActiveIndicator.prototype.removeFromDom = function() {
+ if (this.container_ && this.container_.parentElement) {
+ this.container_.parentElement.removeChild(this.container_);
+ }
+};
+
+/**
+ * Move the indicator to surround the given node.
+ * @param {Node} node The new target of the indicator.
+ */
+cvox.ActiveIndicator.prototype.syncToNode = function(node) {
+ if (!node) {
+ return;
+ }
+ // In the navigation manager, and specifically the node walkers, focusing
+ // on the body means we are before the beginning of the document. In
+ // that case, we simply hide the active indicator.
+ if (node == document.body) {
+ this.removeFromDom();
+ return;
+ }
+ this.syncToNodes([node]);
+};
+
+/**
+ * Move the indicator to surround the given nodes.
+ * @param {Array.<Node>} nodes The new targets of the indicator.
+ */
+cvox.ActiveIndicator.prototype.syncToNodes = function(nodes) {
+ var clientRects = this.clientRectsFromNodes_(nodes);
+ this.moveIndicator_(clientRects, cvox.ActiveIndicator.MARGIN);
+ this.lastSyncTarget_ = nodes;
+ this.lastClientRects_ = clientRects;
+ if (this.updateIndicatorTimeoutId_ != null) {
+ window.clearTimeout(this.updateIndicatorTimeoutId_);
+ this.updateIndicatorTimeoutId_ = null;
+ }
+};
+
+/**
+ * Move the indicator to surround the given range.
+ * @param {Range} range The range.
+ */
+cvox.ActiveIndicator.prototype.syncToRange = function(range) {
+ var margin = cvox.ActiveIndicator.MARGIN;
+ if (range.startContainer == range.endContainer &&
+ range.startOffset + 1 == range.endOffset) {
+ margin = 1;
+ }
+
+ var clientRects = range.getClientRects();
+ this.moveIndicator_(clientRects, margin);
+ this.lastSyncTarget_ = range;
+ this.lastClientRects_ = clientRects;
+ if (this.updateIndicatorTimeoutId_ != null) {
+ window.clearTimeout(this.updateIndicatorTimeoutId_);
+ this.updateIndicatorTimeoutId_ = null;
+ }
+};
+
+/**
+ * Move the indicator to surround the given cursor range.
+ * @param {!cvox.CursorSelection} sel The start cursor position.
+ */
+cvox.ActiveIndicator.prototype.syncToCursorSelection = function(sel) {
+ if (sel.start.node == sel.end.node && sel.start.index == sel.end.index) {
+ this.syncToNode(sel.start.node);
+ } else {
+ var range = document.createRange();
+ range.setStart(sel.start.node, sel.start.index);
+ range.setEnd(sel.end.node, sel.end.index);
+ this.syncToRange(range);
+ }
+};
+
+/**
+ * Called when we should check to see if the indicator target has moved.
+ * Schedule it after a short delay so that we don't waste a lot of time
+ * updating.
+ */
+cvox.ActiveIndicator.prototype.updateIndicatorIfChanged = function() {
+ if (this.updateIndicatorTimeoutId_) {
+ return;
+ }
+ this.updateIndicatorTimeoutId_ = window.setTimeout(goog.bind(function() {
+ this.handleUpdateIndicatorIfChanged_();
+ }, this), 100);
+};
+
+/**
+ * Called when we should check to see if the indicator target has moved.
+ * Schedule it after a short delay so that we don't waste a lot of time
+ * updating.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.handleUpdateIndicatorIfChanged_ = function() {
+ this.updateIndicatorTimeoutId_ = null;
+ if (!this.lastSyncTarget_) {
+ return;
+ }
+
+ var newClientRects;
+ if (this.lastSyncTarget_ instanceof Array) {
+ newClientRects = this.clientRectsFromNodes_(this.lastSyncTarget_);
+ } else {
+ newClientRects = this.lastSyncTarget_.getClientRects();
+ }
+ if (!newClientRects || newClientRects.length == 0) {
+ this.syncToNode(document.body);
+ return;
+ }
+
+ var needsUpdate = false;
+ if (newClientRects.length != this.lastClientRects_.length) {
+ needsUpdate = true;
+ } else {
+ for (var i = 0; i < this.lastClientRects_.length; ++i) {
+ var last = this.lastClientRects_[i];
+ var current = newClientRects[i];
+ if (last.top != current.top ||
+ last.right != current.right ||
+ last.bottom != current.bottom ||
+ last.left != last.left) {
+ needsUpdate = true;
+ break;
+ }
+ }
+ }
+ if (needsUpdate) {
+ this.moveIndicator_(newClientRects, cvox.ActiveIndicator.MARGIN);
+ this.lastClientRects_ = newClientRects;
+ }
+};
+
+/**
+ * @param {Array.<Node>} nodes An array of nodes.
+ * @return {Array.<ClientRect>} An array of client rects corresponding to
+ * those nodes.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.clientRectsFromNodes_ = function(nodes) {
+ var clientRects = [];
+ for (var i = 0; i < nodes.length; ++i) {
+ var node = nodes[i];
+ if (node.constructor == Text) {
+ var range = document.createRange();
+ range.selectNode(node);
+ var rangeRects = range.getClientRects();
+ for (var j = 0; j < rangeRects.length; ++j)
+ clientRects.push(rangeRects[j]);
+ } else {
+ while (!node.getClientRects) {
+ node = node.parentElement;
+ }
+ var nodeRects = node.getClientRects();
+ for (var j = 0; j < nodeRects.length; ++j)
+ clientRects.push(nodeRects[j]);
+ }
+ }
+ return clientRects;
+};
+
+/**
+ * Move the indicator from its current location, if any, to surround
+ * the given set of rectanges.
+ *
+ * The rectangles need not be contiguous - they're automatically
+ * grouped into contiguous regions. The first region is "primary" - it
+ * gets animated smoothly from the previous location to the new location.
+ * Any other region (like, for example, a text range
+ * that continues on a second column) gets a temporary outline that
+ * disappears as soon as the indicator moves again.
+ *
+ * A single region does not have to be rectangular - a region outline
+ * is designed to handle the slightly non-rectangular shape of a typical
+ * text paragraph, but not anything more complicated than that.
+ *
+ * @param {ClientRectList|Array.<ClientRect>} immutableRects The object rectangles.
+ * @param {number} margin Margin in pixels.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.moveIndicator_ = function(
+ immutableRects, margin) {
+ // Never put the active indicator into the DOM when the whole page is
+ // contentEditable; it will end up part of content that the user may
+ // be trying to edit.
+ if (document.body.isContentEditable) {
+ this.removeFromDom();
+ return;
+ }
+
+ var n = immutableRects.length;
+ if (n == 0) {
+ return;
+ }
+
+ // Offset the rects by documentElement, body, and/or scroll offsets,
+ // while copying them into a new mutable array.
+ var offsetX;
+ var offsetY;
+ if (window.getComputedStyle(document.body, null).position != 'static') {
+ offsetX = -document.body.getBoundingClientRect().left;
+ offsetY = -document.body.getBoundingClientRect().top;
+ } else if (window.getComputedStyle(document.documentElement, null).position
+ != 'static') {
+ offsetX = -document.documentElement.getBoundingClientRect().left;
+ offsetY = -document.documentElement.getBoundingClientRect().top;
+ } else {
+ offsetX = window.pageXOffset;
+ offsetY = window.pageYOffset;
+ }
+
+ var rects = [];
+ for (var i = 0; i < n; i++) {
+ rects.push(
+ this.inset_(immutableRects[i], offsetX, offsetY, -offsetX, -offsetY));
+ }
+
+ // Create and attach the container if it doesn't exist or if it was detached.
+ if (!this.container_ || !this.container_.parentElement) {
+ // In case there are any detached containers around, clean them up. One case
+ // that requires clean up like this is when users download a file on Chrome
+ // on Android.
+ var oldContainers =
+ document.getElementsByClassName('cvox_indicator_container');
+ for (var j = 0, oldContainer; oldContainer = oldContainers[j]; j++) {
+ if (oldContainer.parentNode) {
+ oldContainer.parentNode.removeChild(oldContainer);
+ }
+ }
+ this.container_ = this.createDiv_(
+ document.body, 'cvox_indicator_container', document.body.firstChild);
+ }
+
+ // Add the CSS style to the page if it's not already there.
+ var style = document.createElement('style');
+ style.id = 'cvox_indicator_style';
+ style.innerHTML = cvox.ActiveIndicator.STYLE;
+ cvox.DomUtil.addNodeToHead(style, style.id);
+
+ // Decide on the animation speed. By default we do a medium-speed
+ // animation between the previous and new location. If the user is
+ // moving rapidly, we do a fast animation, or no animation.
+ var now = new Date().getTime();
+ var delta = now - this.lastMoveTime_;
+ this.container_.className = 'cvox_indicator_container';
+ if (!document.hasFocus() || this.blurred_) {
+ this.container_.classList.add('cvox_indicator_window_not_focused');
+ }
+ if (delta > cvox.ActiveIndicator.NORMAL_ANIM_DELAY_MS) {
+ this.container_.classList.add('cvox_indicator_animate_normal');
+ } else if (delta > cvox.ActiveIndicator.QUICK_ANIM_DELAY_MS) {
+ this.container_.classList.add('cvox_indicator_animate_quick');
+ }
+ this.lastMoveTime_ = now;
+
+ // Compute the zoom level of the browser - this is needed to avoid
+ // roundoff errors when placing the various pieces of the region
+ // outline.
+ this.computeZoomLevel_();
+
+ // Make it start pulsing after it's drawn the first frame - this is so
+ // that the opacity is always 100% when the indicator appears, and only
+ // starts pulsing afterwards.
+ window.setTimeout(goog.bind(function() {
+ this.container_.classList.add('cvox_indicator_pulsing');
+ }, this), 0);
+
+ // If there was more than one region previously, delete all except
+ // the first one.
+ while (this.container_.childElementCount > 1) {
+ this.container_.removeChild(this.container_.lastElementChild);
+ }
+
+ // Split the rects into contiguous regions.
+ var regions = [[rects[0]]];
+ var regionRects = [rects[0]];
+ for (i = 1; i < rects.length; i++) {
+ var found = false;
+ for (var j = 0; j < regions.length && !found; j++) {
+ if (this.intersects_(rects[i], regionRects[j])) {
+ regions[j].push(rects[i]);
+ regionRects[j] = this.union_(regionRects[j], rects[i]);
+ found = true;
+ }
+ }
+ if (!found) {
+ regions.push([rects[i]]);
+ regionRects.push(rects[i]);
+ }
+ }
+
+ // Keep merging regions that intersect.
+ // TODO(dmazzoni): reduce the worst-case complexity! This appears like
+ // it could be O(n^3), make sure it's not in practice.
+ do {
+ var merged = false;
+ for (i = 0; i < regions.length - 1 && !merged; i++) {
+ for (j = i + 1; j < regions.length && !merged; j++) {
+ if (this.intersects_(regionRects[i], regionRects[j])) {
+ regions[i] = regions[i].concat(regions[j]);
+ regionRects[i] = this.union_(regionRects[i], regionRects[j]);
+ regions.splice(j, 1);
+ regionRects.splice(j, 1);
+ merged = true;
+ }
+ }
+ }
+ } while (merged);
+
+ // Sort rects within each region by y and then x position.
+ for (i = 0; i < regions.length; i++) {
+ regions[i].sort(function(r1, r2) {
+ if (r1.top != r2.top) {
+ return r1.top - r2.top;
+ } else {
+ return r1.left - r2.left;
+ }
+ });
+ }
+
+ // Draw each indicator region. The first region attempts to re-use the
+ // existing elements (which results in animating the transition).
+ for (i = 0; i < regions.length; i++) {
+ var parent = null;
+ if (i == 0 &&
+ this.container_.childElementCount == 1 &&
+ this.container_.children[0].childElementCount == 6) {
+ parent = this.container_.children[0];
+ }
+ this.updateIndicatorRegion_(regions[i], parent, margin);
+ }
+};
+
+/**
+ * Update one indicator region - a set of contiguous rectangles on the
+ * page.
+ *
+ * A region is made up of six pieces, designed to handle the shape of a
+ * typical text paragraph:
+ *
+ * TOP TOP TOP
+ * TOP TOP
+ * NW NW NW NW NW NE NE NE NE NE NE NE NE NE
+ * NW NE
+ * NW NE
+ * SW SE
+ * SW SE
+ * SW SW BOTTOM BOTTOM SE SE
+ * BOTTOM BOTTOM
+ * BOTTOM BOTTOM BOTTOM BOTTOM BOTTOM
+ *
+ * When there's only a single rectangle - like when outlining something
+ * simple like a button, all six pieces are still used - this makes the
+ * animation smooth when sliding from a paragraph to a rectangular object
+ * and then to another paragraph, for example:
+ *
+ * TOP TOP TOP TOP TOP TOP TOP
+ * TOP TOP
+ * NW NE
+ * NW NE
+ * SW SE
+ * SW SE
+ * BOTTOM BOTTOM
+ * BOTTOM BOTTOM BOTTOM BOTTOM
+ *
+ * Each piece is just a div that uses CSS to absolutely position itself.
+ * The outline effect is done using the 'box-shadow' property around the
+ * whole box, with the 'clip' property used to make sure that only 2 - 3
+ * sides of the box are actually shown.
+ *
+ * This code is very subtle! If you want to adjust something by a few
+ * pixels, be prepared to do LOTS of testing!
+ *
+ * Tip: while debugging, comment out the clipping and make each rectangle
+ * a different color. That will make it much easier to see where each piece
+ * starts and ends.
+ *
+ * @param {Array.<ClientRect>} rects The list of rects in the region.
+ * These should already be sorted (top to bottom and left to right).
+ * @param {?Element} parent If present, try to reuse the existing element
+ * (and animate the transition).
+ * @param {number} margin Margin in pixels.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.updateIndicatorRegion_ = function(
+ rects, parent, margin) {
+ if (parent) {
+ // Reuse the existing element (so we animate to the new location).
+ var regionTop = parent.children[0];
+ var regionMiddleNW = parent.children[1];
+ var regionMiddleNE = parent.children[2];
+ var regionMiddleSW = parent.children[3];
+ var regionMiddleSE = parent.children[4];
+ var regionBottom = parent.children[5];
+ } else {
+ // Create a new region (when the indicator first appears, or when
+ // this is a secondary region, like for text continuing on a second
+ // column).
+ parent = this.createDiv_(this.container_, 'cvox_indicator_region');
+ window.setTimeout(function() {
+ parent.classList.add('cvox_indicator_visible');
+ }, 0);
+ regionTop = this.createDiv_(parent, 'cvox_indicator_top');
+ regionMiddleNW = this.createDiv_(parent, 'cvox_indicator_middle_nw');
+ regionMiddleNE = this.createDiv_(parent, 'cvox_indicator_middle_ne');
+ regionMiddleSW = this.createDiv_(parent, 'cvox_indicator_middle_sw');
+ regionMiddleSE = this.createDiv_(parent, 'cvox_indicator_middle_se');
+ regionBottom = this.createDiv_(parent, 'cvox_indicator_bottom');
+ }
+
+ // Grab all of the rectangles in the top row.
+ var topRect = rects[0];
+ var topMiddle = Math.floor((topRect.top + topRect.bottom) / 2);
+ var topIndex = 1;
+ var n = rects.length;
+ while (topIndex < n && rects[topIndex].top < topMiddle) {
+ topRect = this.union_(topRect, rects[topIndex]);
+ topMiddle = Math.floor((topRect.top + topRect.bottom) / 2);
+ topIndex++;
+ }
+
+ if (topIndex == n) {
+ // Everything fits on one line, so use special case code to form
+ // the region into a rectangle.
+ var r = this.inset_(topRect, -margin, -margin, -margin, -margin);
+ var q1 = Math.floor((3 * r.top + 1 * r.bottom) / 4);
+ var q2 = Math.floor((2 * r.top + 2 * r.bottom) / 4);
+ var q3 = Math.floor((1 * r.top + 3 * r.bottom) / 4);
+ this.setElementCoords_(regionTop, r.left, r.top, r.right, q1,
+ true, true, true, false);
+ this.setElementCoords_(regionMiddleNW, r.left, q1, r.left, q2,
+ true, true, false, false);
+ this.setElementCoords_(regionMiddleSW, r.left, q2, r.left, q3,
+ true, false, false, true);
+ this.setElementCoords_(regionMiddleNE, r.right, q1, r.right, q2,
+ false, true, true, false);
+ this.setElementCoords_(regionMiddleSE, r.right, q2, r.right, q3,
+ false, false, true, true);
+ this.setElementCoords_(regionBottom, r.left, q3, r.right, r.bottom,
+ true, false, true, true);
+ return;
+ }
+
+ // Start from the end and grab all of the rectangles in the bottom row.
+ var bottomRect = rects[n - 1];
+ var bottomMiddle = Math.floor((bottomRect.top + bottomRect.bottom) / 2);
+ var bottomIndex = n - 2;
+ while (bottomIndex >= 0 && rects[bottomIndex].bottom > bottomMiddle) {
+ bottomRect = this.union_(bottomRect, rects[bottomIndex]);
+ bottomMiddle = Math.floor((bottomRect.top + bottomRect.bottom) / 2);
+ bottomIndex--;
+ }
+
+ // Extend the top and bottom rectangles a bit.
+ topRect = this.inset_(topRect, -margin, -margin, -margin, margin);
+ bottomRect = this.inset_(bottomRect, -margin, margin, -margin, -margin);
+
+ // Whatever's in-between the top and bottom is the "middle".
+ var middleRect;
+ if (topIndex > bottomIndex) {
+ middleRect = this.union_(topRect, bottomRect);
+ middleRect.top = topRect.bottom;
+ middleRect.bottom = bottomRect.top;
+ middleRect.height = Math.floor((middleRect.top + middleRect.bottom) / 2);
+ } else {
+ middleRect = rects[topIndex];
+ var middleIndex = topIndex + 1;
+ while (middleIndex <= bottomIndex) {
+ middleRect = this.union_(middleRect, rects[middleIndex]);
+ middleIndex++;
+ }
+ middleRect = this.inset_(middleRect, -margin, -margin, -margin, -margin);
+ middleRect.left = Math.min(
+ middleRect.left, topRect.left, bottomRect.left);
+ middleRect.right = Math.max(
+ middleRect.right, topRect.right, bottomRect.right);
+ middleRect.width = middleRect.right - middleRect.left;
+ }
+
+ // If the top or bottom is pretty close to the edge of the middle box,
+ // make them flush.
+ if (topRect.right > middleRect.right - 40) {
+ topRect.right = middleRect.right;
+ topRect.width = topRect.right - topRect.left;
+ }
+ if (topRect.left < middleRect.left + 40) {
+ topRect.left = middleRect.left;
+ topRect.width = topRect.right - topRect.left;
+ }
+ if (bottomRect.right > middleRect.right - 40) {
+ bottomRect.right = middleRect.right;
+ bottomRect.width = bottomRect.right - bottomRect.left;
+ }
+ if (bottomRect.left < middleRect.left + 40) {
+ bottomRect.left = middleRect.left;
+ bottomRect.width = bottomRect.right - bottomRect.left;
+ }
+
+ var midline = Math.floor((middleRect.top + middleRect.bottom) / 2);
+
+ this.setElementRect_(regionTop, topRect, true, true, true, false);
+ this.setElementRect_(regionBottom, bottomRect, true, false, true, true);
+
+ this.setElementCoords_(
+ regionMiddleNW,
+ middleRect.left, topRect.bottom, topRect.left, midline,
+ true, true, false, false);
+ this.setElementCoords_(
+ regionMiddleNE,
+ topRect.right, topRect.bottom,
+ middleRect.right, midline,
+ false, true, true, false);
+ this.setElementCoords_(
+ regionMiddleSW,
+ middleRect.left, midline, bottomRect.left, bottomRect.top,
+ true, false, false, true);
+ this.setElementCoords_(
+ regionMiddleSE,
+ bottomRect.right, midline,
+ middleRect.right, bottomRect.top,
+ false, false, true, true);
+};
+
+/**
+ * Given two rectangles, return whether or not they intersect
+ * (including a bit of slop, so if they're almost touching, we
+ * return true).
+ * @param {ClientRect} r1 The first rect.
+ * @param {ClientRect} r2 The second rect.
+ * @return {boolean} Whether or not they intersect.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.intersects_ = function(r1, r2) {
+ var slop = 2 * cvox.ActiveIndicator.MARGIN;
+ return (r2.left <= r1.right + slop &&
+ r2.right >= r1.left - slop &&
+ r2.top <= r1.bottom + slop &&
+ r2.bottom >= r1.top - slop);
+};
+
+/**
+ * Given two rectangles, compute their union.
+ * @param {ClientRect} r1 The first rect.
+ * @param {ClientRect} r2 The second rect.
+ * @return {ClientRect} The union of the two rectangles.
+ * @private
+ * @suppress {invalidCasts} invalid cast - must be a subtype or supertype
+ * from: {bottom: number, height: number, left: number, right: number, ...}
+ * to : (ClientRect|null)
+ */
+cvox.ActiveIndicator.prototype.union_ = function(r1, r2) {
+ var result = {
+ left: Math.min(r1.left, r2.left),
+ top: Math.min(r1.top, r2.top),
+ right: Math.max(r1.right, r2.right),
+ bottom: Math.max(r1.bottom, r2.bottom)
+ };
+ result.width = result.right - result.left;
+ result.height = result.bottom - result.top;
+ return /** @type {ClientRect} */(result);
+};
+
+/**
+ * Given a rectangle and four offsets, return a new rectangle inset by
+ * the given offsets.
+ * @param {ClientRect} r The first rect.
+ * @param {number} left The left inset.
+ * @param {number} top The top inset.
+ * @param {number} right The right inset.
+ * @param {number} bottom The bottom inset.
+ * @return {ClientRect} The new rectangle.
+ * @private
+ * @suppress {invalidCasts} invalid cast - must be a subtype or supertype
+ * from: {bottom: number, height: number, left: number, right: number, ...}
+ * to : (ClientRect|null)
+ */
+cvox.ActiveIndicator.prototype.inset_ = function(r, left, top, right, bottom) {
+ var result = {
+ left: r.left + left,
+ top: r.top + top,
+ right: r.right - right,
+ bottom: r.bottom - bottom
+ };
+ result.width = result.right - result.left;
+ result.height = result.bottom - result.top;
+ return /** @type {ClientRect} */(result);
+};
+
+/**
+ * Convenience method to create an element of type DIV, give it
+ * particular class name, and add it as a child of a given parent.
+ * @param {Element} parent The parent element of the new div.
+ * @param {string} className The class name of the new div.
+ * @param {Node=} opt_before Will insert before this node, if present.
+ * @return {Element} The new div.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.createDiv_ = function(
+ parent, className, opt_before) {
+ var elem = document.createElement('div');
+ elem.className = className;
+ if (opt_before) {
+ parent.insertBefore(elem, opt_before);
+ } else {
+ parent.appendChild(elem);
+ }
+ return elem;
+};
+
+/**
+ * In WebKit, when the user has zoomed the page, every CSS coordinate is
+ * multiplied by the zoom level and rounded down. This can cause objects to
+ * fail to line up; for example an object with left position 100 and width
+ * 50 may not line up with an object with right position 150 pixels, if the
+ * zoom is not equal to 1.0. To fix this, we compute the actual desired
+ * coordinate when zoomed, then add a small fractional offset and divide
+ * by the zoom factor, and use that value as the item's coordinate instead.
+ *
+ * @param {number} x A coordinate to be transformed.
+ * @return {number} The new coordinate to use.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.fixZoom_ = function(x) {
+ return (Math.round(x * this.zoom_) + 0.1) / this.zoom_;
+};
+
+/**
+ * See fixZoom_, above. This method is the same except that it returns the
+ * width such that right pos (x + width) is correct when multiplied by the
+ * zoom factor.
+ *
+ * @param {number} x A coordinate to be transformed.
+ * @param {number} width The width of the object.
+ * @return {number} The new width to use.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.fixZoomSum_ = function(x, width) {
+ var zoomedX = Math.round(x * this.zoom_);
+ var zoomedRight = Math.round((x + width) * this.zoom_);
+ var zoomedWidth = (zoomedRight - zoomedX);
+ return (zoomedWidth + 0.1) / this.zoom_;
+};
+
+/**
+ * Set the coordinates of an element to the given left, top, right, and
+ * bottom pixel coordinates, taking the browser zoom level into account.
+ * Also set the clipping rectangle to exclude some of the edges of the
+ * rectangle, based on the value of showLeft, showTop, showRight, and
+ * showBottom.
+ *
+ * @param {Element} element The element to move.
+ * @param {number} left The new left coordinate.
+ * @param {number} top The new top coordinate.
+ * @param {number} right The new right coordinate.
+ * @param {number} bottom The new bottom coordinate.
+ * @param {boolean} showLeft Whether to show or clip at the left border.
+ * @param {boolean} showTop Whether to show or clip at the top border.
+ * @param {boolean} showRight Whether to show or clip at the right border.
+ * @param {boolean} showBottom Whether to show or clip at the bottom border.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.setElementCoords_ = function(
+ element,
+ left, top, right, bottom,
+ showLeft, showTop, showRight, showBottom) {
+ var origWidth = right - left;
+ var origHeight = bottom - top;
+
+ var width = right - left;
+ var height = bottom - top;
+ var clipLeft = showLeft ? -20 : 0;
+ var clipTop = showTop ? -20 : 0;
+ var clipRight = showRight ? 20 : 0;
+ var clipBottom = showBottom ? 20 : 0;
+ if (width == 0) {
+ if (showRight) {
+ left -= 5;
+ width += 5;
+ } else if (showLeft) {
+ width += 10;
+ }
+ clipTop = 10;
+ clipBottom = 10;
+ top -= 10;
+ height += 20;
+ }
+ if (!showBottom)
+ height += 5;
+ if (!showTop) {
+ top -= 5;
+ height += 5;
+ clipTop += 5;
+ clipBottom += 5;
+ }
+ if (clipRight == 0 && origWidth == 0) {
+ clipRight = 1;
+ } else {
+ clipRight = this.fixZoomSum_(left, clipRight + origWidth);
+ }
+ clipBottom = this.fixZoomSum_(top, clipBottom + origHeight);
+
+ element.style.left = this.fixZoom_(left) + 'px';
+ element.style.top = this.fixZoom_(top) + 'px';
+ element.style.width = this.fixZoomSum_(left, width) + 'px';
+ element.style.height = this.fixZoomSum_(top, height) + 'px';
+ element.style.clip =
+ 'rect(' + [clipTop, clipRight, clipBottom, clipLeft].join('px ') + 'px)';
+};
+
+/**
+ * Same as setElementCoords_, but takes a rect instead of coordinates.
+ *
+ * @param {Element} element The element to move.
+ * @param {ClientRect} r The new coordinates.
+ * @param {boolean} showLeft Whether to show or clip at the left border.
+ * @param {boolean} showTop Whether to show or clip at the top border.
+ * @param {boolean} showRight Whether to show or clip at the right border.
+ * @param {boolean} showBottom Whether to show or clip at the bottom border.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.setElementRect_ = function(
+ element, r, showLeft, showTop, showRight, showBottom) {
+ this.setElementCoords_(element, r.left, r.top, r.right, r.bottom,
+ showLeft, showTop, showRight, showBottom);
+};
+
+/**
+ * Compute an approximation of the current browser zoom level by
+ * comparing the measurement of a large character of text
+ * with the -webkit-text-size-adjust:none style to the expected
+ * pixel coordinates if it was adjusted.
+ * @private
+ */
+cvox.ActiveIndicator.prototype.computeZoomLevel_ = function() {
+ if (window.innerHeight === this.innerHeight_ &&
+ window.innerWidth === this.innerWidth_) {
+ return;
+ }
+
+ this.innerHeight_ = window.innerHeight;
+ this.innerWidth_ = window.innerWidth;
+
+ var zoomMeasureElement = document.createElement('div');
+ zoomMeasureElement.innerHTML = 'X';
+ zoomMeasureElement.setAttribute(
+ 'style',
+ 'font: 5000px/1em sans-serif !important;' +
+ ' -webkit-text-size-adjust:none !important;' +
+ ' visibility:hidden !important;' +
+ ' left: -10000px !important;' +
+ ' top: -10000px !important;' +
+ ' position:absolute !important;');
+ document.body.appendChild(zoomMeasureElement);
+
+ var zoomLevel = 5000 / zoomMeasureElement.clientHeight;
+ var newZoom = Math.round(zoomLevel * 500) / 500;
+ if (newZoom > 0.1 && newZoom < 10) {
+ this.zoom_ = newZoom;
+ }
+
+ // TODO(dmazzoni): warn or log if the computed zoom is bad?
+ zoomMeasureElement.parentNode.removeChild(zoomMeasureElement);
+};