Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / third_party / WebKit / Source / devtools / front_end / ui / ViewportControl.js
blob6e73242400b034566f9c572136e896f1e1bef0df
1 /*
2 * Copyright (C) 2013 Google Inc. All rights reserved.
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are
6 * met:
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.
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.
31 /**
32 * @constructor
33 * @param {!WebInspector.ViewportControl.Provider} provider
35 WebInspector.ViewportControl = function(provider)
37 this.element = 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";
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);
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;
61 /**
62 * @interface
64 WebInspector.ViewportControl.Provider = function()
68 WebInspector.ViewportControl.Provider.prototype = {
69 /**
70 * @param {number} index
71 * @return {number}
73 fastHeight: function(index) { return 0; },
75 /**
76 * @return {number}
78 itemCount: function() { return 0; },
80 /**
81 * @return {number}
83 minimumRowHeight: function() { return 0; },
85 /**
86 * @param {number} index
87 * @return {?WebInspector.ViewportElement}
89 itemElement: function(index) { return null; }
92 /**
93 * @interface
95 WebInspector.ViewportElement = function() { }
96 WebInspector.ViewportElement.prototype = {
97 cacheFastHeight: function() { },
99 willHide: function() { },
101 wasShown: function() { },
104 * @return {!Element}
106 element: function() { },
110 * @constructor
111 * @implements {WebInspector.ViewportElement}
112 * @param {!Element} element
114 WebInspector.StaticViewportElement = function(element)
116 this._element = element;
119 WebInspector.StaticViewportElement.prototype = {
121 * @override
123 cacheFastHeight: function() { },
126 * @override
128 willHide: function() { },
131 * @override
133 wasShown: function() { },
136 * @override
137 * @return {!Element}
139 element: function()
141 return this._element;
145 WebInspector.ViewportControl.prototype = {
147 * @return {boolean}
149 scrolledToBottom: function()
151 return this._scrolledToBottom;
155 * @param {boolean} value
157 setStickToBottom: function(value)
159 this._stickToBottom = value;
163 * @param {!Event} event
165 _onCopy: function(event)
167 var text = this._selectedText();
168 if (!text)
169 return;
170 event.preventDefault();
171 event.clipboardData.setData("text/plain", text);
175 * @param {!Event} event
177 _onDragStart: function(event)
179 var text = this._selectedText();
180 if (!text)
181 return false;
182 event.dataTransfer.clearData();
183 event.dataTransfer.setData("text/plain", text);
184 event.dataTransfer.effectAllowed = "copy";
185 return true;
189 * @return {!Element}
191 contentElement: function()
193 return this._contentElement;
196 invalidate: function()
198 delete this._cumulativeHeights;
199 delete this._cachedProviderElements;
200 this.refresh();
204 * @param {number} index
205 * @return {?WebInspector.ViewportElement}
207 _providerElement: function(index)
209 if (!this._cachedProviderElements)
210 this._cachedProviderElements = new Array(this._provider.itemCount());
211 var element = this._cachedProviderElements[index];
212 if (!element) {
213 element = this._provider.itemElement(index);
214 this._cachedProviderElements[index] = element;
216 return element;
219 _rebuildCumulativeHeightsIfNeeded: function()
221 if (this._cumulativeHeights)
222 return;
223 var itemCount = this._provider.itemCount();
224 if (!itemCount)
225 return;
226 this._cumulativeHeights = new Int32Array(itemCount);
227 this._cumulativeHeights[0] = this._provider.fastHeight(0);
228 for (var i = 1; i < itemCount; ++i)
229 this._cumulativeHeights[i] = this._cumulativeHeights[i - 1] + this._provider.fastHeight(i);
233 * @param {number} index
234 * @return {number}
236 _cachedItemHeight: function(index)
238 return index === 0 ? this._cumulativeHeights[0] : this._cumulativeHeights[index] - this._cumulativeHeights[index - 1];
242 * @param {?Selection} selection
243 * @suppressGlobalPropertiesCheck
245 _isSelectionBackwards: function(selection)
247 if (!selection || !selection.rangeCount)
248 return false;
249 var range = document.createRange();
250 range.setStart(selection.anchorNode, selection.anchorOffset);
251 range.setEnd(selection.focusNode, selection.focusOffset);
252 return range.collapsed;
256 * @param {number} itemIndex
257 * @param {!Node} node
258 * @param {number} offset
259 * @return {!{item: number, node: !Node, offset: number}}
261 _createSelectionModel: function(itemIndex, node, offset)
263 return {
264 item: itemIndex,
265 node: node,
266 offset: offset
271 * @param {?Selection} selection
273 _updateSelectionModel: function(selection)
275 if (!selection || !selection.rangeCount) {
276 this._headSelection = null;
277 this._anchorSelection = null;
278 return false;
281 var firstSelected = Number.MAX_VALUE;
282 var lastSelected = -1;
284 var range = selection.getRangeAt(0);
285 var hasVisibleSelection = false;
286 for (var i = 0; i < this._renderedItems.length; ++i) {
287 if (range.intersectsNode(this._renderedItems[i].element())) {
288 var index = i + this._firstVisibleIndex;
289 firstSelected = Math.min(firstSelected, index);
290 lastSelected = Math.max(lastSelected, index);
291 hasVisibleSelection = true;
294 if (hasVisibleSelection) {
295 firstSelected = this._createSelectionModel(firstSelected, /** @type {!Node} */(range.startContainer), range.startOffset);
296 lastSelected = this._createSelectionModel(lastSelected, /** @type {!Node} */(range.endContainer), range.endOffset);
298 var topOverlap = range.intersectsNode(this._topGapElement) && this._topGapElement._active;
299 var bottomOverlap = range.intersectsNode(this._bottomGapElement) && this._bottomGapElement._active;
300 if (!topOverlap && !bottomOverlap && !hasVisibleSelection) {
301 this._headSelection = null;
302 this._anchorSelection = null;
303 return false;
306 if (!this._anchorSelection || !this._headSelection) {
307 this._anchorSelection = this._createSelectionModel(0, this.element, 0);
308 this._headSelection = this._createSelectionModel(this._provider.itemCount() - 1, this.element, this.element.children.length);
309 this._selectionIsBackward = false;
312 var isBackward = this._isSelectionBackwards(selection);
313 var startSelection = this._selectionIsBackward ? this._headSelection : this._anchorSelection;
314 var endSelection = this._selectionIsBackward ? this._anchorSelection : this._headSelection;
315 if (topOverlap && bottomOverlap && hasVisibleSelection) {
316 firstSelected = firstSelected.item < startSelection.item ? firstSelected : startSelection;
317 lastSelected = lastSelected.item > endSelection.item ? lastSelected : endSelection;
318 } else if (!hasVisibleSelection) {
319 firstSelected = startSelection;
320 lastSelected = endSelection;
321 } else if (topOverlap)
322 firstSelected = isBackward ? this._headSelection : this._anchorSelection;
323 else if (bottomOverlap)
324 lastSelected = isBackward ? this._anchorSelection : this._headSelection;
326 if (isBackward) {
327 this._anchorSelection = lastSelected;
328 this._headSelection = firstSelected;
329 } else {
330 this._anchorSelection = firstSelected;
331 this._headSelection = lastSelected;
333 this._selectionIsBackward = isBackward;
334 return true;
338 * @param {?Selection} selection
340 _restoreSelection: function(selection)
342 var anchorElement = null;
343 var anchorOffset;
344 if (this._firstVisibleIndex <= this._anchorSelection.item && this._anchorSelection.item <= this._lastVisibleIndex) {
345 anchorElement = this._anchorSelection.node;
346 anchorOffset = this._anchorSelection.offset;
347 } else {
348 if (this._anchorSelection.item < this._firstVisibleIndex)
349 anchorElement = this._topGapElement;
350 else if (this._anchorSelection.item > this._lastVisibleIndex)
351 anchorElement = this._bottomGapElement;
352 anchorOffset = this._selectionIsBackward ? 1 : 0;
355 var headElement = null;
356 var headOffset;
357 if (this._firstVisibleIndex <= this._headSelection.item && this._headSelection.item <= this._lastVisibleIndex) {
358 headElement = this._headSelection.node;
359 headOffset = this._headSelection.offset;
360 } else {
361 if (this._headSelection.item < this._firstVisibleIndex)
362 headElement = this._topGapElement;
363 else if (this._headSelection.item > this._lastVisibleIndex)
364 headElement = this._bottomGapElement;
365 headOffset = this._selectionIsBackward ? 0 : 1;
368 selection.setBaseAndExtent(anchorElement, anchorOffset, headElement, headOffset);
371 refresh: function()
373 if (!this._visibleHeight())
374 return; // Do nothing for invisible controls.
376 var itemCount = this._provider.itemCount();
377 if (!itemCount) {
378 for (var i = 0; i < this._renderedItems.length; ++i)
379 this._renderedItems[i].cacheFastHeight();
380 for (var i = 0; i < this._renderedItems.length; ++i)
381 this._renderedItems[i].willHide();
382 this._renderedItems = [];
383 this._contentElement.removeChildren();
384 this._topGapElement.style.height = "0px";
385 this._bottomGapElement.style.height = "0px";
386 this._firstVisibleIndex = -1;
387 this._lastVisibleIndex = -1;
388 return;
391 var selection = this.element.getComponentSelection();
392 var shouldRestoreSelection = this._updateSelectionModel(selection);
394 var visibleFrom = this.element.scrollTop;
395 var visibleHeight = this._visibleHeight();
396 this._scrolledToBottom = this.element.isScrolledToBottom();
397 var isInvalidating = !this._cumulativeHeights;
399 if (this._cumulativeHeights && itemCount !== this._cumulativeHeights.length)
400 delete this._cumulativeHeights;
401 for (var i = 0; i < this._renderedItems.length; ++i) {
402 this._renderedItems[i].cacheFastHeight();
403 // Tolerate 1-pixel error due to double-to-integer rounding errors.
404 if (this._cumulativeHeights && Math.abs(this._cachedItemHeight(this._firstVisibleIndex + i) - this._provider.fastHeight(i + this._firstVisibleIndex)) > 1)
405 delete this._cumulativeHeights;
407 this._rebuildCumulativeHeightsIfNeeded();
408 var oldFirstVisibleIndex = this._firstVisibleIndex;
409 var oldLastVisibleIndex = this._lastVisibleIndex;
411 var shouldStickToBottom = this._stickToBottom && this._scrolledToBottom;
412 if (shouldStickToBottom) {
413 this._lastVisibleIndex = itemCount - 1;
414 this._firstVisibleIndex = Math.max(itemCount - Math.ceil(visibleHeight / this._provider.minimumRowHeight()), 0);
415 } else {
416 this._firstVisibleIndex = Math.max(Array.prototype.lowerBound.call(this._cumulativeHeights, visibleFrom + 1), 0);
417 // Proactively render more rows in case some of them will be collapsed without triggering refresh. @see crbug.com/390169
418 this._lastVisibleIndex = this._firstVisibleIndex + Math.ceil(visibleHeight / this._provider.minimumRowHeight()) - 1;
419 this._lastVisibleIndex = Math.min(this._lastVisibleIndex, itemCount - 1);
421 var topGapHeight = this._cumulativeHeights[this._firstVisibleIndex - 1] || 0;
422 var bottomGapHeight = this._cumulativeHeights[this._cumulativeHeights.length - 1] - this._cumulativeHeights[this._lastVisibleIndex];
424 this._topGapElement.style.height = topGapHeight + "px";
425 this._bottomGapElement.style.height = bottomGapHeight + "px";
426 this._topGapElement._active = !!topGapHeight;
427 this._bottomGapElement._active = !!bottomGapHeight;
429 this._contentElement.style.setProperty("height", "10000000px");
430 if (isInvalidating)
431 this._fullViewportUpdate();
432 else
433 this._partialViewportUpdate(oldFirstVisibleIndex, oldLastVisibleIndex);
434 this._contentElement.style.removeProperty("height");
435 // Should be the last call in the method as it might force layout.
436 if (shouldRestoreSelection)
437 this._restoreSelection(selection);
438 if (shouldStickToBottom)
439 this.element.scrollTop = this.element.scrollHeight;
442 _fullViewportUpdate: function()
444 for (var i = 0; i < this._renderedItems.length; ++i)
445 this._renderedItems[i].willHide();
446 this._renderedItems = [];
447 this._contentElement.removeChildren();
448 for (var i = this._firstVisibleIndex; i <= this._lastVisibleIndex; ++i) {
449 var viewportElement = this._providerElement(i);
450 this._contentElement.appendChild(viewportElement.element());
451 this._renderedItems.push(viewportElement);
452 viewportElement.wasShown();
457 * @param {number} oldFirstVisibleIndex
458 * @param {number} oldLastVisibleIndex
460 _partialViewportUpdate: function(oldFirstVisibleIndex, oldLastVisibleIndex)
462 var willBeHidden = [];
463 for (var i = 0; i < this._renderedItems.length; ++i) {
464 var index = oldFirstVisibleIndex + i;
465 if (index < this._firstVisibleIndex || this._lastVisibleIndex < index)
466 willBeHidden.push(this._renderedItems[i]);
468 for (var i = 0; i < willBeHidden.length; ++i)
469 willBeHidden[i].willHide();
470 for (var i = 0; i < willBeHidden.length; ++i)
471 willBeHidden[i].element().remove();
473 this._renderedItems = [];
474 var anchor = this._contentElement.firstChild;
475 for (var i = this._firstVisibleIndex; i <= this._lastVisibleIndex; ++i) {
476 var viewportElement = this._providerElement(i);
477 var element = viewportElement.element();
478 if (element !== anchor) {
479 this._contentElement.insertBefore(element, anchor);
480 viewportElement.wasShown();
481 } else {
482 anchor = anchor.nextSibling;
484 this._renderedItems.push(viewportElement);
489 * @return {?string}
491 _selectedText: function()
493 this._updateSelectionModel(this.element.getComponentSelection());
494 if (!this._headSelection || !this._anchorSelection)
495 return null;
497 var startSelection = null;
498 var endSelection = null;
499 if (this._selectionIsBackward) {
500 startSelection = this._headSelection;
501 endSelection = this._anchorSelection;
502 } else {
503 startSelection = this._anchorSelection;
504 endSelection = this._headSelection;
507 var textLines = [];
508 for (var i = startSelection.item; i <= endSelection.item; ++i)
509 textLines.push(this._providerElement(i).element().deepTextContent());
511 var endSelectionElement = this._providerElement(endSelection.item).element();
512 if (endSelection.node && endSelection.node.isSelfOrDescendant(endSelectionElement)) {
513 var itemTextOffset = this._textOffsetInNode(endSelectionElement, endSelection.node, endSelection.offset);
514 textLines[textLines.length - 1] = textLines.peekLast().substring(0, itemTextOffset);
517 var startSelectionElement = this._providerElement(startSelection.item).element();
518 if (startSelection.node && startSelection.node.isSelfOrDescendant(startSelectionElement)) {
519 var itemTextOffset = this._textOffsetInNode(startSelectionElement, startSelection.node, startSelection.offset);
520 textLines[0] = textLines[0].substring(itemTextOffset);
523 return textLines.join("\n");
527 * @param {!Element} itemElement
528 * @param {!Node} container
529 * @param {number} offset
530 * @return {number}
532 _textOffsetInNode: function(itemElement, container, offset)
534 var chars = 0;
535 var node = itemElement;
536 while ((node = node.traverseNextTextNode()) && node !== container)
537 chars += node.textContent.length;
538 return chars + offset;
542 * @param {!Event} event
544 _onScroll: function(event)
546 this.refresh();
550 * @return {number}
552 firstVisibleIndex: function()
554 return this._firstVisibleIndex;
558 * @return {number}
560 lastVisibleIndex: function()
562 return this._lastVisibleIndex;
566 * @return {?Element}
568 renderedElementAt: function(index)
570 if (index < this._firstVisibleIndex)
571 return null;
572 if (index > this._lastVisibleIndex)
573 return null;
574 return this._renderedItems[index - this._firstVisibleIndex].element();
578 * @param {number} index
579 * @param {boolean=} makeLast
581 scrollItemIntoView: function(index, makeLast)
583 if (index > this._firstVisibleIndex && index < this._lastVisibleIndex)
584 return;
585 if (makeLast)
586 this.forceScrollItemToBeLast(index);
587 else if (index <= this._firstVisibleIndex)
588 this.forceScrollItemToBeFirst(index);
589 else if (index >= this._lastVisibleIndex)
590 this.forceScrollItemToBeLast(index);
594 * @param {number} index
596 forceScrollItemToBeFirst: function(index)
598 this._rebuildCumulativeHeightsIfNeeded();
599 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0;
600 this.refresh();
604 * @param {number} index
606 forceScrollItemToBeLast: function(index)
608 this._rebuildCumulativeHeightsIfNeeded();
609 this.element.scrollTop = this._cumulativeHeights[index] - this._visibleHeight();
610 this.refresh();
614 * @return {number}
616 _visibleHeight: function()
618 // Use offsetHeight instead of clientHeight to avoid being affected by horizontal scroll.
619 return this.element.offsetHeight;