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 * @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 |