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; |
+ } |
+}; |