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} 59 60/** 61 * @interface 62 */ 63WebInspector.ViewportControl.Provider = function() 64{ 65} 66 67WebInspector.ViewportControl.Provider.prototype = { 68 /** 69 * @param {number} index 70 * @return {number} 71 */ 72 fastHeight: function(index) { return 0; }, 73 74 /** 75 * @return {number} 76 */ 77 itemCount: function() { return 0; }, 78 79 /** 80 * @return {number} 81 */ 82 minimumRowHeight: function() { return 0; }, 83 84 /** 85 * @param {number} index 86 * @return {?WebInspector.ViewportElement} 87 */ 88 itemElement: function(index) { return null; } 89} 90 91/** 92 * @interface 93 */ 94WebInspector.ViewportElement = function() { } 95WebInspector.ViewportElement.prototype = { 96 cacheFastHeight: function() { }, 97 98 willHide: function() { }, 99 100 wasShown: function() { }, 101 102 /** 103 * @return {!Element} 104 */ 105 element: function() { }, 106} 107 108/** 109 * @constructor 110 * @implements {WebInspector.ViewportElement} 111 * @param {!Element} element 112 */ 113WebInspector.StaticViewportElement = function(element) 114{ 115 this._element = element; 116} 117 118WebInspector.StaticViewportElement.prototype = { 119 cacheFastHeight: function() { }, 120 121 willHide: function() { }, 122 123 wasShown: function() { }, 124 125 /** 126 * @return {!Element} 127 */ 128 element: function() 129 { 130 return this._element; 131 }, 132} 133 134WebInspector.ViewportControl.prototype = { 135 /** 136 * @param {boolean} value 137 */ 138 setStickToBottom: function(value) 139 { 140 this._stickToBottom = value; 141 }, 142 143 /** 144 * @param {?Event} event 145 */ 146 _onCopy: function(event) 147 { 148 var text = this._selectedText(); 149 if (!text) 150 return; 151 event.preventDefault(); 152 event.clipboardData.setData("text/plain", text); 153 }, 154 155 /** 156 * @param {?Event} event 157 */ 158 _onDragStart: function(event) 159 { 160 var text = this._selectedText(); 161 if (!text) 162 return false; 163 event.dataTransfer.clearData(); 164 event.dataTransfer.setData("text/plain", text); 165 event.dataTransfer.effectAllowed = "copy"; 166 return true; 167 }, 168 169 /** 170 * @return {!Element} 171 */ 172 contentElement: function() 173 { 174 return this._contentElement; 175 }, 176 177 invalidate: function() 178 { 179 delete this._cumulativeHeights; 180 this.refresh(); 181 }, 182 183 _rebuildCumulativeHeightsIfNeeded: function() 184 { 185 if (this._cumulativeHeights) 186 return; 187 var itemCount = this._provider.itemCount(); 188 if (!itemCount) 189 return; 190 this._cumulativeHeights = new Int32Array(itemCount); 191 this._cumulativeHeights[0] = this._provider.fastHeight(0); 192 for (var i = 1; i < itemCount; ++i) 193 this._cumulativeHeights[i] = this._cumulativeHeights[i - 1] + this._provider.fastHeight(i); 194 }, 195 196 /** 197 * @param {number} index 198 * @return {number} 199 */ 200 _cachedItemHeight: function(index) 201 { 202 return index === 0 ? this._cumulativeHeights[0] : this._cumulativeHeights[index] - this._cumulativeHeights[index - 1]; 203 }, 204 205 /** 206 * @param {?Selection} selection 207 */ 208 _isSelectionBackwards: function(selection) 209 { 210 if (!selection || !selection.rangeCount) 211 return false; 212 var range = document.createRange(); 213 range.setStart(selection.anchorNode, selection.anchorOffset); 214 range.setEnd(selection.focusNode, selection.focusOffset); 215 return range.collapsed; 216 }, 217 218 /** 219 * @param {number} itemIndex 220 * @param {!Node} node 221 * @param {number} offset 222 * @return {!{item: number, node: !Node, offset: number}} 223 */ 224 _createSelectionModel: function(itemIndex, node, offset) 225 { 226 return { 227 item: itemIndex, 228 node: node, 229 offset: offset 230 }; 231 }, 232 233 /** 234 * @param {?Selection} selection 235 */ 236 _updateSelectionModel: function(selection) 237 { 238 if (!selection || !selection.rangeCount) { 239 this._headSelection = null; 240 this._anchorSelection = null; 241 return false; 242 } 243 244 var firstSelected = Number.MAX_VALUE; 245 var lastSelected = -1; 246 247 var range = selection.getRangeAt(0); 248 var hasVisibleSelection = false; 249 for (var i = 0; i < this._renderedItems.length; ++i) { 250 if (range.intersectsNode(this._renderedItems[i].element())) { 251 var index = i + this._firstVisibleIndex; 252 firstSelected = Math.min(firstSelected, index); 253 lastSelected = Math.max(lastSelected, index); 254 hasVisibleSelection = true; 255 } 256 } 257 if (hasVisibleSelection) { 258 firstSelected = this._createSelectionModel(firstSelected, /** @type {!Node} */(range.startContainer), range.startOffset); 259 lastSelected = this._createSelectionModel(lastSelected, /** @type {!Node} */(range.endContainer), range.endOffset); 260 } 261 var topOverlap = range.intersectsNode(this._topGapElement) && this._topGapElement._active; 262 var bottomOverlap = range.intersectsNode(this._bottomGapElement) && this._bottomGapElement._active; 263 if (!topOverlap && !bottomOverlap && !hasVisibleSelection) { 264 this._headSelection = null; 265 this._anchorSelection = null; 266 return false; 267 } 268 269 if (!this._anchorSelection || !this._headSelection) { 270 this._anchorSelection = this._createSelectionModel(0, this.element, 0); 271 this._headSelection = this._createSelectionModel(this._provider.itemCount() - 1, this.element, this.element.children.length); 272 this._selectionIsBackward = false; 273 } 274 275 var isBackward = this._isSelectionBackwards(selection); 276 var startSelection = this._selectionIsBackward ? this._headSelection : this._anchorSelection; 277 var endSelection = this._selectionIsBackward ? this._anchorSelection : this._headSelection; 278 if (topOverlap && bottomOverlap && hasVisibleSelection) { 279 firstSelected = firstSelected.item < startSelection.item ? firstSelected : startSelection; 280 lastSelected = lastSelected.item > endSelection.item ? lastSelected : endSelection; 281 } else if (!hasVisibleSelection) { 282 firstSelected = startSelection; 283 lastSelected = endSelection; 284 } else if (topOverlap) 285 firstSelected = isBackward ? this._headSelection : this._anchorSelection; 286 else if (bottomOverlap) 287 lastSelected = isBackward ? this._anchorSelection : this._headSelection; 288 289 if (isBackward) { 290 this._anchorSelection = lastSelected; 291 this._headSelection = firstSelected; 292 } else { 293 this._anchorSelection = firstSelected; 294 this._headSelection = lastSelected; 295 } 296 this._selectionIsBackward = isBackward; 297 return true; 298 }, 299 300 /** 301 * @param {?Selection} selection 302 */ 303 _restoreSelection: function(selection) 304 { 305 var anchorElement = null; 306 var anchorOffset; 307 if (this._firstVisibleIndex <= this._anchorSelection.item && this._anchorSelection.item <= this._lastVisibleIndex) { 308 anchorElement = this._anchorSelection.node; 309 anchorOffset = this._anchorSelection.offset; 310 } else { 311 if (this._anchorSelection.item < this._firstVisibleIndex) 312 anchorElement = this._topGapElement; 313 else if (this._anchorSelection.item > this._lastVisibleIndex) 314 anchorElement = this._bottomGapElement; 315 anchorOffset = this._selectionIsBackward ? 1 : 0; 316 } 317 318 var headElement = null; 319 var headOffset; 320 if (this._firstVisibleIndex <= this._headSelection.item && this._headSelection.item <= this._lastVisibleIndex) { 321 headElement = this._headSelection.node; 322 headOffset = this._headSelection.offset; 323 } else { 324 if (this._headSelection.item < this._firstVisibleIndex) 325 headElement = this._topGapElement; 326 else if (this._headSelection.item > this._lastVisibleIndex) 327 headElement = this._bottomGapElement; 328 headOffset = this._selectionIsBackward ? 0 : 1; 329 } 330 331 selection.setBaseAndExtent(anchorElement, anchorOffset, headElement, headOffset); 332 }, 333 334 refresh: function() 335 { 336 if (!this.element.clientHeight) 337 return; // Do nothing for invisible controls. 338 339 var itemCount = this._provider.itemCount(); 340 if (!itemCount) { 341 for (var i = 0; i < this._renderedItems.length; ++i) 342 this._renderedItems[i].cacheFastHeight(); 343 for (var i = 0; i < this._renderedItems.length; ++i) 344 this._renderedItems[i].willHide(); 345 this._renderedItems = []; 346 this._contentElement.removeChildren(); 347 this._topGapElement.style.height = "0px"; 348 this._bottomGapElement.style.height = "0px"; 349 this._firstVisibleIndex = -1; 350 this._lastVisibleIndex = -1; 351 return; 352 } 353 354 var selection = window.getSelection(); 355 var shouldRestoreSelection = this._updateSelectionModel(selection); 356 357 var visibleFrom = this.element.scrollTop; 358 var clientHeight = this.element.clientHeight; 359 var shouldStickToBottom = this._stickToBottom && this.element.isScrolledToBottom(); 360 361 if (this._cumulativeHeights && itemCount !== this._cumulativeHeights.length) 362 delete this._cumulativeHeights; 363 for (var i = 0; i < this._renderedItems.length; ++i) { 364 this._renderedItems[i].cacheFastHeight(); 365 // Tolerate 1-pixel error due to double-to-integer rounding errors. 366 if (this._cumulativeHeights && Math.abs(this._cachedItemHeight(this._firstVisibleIndex + i) - this._provider.fastHeight(i + this._firstVisibleIndex)) > 1) 367 delete this._cumulativeHeights; 368 } 369 this._rebuildCumulativeHeightsIfNeeded(); 370 if (shouldStickToBottom) { 371 this._lastVisibleIndex = itemCount - 1; 372 this._firstVisibleIndex = Math.max(itemCount - Math.ceil(clientHeight / this._provider.minimumRowHeight()), 0); 373 } else { 374 this._firstVisibleIndex = Math.max(Array.prototype.lowerBound.call(this._cumulativeHeights, visibleFrom + 1), 0); 375 // Proactively render more rows in case some of them will be collapsed without triggering refresh. @see crbug.com/390169 376 this._lastVisibleIndex = this._firstVisibleIndex + Math.ceil(clientHeight / this._provider.minimumRowHeight()) - 1; 377 this._lastVisibleIndex = Math.min(this._lastVisibleIndex, itemCount - 1); 378 } 379 var topGapHeight = this._cumulativeHeights[this._firstVisibleIndex - 1] || 0; 380 var bottomGapHeight = this._cumulativeHeights[this._cumulativeHeights.length - 1] - this._cumulativeHeights[this._lastVisibleIndex]; 381 382 this._topGapElement.style.height = topGapHeight + "px"; 383 this._bottomGapElement.style.height = bottomGapHeight + "px"; 384 this._topGapElement._active = !!topGapHeight; 385 this._bottomGapElement._active = !!bottomGapHeight; 386 387 this._contentElement.style.setProperty("height", "10000000px"); 388 for (var i = 0; i < this._renderedItems.length; ++i) 389 this._renderedItems[i].willHide(); 390 this._renderedItems = []; 391 this._contentElement.removeChildren(); 392 for (var i = this._firstVisibleIndex; i <= this._lastVisibleIndex; ++i) { 393 var viewportElement = this._provider.itemElement(i); 394 this._contentElement.appendChild(viewportElement.element()); 395 this._renderedItems.push(viewportElement); 396 viewportElement.wasShown(); 397 } 398 399 this._contentElement.style.removeProperty("height"); 400 // Should be the last call in the method as it might force layout. 401 if (shouldRestoreSelection) 402 this._restoreSelection(selection); 403 if (shouldStickToBottom) 404 this.element.scrollTop = this.element.scrollHeight; 405 }, 406 407 /** 408 * @return {?string} 409 */ 410 _selectedText: function() 411 { 412 this._updateSelectionModel(window.getSelection()); 413 if (!this._headSelection || !this._anchorSelection) 414 return null; 415 416 var startSelection = null; 417 var endSelection = null; 418 if (this._selectionIsBackward) { 419 startSelection = this._headSelection; 420 endSelection = this._anchorSelection; 421 } else { 422 startSelection = this._anchorSelection; 423 endSelection = this._headSelection; 424 } 425 426 var textLines = []; 427 for (var i = startSelection.item; i <= endSelection.item; ++i) 428 textLines.push(this._provider.itemElement(i).element().textContent); 429 430 var endSelectionElement = this._provider.itemElement(endSelection.item).element(); 431 if (endSelection.node && endSelection.node.isSelfOrDescendant(endSelectionElement)) { 432 var itemTextOffset = this._textOffsetInNode(endSelectionElement, endSelection.node, endSelection.offset); 433 textLines[textLines.length - 1] = textLines.peekLast().substring(0, itemTextOffset); 434 } 435 436 var startSelectionElement = this._provider.itemElement(startSelection.item).element(); 437 if (startSelection.node && startSelection.node.isSelfOrDescendant(startSelectionElement)) { 438 var itemTextOffset = this._textOffsetInNode(startSelectionElement, startSelection.node, startSelection.offset); 439 textLines[0] = textLines[0].substring(itemTextOffset); 440 } 441 442 return textLines.join("\n"); 443 }, 444 445 /** 446 * @param {!Element} itemElement 447 * @param {!Node} container 448 * @param {number} offset 449 * @return {number} 450 */ 451 _textOffsetInNode: function(itemElement, container, offset) 452 { 453 var chars = 0; 454 var node = itemElement; 455 while ((node = node.traverseNextTextNode()) && node !== container) 456 chars += node.textContent.length; 457 return chars + offset; 458 }, 459 460 /** 461 * @param {?Event} event 462 */ 463 _onScroll: function(event) 464 { 465 this.refresh(); 466 }, 467 468 /** 469 * @return {number} 470 */ 471 firstVisibleIndex: function() 472 { 473 return this._firstVisibleIndex; 474 }, 475 476 /** 477 * @return {number} 478 */ 479 lastVisibleIndex: function() 480 { 481 return this._lastVisibleIndex; 482 }, 483 484 /** 485 * @return {?Element} 486 */ 487 renderedElementAt: function(index) 488 { 489 if (index < this._firstVisibleIndex) 490 return null; 491 if (index > this._lastVisibleIndex) 492 return null; 493 return this._renderedItems[index - this._firstVisibleIndex].element(); 494 }, 495 496 /** 497 * @param {number} index 498 * @param {boolean=} makeLast 499 */ 500 scrollItemIntoView: function(index, makeLast) 501 { 502 if (index > this._firstVisibleIndex && index < this._lastVisibleIndex) 503 return; 504 if (makeLast) 505 this.forceScrollItemToBeLast(index); 506 else if (index <= this._firstVisibleIndex) 507 this.forceScrollItemToBeFirst(index); 508 else if (index >= this._lastVisibleIndex) 509 this.forceScrollItemToBeLast(index); 510 }, 511 512 /** 513 * @param {number} index 514 */ 515 forceScrollItemToBeFirst: function(index) 516 { 517 this._rebuildCumulativeHeightsIfNeeded(); 518 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0; 519 }, 520 521 /** 522 * @param {number} index 523 */ 524 forceScrollItemToBeLast: function(index) 525 { 526 this._rebuildCumulativeHeightsIfNeeded(); 527 this.element.scrollTop = this._cumulativeHeights[index] - this.element.clientHeight; 528 } 529} 530