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