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