OLD | NEW |
---|---|
(Empty) | |
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 | |
3 // found in the LICENSE file. | |
4 | |
5 /** | |
6 * @template T | |
7 * @interface | |
8 */ | |
9 UI.ListDelegate = function() {}; | |
10 | |
11 UI.ListDelegate.prototype = { | |
12 /** | |
13 * @param {T} item | |
14 * @return {!Element} | |
15 */ | |
16 createElementForItem(item) {}, | |
17 | |
18 /** | |
19 * @param {T} item | |
20 * @return {number} | |
21 */ | |
22 heightForItem(item) {}, | |
23 | |
24 /** | |
25 * @param {T} item | |
26 * @return {boolean} | |
27 */ | |
28 isItemSelectable(item) {}, | |
29 | |
30 /** | |
31 * @param {?T} from | |
32 * @param {?T} to | |
33 * @param {?Element} fromElement | |
34 * @param {?Element} toElement | |
35 */ | |
36 selectedItemChanged(from, to, fromElement, toElement) {}, | |
37 }; | |
38 | |
39 /** @enum {string} */ | |
caseq
2016/12/28 18:40:51
Make it an @enum {symbol}?
dgozman
2016/12/28 19:17:11
Done.
| |
40 UI.ListHeightMode = { | |
41 Fixed: 'Fixed', | |
42 Measured: 'Measured', | |
43 Variable: 'Variable' | |
44 }; | |
45 | |
46 /** | |
47 * @unrestricted | |
caseq
2016/12/28 18:40:51
nit: remove?
dgozman
2016/12/28 19:17:11
Done.
| |
48 * @template T | |
49 */ | |
50 UI.ListControl = class { | |
51 /** | |
52 * @param {!UI.ListDelegate<T>} delegate | |
53 */ | |
54 constructor(delegate) { | |
55 this.element = createElement('div'); | |
56 this.element.style.overflow = 'auto'; | |
57 this._topElement = this.element.createChild('div'); | |
58 this._bottomElement = this.element.createChild('div'); | |
59 this._firstIndex = 0; | |
60 this._lastIndex = 0; | |
61 this._renderedHeight = 0; | |
62 this._topHeight = 0; | |
63 this._topElement.style.height = '0'; | |
64 this._bottomHeight = 0; | |
65 this._bottomElement.style.height = '0'; | |
66 | |
67 /** @type {!Array<T>} */ | |
68 this._items = []; | |
69 /** @type {!Map<T, !Element>} */ | |
70 this._itemToElement = new Map(); | |
71 this._selectedIndex = -1; | |
72 | |
73 this._boundKeyDown = event => { | |
74 if (this.onKeyDown(event)) | |
75 event.consume(true); | |
76 }; | |
77 this._boundClick = event => { | |
78 if (this.onClick(event)) | |
79 event.consume(true); | |
80 }; | |
81 | |
82 this._delegate = delegate; | |
83 this._heightMode = UI.ListHeightMode.Measured; | |
84 this._fixedHeight = 0; | |
85 | |
86 this.element.addEventListener('scroll', this._onScroll.bind(this), false); | |
caseq
2016/12/28 18:40:51
nit: 3rd param is optional now.
| |
87 } | |
88 | |
89 /** | |
90 * @param {!UI.ListHeightMode} mode | |
91 */ | |
92 setHeightMode(mode) { | |
93 if (mode === UI.ListHeightMode.Variable) | |
94 throw 'Variable height is not supported (yet)'; | |
95 this._heightMode = mode; | |
96 this._fixedHeight = 0; | |
97 if (this._items.length) | |
98 this._refresh(); | |
99 } | |
100 | |
101 /** | |
102 * @param {boolean} handleInput | |
103 */ | |
104 setHandleInput(handleInput) { | |
caseq
2016/12/28 18:40:51
remove till used?
dgozman
2016/12/28 19:17:11
Acknowledged.
| |
105 if (handleInput) { | |
106 this.element.addEventListener('keydown', this._boundKeyDown, false); | |
107 this.element.addEventListener('click', this._boundClick, false); | |
108 } else { | |
109 this.element.removeEventListener('keydown', this._boundKeyDown, false); | |
110 this.element.removeEventListener('click', this._boundClick, false); | |
111 } | |
112 } | |
113 | |
114 /** | |
115 * @return {number} | |
116 */ | |
117 length() { | |
118 return this._items.length; | |
119 } | |
120 | |
121 /** | |
122 * @param {number} index | |
123 * @return {T} | |
124 */ | |
125 itemAtIndex(index) { | |
126 return this._items[index]; | |
127 } | |
128 | |
129 /** | |
130 * @param {T} item | |
131 */ | |
132 pushItem(item) { | |
133 this.replaceItemsInRange(this._items.length, this._items.length, [item]); | |
134 } | |
135 | |
136 /** | |
137 * @return {T} | |
138 */ | |
139 popItem() { | |
140 return this.removeItemAtIndex(this._items.length - 1); | |
141 } | |
142 | |
143 /** | |
144 * @param {number} index | |
145 * @param {T} item | |
146 */ | |
147 insertItemAtIndex(index, item) { | |
148 this.replaceItemsInRange(index, index, [item]); | |
149 } | |
150 | |
151 /** | |
152 * @param {number} index | |
153 * @return {T} | |
154 */ | |
155 removeItemAtIndex(index) { | |
156 var result = this._items[index]; | |
157 this.replaceItemsInRange(index, index + 1, []); | |
158 return result; | |
159 } | |
160 | |
161 /** | |
162 * @param {number} from | |
163 * @param {number} to | |
164 * @param {!Array<T>} items | |
165 */ | |
166 replaceItemsInRange(from, to, items) { | |
167 var oldSelectedItem = this._selectedIndex !== -1 ? this._items[this._selecte dIndex] : null; | |
168 var oldSelectedElement = oldSelectedItem ? (this._itemToElement.get(oldSelec tedItem) || null) : null; | |
169 | |
170 for (var i = from; i < to; i++) | |
171 this._itemToElement.delete(this._items[i]); | |
172 if (items.length < 10000) { | |
173 this._items.splice.bind(this._items, from, to - from).apply(null, items); | |
174 } else { | |
175 // Splice may fail with too many arguments. | |
176 var before = this._items.slice(0, from); | |
177 var after = this._items.slice(to); | |
178 this._items = before.concat(items).concat(after); | |
caseq
2016/12/28 18:40:51
before.concat(items, after) or [].concat(before, i
dgozman
2016/12/28 19:17:11
Done.
| |
179 } | |
180 this._invalidate(from, to, items.length); | |
181 | |
182 if (this._selectedIndex >= to) { | |
183 this._selectedIndex += items.length - (to - from); | |
184 } else if (this._selectedIndex >= from) { | |
185 var index = this._findClosestSelectable(from + items.length, +1, 0, false) ; | |
186 if (index === -1) | |
187 index = this._findClosestSelectable(from - 1, -1, 0, false); | |
188 this._select(index, oldSelectedItem, oldSelectedElement); | |
189 } | |
190 } | |
191 | |
192 /** | |
193 * @param {!Array<T>} items | |
194 */ | |
195 replaceAllItems(items) { | |
196 this.replaceItemsInRange(0, this._items.length, items); | |
197 } | |
198 | |
199 /** | |
200 * @param {number} from | |
201 * @param {number} to | |
202 */ | |
203 invalidateRange(from, to) { | |
204 this._invalidate(from, to, to - from); | |
205 } | |
206 | |
207 viewportResized() { | |
208 this._refresh(); | |
209 } | |
210 | |
211 /** | |
212 * @param {number} index | |
213 */ | |
214 scrollItemAtIndexIntoView(index) { | |
215 var top = this._offsetAtIndex(index); | |
216 var bottom = this._offsetAtIndex(index + 1); | |
217 var scrollTop = this.element.scrollTop; | |
218 var height = this.element.offsetHeight; | |
219 if (top < scrollTop) | |
220 this._update(top, height); | |
221 else if (bottom > scrollTop + height) | |
222 this._update(bottom - height, height); | |
223 } | |
224 | |
225 /** | |
226 * @param {number} index | |
227 * @param {boolean=} scrollIntoView | |
228 */ | |
229 selectItemAtIndex(index, scrollIntoView) { | |
230 if (index !== -1 && !this._delegate.isItemSelectable(this._items[index])) | |
231 throw 'Attempt to select non-selectable item'; | |
232 this._select(index); | |
233 if (index !== -1 && !!scrollIntoView) | |
234 this.scrollItemAtIndexIntoView(index); | |
235 } | |
236 | |
237 /** | |
238 * @return {number} | |
239 */ | |
240 selectedIndex() { | |
241 return this._selectedIndex; | |
242 } | |
243 | |
244 /** | |
245 * @return {?T} | |
246 */ | |
247 selectedItem() { | |
248 return this._selectedIndex === -1 ? null : this._items[this._selectedIndex]; | |
249 } | |
250 | |
251 /** | |
252 * @param {!Event} event | |
253 * @return {boolean} | |
254 */ | |
255 onKeyDown(event) { | |
256 var index = -1; | |
257 switch (event.key) { | |
258 case 'ArrowUp': | |
259 index = this._selectedIndex === -1 ? this._items.length - 1 : this._sele ctedIndex; | |
caseq
2016/12/28 18:40:50
This will select second from last when nothing is
dgozman
2016/12/28 19:17:11
Done.
| |
260 index = this._findClosestSelectable(index, -1, 1, true); | |
261 break; | |
262 case 'ArrowDown': | |
263 index = this._selectedIndex === -1 ? 0 : this._selectedIndex; | |
caseq
2016/12/28 18:40:51
Similarly, this will result in second element bein
| |
264 index = this._findClosestSelectable(index, +1, 1, true); | |
265 break; | |
266 case 'PageUp': | |
267 index = this._selectedIndex === -1 ? this._items.length - 1 : this._sele ctedIndex; | |
268 // Compensate for zoom rounding errors with -1. | |
269 index = this._findClosestSelectable(index, -1, this.element.offsetHeight - 1, false); | |
270 break; | |
271 case 'PageDown': | |
272 index = this._selectedIndex === -1 ? 0 : this._selectedIndex; | |
273 // Compensate for zoom rounding errors with -1. | |
274 index = this._findClosestSelectable(index, +1, this.element.offsetHeight - 1, false); | |
275 break; | |
276 default: | |
277 return false; | |
278 } | |
279 if (index !== -1) { | |
280 this.scrollItemAtIndexIntoView(index); | |
281 this._select(index); | |
282 return true; | |
283 } | |
284 return false; | |
285 } | |
286 | |
287 /** | |
288 * @param {!Event} event | |
289 * @return {boolean} | |
290 */ | |
291 onClick(event) { | |
292 var node = event.target; | |
293 while (node && node.parentNodeOrShadowHost() !== this.element) | |
294 node = node.parentNodeOrShadowHost(); | |
295 if (!node || node.nodeType !== Node.ELEMENT_NODE) | |
296 return false; | |
297 var offset = /** @type {!Element} */ (node).getBoundingClientRect().top; | |
298 offset -= this.element.getBoundingClientRect().top; | |
299 var index = this._indexAtOffset(offset + this.element.scrollTop); | |
300 if (index === -1 || !this._delegate.isItemSelectable(this._items[index])) | |
301 return false; | |
302 this._select(index); | |
303 return true; | |
304 } | |
305 | |
306 /** | |
307 * @return {number} | |
308 */ | |
309 _totalHeight() { | |
310 return this._offsetAtIndex(this._items.length); | |
311 } | |
312 | |
313 /** | |
314 * @param {number} offset | |
315 * @return {number} | |
316 */ | |
317 _indexAtOffset(offset) { | |
318 if (!this._items.length || offset < 0) | |
319 return 0; | |
320 if (this._heightMode === UI.ListHeightMode.Variable) | |
321 throw 'Variable height is not supported (yet)'; | |
322 if (!this._fixedHeight) | |
323 this._measureHeight(); | |
324 return Math.min(this._items.length - 1, Math.floor(offset / this._fixedHeigh t)); | |
325 } | |
326 | |
327 /** | |
328 * @param {number} index | |
329 * @return {!Element} | |
330 */ | |
331 _elementAtIndex(index) { | |
332 var item = this._items[index]; | |
333 var element = this._itemToElement.get(item); | |
334 if (!element) { | |
335 element = this._delegate.createElementForItem(item); | |
336 this._itemToElement.set(item, element); | |
337 } | |
338 return element; | |
339 } | |
340 | |
341 /** | |
342 * @param {number} index | |
343 * @return {number} | |
344 */ | |
345 _offsetAtIndex(index) { | |
346 if (!this._items.length) | |
347 return 0; | |
348 if (this._heightMode === UI.ListHeightMode.Variable) | |
349 throw 'Variable height is not supported (yet)'; | |
350 if (!this._fixedHeight) | |
351 this._measureHeight(); | |
352 return index * this._fixedHeight; | |
353 } | |
354 | |
355 _measureHeight() { | |
356 if (this._heightMode === UI.ListHeightMode.Measured) | |
357 this._fixedHeight = UI.measurePreferredSize(this._elementAtIndex(0), this. element).height; | |
358 else | |
359 this._fixedHeight = this._delegate.heightForItem(this._items[0]); | |
360 } | |
361 | |
362 /** | |
363 * @param {number} index | |
364 * @param {?T=} oldItem | |
365 * @param {?Element=} oldElement | |
366 */ | |
367 _select(index, oldItem, oldElement) { | |
368 if (oldItem === undefined) | |
369 oldItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null; | |
370 if (oldElement === undefined) | |
371 oldElement = this._itemToElement.get(oldItem) || null; | |
372 this._selectedIndex = index; | |
373 var newItem = this._selectedIndex !== -1 ? this._items[this._selectedIndex] : null; | |
374 var newElement = this._itemToElement.get(newItem) || null; | |
375 this._delegate.selectedItemChanged(oldItem, newItem, /** @type {?Element} */ (oldElement), newElement); | |
376 } | |
377 | |
378 /** | |
379 * @param {number} index | |
380 * @param {number} direction | |
381 * @param {number} minSkippedHeight | |
382 * @param {boolean} canWrap | |
383 * @return {number} | |
384 */ | |
385 _findClosestSelectable(index, direction, minSkippedHeight, canWrap) { | |
386 var length = this._items.length; | |
387 if (!length) | |
388 return -1; | |
389 | |
390 var lastSelectable = -1; | |
391 var start = -1; | |
392 var startOffset = this._offsetAtIndex(index); | |
393 while (true) { | |
394 if (index < 0 || index >= length) { | |
395 if (!canWrap) | |
396 return lastSelectable; | |
397 index = (index + length) % length; | |
398 } | |
399 | |
400 // Handle full wrap-around. | |
401 if (index === start) | |
402 return lastSelectable; | |
403 if (start === -1) { | |
404 start = index; | |
405 startOffset = this._offsetAtIndex(index); | |
406 } | |
407 | |
408 if (this._delegate.isItemSelectable(this._items[index])) { | |
409 if (Math.abs(this._offsetAtIndex(index) - startOffset) >= minSkippedHeig ht) | |
410 return index; | |
411 lastSelectable = index; | |
412 } | |
413 | |
414 index += direction; | |
415 } | |
416 } | |
417 | |
418 /** | |
419 * @param {number} from | |
420 * @param {number} to | |
421 * @param {number} inserted | |
422 */ | |
423 _invalidate(from, to, inserted) { | |
424 var viewportHeight = this.element.offsetHeight; | |
425 var totalHeight = this._totalHeight(); | |
426 if (this._renderedHeight < viewportHeight || totalHeight < viewportHeight) { | |
427 this._refresh(); | |
428 return; | |
429 } | |
430 | |
431 var scrollTop = this.element.scrollTop; | |
432 var heightDelta = totalHeight - this._renderedHeight; | |
433 if (to <= this._firstIndex) { | |
434 var topHeight = this._topHeight + heightDelta; | |
435 this._topElement.style.height = topHeight + 'px'; | |
436 this.element.scrollTop = scrollTop + heightDelta; | |
437 this._topHeight = topHeight; | |
438 this._renderedHeight = totalHeight; | |
439 var indexDelta = inserted - (to - from); | |
440 this._firstIndex += indexDelta; | |
441 this._lastIndex += indexDelta; | |
442 return; | |
443 } | |
444 | |
445 if (from >= this._lastIndex) { | |
446 var bottomHeight = this._bottomHeight + heightDelta; | |
447 this._bottomElement.style.height = bottomHeight + 'px'; | |
448 this._bottomHeight = bottomHeight; | |
449 this._renderedHeight = totalHeight; | |
450 return; | |
451 } | |
452 | |
453 // TODO(dgozman): try to keep the visible scrollTop the same | |
454 // when invalidating after firstIndex but before first visible element. | |
455 this._refresh(); | |
456 } | |
457 | |
458 _refresh() { | |
459 var viewportHeight = this.element.offsetHeight; | |
460 var scrollTop = Math.max(0, Math.min(this.element.scrollTop, this._totalHeig ht() - viewportHeight)); | |
caseq
2016/12/28 18:40:51
use Number.constrain()?
dgozman
2016/12/28 19:17:11
Done.
| |
461 this._firstIndex = 0; | |
462 this._lastIndex = 0; | |
463 this._renderedHeight = 0; | |
464 this._topHeight = 0; | |
465 this._bottomHeight = 0; | |
466 this.element.removeChildren(); | |
467 this.element.appendChild(this._topElement); | |
468 this.element.appendChild(this._bottomElement); | |
469 this._update(scrollTop, viewportHeight); | |
470 } | |
471 | |
472 _onScroll() { | |
473 this._update(this.element.scrollTop, this.element.offsetHeight); | |
474 } | |
475 | |
476 /** | |
477 * @param {number} scrollTop | |
478 * @param {number} viewportHeight | |
479 */ | |
480 _update(scrollTop, viewportHeight) { | |
481 // Note: this method should not force layout. Be careful. | |
482 | |
483 var totalHeight = this._totalHeight(); | |
484 if (!totalHeight) { | |
485 this._firstIndex = 0; | |
486 this._lastIndex = 0; | |
487 this._topHeight = 0; | |
488 this._bottomHeight = 0; | |
489 this._renderedHeight = 0; | |
490 this._topElement.style.height = '0'; | |
491 this._bottomElement.style.height = '0'; | |
492 return; | |
493 } | |
494 | |
495 var firstIndex = this._indexAtOffset(scrollTop - viewportHeight); | |
496 var lastIndex = this._indexAtOffset(scrollTop + 2 * viewportHeight) + 1; | |
497 | |
498 while (this._firstIndex < Math.min(firstIndex, this._lastIndex)) { | |
499 this._elementAtIndex(this._firstIndex).remove(); | |
500 this._firstIndex++; | |
501 } | |
502 while (this._lastIndex > Math.max(lastIndex, this._firstIndex)) { | |
503 this._elementAtIndex(this._lastIndex - 1).remove(); | |
504 this._lastIndex--; | |
505 } | |
506 | |
507 this._firstIndex = Math.min(this._firstIndex, lastIndex); | |
508 this._lastIndex = Math.max(this._lastIndex, firstIndex); | |
509 for (var index = this._firstIndex - 1; index >= firstIndex; index--) { | |
510 var element = this._elementAtIndex(index); | |
511 this.element.insertBefore(element, this._topElement.nextSibling); | |
512 } | |
513 for (var index = this._lastIndex; index < lastIndex; index++) { | |
514 var element = this._elementAtIndex(index); | |
515 this.element.insertBefore(element, this._bottomElement); | |
516 } | |
517 | |
518 this._firstIndex = firstIndex; | |
519 this._lastIndex = lastIndex; | |
520 this._topHeight = this._offsetAtIndex(firstIndex); | |
521 this._topElement.style.height = this._topHeight + 'px'; | |
522 this._bottomHeight = totalHeight - this._offsetAtIndex(lastIndex); | |
523 this._bottomElement.style.height = this._bottomHeight + 'px'; | |
524 this._renderedHeight = totalHeight; | |
525 this.element.scrollTop = scrollTop; | |
526 } | |
527 }; | |
OLD | NEW |