• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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