Chromium Code Reviews| 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; |
| + } |
| +}; |