Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 // Copyright 2016 The Chromium Authors. All rights reserved. | 1 // Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be | 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
| 4 /** | 4 /** |
| 5 * @unrestricted | 5 * @unrestricted |
| 6 * @template T | |
|
einbinder
2016/12/21 00:23:53
T needs to be some kind of object, otherwise you c
| |
| 6 */ | 7 */ |
| 7 UI.ViewportControl = class { | 8 UI.ViewportControl = class { |
| 8 /** | 9 /** |
| 9 * @param {!UI.ViewportControl.Provider} provider | 10 * @param {?function(T):!Element} renderer |
| 10 */ | 11 */ |
| 11 constructor(provider) { | 12 constructor(renderer) { |
| 12 this.element = createElement('div'); | 13 this.element = createElement('div'); |
| 13 this.element.style.overflow = 'auto'; | 14 this.element.style.overflow = 'auto'; |
| 14 this._innerElement = this.element.createChild('div'); | 15 this._topElement = this.element.createChild('div'); |
| 15 this._innerElement.style.height = '0px'; | 16 this._bottomElement = this.element.createChild('div'); |
| 16 this._innerElement.style.position = 'relative'; | 17 |
| 17 this._innerElement.style.overflow = 'hidden'; | 18 this._firstIndex = 0; |
| 18 | 19 this._lastIndex = 0; |
| 19 this._provider = provider; | 20 this._renderedHeight = 0; |
| 20 this.element.addEventListener('scroll', this._update.bind(this), false); | 21 |
| 21 this._itemCount = 0; | 22 this._fixedHeight = 0; |
| 22 this._indexSymbol = Symbol('UI.ViewportControl._indexSymbol'); | 23 /** @type {?function(T):!Element} */ |
| 23 } | 24 this._renderer = renderer; |
| 24 | 25 /** @type {!Array<T>} */ |
| 25 refresh() { | 26 this._items = []; |
| 26 this._itemCount = this._provider.itemCount(); | 27 |
| 27 this._innerElement.removeChildren(); | 28 this._elementSymbol = Symbol('UI.ViewportControl.element'); |
|
einbinder
2016/12/21 00:23:53
It might make sense to use a WeakMap (or just a Ma
dgozman
2016/12/27 21:32:59
Done.
| |
| 28 | 29 this.element.addEventListener('scroll', this._onScroll.bind(this), false); |
| 29 var height = 0; | 30 this._update(0, this.element.offsetHeight); |
| 30 this._cumulativeHeights = new Int32Array(this._itemCount); | 31 } |
| 31 for (var i = 0; i < this._itemCount; ++i) { | 32 |
| 32 height += this._provider.fastItemHeight(i); | 33 setVariableHeight() { |
| 33 this._cumulativeHeights[i] = height; | 34 // TODO(dgozman): support variable height. |
| 34 } | 35 throw 'Not supported'; |
| 35 this._innerElement.style.height = height + 'px'; | 36 } |
| 36 | 37 |
| 37 this._update(); | 38 /** |
| 38 } | 39 * @param {number} elementHeight |
| 39 | 40 */ |
| 40 _update() { | 41 setFixedHeight(elementHeight) { |
| 41 if (!this._cumulativeHeights) { | 42 this._fixedHeight = elementHeight; |
| 42 this.refresh(); | 43 } |
| 44 | |
| 45 /** | |
| 46 * @param {?function(T):!Element} renderer | |
| 47 */ | |
| 48 setRenderer(renderer) { | |
| 49 this._renderer = renderer; | |
| 50 for (var i = 0; i < this._items.length; i++) | |
| 51 this._items[i][this._elementSymbol] = null; | |
| 52 this._refresh(); | |
| 53 } | |
| 54 | |
| 55 /** | |
| 56 * @param {number} from | |
| 57 * @param {number} to | |
| 58 */ | |
| 59 refreshRange(from, to) { | |
| 60 this._invalidate(from, to); | |
| 61 } | |
| 62 | |
| 63 resized() { | |
| 64 this._refresh(); | |
| 65 } | |
| 66 | |
| 67 /** | |
| 68 * @param {number} index | |
| 69 */ | |
| 70 scrollItemAtIndexIntoView(index) { | |
| 71 var top = this._offsetAtIndex(index); | |
| 72 var bottom = this._offsetAtIndex(index + 1); | |
| 73 var scrollTop = this.element.scrollTop; | |
| 74 var height = this.element.offsetHeight; | |
| 75 if (top < scrollTop) | |
| 76 this._update(top, height); | |
| 77 else if (bottom > scrollTop + height) | |
| 78 this._update(bottom - height, height); | |
| 79 } | |
| 80 | |
| 81 /** | |
| 82 * @param {number} index | |
| 83 * @return {!Element} | |
| 84 */ | |
| 85 elementAtIndex(index) { | |
| 86 return this._elementAtIndex(index); | |
| 87 } | |
| 88 | |
| 89 /** | |
| 90 * @return {number} | |
| 91 */ | |
| 92 length() { | |
| 93 return this._items.length; | |
| 94 } | |
| 95 | |
| 96 /** | |
| 97 * @param {number} index | |
| 98 * @return {T} | |
| 99 */ | |
| 100 itemAtIndex(index) { | |
| 101 return this._items[index]; | |
| 102 } | |
| 103 | |
| 104 /** | |
| 105 * @param {T} item | |
| 106 */ | |
| 107 pushItem(item) { | |
| 108 this._items.push(item); | |
| 109 this._invalidate(this._items.length - 1, this._items.length - 1); | |
| 110 } | |
| 111 | |
| 112 /** | |
| 113 * @return {T} | |
| 114 */ | |
| 115 popItem() { | |
| 116 var result = this._items.pop(); | |
| 117 result[this._elementSymbol] = null; | |
| 118 this._invalidate(this._items.length - 1, this._items.length); | |
| 119 return result; | |
| 120 } | |
| 121 | |
| 122 /** | |
| 123 * @param {number} index | |
| 124 * @param {T} item | |
| 125 */ | |
| 126 insertItemAtIndex(index, item) { | |
| 127 this._items.splice(index, 0, item); | |
| 128 this._invalidate(index, index); | |
| 129 } | |
| 130 | |
| 131 /** | |
| 132 * @param {number} index | |
| 133 * @return {T} | |
| 134 */ | |
| 135 removeItemAtIndex(index) { | |
| 136 var result = this._items[index]; | |
| 137 this._items.splice(index, 1); | |
| 138 result[this._elementSymbol] = null; | |
| 139 this._invalidate(index, index + 1); | |
| 140 return result; | |
| 141 } | |
| 142 | |
| 143 /** | |
| 144 * @param {number} from | |
| 145 * @param {number} to | |
| 146 * @param {!Array<T>} items | |
| 147 */ | |
| 148 replaceItemsInRange(from, to, items) { | |
| 149 for (var i = from; i < to; i++) | |
| 150 this._items[i][this._elementSymbol] = null; | |
| 151 this._items.splice.bind(this._items, from, to - from).apply(null, items); | |
| 152 this._invalidate(from, to); | |
| 153 } | |
| 154 | |
| 155 /** | |
| 156 * @param {!Array<T>} items | |
| 157 */ | |
| 158 replaceAllItems(items) { | |
| 159 var length = this._items.length; | |
| 160 for (var i = 0; i < this._items.length; i++) | |
| 161 this._items[i][this._elementSymbol] = null; | |
| 162 this._items = items; | |
| 163 this._invalidate(0, length); | |
| 164 } | |
| 165 | |
| 166 /** | |
| 167 * @return {number} | |
| 168 */ | |
| 169 _totalHeight() { | |
| 170 return this._items.length * this._fixedHeight; | |
| 171 } | |
| 172 | |
| 173 /** | |
| 174 * @param {number} offset | |
| 175 * @return {number} | |
| 176 */ | |
| 177 _indexAtOffset(offset) { | |
| 178 if (!this._items.length) | |
| 179 return 0; | |
| 180 var index = Math.floor(offset / this._fixedHeight); | |
| 181 if (index >= this._items.length) | |
| 182 return this._items.length - 1; | |
| 183 return index; | |
| 184 } | |
| 185 | |
| 186 /** | |
| 187 * @param {number} index | |
| 188 * @return {!Element} | |
| 189 */ | |
| 190 _elementAtIndex(index) { | |
| 191 var item = this._items[index]; | |
| 192 var element = item[this._elementSymbol]; | |
| 193 if (!element) { | |
| 194 if (this._renderer) { | |
| 195 element = this._renderer.call(null, item); | |
| 196 } else { | |
| 197 element = createElement('div'); | |
| 198 // TODO(dgozman): support variable height. | |
| 199 element.style.height = this._fixedHeight + 'px'; | |
| 200 } | |
| 201 item[this._elementSymbol] = element; | |
| 202 } | |
| 203 return element; | |
| 204 } | |
| 205 | |
| 206 /** | |
| 207 * @param {number} index | |
| 208 * @return {number} | |
| 209 */ | |
| 210 _offsetAtIndex(index) { | |
| 211 return index * this._fixedHeight; | |
| 212 } | |
| 213 | |
| 214 /** | |
| 215 * @param {number} from | |
| 216 * @param {number} to | |
| 217 */ | |
| 218 _invalidate(from, to) { | |
| 219 var availableHeight = this.element.offsetHeight; | |
| 220 var totalHeight = this._totalHeight(); | |
| 221 if (this._renderedHeight < availableHeight || totalHeight < availableHeight) { | |
| 222 this._refresh(); | |
| 43 return; | 223 return; |
| 44 } | 224 } |
| 45 | 225 |
| 46 var visibleHeight = this._visibleHeight(); | 226 var scrollTop = this.element.scrollTop; |
| 47 var visibleFrom = this.element.scrollTop; | 227 var heightDelta = totalHeight - this._renderedHeight; |
| 48 var activeHeight = visibleHeight * 2; | 228 if (to <= this._firstIndex) { |
| 49 var firstActiveIndex = Math.max( | 229 var topHeight = this._topHeight + heightDelta; |
| 50 Array.prototype.lowerBound.call(this._cumulativeHeights, visibleFrom + 1 - (activeHeight - visibleHeight) / 2), | 230 this._topElement.style.height = topHeight + 'px'; |
| 51 0); | 231 this.element.scrollTop = scrollTop + heightDelta; |
| 52 var lastActiveIndex = Math.min( | 232 this._topHeight = topHeight; |
| 53 Array.prototype.lowerBound.call( | 233 this._renderedHeight = totalHeight; |
| 54 this._cumulativeHeights, visibleFrom + visibleHeight + (activeHeight - visibleHeight) / 2), | |
| 55 this._itemCount - 1); | |
| 56 | |
| 57 var children = this._innerElement.children; | |
| 58 for (var i = children.length - 1; i >= 0; --i) { | |
| 59 var element = children[i]; | |
| 60 if (element[this._indexSymbol] < firstActiveIndex || element[this._indexSy mbol] > lastActiveIndex) | |
| 61 element.remove(); | |
| 62 } | |
| 63 | |
| 64 for (var i = firstActiveIndex; i <= lastActiveIndex; ++i) | |
| 65 this._insertElement(i); | |
| 66 } | |
| 67 | |
| 68 /** | |
| 69 * @param {number} index | |
| 70 */ | |
| 71 _insertElement(index) { | |
| 72 var element = this._provider.itemElement(index); | |
| 73 if (!element || element.parentElement === this._innerElement) | |
| 74 return; | 234 return; |
| 75 | 235 } |
| 76 element.style.position = 'absolute'; | 236 |
| 77 element.style.top = (this._cumulativeHeights[index - 1] || 0) + 'px'; | 237 if (from >= this._lastIndex) { |
| 78 element.style.left = '0'; | 238 var bottomHeight = this._bottomHeight + heightDelta; |
| 79 element.style.right = '0'; | 239 this._bottomElement.style.height = bottomHeight + 'px'; |
| 80 element[this._indexSymbol] = index; | 240 this.element.scrollTop = scrollTop + heightDelta; |
| 81 this._innerElement.appendChild(element); | 241 this._bottomHeight = bottomHeight; |
| 82 } | 242 this._renderedHeight = totalHeight; |
| 83 | |
| 84 /** | |
| 85 * @return {number} | |
| 86 */ | |
| 87 firstVisibleIndex() { | |
| 88 return Math.max(Array.prototype.lowerBound.call(this._cumulativeHeights, thi s.element.scrollTop + 1), 0); | |
| 89 } | |
| 90 | |
| 91 /** | |
| 92 * @return {number} | |
| 93 */ | |
| 94 lastVisibleIndex() { | |
| 95 return Math.min( | |
| 96 Array.prototype.lowerBound.call(this._cumulativeHeights, this.element.sc rollTop + this._visibleHeight()), | |
| 97 this._itemCount); | |
| 98 } | |
| 99 | |
| 100 /** | |
| 101 * @param {number} index | |
| 102 * @param {boolean=} makeLast | |
| 103 */ | |
| 104 scrollItemIntoView(index, makeLast) { | |
| 105 var firstVisibleIndex = this.firstVisibleIndex(); | |
| 106 var lastVisibleIndex = this.lastVisibleIndex(); | |
| 107 if (index > firstVisibleIndex && index < lastVisibleIndex) | |
| 108 return; | 243 return; |
| 109 if (makeLast) | 244 } |
| 110 this.forceScrollItemToBeLast(index); | 245 |
| 111 else if (index <= firstVisibleIndex) | 246 this._refresh(); |
| 112 this.forceScrollItemToBeFirst(index); | 247 } |
| 113 else if (index >= lastVisibleIndex) | 248 |
| 114 this.forceScrollItemToBeLast(index); | 249 _refresh() { |
| 115 } | 250 var height = this.element.offsetHeight; |
| 116 | 251 var scrollTop = Math.max(0, Math.min(this.element.scrollTop, this._totalHeig ht() - height)); |
| 117 /** | 252 this._firstIndex = 0; |
| 118 * @param {number} index | 253 this._lastIndex = 0; |
| 119 */ | 254 this._renderedHeight = 0; |
| 120 forceScrollItemToBeFirst(index) { | 255 this.element.removeChildren(); |
| 121 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0; | 256 this.element.appendChild(this._topElement); |
| 122 this._update(); | 257 this.element.appendChild(this._bottomElement); |
| 123 } | 258 this._update(scrollTop, height); |
| 124 | 259 } |
| 125 /** | 260 |
| 126 * @param {number} index | 261 _onScroll() { |
| 127 */ | 262 this._update(this.element.scrollTop, this.element.offsetHeight); |
| 128 forceScrollItemToBeLast(index) { | 263 } |
| 129 this.element.scrollTop = this._cumulativeHeights[index] - this._visibleHeigh t(); | 264 |
| 130 this._update(); | 265 /** |
| 131 } | 266 * @param {number} scrollTop |
| 132 | 267 * @param {number} height |
| 133 /** | 268 */ |
| 134 * @return {number} | 269 _update(scrollTop, height) { |
| 135 */ | 270 // Note: this method should not force layout. Be careful. |
| 136 _visibleHeight() { | 271 |
| 137 return this.element.offsetHeight; | 272 var totalHeight = this._totalHeight(); |
| 273 if (!totalHeight) { | |
| 274 this._firstIndex = 0; | |
| 275 this._lastIndex = 0; | |
| 276 this._topHeight = 0; | |
| 277 this._bottomHeight = 0; | |
| 278 this._renderedHeight = 0; | |
| 279 this._topElement.style.height = '0px'; | |
| 280 this._bottomElement.style.height = '0px'; | |
| 281 return; | |
| 282 } | |
| 283 | |
| 284 var firstIndex = this._indexAtOffset(Math.max(0, scrollTop - height)); | |
| 285 var lastIndex = this._indexAtOffset(Math.min(totalHeight, scrollTop + 2 * he ight)) + 1; | |
| 286 | |
| 287 for (var index = this._firstIndex; index < firstIndex; index++) { | |
| 288 var element = this._elementAtIndex(index); | |
| 289 element.remove(); | |
| 290 this._firstIndex++; | |
| 291 } | |
| 292 for (var index = this._lastIndex - 1; index >= lastIndex; index--) { | |
| 293 var element = this._elementAtIndex(index); | |
| 294 element.remove(); | |
| 295 this._lastIndex--; | |
| 296 } | |
| 297 this._firstIndex = Math.min(this._firstIndex, lastIndex); | |
| 298 this._lastIndex = Math.max(this._lastIndex, firstIndex); | |
| 299 for (var index = this._firstIndex - 1; index >= firstIndex; index--) { | |
| 300 var element = this._elementAtIndex(index); | |
| 301 this.element.insertBefore(element, this._topElement.nextSibling); | |
| 302 } | |
| 303 for (var index = this._lastIndex; index < lastIndex; index++) { | |
| 304 var element = this._elementAtIndex(index); | |
| 305 this.element.insertBefore(element, this._bottomElement); | |
| 306 } | |
| 307 | |
| 308 this._firstIndex = firstIndex; | |
| 309 this._lastIndex = lastIndex; | |
| 310 this._topHeight = this._offsetAtIndex(firstIndex); | |
| 311 this._topElement.style.height = this._topHeight + 'px'; | |
| 312 this._bottomHeight = (totalHeight - this._offsetAtIndex(lastIndex)); | |
| 313 this._bottomElement.style.height = this._bottomHeight + 'px'; | |
| 314 this._renderedHeight = totalHeight; | |
| 315 this.element.scrollTop = scrollTop; | |
| 138 } | 316 } |
| 139 }; | 317 }; |
| 140 | |
| 141 /** | |
| 142 * @interface | |
| 143 */ | |
| 144 UI.ViewportControl.Provider = function() {}; | |
| 145 | |
| 146 UI.ViewportControl.Provider.prototype = { | |
| 147 /** | |
| 148 * @param {number} index | |
| 149 * @return {number} | |
| 150 */ | |
| 151 fastItemHeight(index) { | |
| 152 return 0; | |
| 153 }, | |
| 154 | |
| 155 /** | |
| 156 * @return {number} | |
| 157 */ | |
| 158 itemCount() { | |
| 159 return 0; | |
| 160 }, | |
| 161 | |
| 162 /** | |
| 163 * @param {number} index | |
| 164 * @return {?Element} | |
| 165 */ | |
| 166 itemElement(index) { | |
| 167 return null; | |
| 168 } | |
| 169 }; | |
| OLD | NEW |