| Index: third_party/WebKit/Source/devtools/front_end/ui/ListControl.js
|
| diff --git a/third_party/WebKit/Source/devtools/front_end/ui/ListControl.js b/third_party/WebKit/Source/devtools/front_end/ui/ListControl.js
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..6c8c6b3b25acc545f1e4e7137928ccb777c326a4
|
| --- /dev/null
|
| +++ b/third_party/WebKit/Source/devtools/front_end/ui/ListControl.js
|
| @@ -0,0 +1,528 @@
|
| +// Copyright 2016 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
| +
|
| +/**
|
| + * @template T
|
| + * @interface
|
| + */
|
| +UI.ListDelegate = function() {};
|
| +
|
| +UI.ListDelegate.prototype = {
|
| + /**
|
| + * @param {T} item
|
| + * @return {!Element}
|
| + */
|
| + createElementForItem(item) {},
|
| +
|
| + /**
|
| + * @param {T} item
|
| + * @return {number}
|
| + */
|
| + heightForItem(item) {},
|
| +
|
| + /**
|
| + * @param {T} item
|
| + * @return {boolean}
|
| + */
|
| + isItemSelectable(item) {},
|
| +
|
| + /**
|
| + * @param {?T} from
|
| + * @param {?T} to
|
| + * @param {?Element} fromElement
|
| + * @param {?Element} toElement
|
| + */
|
| + selectedItemChanged(from, to, fromElement, toElement) {},
|
| +};
|
| +
|
| +/** @enum {symbol} */
|
| +UI.ListHeightMode = {
|
| + Fixed: Symbol('UI.ListHeightMode.Fixed'),
|
| + Measured: Symbol('UI.ListHeightMode.Measured'),
|
| + Variable: Symbol('UI.ListHeightMode.Variable')
|
| +};
|
| +
|
| +/**
|
| + * @template T
|
| + */
|
| +UI.ListControl = class {
|
| + /**
|
| + * @param {!UI.ListDelegate<T>} delegate
|
| + */
|
| + constructor(delegate) {
|
| + this.element = createElement('div');
|
| + this.element.style.overflow = 'auto';
|
| + this._topElement = this.element.createChild('div');
|
| + this._bottomElement = this.element.createChild('div');
|
| + this._firstIndex = 0;
|
| + this._lastIndex = 0;
|
| + this._renderedHeight = 0;
|
| + this._topHeight = 0;
|
| + this._topElement.style.height = '0';
|
| + this._bottomHeight = 0;
|
| + this._bottomElement.style.height = '0';
|
| +
|
| + /** @type {!Array<T>} */
|
| + this._items = [];
|
| + /** @type {!Map<T, !Element>} */
|
| + this._itemToElement = new Map();
|
| + this._selectedIndex = -1;
|
| +
|
| + this._boundKeyDown = event => {
|
| + if (this.onKeyDown(event))
|
| + event.consume(true);
|
| + };
|
| + this._boundClick = event => {
|
| + if (this.onClick(event))
|
| + event.consume(true);
|
| + };
|
| +
|
| + this._delegate = delegate;
|
| + this._heightMode = UI.ListHeightMode.Measured;
|
| + this._fixedHeight = 0;
|
| +
|
| + this.element.addEventListener('scroll', this._onScroll.bind(this), false);
|
| + }
|
| +
|
| + /**
|
| + * @param {!UI.ListHeightMode} mode
|
| + */
|
| + setHeightMode(mode) {
|
| + if (mode === UI.ListHeightMode.Variable)
|
| + throw 'Variable height is not supported (yet)';
|
| + this._heightMode = mode;
|
| + this._fixedHeight = 0;
|
| + if (this._items.length) {
|
| + this._itemToElement.clear();
|
| + this._refresh();
|
| + }
|
| + }
|
| +
|
| + /**
|
| + * @param {boolean} handleInput
|
| + */
|
| + setHandleInput(handleInput) {
|
| + if (handleInput) {
|
| + this.element.addEventListener('keydown', this._boundKeyDown, false);
|
| + this.element.addEventListener('click', this._boundClick, false);
|
| + } else {
|
| + this.element.removeEventListener('keydown', this._boundKeyDown, false);
|
| + this.element.removeEventListener('click', this._boundClick, false);
|
| + }
|
| + }
|
| +
|
| + /**
|
| + * @return {number}
|
| + */
|
| + length() {
|
| + return this._items.length;
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @return {T}
|
| + */
|
| + itemAtIndex(index) {
|
| + return this._items[index];
|
| + }
|
| +
|
| + /**
|
| + * @param {T} item
|
| + */
|
| + pushItem(item) {
|
| + this.replaceItemsInRange(this._items.length, this._items.length, [item]);
|
| + }
|
| +
|
| + /**
|
| + * @return {T}
|
| + */
|
| + popItem() {
|
| + return this.removeItemAtIndex(this._items.length - 1);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @param {T} item
|
| + */
|
| + insertItemAtIndex(index, item) {
|
| + this.replaceItemsInRange(index, index, [item]);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @return {T}
|
| + */
|
| + removeItemAtIndex(index) {
|
| + var result = this._items[index];
|
| + this.replaceItemsInRange(index, index + 1, []);
|
| + return result;
|
| + }
|
| +
|
| + /**
|
| + * @param {number} from
|
| + * @param {number} to
|
| + * @param {!Array<T>} items
|
| + */
|
| + replaceItemsInRange(from, to, items) {
|
| + var oldSelectedItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null;
|
| + var oldSelectedElement = oldSelectedItem ? (this._itemToElement.get(oldSelectedItem) || null) : null;
|
| +
|
| + for (var i = from; i < to; i++)
|
| + this._itemToElement.delete(this._items[i]);
|
| + if (items.length < 10000) {
|
| + this._items.splice.bind(this._items, from, to - from).apply(null, items);
|
| + } else {
|
| + // Splice may fail with too many arguments.
|
| + var before = this._items.slice(0, from);
|
| + var after = this._items.slice(to);
|
| + this._items = [].concat(before, items, after);
|
| + }
|
| + this._invalidate(from, to, items.length);
|
| +
|
| + if (this._selectedIndex >= to) {
|
| + this._selectedIndex += items.length - (to - from);
|
| + } else if (this._selectedIndex >= from) {
|
| + var index = this._findClosestSelectable(from + items.length, +1, 0, false);
|
| + if (index === -1)
|
| + index = this._findClosestSelectable(from - 1, -1, 0, false);
|
| + this._select(index, oldSelectedItem, oldSelectedElement);
|
| + }
|
| + }
|
| +
|
| + /**
|
| + * @param {!Array<T>} items
|
| + */
|
| + replaceAllItems(items) {
|
| + this.replaceItemsInRange(0, this._items.length, items);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} from
|
| + * @param {number} to
|
| + */
|
| + invalidateRange(from, to) {
|
| + this._invalidate(from, to, to - from);
|
| + }
|
| +
|
| + viewportResized() {
|
| + this._refresh();
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + */
|
| + scrollItemAtIndexIntoView(index) {
|
| + var top = this._offsetAtIndex(index);
|
| + var bottom = this._offsetAtIndex(index + 1);
|
| + var scrollTop = this.element.scrollTop;
|
| + var height = this.element.offsetHeight;
|
| + if (top < scrollTop)
|
| + this._update(top, height);
|
| + else if (bottom > scrollTop + height)
|
| + this._update(bottom - height, height);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @param {boolean=} scrollIntoView
|
| + */
|
| + selectItemAtIndex(index, scrollIntoView) {
|
| + if (index !== -1 && !this._delegate.isItemSelectable(this._items[index]))
|
| + throw 'Attempt to select non-selectable item';
|
| + this._select(index);
|
| + if (index !== -1 && !!scrollIntoView)
|
| + this.scrollItemAtIndexIntoView(index);
|
| + }
|
| +
|
| + /**
|
| + * @return {number}
|
| + */
|
| + selectedIndex() {
|
| + return this._selectedIndex;
|
| + }
|
| +
|
| + /**
|
| + * @return {?T}
|
| + */
|
| + selectedItem() {
|
| + return this._selectedIndex === -1 ? null : this._items[this._selectedIndex];
|
| + }
|
| +
|
| + /**
|
| + * @param {!Event} event
|
| + * @return {boolean}
|
| + */
|
| + onKeyDown(event) {
|
| + var index = -1;
|
| + switch (event.key) {
|
| + case 'ArrowUp':
|
| + index = this._selectedIndex === -1 ? this._findClosestSelectable(this._items.length - 1, -1, 0, true) :
|
| + this._findClosestSelectable(this._selectedIndex, -1, 1, true);
|
| + break;
|
| + case 'ArrowDown':
|
| + index = this._selectedIndex === -1 ? this._findClosestSelectable(0, +1, 0, true) :
|
| + this._findClosestSelectable(this._selectedIndex, +1, 1, true);
|
| + break;
|
| + case 'PageUp':
|
| + index = this._selectedIndex === -1 ? this._items.length - 1 : this._selectedIndex;
|
| + // Compensate for zoom rounding errors with -1.
|
| + index = this._findClosestSelectable(index, -1, this.element.offsetHeight - 1, false);
|
| + break;
|
| + case 'PageDown':
|
| + index = this._selectedIndex === -1 ? 0 : this._selectedIndex;
|
| + // Compensate for zoom rounding errors with -1.
|
| + index = this._findClosestSelectable(index, +1, this.element.offsetHeight - 1, false);
|
| + break;
|
| + default:
|
| + return false;
|
| + }
|
| + if (index !== -1) {
|
| + this.scrollItemAtIndexIntoView(index);
|
| + this._select(index);
|
| + return true;
|
| + }
|
| + return false;
|
| + }
|
| +
|
| + /**
|
| + * @param {!Event} event
|
| + * @return {boolean}
|
| + */
|
| + onClick(event) {
|
| + var node = event.target;
|
| + while (node && node.parentNodeOrShadowHost() !== this.element)
|
| + node = node.parentNodeOrShadowHost();
|
| + if (!node || node.nodeType !== Node.ELEMENT_NODE)
|
| + return false;
|
| + var offset = /** @type {!Element} */ (node).getBoundingClientRect().top;
|
| + offset -= this.element.getBoundingClientRect().top;
|
| + var index = this._indexAtOffset(offset + this.element.scrollTop);
|
| + if (index === -1 || !this._delegate.isItemSelectable(this._items[index]))
|
| + return false;
|
| + this._select(index);
|
| + return true;
|
| + }
|
| +
|
| + /**
|
| + * @return {number}
|
| + */
|
| + _totalHeight() {
|
| + return this._offsetAtIndex(this._items.length);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} offset
|
| + * @return {number}
|
| + */
|
| + _indexAtOffset(offset) {
|
| + if (!this._items.length || offset < 0)
|
| + return 0;
|
| + if (this._heightMode === UI.ListHeightMode.Variable)
|
| + throw 'Variable height is not supported (yet)';
|
| + if (!this._fixedHeight)
|
| + this._measureHeight();
|
| + return Math.min(this._items.length - 1, Math.floor(offset / this._fixedHeight));
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @return {!Element}
|
| + */
|
| + _elementAtIndex(index) {
|
| + var item = this._items[index];
|
| + var element = this._itemToElement.get(item);
|
| + if (!element) {
|
| + element = this._delegate.createElementForItem(item);
|
| + this._itemToElement.set(item, element);
|
| + }
|
| + return element;
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @return {number}
|
| + */
|
| + _offsetAtIndex(index) {
|
| + if (!this._items.length)
|
| + return 0;
|
| + if (this._heightMode === UI.ListHeightMode.Variable)
|
| + throw 'Variable height is not supported (yet)';
|
| + if (!this._fixedHeight)
|
| + this._measureHeight();
|
| + return index * this._fixedHeight;
|
| + }
|
| +
|
| + _measureHeight() {
|
| + if (this._heightMode === UI.ListHeightMode.Measured)
|
| + this._fixedHeight = UI.measurePreferredSize(this._elementAtIndex(0), this.element).height;
|
| + else
|
| + this._fixedHeight = this._delegate.heightForItem(this._items[0]);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @param {?T=} oldItem
|
| + * @param {?Element=} oldElement
|
| + */
|
| + _select(index, oldItem, oldElement) {
|
| + if (oldItem === undefined)
|
| + oldItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null;
|
| + if (oldElement === undefined)
|
| + oldElement = this._itemToElement.get(oldItem) || null;
|
| + this._selectedIndex = index;
|
| + var newItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null;
|
| + var newElement = this._itemToElement.get(newItem) || null;
|
| + this._delegate.selectedItemChanged(oldItem, newItem, /** @type {?Element} */ (oldElement), newElement);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} index
|
| + * @param {number} direction
|
| + * @param {number} minSkippedHeight
|
| + * @param {boolean} canWrap
|
| + * @return {number}
|
| + */
|
| + _findClosestSelectable(index, direction, minSkippedHeight, canWrap) {
|
| + var length = this._items.length;
|
| + if (!length)
|
| + return -1;
|
| +
|
| + var lastSelectable = -1;
|
| + var start = -1;
|
| + var startOffset = this._offsetAtIndex(index);
|
| + while (true) {
|
| + if (index < 0 || index >= length) {
|
| + if (!canWrap)
|
| + return lastSelectable;
|
| + index = (index + length) % length;
|
| + }
|
| +
|
| + // Handle full wrap-around.
|
| + if (index === start)
|
| + return lastSelectable;
|
| + if (start === -1) {
|
| + start = index;
|
| + startOffset = this._offsetAtIndex(index);
|
| + }
|
| +
|
| + if (this._delegate.isItemSelectable(this._items[index])) {
|
| + if (Math.abs(this._offsetAtIndex(index) - startOffset) >= minSkippedHeight)
|
| + return index;
|
| + lastSelectable = index;
|
| + }
|
| +
|
| + index += direction;
|
| + }
|
| + }
|
| +
|
| + /**
|
| + * @param {number} from
|
| + * @param {number} to
|
| + * @param {number} inserted
|
| + */
|
| + _invalidate(from, to, inserted) {
|
| + var viewportHeight = this.element.offsetHeight;
|
| + var totalHeight = this._totalHeight();
|
| + if (this._renderedHeight < viewportHeight || totalHeight < viewportHeight) {
|
| + this._refresh();
|
| + return;
|
| + }
|
| +
|
| + var scrollTop = this.element.scrollTop;
|
| + var heightDelta = totalHeight - this._renderedHeight;
|
| + if (to <= this._firstIndex) {
|
| + var topHeight = this._topHeight + heightDelta;
|
| + this._topElement.style.height = topHeight + 'px';
|
| + this.element.scrollTop = scrollTop + heightDelta;
|
| + this._topHeight = topHeight;
|
| + this._renderedHeight = totalHeight;
|
| + var indexDelta = inserted - (to - from);
|
| + this._firstIndex += indexDelta;
|
| + this._lastIndex += indexDelta;
|
| + return;
|
| + }
|
| +
|
| + if (from >= this._lastIndex) {
|
| + var bottomHeight = this._bottomHeight + heightDelta;
|
| + this._bottomElement.style.height = bottomHeight + 'px';
|
| + this._bottomHeight = bottomHeight;
|
| + this._renderedHeight = totalHeight;
|
| + return;
|
| + }
|
| +
|
| + // TODO(dgozman): try to keep the visible scrollTop the same
|
| + // when invalidating after firstIndex but before first visible element.
|
| + this._refresh();
|
| + }
|
| +
|
| + _refresh() {
|
| + var viewportHeight = this.element.offsetHeight;
|
| + var scrollTop = Number.constrain(this.element.scrollTop, 0, this._totalHeight() - viewportHeight);
|
| + this._firstIndex = 0;
|
| + this._lastIndex = 0;
|
| + this._renderedHeight = 0;
|
| + this._topHeight = 0;
|
| + this._bottomHeight = 0;
|
| + this.element.removeChildren();
|
| + this.element.appendChild(this._topElement);
|
| + this.element.appendChild(this._bottomElement);
|
| + this._update(scrollTop, viewportHeight);
|
| + }
|
| +
|
| + _onScroll() {
|
| + this._update(this.element.scrollTop, this.element.offsetHeight);
|
| + }
|
| +
|
| + /**
|
| + * @param {number} scrollTop
|
| + * @param {number} viewportHeight
|
| + */
|
| + _update(scrollTop, viewportHeight) {
|
| + // Note: this method should not force layout. Be careful.
|
| +
|
| + var totalHeight = this._totalHeight();
|
| + if (!totalHeight) {
|
| + this._firstIndex = 0;
|
| + this._lastIndex = 0;
|
| + this._topHeight = 0;
|
| + this._bottomHeight = 0;
|
| + this._renderedHeight = 0;
|
| + this._topElement.style.height = '0';
|
| + this._bottomElement.style.height = '0';
|
| + return;
|
| + }
|
| +
|
| + var firstIndex = this._indexAtOffset(scrollTop - viewportHeight);
|
| + var lastIndex = this._indexAtOffset(scrollTop + 2 * viewportHeight) + 1;
|
| +
|
| + while (this._firstIndex < Math.min(firstIndex, this._lastIndex)) {
|
| + this._elementAtIndex(this._firstIndex).remove();
|
| + this._firstIndex++;
|
| + }
|
| + while (this._lastIndex > Math.max(lastIndex, this._firstIndex)) {
|
| + this._elementAtIndex(this._lastIndex - 1).remove();
|
| + this._lastIndex--;
|
| + }
|
| +
|
| + this._firstIndex = Math.min(this._firstIndex, lastIndex);
|
| + this._lastIndex = Math.max(this._lastIndex, firstIndex);
|
| + for (var index = this._firstIndex - 1; index >= firstIndex; index--) {
|
| + var element = this._elementAtIndex(index);
|
| + this.element.insertBefore(element, this._topElement.nextSibling);
|
| + }
|
| + for (var index = this._lastIndex; index < lastIndex; index++) {
|
| + var element = this._elementAtIndex(index);
|
| + this.element.insertBefore(element, this._bottomElement);
|
| + }
|
| +
|
| + this._firstIndex = firstIndex;
|
| + this._lastIndex = lastIndex;
|
| + this._topHeight = this._offsetAtIndex(firstIndex);
|
| + this._topElement.style.height = this._topHeight + 'px';
|
| + this._bottomHeight = totalHeight - this._offsetAtIndex(lastIndex);
|
| + this._bottomElement.style.height = this._bottomHeight + 'px';
|
| + this._renderedHeight = totalHeight;
|
| + this.element.scrollTop = scrollTop;
|
| + }
|
| +};
|
|
|