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     this._scrolledToBottom = true;
     59 }
     60 
     61 /**
     62  * @interface
     63  */
     64 WebInspector.ViewportControl.Provider = function()
     65 {
     66 }
     67 
     68 WebInspector.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  */
     95 WebInspector.ViewportElement = function() { }
     96 WebInspector.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  */
    114 WebInspector.StaticViewportElement = function(element)
    115 {
    116     this._element = element;
    117 }
    118 
    119 WebInspector.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 
    135 WebInspector.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