1/* 2 * Copyright (C) 2013 Google Inc. All rights reserved. 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * * Neither the name of Google Inc. nor the names of its 15 * contributors may be used to endorse or promote products derived from 16 * this software without specific prior written permission. 17 * 18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 */ 30 31/** 32 * @constructor 33 * @param {!WebInspector.ViewportControl.Provider} provider 34 */ 35WebInspector.ViewportControl = function(provider) 36{ 37 this.element = document.createElement("div"); 38 this.element.style.overflow = "auto"; 39 this._topGapElement = this.element.createChild("div", "viewport-control-gap-element"); 40 this._topGapElement.textContent = "."; 41 this._topGapElement.style.height = "0px"; 42 this._contentElement = this.element.createChild("div"); 43 this._bottomGapElement = this.element.createChild("div", "viewport-control-gap-element"); 44 this._bottomGapElement.textContent = "."; 45 this._bottomGapElement.style.height = "0px"; 46 47 this._provider = provider; 48 this.element.addEventListener("scroll", this._onScroll.bind(this), false); 49 this.element.addEventListener("copy", this._onCopy.bind(this), false); 50 this.element.addEventListener("dragstart", this._onDragStart.bind(this), false); 51 52 this._firstVisibleIndex = 0; 53 this._lastVisibleIndex = -1; 54 this._renderedItems = []; 55 this._anchorSelection = null; 56 this._headSelection = null; 57 this._stickToBottom = false; 58 this._scrolledToBottom = true; 59} 60 61/** 62 * @interface 63 */ 64WebInspector.ViewportControl.Provider = function() 65{ 66} 67 68WebInspector.ViewportControl.Provider.prototype = { 69 /** 70 * @param {number} index 71 * @return {number} 72 */ 73 fastHeight: function(index) { return 0; }, 74 75 /** 76 * @return {number} 77 */ 78 itemCount: function() { return 0; }, 79 80 /** 81 * @return {number} 82 */ 83 minimumRowHeight: function() { return 0; }, 84 85 /** 86 * @param {number} index 87 * @return {?WebInspector.ViewportElement} 88 */ 89 itemElement: function(index) { return null; } 90} 91 92/** 93 * @interface 94 */ 95WebInspector.ViewportElement = function() { } 96WebInspector.ViewportElement.prototype = { 97 cacheFastHeight: function() { }, 98 99 willHide: function() { }, 100 101 wasShown: function() { }, 102 103 /** 104 * @return {!Element} 105 */ 106 element: function() { }, 107} 108 109/** 110 * @constructor 111 * @implements {WebInspector.ViewportElement} 112 * @param {!Element} element 113 */ 114WebInspector.StaticViewportElement = function(element) 115{ 116 this._element = element; 117} 118 119WebInspector.StaticViewportElement.prototype = { 120 cacheFastHeight: function() { }, 121 122 willHide: function() { }, 123 124 wasShown: function() { }, 125 126 /** 127 * @return {!Element} 128 */ 129 element: function() 130 { 131 return this._element; 132 }, 133} 134 135WebInspector.ViewportControl.prototype = { 136 /** 137 * @return {boolean} 138 */ 139 scrolledToBottom: function() 140 { 141 return this._scrolledToBottom; 142 }, 143 144 /** 145 * @param {boolean} value 146 */ 147 setStickToBottom: function(value) 148 { 149 this._stickToBottom = value; 150 }, 151 152 /** 153 * @param {!Event} event 154 */ 155 _onCopy: function(event) 156 { 157 var text = this._selectedText(); 158 if (!text) 159 return; 160 event.preventDefault(); 161 event.clipboardData.setData("text/plain", text); 162 }, 163 164 /** 165 * @param {!Event} event 166 */ 167 _onDragStart: function(event) 168 { 169 var text = this._selectedText(); 170 if (!text) 171 return false; 172 event.dataTransfer.clearData(); 173 event.dataTransfer.setData("text/plain", text); 174 event.dataTransfer.effectAllowed = "copy"; 175 return true; 176 }, 177 178 /** 179 * @return {!Element} 180 */ 181 contentElement: function() 182 { 183 return this._contentElement; 184 }, 185 186 invalidate: function() 187 { 188 delete this._cumulativeHeights; 189 delete this._cachedProviderElements; 190 this.refresh(); 191 }, 192 193 /** 194 * @param {number} index 195 * @return {?WebInspector.ViewportElement} 196 */ 197 _providerElement: function(index) 198 { 199 if (!this._cachedProviderElements) 200 this._cachedProviderElements = new Array(this._provider.itemCount()); 201 var element = this._cachedProviderElements[index]; 202 if (!element) { 203 element = this._provider.itemElement(index); 204 this._cachedProviderElements[index] = element; 205 } 206 return element; 207 }, 208 209 _rebuildCumulativeHeightsIfNeeded: function() 210 { 211 if (this._cumulativeHeights) 212 return; 213 var itemCount = this._provider.itemCount(); 214 if (!itemCount) 215 return; 216 this._cumulativeHeights = new Int32Array(itemCount); 217 this._cumulativeHeights[0] = this._provider.fastHeight(0); 218 for (var i = 1; i < itemCount; ++i) 219 this._cumulativeHeights[i] = this._cumulativeHeights[i - 1] + this._provider.fastHeight(i); 220 }, 221 222 /** 223 * @param {number} index 224 * @return {number} 225 */ 226 _cachedItemHeight: function(index) 227 { 228 return index === 0 ? this._cumulativeHeights[0] : this._cumulativeHeights[index] - this._cumulativeHeights[index - 1]; 229 }, 230 231 /** 232 * @param {?Selection} selection 233 */ 234 _isSelectionBackwards: function(selection) 235 { 236 if (!selection || !selection.rangeCount) 237 return false; 238 var range = document.createRange(); 239 range.setStart(selection.anchorNode, selection.anchorOffset); 240 range.setEnd(selection.focusNode, selection.focusOffset); 241 return range.collapsed; 242 }, 243 244 /** 245 * @param {number} itemIndex 246 * @param {!Node} node 247 * @param {number} offset 248 * @return {!{item: number, node: !Node, offset: number}} 249 */ 250 _createSelectionModel: function(itemIndex, node, offset) 251 { 252 return { 253 item: itemIndex, 254 node: node, 255 offset: offset 256 }; 257 }, 258 259 /** 260 * @param {?Selection} selection 261 */ 262 _updateSelectionModel: function(selection) 263 { 264 if (!selection || !selection.rangeCount) { 265 this._headSelection = null; 266 this._anchorSelection = null; 267 return false; 268 } 269 270 var firstSelected = Number.MAX_VALUE; 271 var lastSelected = -1; 272 273 var range = selection.getRangeAt(0); 274 var hasVisibleSelection = false; 275 for (var i = 0; i < this._renderedItems.length; ++i) { 276 if (range.intersectsNode(this._renderedItems[i].element())) { 277 var index = i + this._firstVisibleIndex; 278 firstSelected = Math.min(firstSelected, index); 279 lastSelected = Math.max(lastSelected, index); 280 hasVisibleSelection = true; 281 } 282 } 283 if (hasVisibleSelection) { 284 firstSelected = this._createSelectionModel(firstSelected, /** @type {!Node} */(range.startContainer), range.startOffset); 285 lastSelected = this._createSelectionModel(lastSelected, /** @type {!Node} */(range.endContainer), range.endOffset); 286 } 287 var topOverlap = range.intersectsNode(this._topGapElement) && this._topGapElement._active; 288 var bottomOverlap = range.intersectsNode(this._bottomGapElement) && this._bottomGapElement._active; 289 if (!topOverlap && !bottomOverlap && !hasVisibleSelection) { 290 this._headSelection = null; 291 this._anchorSelection = null; 292 return false; 293 } 294 295 if (!this._anchorSelection || !this._headSelection) { 296 this._anchorSelection = this._createSelectionModel(0, this.element, 0); 297 this._headSelection = this._createSelectionModel(this._provider.itemCount() - 1, this.element, this.element.children.length); 298 this._selectionIsBackward = false; 299 } 300 301 var isBackward = this._isSelectionBackwards(selection); 302 var startSelection = this._selectionIsBackward ? this._headSelection : this._anchorSelection; 303 var endSelection = this._selectionIsBackward ? this._anchorSelection : this._headSelection; 304 if (topOverlap && bottomOverlap && hasVisibleSelection) { 305 firstSelected = firstSelected.item < startSelection.item ? firstSelected : startSelection; 306 lastSelected = lastSelected.item > endSelection.item ? lastSelected : endSelection; 307 } else if (!hasVisibleSelection) { 308 firstSelected = startSelection; 309 lastSelected = endSelection; 310 } else if (topOverlap) 311 firstSelected = isBackward ? this._headSelection : this._anchorSelection; 312 else if (bottomOverlap) 313 lastSelected = isBackward ? this._anchorSelection : this._headSelection; 314 315 if (isBackward) { 316 this._anchorSelection = lastSelected; 317 this._headSelection = firstSelected; 318 } else { 319 this._anchorSelection = firstSelected; 320 this._headSelection = lastSelected; 321 } 322 this._selectionIsBackward = isBackward; 323 return true; 324 }, 325 326 /** 327 * @param {?Selection} selection 328 */ 329 _restoreSelection: function(selection) 330 { 331 var anchorElement = null; 332 var anchorOffset; 333 if (this._firstVisibleIndex <= this._anchorSelection.item && this._anchorSelection.item <= this._lastVisibleIndex) { 334 anchorElement = this._anchorSelection.node; 335 anchorOffset = this._anchorSelection.offset; 336 } else { 337 if (this._anchorSelection.item < this._firstVisibleIndex) 338 anchorElement = this._topGapElement; 339 else if (this._anchorSelection.item > this._lastVisibleIndex) 340 anchorElement = this._bottomGapElement; 341 anchorOffset = this._selectionIsBackward ? 1 : 0; 342 } 343 344 var headElement = null; 345 var headOffset; 346 if (this._firstVisibleIndex <= this._headSelection.item && this._headSelection.item <= this._lastVisibleIndex) { 347 headElement = this._headSelection.node; 348 headOffset = this._headSelection.offset; 349 } else { 350 if (this._headSelection.item < this._firstVisibleIndex) 351 headElement = this._topGapElement; 352 else if (this._headSelection.item > this._lastVisibleIndex) 353 headElement = this._bottomGapElement; 354 headOffset = this._selectionIsBackward ? 0 : 1; 355 } 356 357 selection.setBaseAndExtent(anchorElement, anchorOffset, headElement, headOffset); 358 }, 359 360 refresh: function() 361 { 362 if (!this._visibleHeight()) 363 return; // Do nothing for invisible controls. 364 365 var itemCount = this._provider.itemCount(); 366 if (!itemCount) { 367 for (var i = 0; i < this._renderedItems.length; ++i) 368 this._renderedItems[i].cacheFastHeight(); 369 for (var i = 0; i < this._renderedItems.length; ++i) 370 this._renderedItems[i].willHide(); 371 this._renderedItems = []; 372 this._contentElement.removeChildren(); 373 this._topGapElement.style.height = "0px"; 374 this._bottomGapElement.style.height = "0px"; 375 this._firstVisibleIndex = -1; 376 this._lastVisibleIndex = -1; 377 return; 378 } 379 380 var selection = window.getSelection(); 381 var shouldRestoreSelection = this._updateSelectionModel(selection); 382 383 var visibleFrom = this.element.scrollTop; 384 var visibleHeight = this._visibleHeight(); 385 this._scrolledToBottom = this.element.isScrolledToBottom(); 386 var isInvalidating = !this._cumulativeHeights; 387 388 if (this._cumulativeHeights && itemCount !== this._cumulativeHeights.length) 389 delete this._cumulativeHeights; 390 for (var i = 0; i < this._renderedItems.length; ++i) { 391 this._renderedItems[i].cacheFastHeight(); 392 // Tolerate 1-pixel error due to double-to-integer rounding errors. 393 if (this._cumulativeHeights && Math.abs(this._cachedItemHeight(this._firstVisibleIndex + i) - this._provider.fastHeight(i + this._firstVisibleIndex)) > 1) 394 delete this._cumulativeHeights; 395 } 396 this._rebuildCumulativeHeightsIfNeeded(); 397 var oldFirstVisibleIndex = this._firstVisibleIndex; 398 var oldLastVisibleIndex = this._lastVisibleIndex; 399 400 var shouldStickToBottom = this._stickToBottom && this._scrolledToBottom; 401 if (shouldStickToBottom) { 402 this._lastVisibleIndex = itemCount - 1; 403 this._firstVisibleIndex = Math.max(itemCount - Math.ceil(visibleHeight / this._provider.minimumRowHeight()), 0); 404 } else { 405 this._firstVisibleIndex = Math.max(Array.prototype.lowerBound.call(this._cumulativeHeights, visibleFrom + 1), 0); 406 // Proactively render more rows in case some of them will be collapsed without triggering refresh. @see crbug.com/390169 407 this._lastVisibleIndex = this._firstVisibleIndex + Math.ceil(visibleHeight / this._provider.minimumRowHeight()) - 1; 408 this._lastVisibleIndex = Math.min(this._lastVisibleIndex, itemCount - 1); 409 } 410 var topGapHeight = this._cumulativeHeights[this._firstVisibleIndex - 1] || 0; 411 var bottomGapHeight = this._cumulativeHeights[this._cumulativeHeights.length - 1] - this._cumulativeHeights[this._lastVisibleIndex]; 412 413 this._topGapElement.style.height = topGapHeight + "px"; 414 this._bottomGapElement.style.height = bottomGapHeight + "px"; 415 this._topGapElement._active = !!topGapHeight; 416 this._bottomGapElement._active = !!bottomGapHeight; 417 418 this._contentElement.style.setProperty("height", "10000000px"); 419 if (isInvalidating) 420 this._fullViewportUpdate(); 421 else 422 this._partialViewportUpdate(oldFirstVisibleIndex, oldLastVisibleIndex); 423 this._contentElement.style.removeProperty("height"); 424 // Should be the last call in the method as it might force layout. 425 if (shouldRestoreSelection) 426 this._restoreSelection(selection); 427 if (shouldStickToBottom) 428 this.element.scrollTop = this.element.scrollHeight; 429 }, 430 431 _fullViewportUpdate: function() 432 { 433 for (var i = 0; i < this._renderedItems.length; ++i) 434 this._renderedItems[i].willHide(); 435 this._renderedItems = []; 436 this._contentElement.removeChildren(); 437 for (var i = this._firstVisibleIndex; i <= this._lastVisibleIndex; ++i) { 438 var viewportElement = this._providerElement(i); 439 this._contentElement.appendChild(viewportElement.element()); 440 this._renderedItems.push(viewportElement); 441 viewportElement.wasShown(); 442 } 443 }, 444 445 /** 446 * @param {number} oldFirstVisibleIndex 447 * @param {number} oldLastVisibleIndex 448 */ 449 _partialViewportUpdate: function(oldFirstVisibleIndex, oldLastVisibleIndex) 450 { 451 var willBeHidden = []; 452 for (var i = 0; i < this._renderedItems.length; ++i) { 453 var index = oldFirstVisibleIndex + i; 454 if (index < this._firstVisibleIndex || this._lastVisibleIndex < index) 455 willBeHidden.push(this._renderedItems[i]); 456 } 457 for (var i = 0; i < willBeHidden.length; ++i) 458 willBeHidden[i].willHide(); 459 for (var i = 0; i < willBeHidden.length; ++i) 460 willBeHidden[i].element().remove(); 461 462 this._renderedItems = []; 463 var anchor = this._contentElement.firstChild; 464 for (var i = this._firstVisibleIndex; i <= this._lastVisibleIndex; ++i) { 465 var viewportElement = this._providerElement(i); 466 var element = viewportElement.element(); 467 if (element !== anchor) { 468 this._contentElement.insertBefore(element, anchor); 469 viewportElement.wasShown(); 470 } else { 471 anchor = anchor.nextSibling; 472 } 473 this._renderedItems.push(viewportElement); 474 } 475 }, 476 477 /** 478 * @return {?string} 479 */ 480 _selectedText: function() 481 { 482 this._updateSelectionModel(window.getSelection()); 483 if (!this._headSelection || !this._anchorSelection) 484 return null; 485 486 var startSelection = null; 487 var endSelection = null; 488 if (this._selectionIsBackward) { 489 startSelection = this._headSelection; 490 endSelection = this._anchorSelection; 491 } else { 492 startSelection = this._anchorSelection; 493 endSelection = this._headSelection; 494 } 495 496 var textLines = []; 497 for (var i = startSelection.item; i <= endSelection.item; ++i) 498 textLines.push(this._providerElement(i).element().textContent); 499 500 var endSelectionElement = this._providerElement(endSelection.item).element(); 501 if (endSelection.node && endSelection.node.isSelfOrDescendant(endSelectionElement)) { 502 var itemTextOffset = this._textOffsetInNode(endSelectionElement, endSelection.node, endSelection.offset); 503 textLines[textLines.length - 1] = textLines.peekLast().substring(0, itemTextOffset); 504 } 505 506 var startSelectionElement = this._providerElement(startSelection.item).element(); 507 if (startSelection.node && startSelection.node.isSelfOrDescendant(startSelectionElement)) { 508 var itemTextOffset = this._textOffsetInNode(startSelectionElement, startSelection.node, startSelection.offset); 509 textLines[0] = textLines[0].substring(itemTextOffset); 510 } 511 512 return textLines.join("\n"); 513 }, 514 515 /** 516 * @param {!Element} itemElement 517 * @param {!Node} container 518 * @param {number} offset 519 * @return {number} 520 */ 521 _textOffsetInNode: function(itemElement, container, offset) 522 { 523 var chars = 0; 524 var node = itemElement; 525 while ((node = node.traverseNextTextNode()) && node !== container) 526 chars += node.textContent.length; 527 return chars + offset; 528 }, 529 530 /** 531 * @param {!Event} event 532 */ 533 _onScroll: function(event) 534 { 535 this.refresh(); 536 }, 537 538 /** 539 * @return {number} 540 */ 541 firstVisibleIndex: function() 542 { 543 return this._firstVisibleIndex; 544 }, 545 546 /** 547 * @return {number} 548 */ 549 lastVisibleIndex: function() 550 { 551 return this._lastVisibleIndex; 552 }, 553 554 /** 555 * @return {?Element} 556 */ 557 renderedElementAt: function(index) 558 { 559 if (index < this._firstVisibleIndex) 560 return null; 561 if (index > this._lastVisibleIndex) 562 return null; 563 return this._renderedItems[index - this._firstVisibleIndex].element(); 564 }, 565 566 /** 567 * @param {number} index 568 * @param {boolean=} makeLast 569 */ 570 scrollItemIntoView: function(index, makeLast) 571 { 572 if (index > this._firstVisibleIndex && index < this._lastVisibleIndex) 573 return; 574 if (makeLast) 575 this.forceScrollItemToBeLast(index); 576 else if (index <= this._firstVisibleIndex) 577 this.forceScrollItemToBeFirst(index); 578 else if (index >= this._lastVisibleIndex) 579 this.forceScrollItemToBeLast(index); 580 }, 581 582 /** 583 * @param {number} index 584 */ 585 forceScrollItemToBeFirst: function(index) 586 { 587 this._rebuildCumulativeHeightsIfNeeded(); 588 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0; 589 this.refresh(); 590 }, 591 592 /** 593 * @param {number} index 594 */ 595 forceScrollItemToBeLast: function(index) 596 { 597 this._rebuildCumulativeHeightsIfNeeded(); 598 this.element.scrollTop = this._cumulativeHeights[index] - this._visibleHeight(); 599 this.refresh(); 600 }, 601 602 /** 603 * @return {number} 604 */ 605 _visibleHeight: function() 606 { 607 // Use offsetHeight instead of clientHeight to avoid being affected by horizontal scroll. 608 return this.element.offsetHeight; 609 } 610} 611