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