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.setAttribute('aria-hidden', 'true'); 832 833 // This allows the MutationObserver used for live regions to quickly 834 // ignore changes to this element rather than doing a lot of calculations 835 // first. 836 elem.setAttribute('cvoxIgnore', ''); 837 838 elem.className = className; 839 if (opt_before) { 840 parent.insertBefore(elem, opt_before); 841 } else { 842 parent.appendChild(elem); 843 } 844 return elem; 845}; 846 847/** 848 * In WebKit, when the user has zoomed the page, every CSS coordinate is 849 * multiplied by the zoom level and rounded down. This can cause objects to 850 * fail to line up; for example an object with left position 100 and width 851 * 50 may not line up with an object with right position 150 pixels, if the 852 * zoom is not equal to 1.0. To fix this, we compute the actual desired 853 * coordinate when zoomed, then add a small fractional offset and divide 854 * by the zoom factor, and use that value as the item's coordinate instead. 855 * 856 * @param {number} x A coordinate to be transformed. 857 * @return {number} The new coordinate to use. 858 * @private 859 */ 860cvox.ActiveIndicator.prototype.fixZoom_ = function(x) { 861 return (Math.round(x * this.zoom_) + 0.1) / this.zoom_; 862}; 863 864/** 865 * See fixZoom_, above. This method is the same except that it returns the 866 * width such that right pos (x + width) is correct when multiplied by the 867 * zoom factor. 868 * 869 * @param {number} x A coordinate to be transformed. 870 * @param {number} width The width of the object. 871 * @return {number} The new width to use. 872 * @private 873 */ 874cvox.ActiveIndicator.prototype.fixZoomSum_ = function(x, width) { 875 var zoomedX = Math.round(x * this.zoom_); 876 var zoomedRight = Math.round((x + width) * this.zoom_); 877 var zoomedWidth = (zoomedRight - zoomedX); 878 return (zoomedWidth + 0.1) / this.zoom_; 879}; 880 881/** 882 * Set the coordinates of an element to the given left, top, right, and 883 * bottom pixel coordinates, taking the browser zoom level into account. 884 * Also set the clipping rectangle to exclude some of the edges of the 885 * rectangle, based on the value of showLeft, showTop, showRight, and 886 * showBottom. 887 * 888 * @param {Element} element The element to move. 889 * @param {number} left The new left coordinate. 890 * @param {number} top The new top coordinate. 891 * @param {number} right The new right coordinate. 892 * @param {number} bottom The new bottom coordinate. 893 * @param {boolean} showLeft Whether to show or clip at the left border. 894 * @param {boolean} showTop Whether to show or clip at the top border. 895 * @param {boolean} showRight Whether to show or clip at the right border. 896 * @param {boolean} showBottom Whether to show or clip at the bottom border. 897 * @private 898 */ 899cvox.ActiveIndicator.prototype.setElementCoords_ = function( 900 element, 901 left, top, right, bottom, 902 showLeft, showTop, showRight, showBottom) { 903 var origWidth = right - left; 904 var origHeight = bottom - top; 905 906 var width = right - left; 907 var height = bottom - top; 908 var clipLeft = showLeft ? -20 : 0; 909 var clipTop = showTop ? -20 : 0; 910 var clipRight = showRight ? 20 : 0; 911 var clipBottom = showBottom ? 20 : 0; 912 if (width == 0) { 913 if (showRight) { 914 left -= 5; 915 width += 5; 916 } else if (showLeft) { 917 width += 10; 918 } 919 clipTop = 10; 920 clipBottom = 10; 921 top -= 10; 922 height += 20; 923 } 924 if (!showBottom) 925 height += 5; 926 if (!showTop) { 927 top -= 5; 928 height += 5; 929 clipTop += 5; 930 clipBottom += 5; 931 } 932 if (clipRight == 0 && origWidth == 0) { 933 clipRight = 1; 934 } else { 935 clipRight = this.fixZoomSum_(left, clipRight + origWidth); 936 } 937 clipBottom = this.fixZoomSum_(top, clipBottom + origHeight); 938 939 element.style.left = this.fixZoom_(left) + 'px'; 940 element.style.top = this.fixZoom_(top) + 'px'; 941 element.style.width = this.fixZoomSum_(left, width) + 'px'; 942 element.style.height = this.fixZoomSum_(top, height) + 'px'; 943 element.style.clip = 944 'rect(' + [clipTop, clipRight, clipBottom, clipLeft].join('px ') + 'px)'; 945}; 946 947/** 948 * Same as setElementCoords_, but takes a rect instead of coordinates. 949 * 950 * @param {Element} element The element to move. 951 * @param {ClientRect} r The new coordinates. 952 * @param {boolean} showLeft Whether to show or clip at the left border. 953 * @param {boolean} showTop Whether to show or clip at the top border. 954 * @param {boolean} showRight Whether to show or clip at the right border. 955 * @param {boolean} showBottom Whether to show or clip at the bottom border. 956 * @private 957 */ 958cvox.ActiveIndicator.prototype.setElementRect_ = function( 959 element, r, showLeft, showTop, showRight, showBottom) { 960 this.setElementCoords_(element, r.left, r.top, r.right, r.bottom, 961 showLeft, showTop, showRight, showBottom); 962}; 963 964/** 965 * Compute an approximation of the current browser zoom level by 966 * comparing the measurement of a large character of text 967 * with the -webkit-text-size-adjust:none style to the expected 968 * pixel coordinates if it was adjusted. 969 * @private 970 */ 971cvox.ActiveIndicator.prototype.computeZoomLevel_ = function() { 972 if (window.innerHeight === this.innerHeight_ && 973 window.innerWidth === this.innerWidth_) { 974 return; 975 } 976 977 this.innerHeight_ = window.innerHeight; 978 this.innerWidth_ = window.innerWidth; 979 980 var zoomMeasureElement = document.createElement('div'); 981 zoomMeasureElement.innerHTML = 'X'; 982 zoomMeasureElement.setAttribute( 983 'style', 984 'font: 5000px/1em sans-serif !important;' + 985 ' -webkit-text-size-adjust:none !important;' + 986 ' visibility:hidden !important;' + 987 ' left: -10000px !important;' + 988 ' top: -10000px !important;' + 989 ' position:absolute !important;'); 990 document.body.appendChild(zoomMeasureElement); 991 992 var zoomLevel = 5000 / zoomMeasureElement.clientHeight; 993 var newZoom = Math.round(zoomLevel * 500) / 500; 994 if (newZoom > 0.1 && newZoom < 10) { 995 this.zoom_ = newZoom; 996 } 997 998 // TODO(dmazzoni): warn or log if the computed zoom is bad? 999 zoomMeasureElement.parentNode.removeChild(zoomMeasureElement); 1000}; 1001