| 38 |
38 |
|
// State
|
| 39 |
39 |
|
this.items = [];
|
| 40 |
40 |
|
this.heightCache = new Map(); // itemId -> measured height
|
|
41 |
+ |
this._offsetCache = null; // cumulative row offsets, see _offsets()
|
|
42 |
+ |
this._offsetsDirty = true;
|
| 41 |
43 |
|
this.scrollTop = 0;
|
| 42 |
44 |
|
this.viewportHeight = 0;
|
| 43 |
45 |
|
this.startIndex = 0;
|
| 104 |
106 |
|
return this.estimatedRowHeight;
|
| 105 |
107 |
|
}
|
| 106 |
108 |
|
|
|
109 |
+ |
/**
|
|
110 |
+ |
* Mark the cached offset table stale. Cheap; the table is rebuilt lazily on
|
|
111 |
+ |
* the next query rather than eagerly here.
|
|
112 |
+ |
* @private
|
|
113 |
+ |
*/
|
|
114 |
+ |
_invalidateOffsets() {
|
|
115 |
+ |
this._offsetsDirty = true;
|
|
116 |
+ |
}
|
|
117 |
+ |
|
|
118 |
+ |
/**
|
|
119 |
+ |
* Cumulative row offsets: `offsets[i]` is the top of row `i`, and
|
|
120 |
+ |
* `offsets[items.length]` is the total content height. Rebuilt only when
|
|
121 |
+ |
* the item list or a measured height changed, so a scroll frame that
|
|
122 |
+ |
* crosses no boundary costs nothing and a range query costs O(log n)
|
|
123 |
+ |
* instead of walking every row.
|
|
124 |
+ |
* @private
|
|
125 |
+ |
* @returns {number[]} Offset table of length items.length + 1
|
|
126 |
+ |
*/
|
|
127 |
+ |
_offsets() {
|
|
128 |
+ |
if (!this._offsetsDirty && this._offsetCache
|
|
129 |
+ |
&& this._offsetCache.length === this.items.length + 1) {
|
|
130 |
+ |
return this._offsetCache;
|
|
131 |
+ |
}
|
|
132 |
+ |
const offsets = new Array(this.items.length + 1);
|
|
133 |
+ |
let total = 0;
|
|
134 |
+ |
for (let i = 0; i < this.items.length; i++) {
|
|
135 |
+ |
offsets[i] = total;
|
|
136 |
+ |
total += this._getItemHeight(this.items[i], i);
|
|
137 |
+ |
}
|
|
138 |
+ |
offsets[this.items.length] = total;
|
|
139 |
+ |
this._offsetCache = offsets;
|
|
140 |
+ |
this._offsetsDirty = false;
|
|
141 |
+ |
return offsets;
|
|
142 |
+ |
}
|
|
143 |
+ |
|
| 107 |
144 |
|
/**
|
| 108 |
145 |
|
* Calculate total content height.
|
| 109 |
146 |
|
* @private
|
| 110 |
147 |
|
* @returns {number} Total height in pixels
|
| 111 |
148 |
|
*/
|
| 112 |
149 |
|
_getTotalHeight() {
|
| 113 |
|
- |
let total = 0;
|
| 114 |
|
- |
for (let i = 0; i < this.items.length; i++) {
|
| 115 |
|
- |
total += this._getItemHeight(this.items[i], i);
|
|
150 |
+ |
return this._offsets()[this.items.length];
|
|
151 |
+ |
}
|
|
152 |
+ |
|
|
153 |
+ |
/**
|
|
154 |
+ |
* Index of the row containing `offset`, by binary search over the offset
|
|
155 |
+ |
* table. Clamped to a real row: an offset past the end of the content
|
|
156 |
+ |
* yields the last row, never one past it.
|
|
157 |
+ |
* @private
|
|
158 |
+ |
* @param {number} offset - Pixel offset from the top of the content
|
|
159 |
+ |
* @returns {number} Row index in [0, items.length - 1]
|
|
160 |
+ |
*/
|
|
161 |
+ |
_indexAtOffset(offset) {
|
|
162 |
+ |
const offsets = this._offsets();
|
|
163 |
+ |
const last = this.items.length - 1;
|
|
164 |
+ |
if (last < 0) return 0;
|
|
165 |
+ |
if (offset <= 0) return 0;
|
|
166 |
+ |
if (offset >= offsets[last]) return last;
|
|
167 |
+ |
|
|
168 |
+ |
// Largest i such that offsets[i] <= offset.
|
|
169 |
+ |
let lo = 0;
|
|
170 |
+ |
let hi = last;
|
|
171 |
+ |
while (lo < hi) {
|
|
172 |
+ |
const mid = (lo + hi + 1) >> 1;
|
|
173 |
+ |
if (offsets[mid] <= offset) {
|
|
174 |
+ |
lo = mid;
|
|
175 |
+ |
} else {
|
|
176 |
+ |
hi = mid - 1;
|
|
177 |
+ |
}
|
| 116 |
178 |
|
}
|
| 117 |
|
- |
return total;
|
|
179 |
+ |
return lo;
|
| 118 |
180 |
|
}
|
| 119 |
181 |
|
|
| 120 |
182 |
|
/**
|
| 129 |
191 |
|
|
| 130 |
192 |
|
const scrollTop = this.container.scrollTop;
|
| 131 |
193 |
|
const viewportHeight = this.container.clientHeight;
|
|
194 |
+ |
const offsets = this._offsets();
|
| 132 |
195 |
|
|
| 133 |
|
- |
let accumulatedHeight = 0;
|
| 134 |
|
- |
let startIndex = 0;
|
| 135 |
|
- |
let topOffset = 0;
|
|
196 |
+ |
// First row intersecting the viewport, then pull back by the overscan.
|
|
197 |
+ |
// _indexAtOffset clamps to a real row, so scrolling past the end of the
|
|
198 |
+ |
// content (over-scroll, or a list that shrank under a stale scrollTop)
|
|
199 |
+ |
// still renders the tail instead of blanking the list.
|
|
200 |
+ |
const firstVisible = this._indexAtOffset(scrollTop);
|
|
201 |
+ |
const startIndex = Math.max(0, firstVisible - this.overscan);
|
|
202 |
+ |
const topOffset = offsets[startIndex];
|
| 136 |
203 |
|
|
| 137 |
|
- |
// Find start index
|
| 138 |
|
- |
for (let i = 0; i < this.items.length; i++) {
|
| 139 |
|
- |
const height = this._getItemHeight(this.items[i], i);
|
| 140 |
|
- |
if (accumulatedHeight + height > scrollTop) {
|
| 141 |
|
- |
startIndex = i;
|
| 142 |
|
- |
topOffset = accumulatedHeight;
|
| 143 |
|
- |
break;
|
| 144 |
|
- |
}
|
| 145 |
|
- |
accumulatedHeight += height;
|
| 146 |
|
- |
if (i === this.items.length - 1) {
|
| 147 |
|
- |
startIndex = this.items.length;
|
| 148 |
|
- |
topOffset = accumulatedHeight;
|
| 149 |
|
- |
}
|
| 150 |
|
- |
}
|
| 151 |
|
- |
|
| 152 |
|
- |
// Apply overscan to start
|
| 153 |
|
- |
startIndex = Math.max(0, startIndex - this.overscan);
|
| 154 |
|
- |
|
| 155 |
|
- |
// Recalculate topOffset for adjusted start
|
| 156 |
|
- |
topOffset = 0;
|
| 157 |
|
- |
for (let i = 0; i < startIndex; i++) {
|
| 158 |
|
- |
topOffset += this._getItemHeight(this.items[i], i);
|
| 159 |
|
- |
}
|
| 160 |
|
- |
|
| 161 |
|
- |
// Find end index
|
| 162 |
|
- |
let endIndex = startIndex;
|
| 163 |
|
- |
let renderedHeight = 0;
|
| 164 |
|
- |
const targetHeight = viewportHeight + (this.overscan * 2 * this.estimatedRowHeight);
|
| 165 |
|
- |
|
| 166 |
|
- |
for (let i = startIndex; i < this.items.length && renderedHeight < targetHeight; i++) {
|
| 167 |
|
- |
renderedHeight += this._getItemHeight(this.items[i], i);
|
| 168 |
|
- |
endIndex = i + 1;
|
| 169 |
|
- |
}
|
| 170 |
|
- |
|
| 171 |
|
- |
// Apply overscan to end
|
| 172 |
|
- |
endIndex = Math.min(this.items.length, endIndex + this.overscan);
|
|
204 |
+ |
// Last row intersecting the viewport, then push out by the overscan.
|
|
205 |
+ |
const viewportBottom = scrollTop + viewportHeight;
|
|
206 |
+ |
const lastVisible = this._indexAtOffset(viewportBottom);
|
|
207 |
+ |
const endIndex = Math.min(this.items.length, lastVisible + 1 + this.overscan);
|
| 173 |
208 |
|
|
| 174 |
209 |
|
return { startIndex, endIndex, topOffset };
|
| 175 |
210 |
|
}
|
| 201 |
236 |
|
}
|
| 202 |
237 |
|
}
|
| 203 |
238 |
|
}
|
|
239 |
+ |
if (changed) this._invalidateOffsets();
|
| 204 |
240 |
|
return changed;
|
| 205 |
241 |
|
}
|
| 206 |
242 |
|
|
| 212 |
248 |
|
_render(forceRender = false) {
|
| 213 |
249 |
|
if (this.isDestroyed) return;
|
| 214 |
250 |
|
|
|
251 |
+ |
const previousItems = this.items;
|
| 215 |
252 |
|
this.items = this.getItems() || [];
|
|
253 |
+ |
// getItems() may hand back a new array, a mutated one, or the very same
|
|
254 |
+ |
// reference. Only the last case is safely cacheable.
|
|
255 |
+ |
if (this.items !== previousItems) this._invalidateOffsets();
|
| 216 |
256 |
|
|
| 217 |
257 |
|
if (this.items.length === 0) {
|
| 218 |
258 |
|
this.topSpacer.style.height = '0px';
|
| 244 |
284 |
|
this._hasRendered = true;
|
| 245 |
285 |
|
|
| 246 |
286 |
|
// Set spacers from the values we just computed, no need to recompute.
|
|
287 |
+ |
const offsets = this._offsets();
|
| 247 |
288 |
|
this.topSpacer.style.height = `${topOffset}px`;
|
| 248 |
|
- |
let bottomHeight = 0;
|
| 249 |
|
- |
for (let i = endIndex; i < this.items.length; i++) {
|
| 250 |
|
- |
bottomHeight += this._getItemHeight(this.items[i], i);
|
| 251 |
|
- |
}
|
| 252 |
|
- |
this.bottomSpacer.style.height = `${bottomHeight}px`;
|
|
289 |
+ |
this.bottomSpacer.style.height = `${offsets[this.items.length] - offsets[endIndex]}px`;
|
| 253 |
290 |
|
|
| 254 |
291 |
|
// Measure after layout. Only re-update spacers if measurement
|
| 255 |
292 |
|
// actually changed cached heights for items above the viewport
|
| 282 |
319 |
|
* @private
|
| 283 |
320 |
|
*/
|
| 284 |
321 |
|
_updateSpacers() {
|
| 285 |
|
- |
let topHeight = 0;
|
| 286 |
|
- |
for (let i = 0; i < this.startIndex; i++) {
|
| 287 |
|
- |
topHeight += this._getItemHeight(this.items[i], i);
|
| 288 |
|
- |
}
|
| 289 |
|
- |
this.topSpacer.style.height = `${topHeight}px`;
|
| 290 |
|
- |
|
| 291 |
|
- |
let bottomHeight = 0;
|
| 292 |
|
- |
for (let i = this.endIndex; i < this.items.length; i++) {
|
| 293 |
|
- |
bottomHeight += this._getItemHeight(this.items[i], i);
|
| 294 |
|
- |
}
|
| 295 |
|
- |
this.bottomSpacer.style.height = `${bottomHeight}px`;
|
|
322 |
+ |
const offsets = this._offsets();
|
|
323 |
+ |
this.topSpacer.style.height = `${offsets[this.startIndex]}px`;
|
|
324 |
+ |
this.bottomSpacer.style.height =
|
|
325 |
+ |
`${offsets[this.items.length] - offsets[this.endIndex]}px`;
|
| 296 |
326 |
|
}
|
| 297 |
327 |
|
|
| 298 |
328 |
|
/**
|
| 334 |
364 |
|
// Re-arm the infinite-scroll trigger: the data set just changed (likely
|
| 335 |
365 |
|
// a freshly appended page), so a new near-tail render may need more.
|
| 336 |
366 |
|
this._loadingMore = false;
|
|
367 |
+ |
// refresh() means "the data changed", which includes in-place mutation
|
|
368 |
+ |
// of the same array; drop the offset table rather than trust it.
|
|
369 |
+ |
this._invalidateOffsets();
|
| 337 |
370 |
|
this._render(true);
|
| 338 |
371 |
|
}
|
| 339 |
372 |
|
|
| 346 |
379 |
|
if (this.isDestroyed) return;
|
| 347 |
380 |
|
if (index < 0 || index >= this.items.length) return;
|
| 348 |
381 |
|
|
| 349 |
|
- |
let targetScrollTop = 0;
|
| 350 |
|
- |
for (let i = 0; i < index; i++) {
|
| 351 |
|
- |
targetScrollTop += this._getItemHeight(this.items[i], i);
|
| 352 |
|
- |
}
|
|
382 |
+ |
let targetScrollTop = this._offsets()[index];
|
| 353 |
383 |
|
|
| 354 |
384 |
|
const itemHeight = this._getItemHeight(this.items[index], index);
|
| 355 |
385 |
|
const viewportHeight = this.container.clientHeight;
|
| 390 |
420 |
|
*/
|
| 391 |
421 |
|
clearHeightCache() {
|
| 392 |
422 |
|
this.heightCache.clear();
|
|
423 |
+ |
this._invalidateOffsets();
|
| 393 |
424 |
|
this._hasRendered = false;
|
| 394 |
425 |
|
this.refresh();
|
| 395 |
426 |
|
}
|