UNPKG

svelte-tiny-virtual-list

Version:

A tiny but mighty list virtualization component for svelte, with zero dependencies 💪

410 lines (341 loc) • 9.96 kB
/* * SizeAndPositionManager was forked from react-tiny-virtual-list, which was * forked from react-virtualized. */ import { ALIGNMENT } from './constants.js'; /** * @callback ItemSizeGetter * @param {number} index * @return {number} */ /** * @typedef ItemSize * @type {number | number[] | ItemSizeGetter} */ /** * @typedef SizeAndPosition * @type {object} * @property {number} size * @property {number} offset */ /** * @typedef SizeAndPositionData * @type {Object.<number, SizeAndPosition>} */ /** * @typedef Options * @type {object} * @property {number} itemCount * @property {ItemSize} itemSize * @property {number} estimatedItemSize */ export default class SizeAndPositionManager { /** * @param {Options} options */ constructor({ itemSize, itemCount, estimatedItemSize }) { /** * @private * @type {ItemSize} */ this.itemSize = itemSize; /** * @private * @type {number} */ this.itemCount = itemCount; /** * @private * @type {number} */ this.estimatedItemSize = estimatedItemSize; /** * Cache of size and position data for items, mapped by item index. * * @private * @type {SizeAndPositionData} */ this.itemSizeAndPositionData = {}; /** * Measurements for items up to this index can be trusted; items afterward should be estimated. * * @private * @type {number} */ this.lastMeasuredIndex = -1; this.checkForMismatchItemSizeAndItemCount(); if (!this.justInTime) this.computeTotalSizeAndPositionData(); } get justInTime() { return typeof this.itemSize === 'function'; } /** * @param {Options} options */ updateConfig({ itemSize, itemCount, estimatedItemSize }) { if (itemCount != null) { this.itemCount = itemCount; } if (estimatedItemSize != null) { this.estimatedItemSize = estimatedItemSize; } if (itemSize != null) { this.itemSize = itemSize; } this.checkForMismatchItemSizeAndItemCount(); if (this.justInTime && this.totalSize != null) { this.totalSize = undefined; } else { this.computeTotalSizeAndPositionData(); } } checkForMismatchItemSizeAndItemCount() { if (Array.isArray(this.itemSize) && this.itemSize.length < this.itemCount) { throw Error(`When itemSize is an array, itemSize.length can't be smaller than itemCount`); } } /** * @param {number} index */ getSize(index) { const { itemSize } = this; if (typeof itemSize === 'function') { return itemSize(index); } return Array.isArray(itemSize) ? itemSize[index] : itemSize; } /** * Compute the totalSize and itemSizeAndPositionData at the start, * only when itemSize is a number or an array. */ computeTotalSizeAndPositionData() { let totalSize = 0; for (let i = 0; i < this.itemCount; i++) { const size = this.getSize(i); const offset = totalSize; totalSize += size; this.itemSizeAndPositionData[i] = { offset, size }; } this.totalSize = totalSize; } getLastMeasuredIndex() { return this.lastMeasuredIndex; } /** * This method returns the size and position for the item at the specified index. * * @param {number} index */ getSizeAndPositionForIndex(index) { if (index < 0 || index >= this.itemCount) { throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`); } return this.justInTime ? this.getJustInTimeSizeAndPositionForIndex(index) : this.itemSizeAndPositionData[index]; } /** * This is used when itemSize is a function. * just-in-time calculates (or used cached values) for items leading up to the index. * * @param {number} index */ getJustInTimeSizeAndPositionForIndex(index) { if (index > this.lastMeasuredIndex) { const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size; for (let i = this.lastMeasuredIndex + 1; i <= index; i++) { const size = this.getSize(i); if (size == null || isNaN(size)) { throw Error(`Invalid size returned for index ${i} of value ${size}`); } this.itemSizeAndPositionData[i] = { offset, size }; offset += size; } this.lastMeasuredIndex = index; } return this.itemSizeAndPositionData[index]; } getSizeAndPositionOfLastMeasuredItem() { return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : { offset: 0, size: 0 }; } /** * Total size of all items being measured. * * @return {number} */ getTotalSize() { // Return the pre computed totalSize when itemSize is number or array. if (this.totalSize) return this.totalSize; /** * When itemSize is a function, * This value will be completedly estimated initially. * As items as measured the estimate will be updated. */ const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); return ( lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize ); } /** * Determines a new offset that ensures a certain item is visible, given the alignment. * * @param {'auto' | 'start' | 'center' | 'end'} align Desired alignment within container * @param {number | undefined} containerSize Size (width or height) of the container viewport * @param {number | undefined} currentOffset * @param {number | undefined} targetIndex * @return {number} Offset to use to ensure the specified item is visible */ getUpdatedOffsetForIndex({ align = ALIGNMENT.START, containerSize, currentOffset, targetIndex }) { if (containerSize <= 0) { return 0; } const datum = this.getSizeAndPositionForIndex(targetIndex); const maxOffset = datum.offset; const minOffset = maxOffset - containerSize + datum.size; let idealOffset; switch (align) { case ALIGNMENT.END: idealOffset = minOffset; break; case ALIGNMENT.CENTER: idealOffset = maxOffset - (containerSize - datum.size) / 2; break; case ALIGNMENT.START: idealOffset = maxOffset; break; default: idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); } const totalSize = this.getTotalSize(); return Math.max(0, Math.min(totalSize - containerSize, idealOffset)); } /** * @param {number} containerSize * @param {number} offset * @param {number} overscanCount * @return {{stop: number|undefined, start: number|undefined}} */ getVisibleRange({ containerSize = 0, offset, overscanCount }) { const totalSize = this.getTotalSize(); if (totalSize === 0) { return {}; } const maxOffset = offset + containerSize; let start = this.findNearestItem(offset); if (start === undefined) { throw Error(`Invalid offset ${offset} specified`); } const datum = this.getSizeAndPositionForIndex(start); offset = datum.offset + datum.size; let stop = start; while (offset < maxOffset && stop < this.itemCount - 1) { stop++; offset += this.getSizeAndPositionForIndex(stop).size; } if (overscanCount) { start = Math.max(0, start - overscanCount); stop = Math.min(stop + overscanCount, this.itemCount - 1); } return { start, stop }; } /** * Clear all cached values for items after the specified index. * This method should be called for any item that has changed its size. * It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called. * * @param {number} index */ resetItem(index) { this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1); } /** * Searches for the item (index) nearest the specified offset. * * If no exact match is found the next lowest item index will be returned. * This allows partially visible items (with offsets just before/above the fold) to be visible. * * @param {number} offset */ findNearestItem(offset) { if (isNaN(offset)) { throw Error(`Invalid offset ${offset} specified`); } // Our search algorithms find the nearest match at or below the specified offset. // So make sure the offset is at least 0 or no match will be found. offset = Math.max(0, offset); const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex); if (lastMeasuredSizeAndPosition.offset >= offset) { // If we've already measured items within this range just use a binary search as it's faster. return this.binarySearch({ high: lastMeasuredIndex, low: 0, offset }); } else { // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. // The overall complexity for this approach is O(log n). return this.exponentialSearch({ index: lastMeasuredIndex, offset }); } } /** * @private * @param {number} low * @param {number} high * @param {number} offset */ binarySearch({ low, high, offset }) { let middle = 0; let currentOffset = 0; while (low <= high) { middle = low + Math.floor((high - low) / 2); currentOffset = this.getSizeAndPositionForIndex(middle).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } return 0; } /** * @private * @param {number} index * @param {number} offset */ exponentialSearch({ index, offset }) { let interval = 1; while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) { index += interval; interval *= 2; } return this.binarySearch({ high: Math.min(index, this.itemCount - 1), low: Math.floor(index / 2), offset }); } }