Home | History | Annotate | Download | only in ui
      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  */
     35 WebInspector.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  */
     63 WebInspector.ViewportControl.Provider = function()
     64 {
     65 }
     66 
     67 WebInspector.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  */
     94 WebInspector.ViewportElement = function() { }
     95 WebInspector.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  */
    113 WebInspector.StaticViewportElement = function(element)
    114 {
    115     this._element = element;
    116 }
    117 
    118 WebInspector.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 
    134 WebInspector.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