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 |
6 */ | 7 */ |
7 UI.ViewportControl = class { | 8 UI.ViewportControl = class { |
8 /** | 9 constructor() { |
9 * @param {!UI.ViewportControl.Provider} provider | |
10 */ | |
11 constructor(provider) { | |
12 this.element = createElement('div'); | 10 this.element = createElement('div'); |
13 this.element.style.overflow = 'auto'; | 11 this.element.style.overflow = 'auto'; |
14 this._innerElement = this.element.createChild('div'); | 12 this._topElement = this.element.createChild('div'); |
15 this._innerElement.style.height = '0px'; | 13 this._bottomElement = this.element.createChild('div'); |
16 this._innerElement.style.position = 'relative'; | 14 this._firstIndex = 0; |
17 this._innerElement.style.overflow = 'hidden'; | 15 this._lastIndex = 0; |
18 | 16 this._fixedHeight = true; |
19 this._provider = provider; | 17 this._measuredHeight = 0; |
20 this.element.addEventListener('scroll', this._update.bind(this), false); | 18 /** @type {?function(T):!Element} */ |
21 this._itemCount = 0; | 19 this._renderer = null; |
22 this._indexSymbol = Symbol('UI.ViewportControl._indexSymbol'); | 20 /** @type {!Array<T>} */ |
23 } | 21 this._items = []; |
24 | 22 this._elementSymbol = Symbol('UI.ViewportControl.element'); |
25 refresh() { | 23 this.element.addEventListener('scroll', this._onScroll.bind(this), false); |
26 this._itemCount = this._provider.itemCount(); | 24 this._update(0); |
27 this._innerElement.removeChildren(); | 25 } |
28 | 26 |
29 var height = 0; | 27 setVariableHeights() { |
30 this._cumulativeHeights = new Int32Array(this._itemCount); | 28 // TODO(dgozman): support variable height. |
31 for (var i = 0; i < this._itemCount; ++i) { | 29 throw 'Not supported'; |
32 height += this._provider.fastItemHeight(i); | 30 } |
33 this._cumulativeHeights[i] = height; | 31 |
34 } | 32 /** |
35 this._innerElement.style.height = height + 'px'; | 33 * @param {number} elementHeight |
36 | 34 */ |
37 this._update(); | 35 setFixedHeights(elementHeight) { |
38 } | 36 this._fixedHeight = true; |
39 | 37 this._measuredHeight = elementHeight; |
40 _update() { | 38 } |
41 if (!this._cumulativeHeights) { | 39 |
42 this.refresh(); | 40 // !!!!! Move this to clients. |
| 41 setFixedMeasuredHeights() { |
| 42 this._fixedHeight = true; |
| 43 this._measuredHeight = 0; |
| 44 } |
| 45 |
| 46 /** |
| 47 * @param {?function(T):!Element} renderer |
| 48 */ |
| 49 setRenderer(renderer) { |
| 50 this._renderer = renderer; |
| 51 for (var i = 0; i < this._items; i++) |
| 52 this._items[i][this._elementSymbol] = null; |
| 53 this._refresh(); |
| 54 } |
| 55 |
| 56 slowRefresh() { |
| 57 this._refresh(); |
| 58 } |
| 59 |
| 60 /** |
| 61 * @return {number} |
| 62 */ |
| 63 measuredElementHeight() { |
| 64 return this._elementHeight(); |
| 65 } |
| 66 |
| 67 /** |
| 68 * @return {number} |
| 69 */ |
| 70 elementsPerViewport() { |
| 71 if (!this._fixedHeight) |
| 72 throw 'Only supported in fixed-height scenario'; |
| 73 var elementHeight = this._elementHeight(); |
| 74 return elementHeight ? Math.floor(this.element.offsetHeight / elementHeight)
: 0; |
| 75 } |
| 76 |
| 77 /** |
| 78 * @param {number} index |
| 79 * @return {!Element} |
| 80 */ |
| 81 elementAtIndex(index) { |
| 82 return this._elementAtIndex(index); |
| 83 } |
| 84 |
| 85 /** |
| 86 * @return {number} |
| 87 */ |
| 88 length() { |
| 89 return this._items.length; |
| 90 } |
| 91 |
| 92 /** |
| 93 * @param {number} index |
| 94 * @return {T} |
| 95 */ |
| 96 itemAtIndex(index) { |
| 97 return this._items[index]; |
| 98 } |
| 99 |
| 100 /** |
| 101 * @param {T} item |
| 102 */ |
| 103 pushItem(item) { |
| 104 var totalHeight = this._totalHeight(); |
| 105 this._items.push(item); |
| 106 this._invalidate(totalHeight, this._items.length - 1, this._items.length - 1
); |
| 107 } |
| 108 |
| 109 /** |
| 110 * @return {T} |
| 111 */ |
| 112 popItem() { |
| 113 var totalHeight = this._totalHeight(); |
| 114 var result = this._items.pop(); |
| 115 result[this._elementSymbol] = null; |
| 116 this._invalidate(totalHeight, this._items.length - 1, this._items.length); |
| 117 return result; |
| 118 } |
| 119 |
| 120 /** |
| 121 * @param {number} index |
| 122 * @param {T} item |
| 123 */ |
| 124 insertItemAtIndex(index, item) { |
| 125 var totalHeight = this._totalHeight(); |
| 126 this._items.splice(index, 0, item); |
| 127 this._invalidate(totalHeight, index, index); |
| 128 } |
| 129 |
| 130 /** |
| 131 * @param {number} index |
| 132 * @return {T} |
| 133 */ |
| 134 removeItemAtIndex(index) { |
| 135 var totalHeight = this._totalHeight(); |
| 136 var result = this._items[index]; |
| 137 this._items.splice(index, 1); |
| 138 result[this._elementSymbol] = null; |
| 139 this._invalidate(totalHeight, 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 var totalHeight = this._totalHeight(); |
| 150 for (var i = from; i < to; i++) |
| 151 this._items[i][this._elementSymbol] = null; |
| 152 this._items.splice.bind(this._items, from, to - from).apply(null, items); |
| 153 this._invalidate(totalHeight, from, to); |
| 154 } |
| 155 |
| 156 /** |
| 157 * @param {!Array<T>} items |
| 158 */ |
| 159 replaceAllItems(items) { |
| 160 var totalHeight = this._totalHeight(); |
| 161 var length = this._items.length; |
| 162 for (var i = 0; i < this._items; i++) |
| 163 this._items[i][this._elementSymbol] = null; |
| 164 this._items = items; |
| 165 this._invalidate(totalHeight, 0, length); |
| 166 } |
| 167 |
| 168 /** |
| 169 * @param {number} index |
| 170 */ |
| 171 scrollItemAtIndexIntoView(index) { |
| 172 var top = this._offsetAtIndex(index); |
| 173 var bottom = this._offsetAtIndex(index + 1); |
| 174 var scrollTop = this.element.scrollTop; |
| 175 var height = this.element.offsetHeight; |
| 176 if (top < scrollTop) |
| 177 this._update(top); |
| 178 else if (bottom > scrollTop + height) |
| 179 this._update(bottom - height); |
| 180 } |
| 181 |
| 182 /** |
| 183 * @return {number} |
| 184 */ |
| 185 _elementHeight() { |
| 186 if (!this._fixedHeight) |
| 187 throw 'Only measure in fixed-height scenario'; |
| 188 if (!this._measuredHeight && this._items.length) |
| 189 this._measuredHeight = UI.measurePreferredSize(this._elementAtIndex(0), th
is.element).height; |
| 190 return this._measuredHeight; |
| 191 } |
| 192 |
| 193 /** |
| 194 * @return {number} |
| 195 */ |
| 196 _totalHeight() { |
| 197 return this._items.length ? this._items.length * this._elementHeight() : 0; |
| 198 } |
| 199 |
| 200 /** |
| 201 * @param {number} offset |
| 202 * @return {number} |
| 203 */ |
| 204 _indexAtOffset(offset) { |
| 205 if (!this._items.length) |
| 206 return 0; |
| 207 var index = Math.floor(offset / this._elementHeight()); |
| 208 if (index >= this._items.length) |
| 209 return this._items.length - 1; |
| 210 return index; |
| 211 } |
| 212 |
| 213 /** |
| 214 * @param {number} index |
| 215 * @return {!Element} |
| 216 */ |
| 217 _elementAtIndex(index) { |
| 218 var item = this._items[index]; |
| 219 var element = item[this._elementSymbol]; |
| 220 if (!element) { |
| 221 if (this._renderer) { |
| 222 element = this._renderer.call(null, item); |
| 223 } else { |
| 224 element = createElement('div'); |
| 225 // TODO(dgozman): support variable height. |
| 226 element.style.height = this._measuredHeight + 'px'; |
| 227 } |
| 228 item[this._elementSymbol] = element; |
| 229 } |
| 230 return element; |
| 231 } |
| 232 |
| 233 /** |
| 234 * @param {number} index |
| 235 * @return {number} |
| 236 */ |
| 237 _offsetAtIndex(index) { |
| 238 return this._items.length ? index * this._elementHeight() : 0; |
| 239 } |
| 240 |
| 241 /** |
| 242 * @param {number} totalHeight |
| 243 * @param {number} from |
| 244 * @param {number} to |
| 245 */ |
| 246 _invalidate(totalHeight, from, to) { |
| 247 var scrollTop = this.element.scrollTop; |
| 248 var availableHeight = this.element.offsetHeight; |
| 249 var heightDelta = this._totalHeight() - totalHeight; |
| 250 if (totalHeight < availableHeight || this._totalHeight() < availableHeight)
{ |
| 251 this._refresh(); |
43 return; | 252 return; |
44 } | 253 } |
45 | 254 if (to <= this._firstIndex) { |
46 var visibleHeight = this._visibleHeight(); | 255 var topHeight = this._topHeight + heightDelta; |
47 var visibleFrom = this.element.scrollTop; | 256 this._topElement.style.height = topHeight + 'px'; |
48 var activeHeight = visibleHeight * 2; | 257 this.element.scrollTop = scrollTop + heightDelta; |
49 var firstActiveIndex = Math.max( | 258 this._topHeight = topHeight; |
50 Array.prototype.lowerBound.call(this._cumulativeHeights, visibleFrom + 1
- (activeHeight - visibleHeight) / 2), | |
51 0); | |
52 var lastActiveIndex = Math.min( | |
53 Array.prototype.lowerBound.call( | |
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; | 259 return; |
75 | 260 } |
76 element.style.position = 'absolute'; | 261 if (from >= this._lastIndex) { |
77 element.style.top = (this._cumulativeHeights[index - 1] || 0) + 'px'; | 262 var bottomHeight = this._bottomHeight + heightDelta; |
78 element.style.left = '0'; | 263 this._bottomElement.style.height = bottomHeight + 'px'; |
79 element.style.right = '0'; | 264 this.element.scrollTop = scrollTop + heightDelta; |
80 element[this._indexSymbol] = index; | 265 this._bottomHeight = bottomHeight; |
81 this._innerElement.appendChild(element); | |
82 } | |
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; | 266 return; |
109 if (makeLast) | 267 } |
110 this.forceScrollItemToBeLast(index); | 268 this._refresh(); |
111 else if (index <= firstVisibleIndex) | 269 } |
112 this.forceScrollItemToBeFirst(index); | 270 |
113 else if (index >= lastVisibleIndex) | 271 _refresh() { |
114 this.forceScrollItemToBeLast(index); | 272 this._firstIndex = 0; |
115 } | 273 this._lastIndex = 0; |
116 | 274 this.element.removeChildren(); |
117 /** | 275 this.element.appendChild(this._topElement); |
118 * @param {number} index | 276 this.element.appendChild(this._bottomElement); |
119 */ | 277 this._update(0); |
120 forceScrollItemToBeFirst(index) { | 278 } |
121 this.element.scrollTop = index > 0 ? this._cumulativeHeights[index - 1] : 0; | 279 |
122 this._update(); | 280 _onScroll() { |
123 } | 281 this._update(this.element.scrollTop); |
124 | 282 } |
125 /** | 283 |
126 * @param {number} index | 284 /** |
127 */ | 285 * @param {number} scrollTop |
128 forceScrollItemToBeLast(index) { | 286 */ |
129 this.element.scrollTop = this._cumulativeHeights[index] - this._visibleHeigh
t(); | 287 _update(scrollTop) { |
130 this._update(); | 288 var totalHeight = this._totalHeight(); |
131 } | 289 if (!totalHeight) { |
132 | 290 this._firstIndex = 0; |
133 /** | 291 this._lastIndex = 0; |
134 * @return {number} | 292 this._topHeight = 0; |
135 */ | 293 this._bottomHeight = 0; |
136 _visibleHeight() { | 294 this._topElement.style.height = '0px'; |
137 return this.element.offsetHeight; | 295 this._bottomElement.style.height = '0px'; |
| 296 return; |
| 297 } |
| 298 |
| 299 var height = this.element.offsetHeight; |
| 300 var firstIndex = this._indexAtOffset(Math.max(0, scrollTop - height)); |
| 301 var lastIndex = this._indexAtOffset(Math.min(totalHeight, scrollTop + 2 * he
ight)) + 1; |
| 302 |
| 303 for (var index = this._firstIndex; index < firstIndex; index++) { |
| 304 var element = this._elementAtIndex(index); |
| 305 element.remove(); |
| 306 this._firstIndex++; |
| 307 } |
| 308 for (var index = this._lastIndex - 1; index >= lastIndex; index--) { |
| 309 var element = this._elementAtIndex(index); |
| 310 element.remove(); |
| 311 this._lastIndex--; |
| 312 } |
| 313 this._firstIndex = Math.min(this._firstIndex, lastIndex); |
| 314 this._lastIndex = Math.max(this._lastIndex, firstIndex); |
| 315 for (var index = this._firstIndex - 1; index >= firstIndex; index--) { |
| 316 var element = this._elementAtIndex(index); |
| 317 this.element.insertBefore(element, this._topElement.nextSibling); |
| 318 } |
| 319 for (var index = this._lastIndex; index < lastIndex; index++) { |
| 320 var element = this._elementAtIndex(index); |
| 321 this.element.insertBefore(element, this._bottomElement); |
| 322 } |
| 323 |
| 324 this._firstIndex = firstIndex; |
| 325 this._lastIndex = lastIndex; |
| 326 this._topHeight = this._offsetAtIndex(firstIndex); |
| 327 this._topElement.style.height = this._topHeight + 'px'; |
| 328 this._bottomHeight = (totalHeight - this._offsetAtIndex(lastIndex)); |
| 329 this._bottomElement.style.height = this._bottomHeight + 'px'; |
| 330 this.element.scrollTop = scrollTop; |
138 } | 331 } |
139 }; | 332 }; |
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 |