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
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
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.
33 * @param {!WebInspector.ViewportControl.Provider} provider
35 WebInspector.ViewportControl = function(provider)
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";
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;
63 WebInspector.ViewportControl.Provider = function()
67 WebInspector.ViewportControl.Provider.prototype = {
69 * @param {number} index
72 fastHeight: function(index) { return 0; },
77 itemCount: function() { return 0; },
80 * @param {number} index
81 * @return {?WebInspector.ViewportElement}
83 itemElement: function(index) { return null; }
89 WebInspector.ViewportElement = function() { }
90 WebInspector.ViewportElement.prototype = {
91 cacheFastHeight: function() { },
93 willHide: function() { },
95 wasShown: function() { },
100 element: function() { },
105 * @implements {WebInspector.ViewportElement}
106 * @param {!Element} element
108 WebInspector.StaticViewportElement = function(element)
110 this._element = element;
113 WebInspector.StaticViewportElement.prototype = {
114 cacheFastHeight: function() { },
116 willHide: function() { },
118 wasShown: function() { },
125 return this._element;
129 WebInspector.ViewportControl.prototype = {
131 * @param {boolean} value
133 setStickToBottom: function(value)
135 this._stickToBottom = value;
139 * @param {?Event} event
141 _onCopy: function(event)
143 var text = this._selectedText();
146 event.preventDefault();
147 event.clipboardData.setData("text/plain", text);
151 * @param {?Event} event
153 _onDragStart: function(event)
155 var text = this._selectedText();
158 event.dataTransfer.clearData();
159 event.dataTransfer.setData("text/plain", text);
160 event.dataTransfer.effectAllowed = "copy";
167 contentElement: function()
169 return this._contentElement;
172 invalidate: function()
174 delete this._cumulativeHeights;
178 _rebuildCumulativeHeightsIfNeeded: function()
180 if (this._cumulativeHeights)
182 var itemCount = this._provider.itemCount();
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);
192 * @param {number} index
195 _cachedItemHeight: function(index)
197 return index === 0 ? this._cumulativeHeights[0] : this._cumulativeHeights[index] - this._cumulativeHeights[index - 1];
201 * @param {?Selection} selection
203 _isSelectionBackwards: function(selection)
205 if (!selection || !selection.rangeCount)
207 var range = document.createRange();
208 range.setStart(selection.anchorNode, selection.anchorOffset);
209 range.setEnd(selection.focusNode, selection.focusOffset);
210 return range.collapsed;
214 * @param {number} itemIndex
215 * @param {!Node} node
216 * @param {number} offset
217 * @return {!{item: number, node: !Node, offset: number}}
219 _createSelectionModel: function(itemIndex, node, offset)
229 * @param {?Selection} selection
231 _updateSelectionModel: function(selection)
233 if (!selection || !selection.rangeCount) {
234 this._headSelection = null;
235 this._anchorSelection = null;
239 var firstSelected = Number.MAX_VALUE;
240 var lastSelected = -1;
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;
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);
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;
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;
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;
285 this._anchorSelection = lastSelected;
286 this._headSelection = firstSelected;
288 this._anchorSelection = firstSelected;
289 this._headSelection = lastSelected;
291 this._selectionIsBackward = isBackward;
296 * @param {?Selection} selection
298 _restoreSelection: function(selection)
300 var anchorElement = null;
302 if (this._firstVisibleIndex <= this._anchorSelection.item && this._anchorSelection.item <= this._lastVisibleIndex) {
303 anchorElement = this._anchorSelection.node;
304 anchorOffset = this._anchorSelection.offset;
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;
313 var headElement = null;
315 if (this._firstVisibleIndex <= this._headSelection.item && this._headSelection.item <= this._lastVisibleIndex) {
316 headElement = this._headSelection.node;
317 headOffset = this._headSelection.offset;
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;
326 selection.setBaseAndExtent(anchorElement, anchorOffset, headElement, headOffset);
331 if (!this.element.clientHeight)
332 return; // Do nothing for invisible controls.
334 var itemCount = this._provider.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;
349 var selection = window.getSelection();
350 var shouldRestoreSelection = this._updateSelectionModel(selection);
352 var visibleFrom = this.element.scrollTop;
353 var clientHeight = this.element.clientHeight;
354 var shouldStickToBottom = this._stickToBottom && this.element.isScrolledToBottom();
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;
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);
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);
372 var topGapHeight = this._cumulativeHeights[this._firstVisibleIndex - 1] || 0;
373 var bottomGapHeight = this._cumulativeHeights[this._cumulativeHeights.length - 1] - this._cumulativeHeights[this._lastVisibleIndex];
375 this._topGapElement.style.height = topGapHeight + "px";
376 this._bottomGapElement.style.height = bottomGapHeight + "px";
377 this._topGapElement._active = !!topGapHeight;
378 this._bottomGapElement._active = !!bottomGapHeight;
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();
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;
403 _selectedText: function()
405 this._updateSelectionModel(window.getSelection());
406 if (!this._headSelection || !this._anchorSelection)
409 var startSelection = null;
410 var endSelection = null;
411 if (this._selectionIsBackward) {
412 startSelection = this._headSelection;
413 endSelection = this._anchorSelection;
415 startSelection = this._anchorSelection;
416 endSelection = this._headSelection;
420 for (var i = startSelection.item; i <= endSelection.item; ++i)
421 textLines.push(this._provider.itemElement(i).element().textContent);
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);
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);
435 return textLines.join("\n");
439 * @param {!Element} itemElement
440 * @param {!Node} container
441 * @param {number} offset
444 _textOffsetInNode: function(itemElement, container, offset)
449 var node = itemElement;
450 while ((node = node.traverseNextTextNode()) && node !== container)
451 chars += node.textContent.length;
452 return chars + offset;
456 * @param {?Event} event
458 _onScroll: function(event)
466 firstVisibleIndex: function()
468 return this._firstVisibleIndex;
474 lastVisibleIndex: function()
476 return this._lastVisibleIndex;
482 renderedElementAt: function(index)
484 if (index < this._firstVisibleIndex)
486 if (index > this._lastVisibleIndex)
488 return this._renderedItems[index - this._firstVisibleIndex].element();
492 * @param {number} index
493 * @param {boolean=} makeLast
495 scrollItemIntoView: function(index, makeLast)
497 if (index > this._firstVisibleIndex && index < this._lastVisibleIndex)
500 this.forceScrollItemToBeLast(index);
501 else if (index <= this._firstVisibleIndex)
502 this.forceScrollItemToBeFirst(index);
503 else if (index >= this._lastVisibleIndex)
504 this.forceScrollItemToBeLast(index);
508 * @param {number} index
510 forceScrollItemToBeFirst: function(index)
512 this._rebuildCumulativeHeightsIfNeeded();
513 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0;
517 * @param {number} index
519 forceScrollItemToBeLast: function(index)
521 this._rebuildCumulativeHeightsIfNeeded();
522 this.element.scrollTop = this._cumulativeHeights[index] - this.element.clientHeight;