active_indicator.js revision cedac228d2dd51db4b79ea1e72c7f249408ee061
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
11goog.provide('cvox.ActiveIndicator');
12
13goog.require('cvox.Cursor');
14goog.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 */
26cvox.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 */
138cvox.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 */
209cvox.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 */
218cvox.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 */
225cvox.ActiveIndicator.MARGIN = 8;
226
227/**
228 * Remove the indicator from the DOM.
229 */
230cvox.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 */
240cvox.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 */
258cvox.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 */
273cvox.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 */
294cvox.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 */
310cvox.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 */
325cvox.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 */
370cvox.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 */
411cvox.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 */
615cvox.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 */
763cvox.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 */
781cvox.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 */
807cvox.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 */
828cvox.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 */
853cvox.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 */
867cvox.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 */
892cvox.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 */
951cvox.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 */
964cvox.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