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..0e2d855dcd97e227d04f39e555006635f172ab31 |
--- /dev/null |
+++ b/third_party/WebKit/Source/devtools/front_end/ui/ListControl.js |
@@ -0,0 +1,492 @@ |
+// 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 = { |
+ /** |
+ * @return {?number} |
+ */ |
+ fixedHeight() {}, |
+ |
+ /** |
+ * @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) {}, |
+}; |
+ |
+/** |
+ * @unrestricted |
+ * @template T |
+ */ |
+UI.ListControl = class { |
+ /** |
+ * @param {!UI.ListDelegate<T>} delegate |
+ * @param {boolean} handleInput |
+ */ |
+ constructor(delegate, handleInput) { |
+ 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; |
+ |
+ /** @type {!Array<T>} */ |
+ this._items = []; |
+ /** @type {!Map<T, !Element>} */ |
+ this._itemToElement = new Map(); |
+ /** @type {!WeakMap<!Element, T>} */ |
+ this._elementToItem = new WeakMap(); |
+ this._selectedIndex = -1; |
+ |
+ this._boundKeyDown = (event) => { |
alph
2016/12/27 23:38:33
drop ()
dgozman
2016/12/28 06:15:21
Done.
|
+ if (this.onKeyDown(event)) |
+ event.consume(true); |
+ }; |
+ this._boundClick = (event) => { |
alph
2016/12/27 23:38:33
ditto
dgozman
2016/12/28 06:15:20
Done.
|
+ if (this.onClick(event)) |
+ event.consume(true); |
+ }; |
+ this._setUp(delegate, handleInput); |
+ |
+ this.element.addEventListener('scroll', this._onScroll.bind(this), false); |
+ this._update(0, this.element.offsetHeight); |
+ } |
+ |
+ /** |
+ * @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() { |
alph
2016/12/27 23:38:33
return this.removeItemAtIndex(...)
dgozman
2016/12/28 06:15:21
Done.
|
+ var result = this._items[this._items.length - 1]; |
alph
2016/12/27 23:38:32
peekLast
|
+ this.replaceItemsInRange(this._items.length - 1, this._items.length, []); |
+ return result; |
+ } |
+ |
+ /** |
+ * @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]); |
+ this._items.splice.bind(this._items, from, to - from).apply(null, items); |
alph
2016/12/27 23:38:32
apply with too many items may fail.
dgozman
2016/12/28 06:15:21
Done.
|
+ this._invalidate(from, to); |
+ |
+ if (this._selectedIndex >= to) |
+ this._selectedIndex += items.length - (to - from); |
+ else if (this._selectedIndex >= from) |
+ this._select(this._selectClosest(from - 1, -1, false), oldSelectedItem, oldSelectedElement); |
alph
2016/12/27 23:38:33
s/-1/+1/
Also put selection on n-1 if the last one
dgozman
2016/12/28 06:15:21
Done.
|
+ } |
+ |
+ /** |
+ * @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); |
alph
2016/12/27 23:38:32
you can merge these two.
dgozman
2016/12/28 06:15:20
Acknowledged.
|
+ } |
+ |
+ viewportResized() { |
+ this._refresh(); |
+ } |
+ |
+ /** |
+ * @param {!UI.ListDelegate<T>} delegate |
+ * @param {boolean} handleInput |
+ */ |
+ reset(delegate, handleInput) { |
+ this._selectedIndex = -1; |
+ this._items = []; |
+ this._itemToElement.clear(); |
+ this._elementToItem = new WeakMap(); |
+ this._setUp(delegate, handleInput); |
+ 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 |
alph
2016/12/27 23:38:32
boolean=
dgozman
2016/12/28 06:15:21
Done.
|
+ */ |
+ 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._items.length - 1 : this._selectedIndex - 1; |
+ index = this._selectClosest(index, -1, true); |
+ break; |
+ case 'ArrowDown': |
+ index = this._selectedIndex === -1 ? 0 : this._selectedIndex + 1; |
+ index = this._selectClosest(index, 1, true); |
+ break; |
+ case 'PageUp': |
+ index = this._selectedIndex === -1 ? |
+ this._firstIndex : |
alph
2016/12/27 23:38:33
Use first index in viewport
dgozman
2016/12/28 06:15:21
Done.
|
+ this._indexAtOffset(this._offsetAtIndex(this._selectedIndex) - this.element.offsetHeight); |
+ index = this._selectClosest(index, 1, false); |
alph
2016/12/27 23:38:33
-1
dgozman
2016/12/28 06:15:20
Done.
|
+ break; |
+ case 'PageDown': |
+ index = this._selectedIndex === -1 ? |
+ this._lastIndex : |
+ this._indexAtOffset(this._offsetAtIndex(this._selectedIndex) + this.element.offsetHeight); |
+ index = this._selectClosest(index, 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.parentNode !== this.element) |
alph
2016/12/27 23:38:32
handle shadow DOM
dgozman
2016/12/28 06:15:21
Done.
|
+ node = node.parentNode; |
+ if (!node) |
+ return false; |
+ var item = this._elementToItem.get(node); |
+ if (!item) |
+ return false; |
+ this._select(item); |
alph
2016/12/27 23:38:32
something's wrong here
dgozman
2016/12/28 06:15:21
Done.
|
+ return true; |
+ } |
+ |
+ /** |
+ * @return {number} |
+ */ |
+ _totalHeight() { |
+ return this._items.length * this._fixedHeight; |
+ } |
+ |
+ /** |
+ * @param {number} offset |
+ * @return {number} |
+ */ |
+ _indexAtOffset(offset) { |
+ if (!this._items.length) |
+ return 0; |
+ if (offset < 0) |
+ return 0; |
+ var index = Math.floor(offset / this._fixedHeight); |
+ if (index >= this._items.length) |
+ return this._items.length - 1; |
+ return index; |
+ } |
+ |
+ /** |
+ * @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); |
+ this._elementToItem.set(element, item); |
+ } |
+ return element; |
+ } |
+ |
+ /** |
+ * @param {number} index |
+ * @return {number} |
+ */ |
+ _offsetAtIndex(index) { |
+ return index * this._fixedHeight; |
+ } |
+ |
+ /** |
+ * @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 = oldItem ? (this._itemToElement.get(oldItem) || null) : null; |
alph
2016/12/27 23:38:32
you can drop ?:
dgozman
2016/12/28 06:15:21
Done.
|
+ this._selectedIndex = index; |
+ var newItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null; |
+ var newElement = newItem ? (this._itemToElement.get(newItem) || null) : null; |
alph
2016/12/27 23:38:33
ditto
dgozman
2016/12/28 06:15:21
Done.
|
+ this._delegate.selectedItemChanged(oldItem, newItem, oldElement, newElement); |
+ } |
+ |
+ /** |
+ * @param {number} index |
+ * @param {number} direction |
+ * @param {boolean} canWrap |
+ * @return {number} |
+ */ |
+ _selectClosest(index, direction, canWrap) { |
+ var length = this._items.length; |
+ if (!length) |
+ return -1; |
+ var start = -1; |
+ while (true) { |
+ if (index < 0 || index >= length) { |
+ if (!canWrap) |
+ return -1; |
alph
2016/12/27 23:38:32
When reaching top, it should return first selectab
dgozman
2016/12/28 06:15:21
Done.
|
+ index = (index + length) % length; |
+ } |
+ |
+ // Handle full wrap-around. |
+ if (index === start) |
+ return -1; |
+ if (start === -1) |
+ start = index; |
+ |
+ if (this._delegate.isItemSelectable(this._items[index])) |
+ return index; |
+ index += direction; |
+ } |
+ } |
+ |
+ /** |
+ * @param {!UI.ListDelegate<T>} delegate |
+ * @param {boolean} handleInput |
+ */ |
+ _setUp(delegate, handleInput) { |
+ this._delegate = delegate; |
+ 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); |
+ } |
+ this._fixedHeight = this._delegate.fixedHeight(); |
+ if (!this._fixedHeight) |
+ throw 'Variable height is not supported'; |
+ } |
+ |
+ /** |
+ * @param {number} from |
+ * @param {number} to |
+ */ |
+ _invalidate(from, to) { |
+ var availableHeight = this.element.offsetHeight; |
alph
2016/12/27 23:38:33
viewportHeight
dgozman
2016/12/28 06:15:21
Done.
|
+ var totalHeight = this._totalHeight(); |
+ if (this._renderedHeight < availableHeight || totalHeight < availableHeight) { |
+ 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; |
alph
2016/12/27 23:38:32
update _firstIndex
dgozman
2016/12/28 06:15:21
Done.
|
+ return; |
+ } |
+ |
+ if (from >= this._lastIndex) { |
+ var bottomHeight = this._bottomHeight + heightDelta; |
+ this._bottomElement.style.height = bottomHeight + 'px'; |
+ this.element.scrollTop = scrollTop + heightDelta; |
alph
2016/12/27 23:38:33
drop this
dgozman
2016/12/28 06:15:21
Done.
|
+ this._bottomHeight = bottomHeight; |
+ this._renderedHeight = totalHeight; |
+ return; |
+ } |
+ |
+ this._refresh(); |
+ } |
+ |
+ _refresh() { |
+ var height = this.element.offsetHeight; |
alph
2016/12/27 23:38:33
viewportHeight
dgozman
2016/12/28 06:15:20
Done.
|
+ var scrollTop = Math.max(0, Math.min(this.element.scrollTop, this._totalHeight() - height)); |
alph
2016/12/27 23:38:32
If items are added before visible ones, I'd expect
dgozman
2016/12/28 06:15:21
Added TODO.
|
+ this._firstIndex = 0; |
+ this._lastIndex = 0; |
+ this._renderedHeight = 0; |
+ this.element.removeChildren(); |
+ this.element.appendChild(this._topElement); |
+ this.element.appendChild(this._bottomElement); |
+ this._update(scrollTop, height); |
+ } |
+ |
+ _onScroll() { |
+ this._update(this.element.scrollTop, this.element.offsetHeight); |
+ } |
+ |
+ /** |
+ * @param {number} scrollTop |
+ * @param {number} height |
+ */ |
+ _update(scrollTop, height) { |
+ // 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 = '0px'; |
alph
2016/12/27 23:38:33
'0'
dgozman
2016/12/28 06:15:21
Done.
|
+ this._bottomElement.style.height = '0px'; |
+ return; |
+ } |
+ |
+ var firstIndex = this._indexAtOffset(Math.max(0, scrollTop - height)); |
+ var lastIndex = this._indexAtOffset(Math.min(totalHeight, scrollTop + 2 * height)) + 1; |
+ |
+ for (var index = this._firstIndex; index < firstIndex; index++) { |
alph
2016/12/27 23:38:32
while (this._firstIndex < Math.min(firstIndex, thi
dgozman
2016/12/28 06:15:21
Done.
|
+ var element = this._elementAtIndex(index); |
+ element.remove(); |
+ this._firstIndex++; |
+ } |
+ for (var index = this._lastIndex - 1; index >= lastIndex; index--) { |
alph
2016/12/27 23:38:33
ditto
dgozman
2016/12/28 06:15:20
Done.
|
+ var element = this._elementAtIndex(index); |
+ element.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)); |
alph
2016/12/27 23:38:33
drop ()
dgozman
2016/12/28 06:15:20
Done.
|
+ this._bottomElement.style.height = this._bottomHeight + 'px'; |
+ this._renderedHeight = totalHeight; |
+ this.element.scrollTop = scrollTop; |
+ } |
+}; |