Chromium Code Reviews| 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 |