Home | History | Annotate | Download | only in injected
      1 // Copyright 2014 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 /**
      6  * @fileoverview Draws and animates the graphical indicator around the active
      7  *    object or text range, and handles animation when the indicator is moving.
      8  */
      9 
     10 
     11 goog.provide('cvox.ActiveIndicator');
     12 
     13 goog.require('cvox.Cursor');
     14 goog.require('cvox.DomUtil');
     15 
     16 
     17 /**
     18  * Constructs and ActiveIndicator, a glowing outline around whatever
     19  * node or text range is currently active. Initially it won't display
     20  * anything; call syncToNode, syncToRange, or syncToCursorSelection to
     21  * make it animate and move. It only displays when this window/iframe
     22  * has focus.
     23  *
     24  * @constructor
     25  */
     26 cvox.ActiveIndicator = function() {
     27   /**
     28    * The time when the indicator was most recently moved.
     29    * @type {number}
     30    * @private
     31    */
     32   this.lastMoveTime_ = 0;
     33 
     34   /**
     35    * An estimate of the current zoom factor of the webpage. This is
     36    * needed in order to accurately line up the different pieces of the
     37    * indicator border and avoid rounding errors.
     38    * @type {number}
     39    * @private
     40    */
     41   this.zoom_ = 1;
     42 
     43   /**
     44    * The parent element of the indicator.
     45    * @type {?Element}
     46    * @private
     47    */
     48   this.container_ = null;
     49 
     50   /**
     51    * The current indicator rects.
     52    * @type {Array.<ClientRect>}
     53    * @private
     54    */
     55   this.rects_ = null;
     56 
     57   /**
     58    * The most recent target of a call to syncToNode, syncToRange, or
     59    * syncToCursorSelection.
     60    * @type {Array.<Node>|Range}
     61    * @private
     62    */
     63   this.lastSyncTarget_ = null;
     64 
     65   /**
     66    * The most recent client rects for the active indicator, so we
     67    * can tell when it moved.
     68    * @type {ClientRectList|Array.<ClientRect>}
     69    * @private
     70    */
     71   this.lastClientRects_ = null;
     72 
     73   /**
     74    * The id from window.setTimeout when updating the indicator if needed.
     75    * @type {?number}
     76    * @private
     77    */
     78   this.updateIndicatorTimeoutId_ = null;
     79 
     80   /**
     81    * True if this window is blurred and we shouldn't show the indicator.
     82    * @type {boolean}
     83    * @private
     84    */
     85   this.blurred_ = false;
     86 
     87   /**
     88    * A cached value of window height.
     89    * @type {number|undefined}
     90    * @private
     91    */
     92   this.innerHeight_;
     93 
     94   /**
     95    * A cached value of window width.
     96    * @type {number|undefined}
     97    * @private
     98    */
     99   this.innerWidth_;
    100 
    101   // Hide the indicator when the window doesn't have focus.
    102   window.addEventListener('focus', goog.bind(function() {
    103     this.blurred_ = false;
    104     if (this.container_) {
    105       this.container_.classList.remove('cvox_indicator_window_not_focused');
    106     }
    107   }, this), false);
    108   window.addEventListener('blur', goog.bind(function() {
    109     this.blurred_ = true;
    110     if (this.container_) {
    111       this.container_.classList.add('cvox_indicator_window_not_focused');
    112     }
    113   }, this), false);
    114 };
    115 
    116 /**
    117  * CSS for the active indicator. The basic hierarchy looks like this:
    118  *
    119  * container (pulsing) (animate_normal, animate_quick)
    120  *   region (visible)
    121  *     top
    122  *     middle_nw
    123  *     middle_ne
    124  *     middle_sw
    125  *     middle_se
    126  *     bottom
    127  *   region (visible)
    128  *     top
    129  *     middle_nw
    130  *     middle_ne
    131  *     middle_sw
    132  *     middle_se
    133  *     bottom
    134  *
    135  * @type {string}
    136  * @const
    137  */
    138 cvox.ActiveIndicator.STYLE =
    139     '.cvox_indicator_container {' +
    140     '  position: absolute !important;' +
    141     '  left: 0 !important;' +
    142     '  top: 0 !important;' +
    143     '  z-index: 2147483647 !important;' +
    144     '  pointer-events: none !important;' +
    145     '  margin: 0px !important;' +
    146     '  padding: 0px !important;' +
    147     '}' +
    148     '.cvox_indicator_window_not_focused {' +
    149     '  visibility: hidden !important;' +
    150     '}' +
    151     '.cvox_indicator_pulsing {' +
    152     '  -webkit-animation: ' +
    153     // NOTE(deboer): This animation is 0 seconds long to work around
    154     // http://crbug.com/128993.  Revert it to 2s when the bug is fixed.
    155     '      cvox_indicator_pulsing_animation 0s 2 alternate !important;' +
    156     '  -webkit-animation-timing-function: ease-in-out !important;' +
    157     '}' +
    158     '.cvox_indicator_region {' +
    159     '  opacity: 0 !important;' +
    160     '  -webkit-transition: opacity 1s !important;' +
    161     '}' +
    162     '.cvox_indicator_visible {' +
    163     '  opacity: 1 !important;' +
    164     '}' +
    165     '.cvox_indicator_container .cvox_indicator_region * {' +
    166     '  position:absolute !important;' +
    167     '  box-shadow: 0 0 4px 4px #f7983a !important;' +
    168     '  border-radius: 6px !important;' +
    169     '  margin: 0px !important;' +
    170     '  padding: 0px !important;' +
    171     '  -webkit-transition: none !important;' +
    172     '}' +
    173     '.cvox_indicator_animate_normal .cvox_indicator_region * {' +
    174     '  -webkit-transition: all 0.3s !important;' +
    175     '}' +
    176     '.cvox_indicator_animate_quick .cvox_indicator_region * {' +
    177     '  -webkit-transition: all 0.1s !important;' +
    178     '}' +
    179     '.cvox_indicator_top {' +
    180     '  border-radius: inherit inherit 0 0 !important;' +
    181     '}' +
    182     '.cvox_indicator_middle_nw {' +
    183     '  border-radius: inherit 0 0 0 !important;' +
    184     '}' +
    185     '.cvox_indicator_middle_ne {' +
    186     '  border-radius: 0 inherit 0 0 !important;' +
    187     '}' +
    188     '.cvox_indicator_middle_se {' +
    189     '  border-radius: 0 0 inherit 0 !important;' +
    190     '}' +
    191     '.cvox_indicator_middle_sw {' +
    192     '  border-radius: 0 0 0 inherit !important;' +
    193     '}' +
    194     '.cvox_indicator_bottom {' +
    195     '  border-radius: 0 0 inherit inherit !important;' +
    196     '}' +
    197     '@-webkit-keyframes cvox_indicator_pulsing_animation {' +
    198     '   0% {opacity: 1.0}' +
    199     '  50% {opacity: 0.5}' +
    200     ' 100% {opacity: 1.0}' +
    201     '}';
    202 
    203 /**
    204  * The minimum number of milliseconds that must have elapsed
    205  * since the last navigation for a quick animation to be allowed.
    206  * @type {number}
    207  * @const
    208  */
    209 cvox.ActiveIndicator.QUICK_ANIM_DELAY_MS = 100;
    210 
    211 /**
    212  * The minimum number of milliseconds that must have elapsed
    213  * since the last navigation for a normal (slower) animation
    214  * to be allowed.
    215  * @type {number}
    216  * @const
    217  */
    218 cvox.ActiveIndicator.NORMAL_ANIM_DELAY_MS = 300;
    219 
    220 /**
    221  * Margin between the active object's rect and the indicator border.
    222  * @type {number}
    223  * @const
    224  */
    225 cvox.ActiveIndicator.MARGIN = 8;
    226 
    227 /**
    228  * Remove the indicator from the DOM.
    229  */
    230 cvox.ActiveIndicator.prototype.removeFromDom = function() {
    231   if (this.container_ && this.container_.parentElement) {
    232     this.container_.parentElement.removeChild(this.container_);
    233   }
    234 };
    235 
    236 /**
    237  * Move the indicator to surround the given node.
    238  * @param {Node} node The new target of the indicator.
    239  */
    240 cvox.ActiveIndicator.prototype.syncToNode = function(node) {
    241   if (!node) {
    242     return;
    243   }
    244   // In the navigation manager, and specifically the node walkers, focusing
    245   // on the body means we are before the beginning of the document.  In
    246   // that case, we simply hide the active indicator.
    247   if (node == document.body) {
    248     this.removeFromDom();
    249     return;
    250   }
    251   this.syncToNodes([node]);
    252 };
    253 
    254 /**
    255  * Move the indicator to surround the given nodes.
    256  * @param {Array.<Node>} nodes The new targets of the indicator.
    257  */
    258 cvox.ActiveIndicator.prototype.syncToNodes = function(nodes) {
    259   var clientRects = this.clientRectsFromNodes_(nodes);
    260   this.moveIndicator_(clientRects, cvox.ActiveIndicator.MARGIN);
    261   this.lastSyncTarget_ = nodes;
    262   this.lastClientRects_ = clientRects;
    263   if (this.updateIndicatorTimeoutId_ != null) {
    264     window.clearTimeout(this.updateIndicatorTimeoutId_);
    265     this.updateIndicatorTimeoutId_ = null;
    266   }
    267 };
    268 
    269 /**
    270  * Move the indicator to surround the given range.
    271  * @param {Range} range The range.
    272  */
    273 cvox.ActiveIndicator.prototype.syncToRange = function(range) {
    274   var margin = cvox.ActiveIndicator.MARGIN;
    275   if (range.startContainer == range.endContainer &&
    276       range.startOffset + 1 == range.endOffset) {
    277     margin = 1;
    278   }
    279 
    280   var clientRects = range.getClientRects();
    281   this.moveIndicator_(clientRects, margin);
    282   this.lastSyncTarget_ = range;
    283   this.lastClientRects_ = clientRects;
    284   if (this.updateIndicatorTimeoutId_ != null) {
    285     window.clearTimeout(this.updateIndicatorTimeoutId_);
    286     this.updateIndicatorTimeoutId_ = null;
    287   }
    288 };
    289 
    290 /**
    291  * Move the indicator to surround the given cursor range.
    292  * @param {!cvox.CursorSelection} sel The start cursor position.
    293  */
    294 cvox.ActiveIndicator.prototype.syncToCursorSelection = function(sel) {
    295   if (sel.start.node == sel.end.node && sel.start.index == sel.end.index) {
    296     this.syncToNode(sel.start.node);
    297   } else {
    298     var range = document.createRange();
    299     range.setStart(sel.start.node, sel.start.index);
    300     range.setEnd(sel.end.node, sel.end.index);
    301     this.syncToRange(range);
    302   }
    303 };
    304 
    305 /**
    306  * Called when we should check to see if the indicator target has moved.
    307  * Schedule it after a short delay so that we don't waste a lot of time
    308  * updating.
    309  */
    310 cvox.ActiveIndicator.prototype.updateIndicatorIfChanged = function() {
    311   if (this.updateIndicatorTimeoutId_) {
    312     return;
    313   }
    314   this.updateIndicatorTimeoutId_ = window.setTimeout(goog.bind(function() {
    315     this.handleUpdateIndicatorIfChanged_();
    316   }, this), 100);
    317 };
    318 
    319 /**
    320  * Called when we should check to see if the indicator target has moved.
    321  * Schedule it after a short delay so that we don't waste a lot of time
    322  * updating.
    323  * @private
    324  */
    325 cvox.ActiveIndicator.prototype.handleUpdateIndicatorIfChanged_ = function() {
    326   this.updateIndicatorTimeoutId_ = null;
    327   if (!this.lastSyncTarget_) {
    328     return;
    329   }
    330 
    331   var newClientRects;
    332   if (this.lastSyncTarget_ instanceof Array) {
    333     newClientRects = this.clientRectsFromNodes_(this.lastSyncTarget_);
    334   } else {
    335     newClientRects = this.lastSyncTarget_.getClientRects();
    336   }
    337   if (!newClientRects || newClientRects.length == 0) {
    338     this.syncToNode(document.body);
    339     return;
    340   }
    341 
    342   var needsUpdate = false;
    343   if (newClientRects.length != this.lastClientRects_.length) {
    344     needsUpdate = true;
    345   } else {
    346     for (var i = 0; i < this.lastClientRects_.length; ++i) {
    347       var last = this.lastClientRects_[i];
    348       var current = newClientRects[i];
    349       if (last.top != current.top ||
    350           last.right != current.right ||
    351           last.bottom != current.bottom ||
    352           last.left != last.left) {
    353         needsUpdate = true;
    354         break;
    355       }
    356     }
    357   }
    358   if (needsUpdate) {
    359     this.moveIndicator_(newClientRects, cvox.ActiveIndicator.MARGIN);
    360     this.lastClientRects_ = newClientRects;
    361   }
    362 };
    363 
    364 /**
    365  * @param {Array.<Node>} nodes An array of nodes.
    366  * @return {Array.<ClientRect>} An array of client rects corresponding to
    367  *     those nodes.
    368  * @private
    369  */
    370 cvox.ActiveIndicator.prototype.clientRectsFromNodes_ = function(nodes) {
    371   var clientRects = [];
    372   for (var i = 0; i < nodes.length; ++i) {
    373     var node = nodes[i];
    374     if (node.constructor == Text) {
    375       var range = document.createRange();
    376       range.selectNode(node);
    377       var rangeRects = range.getClientRects();
    378       for (var j = 0; j < rangeRects.length; ++j)
    379         clientRects.push(rangeRects[j]);
    380     } else {
    381       while (!node.getClientRects) {
    382         node = node.parentElement;
    383       }
    384       var nodeRects = node.getClientRects();
    385       for (var j = 0; j < nodeRects.length; ++j)
    386         clientRects.push(nodeRects[j]);
    387     }
    388   }
    389   return clientRects;
    390 };
    391 
    392 /**
    393  * Move the indicator from its current location, if any, to surround
    394  * the given set of rectanges.
    395  *
    396  * The rectangles need not be contiguous - they're automatically
    397  * grouped into contiguous regions. The first region is "primary" - it
    398  * gets animated smoothly from the previous location to the new location.
    399  * Any other region (like, for example, a text range
    400  * that continues on a second column) gets a temporary outline that
    401  * disappears as soon as the indicator moves again.
    402  *
    403  * A single region does not have to be rectangular - a region outline
    404  * is designed to handle the slightly non-rectangular shape of a typical
    405  * text paragraph, but not anything more complicated than that.
    406  *
    407  * @param {ClientRectList|Array.<ClientRect>} immutableRects The object rectangles.
    408  * @param {number} margin Margin in pixels.
    409  * @private
    410  */
    411 cvox.ActiveIndicator.prototype.moveIndicator_ = function(
    412     immutableRects, margin) {
    413   // Never put the active indicator into the DOM when the whole page is
    414   // contentEditable; it will end up part of content that the user may
    415   // be trying to edit.
    416   if (document.body.isContentEditable) {
    417     this.removeFromDom();
    418     return;
    419   }
    420 
    421   var n = immutableRects.length;
    422   if (n == 0) {
    423     return;
    424   }
    425 
    426   // Offset the rects by documentElement, body, and/or scroll offsets,
    427   // while copying them into a new mutable array.
    428   var offsetX;
    429   var offsetY;
    430   if (window.getComputedStyle(document.body, null).position != 'static') {
    431     offsetX = -document.body.getBoundingClientRect().left;
    432     offsetY = -document.body.getBoundingClientRect().top;
    433   } else if (window.getComputedStyle(document.documentElement, null).position
    434                  != 'static') {
    435     offsetX = -document.documentElement.getBoundingClientRect().left;
    436     offsetY = -document.documentElement.getBoundingClientRect().top;
    437   } else {
    438     offsetX = window.pageXOffset;
    439     offsetY = window.pageYOffset;
    440   }
    441 
    442   var rects = [];
    443   for (var i = 0; i < n; i++) {
    444     rects.push(
    445         this.inset_(immutableRects[i], offsetX, offsetY, -offsetX, -offsetY));
    446   }
    447 
    448   // Create and attach the container if it doesn't exist or if it was detached.
    449   if (!this.container_ || !this.container_.parentElement) {
    450     // In case there are any detached containers around, clean them up. One case
    451     // that requires clean up like this is when users download a file on Chrome
    452     // on Android.
    453     var oldContainers =
    454         document.getElementsByClassName('cvox_indicator_container');
    455     for (var j = 0, oldContainer; oldContainer = oldContainers[j]; j++) {
    456       if (oldContainer.parentNode) {
    457         oldContainer.parentNode.removeChild(oldContainer);
    458       }
    459     }
    460     this.container_ = this.createDiv_(
    461         document.body, 'cvox_indicator_container', document.body.firstChild);
    462   }
    463 
    464   // Add the CSS style to the page if it's not already there.
    465   var style = document.createElement('style');
    466   style.id = 'cvox_indicator_style';
    467   style.innerHTML = cvox.ActiveIndicator.STYLE;
    468   cvox.DomUtil.addNodeToHead(style, style.id);
    469 
    470   // Decide on the animation speed. By default we do a medium-speed
    471   // animation between the previous and new location. If the user is
    472   // moving rapidly, we do a fast animation, or no animation.
    473   var now = new Date().getTime();
    474   var delta = now - this.lastMoveTime_;
    475   this.container_.className = 'cvox_indicator_container';
    476   if (!document.hasFocus() || this.blurred_) {
    477     this.container_.classList.add('cvox_indicator_window_not_focused');
    478   }
    479   if (delta > cvox.ActiveIndicator.NORMAL_ANIM_DELAY_MS) {
    480     this.container_.classList.add('cvox_indicator_animate_normal');
    481   } else if (delta > cvox.ActiveIndicator.QUICK_ANIM_DELAY_MS) {
    482     this.container_.classList.add('cvox_indicator_animate_quick');
    483   }
    484   this.lastMoveTime_ = now;
    485 
    486   // Compute the zoom level of the browser - this is needed to avoid
    487   // roundoff errors when placing the various pieces of the region
    488   // outline.
    489   this.computeZoomLevel_();
    490 
    491   // Make it start pulsing after it's drawn the first frame - this is so
    492   // that the opacity is always 100% when the indicator appears, and only
    493   // starts pulsing afterwards.
    494   window.setTimeout(goog.bind(function() {
    495     this.container_.classList.add('cvox_indicator_pulsing');
    496   }, this), 0);
    497 
    498   // If there was more than one region previously, delete all except
    499   // the first one.
    500   while (this.container_.childElementCount > 1) {
    501     this.container_.removeChild(this.container_.lastElementChild);
    502   }
    503 
    504   // Split the rects into contiguous regions.
    505   var regions = [[rects[0]]];
    506   var regionRects = [rects[0]];
    507   for (i = 1; i < rects.length; i++) {
    508     var found = false;
    509     for (var j = 0; j < regions.length && !found; j++) {
    510       if (this.intersects_(rects[i], regionRects[j])) {
    511         regions[j].push(rects[i]);
    512         regionRects[j] = this.union_(regionRects[j], rects[i]);
    513         found = true;
    514       }
    515     }
    516     if (!found) {
    517       regions.push([rects[i]]);
    518       regionRects.push(rects[i]);
    519     }
    520   }
    521 
    522   // Keep merging regions that intersect.
    523   // TODO(dmazzoni): reduce the worst-case complexity! This appears like
    524   // it could be O(n^3), make sure it's not in practice.
    525   do {
    526     var merged = false;
    527     for (i = 0; i < regions.length - 1 && !merged; i++) {
    528       for (j = i + 1; j < regions.length && !merged; j++) {
    529         if (this.intersects_(regionRects[i], regionRects[j])) {
    530           regions[i] = regions[i].concat(regions[j]);
    531           regionRects[i] = this.union_(regionRects[i], regionRects[j]);
    532           regions.splice(j, 1);
    533           regionRects.splice(j, 1);
    534           merged = true;
    535         }
    536       }
    537     }
    538   } while (merged);
    539 
    540   // Sort rects within each region by y and then x position.
    541   for (i = 0; i < regions.length; i++) {
    542     regions[i].sort(function(r1, r2) {
    543       if (r1.top != r2.top) {
    544         return r1.top - r2.top;
    545       } else {
    546         return r1.left - r2.left;
    547       }
    548     });
    549   }
    550 
    551   // Draw each indicator region. The first region attempts to re-use the
    552   // existing elements (which results in animating the transition).
    553   for (i = 0; i < regions.length; i++) {
    554     var parent = null;
    555     if (i == 0 &&
    556         this.container_.childElementCount == 1 &&
    557         this.container_.children[0].childElementCount == 6) {
    558       parent = this.container_.children[0];
    559     }
    560     this.updateIndicatorRegion_(regions[i], parent, margin);
    561   }
    562 };
    563 
    564 /**
    565  * Update one indicator region - a set of contiguous rectangles on the
    566  * page.
    567  *
    568  * A region is made up of six pieces, designed to handle the shape of a
    569  * typical text paragraph:
    570  *
    571  *              TOP TOP TOP
    572  *              TOP     TOP
    573  *  NW NW NW NW NW      NE NE NE NE NE NE NE NE NE
    574  *  NW                                          NE
    575  *  NW                                          NE
    576  *  SW                                          SE
    577  *  SW                                          SE
    578  *  SW SW BOTTOM                      BOTTOM SE SE
    579  *        BOTTOM                      BOTTOM
    580  *        BOTTOM BOTTOM BOTTOM BOTTOM BOTTOM
    581  *
    582  * When there's only a single rectangle - like when outlining something
    583  * simple like a button, all six pieces are still used - this makes the
    584  * animation smooth when sliding from a paragraph to a rectangular object
    585  * and then to another paragraph, for example:
    586  *
    587  *       TOP TOP TOP TOP TOP TOP TOP
    588  *       TOP                     TOP
    589  *       NW                       NE
    590  *       NW                       NE
    591  *       SW                       SE
    592  *       SW                       SE
    593  *       BOTTOM               BOTTOM
    594  *       BOTTOM BOTTOM BOTTOM BOTTOM
    595  *
    596  * Each piece is just a div that uses CSS to absolutely position itself.
    597  * The outline effect is done using the 'box-shadow' property around the
    598  * whole box, with the 'clip' property used to make sure that only 2 - 3
    599  * sides of the box are actually shown.
    600  *
    601  * This code is very subtle! If you want to adjust something by a few
    602  * pixels, be prepared to do LOTS of testing!
    603  *
    604  * Tip: while debugging, comment out the clipping and make each rectangle
    605  * a different color. That will make it much easier to see where each piece
    606  * starts and ends.
    607  *
    608  * @param {Array.<ClientRect>} rects The list of rects in the region.
    609  *     These should already be sorted (top to bottom and left to right).
    610  * @param {?Element} parent If present, try to reuse the existing element
    611  *     (and animate the transition).
    612  * @param {number} margin Margin in pixels.
    613  * @private
    614  */
    615 cvox.ActiveIndicator.prototype.updateIndicatorRegion_ = function(
    616     rects, parent, margin) {
    617   if (parent) {
    618     // Reuse the existing element (so we animate to the new location).
    619     var regionTop = parent.children[0];
    620     var regionMiddleNW = parent.children[1];
    621     var regionMiddleNE = parent.children[2];
    622     var regionMiddleSW = parent.children[3];
    623     var regionMiddleSE = parent.children[4];
    624     var regionBottom = parent.children[5];
    625   } else {
    626     // Create a new region (when the indicator first appears, or when
    627     // this is a secondary region, like for text continuing on a second
    628     // column).
    629     parent = this.createDiv_(this.container_, 'cvox_indicator_region');
    630     window.setTimeout(function() {
    631       parent.classList.add('cvox_indicator_visible');
    632     }, 0);
    633     regionTop = this.createDiv_(parent, 'cvox_indicator_top');
    634     regionMiddleNW = this.createDiv_(parent, 'cvox_indicator_middle_nw');
    635     regionMiddleNE = this.createDiv_(parent, 'cvox_indicator_middle_ne');
    636     regionMiddleSW = this.createDiv_(parent, 'cvox_indicator_middle_sw');
    637     regionMiddleSE = this.createDiv_(parent, 'cvox_indicator_middle_se');
    638     regionBottom = this.createDiv_(parent, 'cvox_indicator_bottom');
    639   }
    640 
    641   // Grab all of the rectangles in the top row.
    642   var topRect = rects[0];
    643   var topMiddle = Math.floor((topRect.top + topRect.bottom) / 2);
    644   var topIndex = 1;
    645   var n = rects.length;
    646   while (topIndex < n && rects[topIndex].top < topMiddle) {
    647     topRect = this.union_(topRect, rects[topIndex]);
    648     topMiddle = Math.floor((topRect.top + topRect.bottom) / 2);
    649     topIndex++;
    650   }
    651 
    652   if (topIndex == n) {
    653     // Everything fits on one line, so use special case code to form
    654     // the region into a rectangle.
    655     var r = this.inset_(topRect, -margin, -margin, -margin, -margin);
    656     var q1 = Math.floor((3 * r.top + 1 * r.bottom) / 4);
    657     var q2 = Math.floor((2 * r.top + 2 * r.bottom) / 4);
    658     var q3 = Math.floor((1 * r.top + 3 * r.bottom) / 4);
    659     this.setElementCoords_(regionTop, r.left, r.top, r.right, q1,
    660                                       true, true, true, false);
    661     this.setElementCoords_(regionMiddleNW, r.left, q1, r.left, q2,
    662                                            true, true, false, false);
    663     this.setElementCoords_(regionMiddleSW, r.left, q2, r.left, q3,
    664                                            true, false, false, true);
    665     this.setElementCoords_(regionMiddleNE, r.right, q1, r.right, q2,
    666                                            false, true, true, false);
    667     this.setElementCoords_(regionMiddleSE, r.right, q2, r.right, q3,
    668                                            false, false, true, true);
    669     this.setElementCoords_(regionBottom, r.left, q3, r.right, r.bottom,
    670                                          true, false, true, true);
    671     return;
    672   }
    673 
    674   // Start from the end and grab all of the rectangles in the bottom row.
    675   var bottomRect = rects[n - 1];
    676   var bottomMiddle = Math.floor((bottomRect.top + bottomRect.bottom) / 2);
    677   var bottomIndex = n - 2;
    678   while (bottomIndex >= 0 && rects[bottomIndex].bottom > bottomMiddle) {
    679     bottomRect = this.union_(bottomRect, rects[bottomIndex]);
    680     bottomMiddle = Math.floor((bottomRect.top + bottomRect.bottom) / 2);
    681     bottomIndex--;
    682   }
    683 
    684   // Extend the top and bottom rectangles a bit.
    685   topRect = this.inset_(topRect, -margin, -margin, -margin, margin);
    686   bottomRect = this.inset_(bottomRect, -margin, margin, -margin, -margin);
    687 
    688   // Whatever's in-between the top and bottom is the "middle".
    689   var middleRect;
    690   if (topIndex > bottomIndex) {
    691     middleRect = this.union_(topRect, bottomRect);
    692     middleRect.top = topRect.bottom;
    693     middleRect.bottom = bottomRect.top;
    694     middleRect.height = Math.floor((middleRect.top + middleRect.bottom) / 2);
    695   } else {
    696     middleRect = rects[topIndex];
    697     var middleIndex = topIndex + 1;
    698     while (middleIndex <= bottomIndex) {
    699       middleRect = this.union_(middleRect, rects[middleIndex]);
    700       middleIndex++;
    701     }
    702     middleRect = this.inset_(middleRect, -margin, -margin, -margin, -margin);
    703     middleRect.left = Math.min(
    704         middleRect.left, topRect.left, bottomRect.left);
    705     middleRect.right = Math.max(
    706         middleRect.right, topRect.right, bottomRect.right);
    707     middleRect.width = middleRect.right - middleRect.left;
    708   }
    709 
    710   // If the top or bottom is pretty close to the edge of the middle box,
    711   // make them flush.
    712   if (topRect.right > middleRect.right - 40) {
    713     topRect.right = middleRect.right;
    714     topRect.width = topRect.right - topRect.left;
    715   }
    716   if (topRect.left < middleRect.left + 40) {
    717     topRect.left = middleRect.left;
    718     topRect.width = topRect.right - topRect.left;
    719   }
    720   if (bottomRect.right > middleRect.right - 40) {
    721     bottomRect.right = middleRect.right;
    722     bottomRect.width = bottomRect.right - bottomRect.left;
    723   }
    724   if (bottomRect.left < middleRect.left + 40) {
    725     bottomRect.left = middleRect.left;
    726     bottomRect.width = bottomRect.right - bottomRect.left;
    727   }
    728 
    729   var midline = Math.floor((middleRect.top + middleRect.bottom) / 2);
    730 
    731   this.setElementRect_(regionTop, topRect, true, true, true, false);
    732   this.setElementRect_(regionBottom, bottomRect, true, false, true, true);
    733 
    734   this.setElementCoords_(
    735       regionMiddleNW,
    736       middleRect.left, topRect.bottom, topRect.left, midline,
    737       true, true, false, false);
    738   this.setElementCoords_(
    739       regionMiddleNE,
    740       topRect.right, topRect.bottom,
    741       middleRect.right, midline,
    742       false, true, true, false);
    743   this.setElementCoords_(
    744       regionMiddleSW,
    745       middleRect.left, midline, bottomRect.left, bottomRect.top,
    746       true, false, false, true);
    747   this.setElementCoords_(
    748       regionMiddleSE,
    749       bottomRect.right, midline,
    750       middleRect.right, bottomRect.top,
    751       false, false, true, true);
    752 };
    753 
    754 /**
    755  * Given two rectangles, return whether or not they intersect
    756  * (including a bit of slop, so if they're almost touching, we
    757  * return true).
    758  * @param {ClientRect} r1 The first rect.
    759  * @param {ClientRect} r2 The second rect.
    760  * @return {boolean} Whether or not they intersect.
    761  * @private
    762  */
    763 cvox.ActiveIndicator.prototype.intersects_ = function(r1, r2) {
    764   var slop = 2 * cvox.ActiveIndicator.MARGIN;
    765   return (r2.left <= r1.right + slop &&
    766           r2.right >= r1.left - slop &&
    767           r2.top <= r1.bottom + slop &&
    768           r2.bottom >= r1.top - slop);
    769 };
    770 
    771 /**
    772  * Given two rectangles, compute their union.
    773  * @param {ClientRect} r1 The first rect.
    774  * @param {ClientRect} r2 The second rect.
    775  * @return {ClientRect} The union of the two rectangles.
    776  * @private
    777  * @suppress {invalidCasts} invalid cast - must be a subtype or supertype
    778  * from: {bottom: number, height: number, left: number, right: number, ...}
    779  * to  : (ClientRect|null)
    780  */
    781 cvox.ActiveIndicator.prototype.union_ = function(r1, r2) {
    782   var result = {
    783     left: Math.min(r1.left, r2.left),
    784     top: Math.min(r1.top, r2.top),
    785     right: Math.max(r1.right, r2.right),
    786     bottom: Math.max(r1.bottom, r2.bottom)
    787   };
    788   result.width = result.right - result.left;
    789   result.height = result.bottom - result.top;
    790   return /** @type {ClientRect} */(result);
    791 };
    792 
    793 /**
    794  * Given a rectangle and four offsets, return a new rectangle inset by
    795  * the given offsets.
    796  * @param {ClientRect} r The first rect.
    797  * @param {number} left The left inset.
    798  * @param {number} top The top inset.
    799  * @param {number} right The right inset.
    800  * @param {number} bottom The bottom inset.
    801  * @return {ClientRect} The new rectangle.
    802  * @private
    803  * @suppress {invalidCasts} invalid cast - must be a subtype or supertype
    804  * from: {bottom: number, height: number, left: number, right: number, ...}
    805  * to  : (ClientRect|null)
    806  */
    807 cvox.ActiveIndicator.prototype.inset_ = function(r, left, top, right, bottom) {
    808   var result = {
    809     left: r.left + left,
    810     top: r.top + top,
    811     right: r.right - right,
    812     bottom: r.bottom - bottom
    813   };
    814   result.width = result.right - result.left;
    815   result.height = result.bottom - result.top;
    816   return /** @type {ClientRect} */(result);
    817 };
    818 
    819 /**
    820  * Convenience method to create an element of type DIV, give it
    821  * particular class name, and add it as a child of a given parent.
    822  * @param {Element} parent The parent element of the new div.
    823  * @param {string} className The class name of the new div.
    824  * @param {Node=} opt_before Will insert before this node, if present.
    825  * @return {Element} The new div.
    826  * @private
    827  */
    828 cvox.ActiveIndicator.prototype.createDiv_ = function(
    829       parent, className, opt_before) {
    830   var elem = document.createElement('div');
    831   elem.className = className;
    832   if (opt_before) {
    833     parent.insertBefore(elem, opt_before);
    834   } else {
    835     parent.appendChild(elem);
    836   }
    837   return elem;
    838 };
    839 
    840 /**
    841  * In WebKit, when the user has zoomed the page, every CSS coordinate is
    842  * multiplied by the zoom level and rounded down. This can cause objects to
    843  * fail to line up; for example an object with left position 100 and width
    844  * 50 may not line up with an object with right position 150 pixels, if the
    845  * zoom is not equal to 1.0. To fix this, we compute the actual desired
    846  * coordinate when zoomed, then add a small fractional offset and divide
    847  * by the zoom factor, and use that value as the item's coordinate instead.
    848  *
    849  * @param {number} x A coordinate to be transformed.
    850  * @return {number} The new coordinate to use.
    851  * @private
    852  */
    853 cvox.ActiveIndicator.prototype.fixZoom_ = function(x) {
    854   return (Math.round(x * this.zoom_) + 0.1) / this.zoom_;
    855 };
    856 
    857 /**
    858  * See fixZoom_, above. This method is the same except that it returns the
    859  * width such that right pos (x + width) is correct when multiplied by the
    860  * zoom factor.
    861  *
    862  * @param {number} x A coordinate to be transformed.
    863  * @param {number} width The width of the object.
    864  * @return {number} The new width to use.
    865  * @private
    866  */
    867 cvox.ActiveIndicator.prototype.fixZoomSum_ = function(x, width) {
    868   var zoomedX = Math.round(x * this.zoom_);
    869   var zoomedRight = Math.round((x + width) * this.zoom_);
    870   var zoomedWidth = (zoomedRight - zoomedX);
    871   return (zoomedWidth + 0.1) / this.zoom_;
    872 };
    873 
    874 /**
    875  * Set the coordinates of an element to the given left, top, right, and
    876  * bottom pixel coordinates, taking the browser zoom level into account.
    877  * Also set the clipping rectangle to exclude some of the edges of the
    878  * rectangle, based on the value of showLeft, showTop, showRight, and
    879  * showBottom.
    880  *
    881  * @param {Element} element The element to move.
    882  * @param {number} left The new left coordinate.
    883  * @param {number} top The new top coordinate.
    884  * @param {number} right The new right coordinate.
    885  * @param {number} bottom The new bottom coordinate.
    886  * @param {boolean} showLeft Whether to show or clip at the left border.
    887  * @param {boolean} showTop Whether to show or clip at the top border.
    888  * @param {boolean} showRight Whether to show or clip at the right border.
    889  * @param {boolean} showBottom Whether to show or clip at the bottom border.
    890  * @private
    891  */
    892 cvox.ActiveIndicator.prototype.setElementCoords_ = function(
    893       element,
    894       left, top, right, bottom,
    895       showLeft, showTop, showRight, showBottom) {
    896   var origWidth = right - left;
    897   var origHeight = bottom - top;
    898 
    899   var width = right - left;
    900   var height = bottom - top;
    901   var clipLeft = showLeft ? -20 : 0;
    902   var clipTop = showTop ? -20 : 0;
    903   var clipRight = showRight ? 20 : 0;
    904   var clipBottom = showBottom ? 20 : 0;
    905   if (width == 0) {
    906     if (showRight) {
    907       left -= 5;
    908       width += 5;
    909     } else if (showLeft) {
    910       width += 10;
    911     }
    912     clipTop = 10;
    913     clipBottom = 10;
    914     top -= 10;
    915     height += 20;
    916   }
    917   if (!showBottom)
    918     height += 5;
    919   if (!showTop) {
    920     top -= 5;
    921     height += 5;
    922     clipTop += 5;
    923     clipBottom += 5;
    924   }
    925   if (clipRight == 0 && origWidth == 0) {
    926     clipRight = 1;
    927   } else {
    928     clipRight = this.fixZoomSum_(left, clipRight + origWidth);
    929   }
    930   clipBottom = this.fixZoomSum_(top, clipBottom + origHeight);
    931 
    932   element.style.left = this.fixZoom_(left) + 'px';
    933   element.style.top = this.fixZoom_(top) + 'px';
    934   element.style.width = this.fixZoomSum_(left, width) + 'px';
    935   element.style.height = this.fixZoomSum_(top, height) + 'px';
    936   element.style.clip =
    937       'rect(' + [clipTop, clipRight, clipBottom, clipLeft].join('px ') + 'px)';
    938 };
    939 
    940 /**
    941  * Same as setElementCoords_, but takes a rect instead of coordinates.
    942  *
    943  * @param {Element} element The element to move.
    944  * @param {ClientRect} r The new coordinates.
    945  * @param {boolean} showLeft Whether to show or clip at the left border.
    946  * @param {boolean} showTop Whether to show or clip at the top border.
    947  * @param {boolean} showRight Whether to show or clip at the right border.
    948  * @param {boolean} showBottom Whether to show or clip at the bottom border.
    949  * @private
    950  */
    951 cvox.ActiveIndicator.prototype.setElementRect_ = function(
    952       element, r, showLeft, showTop, showRight, showBottom) {
    953   this.setElementCoords_(element, r.left, r.top, r.right, r.bottom,
    954                          showLeft, showTop, showRight, showBottom);
    955 };
    956 
    957 /**
    958  * Compute an approximation of the current browser zoom level by
    959  * comparing the measurement of a large character of text
    960  * with the -webkit-text-size-adjust:none style to the expected
    961  * pixel coordinates if it was adjusted.
    962  * @private
    963  */
    964 cvox.ActiveIndicator.prototype.computeZoomLevel_ = function() {
    965   if (window.innerHeight === this.innerHeight_ &&
    966       window.innerWidth === this.innerWidth_) {
    967     return;
    968   }
    969 
    970   this.innerHeight_ = window.innerHeight;
    971   this.innerWidth_ = window.innerWidth;
    972 
    973   var zoomMeasureElement = document.createElement('div');
    974   zoomMeasureElement.innerHTML = 'X';
    975   zoomMeasureElement.setAttribute(
    976       'style',
    977       'font: 5000px/1em sans-serif !important;' +
    978           ' -webkit-text-size-adjust:none !important;' +
    979           ' visibility:hidden !important;' +
    980           ' left: -10000px !important;' +
    981           ' top: -10000px !important;' +
    982           ' position:absolute !important;');
    983   document.body.appendChild(zoomMeasureElement);
    984 
    985   var zoomLevel = 5000 / zoomMeasureElement.clientHeight;
    986   var newZoom = Math.round(zoomLevel * 500) / 500;
    987   if (newZoom > 0.1 && newZoom < 10) {
    988     this.zoom_ = newZoom;
    989   }
    990 
    991   // TODO(dmazzoni): warn or log if the computed zoom is bad?
    992   zoomMeasureElement.parentNode.removeChild(zoomMeasureElement);
    993 };
    994