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