export class VirtualList {
constructor({ container, itemHeight, totalCount, renderItem, overscan = 3, onScrollEnd = null }) {
this.container = container;
this.itemHeight = itemHeight;
this.totalCount = totalCount;
this.renderItem = renderItem;
this.overscan = overscan;
this.onScrollEnd = onScrollEnd;
this.scrollTop = 0;
this.containerHeight = 0;
this.items = new Map(); this.lastVisibleRange = { start: -1, end: -1 };
this.metrics = {
renders: 0,
recycled: 0,
created: 0,
};
this.destroyed = false;
this._init();
}
_init() {
this.inner = document.createElement('div');
this.inner.className = 'virtual-list-inner';
this.inner.style.height = `${this.totalCount * this.itemHeight}px`;
this.inner.style.position = 'relative';
this.inner.style.width = '100%';
this.container.innerHTML = '';
this.container.style.overflow = 'auto';
this.container.style.position = 'relative';
this.container.appendChild(this.inner);
this._resizeObserver = new ResizeObserver(() => this._onResize());
this._resizeObserver.observe(this.container);
this._scrollHandler = this._createThrottledHandler(() => this._onScroll(), 16);
this.container.addEventListener('scroll', this._scrollHandler, { passive: true });
this._onResize();
console.debug('[VirtualList] Initialized with', this.totalCount, 'items');
}
_createThrottledHandler(fn, wait) {
let pending = false;
return () => {
if (this.destroyed || pending) {
return;
}
if (!pending) {
pending = true;
requestAnimationFrame(() => {
if (this.destroyed) {
pending = false;
return;
}
fn();
pending = false;
});
}
};
}
_onResize() {
if (this.destroyed) {
return;
}
this.containerHeight = this.container.clientHeight;
this._render();
}
_onScroll() {
if (this.destroyed) {
return;
}
this.scrollTop = this.container.scrollTop;
this._render();
if (this.onScrollEnd && this._isNearEnd()) {
this.onScrollEnd();
}
}
_isNearEnd() {
const totalHeight = this.totalCount * this.itemHeight;
const remaining = totalHeight - this.scrollTop - this.containerHeight;
return remaining < this.containerHeight * 2;
}
_getVisibleRange() {
const startIndex = Math.max(0,
Math.floor(this.scrollTop / this.itemHeight) - this.overscan
);
const endIndex = Math.min(this.totalCount,
Math.ceil((this.scrollTop + this.containerHeight) / this.itemHeight) + this.overscan
);
return { start: startIndex, end: endIndex };
}
_render() {
if (this.destroyed || !this.inner) {
return;
}
const { start, end } = this._getVisibleRange();
if (start === this.lastVisibleRange.start && end === this.lastVisibleRange.end) {
return;
}
this.lastVisibleRange = { start, end };
this.metrics.renders++;
const visible = new Set();
for (let i = start; i < end; i++) {
visible.add(i);
if (!this.items.has(i)) {
const element = this.renderItem(i);
element.style.position = 'absolute';
element.style.top = `${i * this.itemHeight}px`;
element.style.left = '0';
element.style.right = '0';
element.style.height = `${this.itemHeight}px`;
element.dataset.virtualIndex = i;
this.inner.appendChild(element);
this.items.set(i, element);
this.metrics.created++;
}
}
for (const [index, element] of this.items) {
if (!visible.has(index)) {
element.remove();
this.items.delete(index);
this.metrics.recycled++;
}
}
console.debug(`[VirtualList] Rendering ${this.items.size} of ${this.totalCount} items (range: ${start}-${end})`);
}
updateTotalCount(newCount) {
this.totalCount = newCount;
this.inner.style.height = `${newCount * this.itemHeight}px`;
this.lastVisibleRange = { start: -1, end: -1 };
this._render();
}
scrollToIndex(index, align = 'start') {
let targetTop = index * this.itemHeight;
if (align === 'center') {
targetTop = targetTop - (this.containerHeight / 2) + (this.itemHeight / 2);
} else if (align === 'end') {
targetTop = targetTop - this.containerHeight + this.itemHeight;
}
this.container.scrollTop = Math.max(0, targetTop);
this.scrollTop = this.container.scrollTop;
this._render();
}
refresh() {
for (const [, element] of this.items) {
element.remove();
}
this.items.clear();
this.lastVisibleRange = { start: -1, end: -1 };
this._render();
}
getVisibleRange() {
return { ...this.lastVisibleRange };
}
getMetrics() {
return { ...this.metrics };
}
destroy() {
this.destroyed = true;
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
if (this._scrollHandler) {
this.container.removeEventListener('scroll', this._scrollHandler);
this._scrollHandler = null;
}
for (const [, element] of this.items) {
element.remove();
}
this.items.clear();
if (this.inner) {
this.inner.remove();
this.inner = null;
}
console.debug('[VirtualList] Destroyed. Metrics:', this.metrics);
}
}
export class VariableHeightVirtualList {
constructor({ container, totalCount, estimatedItemHeight, renderItem, overscan = 5 }) {
this.container = container;
this.totalCount = totalCount;
this.estimatedHeight = estimatedItemHeight;
this.renderItem = renderItem;
this.overscan = overscan;
this.scrollTop = 0;
this.containerHeight = 0;
this.heights = new Map(); this.positions = [];
this.items = new Map(); this.lastVisibleRange = { start: -1, end: -1 };
this.destroyed = false;
this._scrollFramePending = false;
this._scrollFrameId = null;
this._init();
}
_init() {
this._calculatePositions();
this.inner = document.createElement('div');
this.inner.className = 'virtual-list-inner variable-height';
this.inner.style.position = 'relative';
this.inner.style.width = '100%';
this._updateTotalHeight();
this.container.innerHTML = '';
this.container.style.overflow = 'auto';
this.container.style.position = 'relative';
this.container.appendChild(this.inner);
this._resizeObserver = new ResizeObserver(() => this._onResize());
this._resizeObserver.observe(this.container);
this._scrollHandler = () => {
if (this.destroyed || this._scrollFramePending) {
return;
}
this._scrollFramePending = true;
this._scrollFrameId = requestAnimationFrame(() => {
this._scrollFramePending = false;
this._scrollFrameId = null;
if (this.destroyed) {
return;
}
this._onScroll();
});
};
this.container.addEventListener('scroll', this._scrollHandler, { passive: true });
this._onResize();
console.debug('[VariableVirtualList] Initialized with', this.totalCount, 'items');
}
_calculatePositions() {
this.positions = new Array(this.totalCount + 1);
this.positions[0] = 0;
for (let i = 0; i < this.totalCount; i++) {
const height = this.heights.get(i) ?? this.estimatedHeight;
this.positions[i + 1] = this.positions[i] + height;
}
}
_updateTotalHeight() {
const totalHeight = this.positions[this.totalCount] ?? this.totalCount * this.estimatedHeight;
this.inner.style.height = `${totalHeight}px`;
}
_getItemHeight(index) {
return this.heights.get(index) ?? this.estimatedHeight;
}
_getItemPosition(index) {
return this.positions[index] ?? index * this.estimatedHeight;
}
_findIndexAtPosition(scrollTop) {
let low = 0;
let high = this.totalCount - 1;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (this._getItemPosition(mid) <= scrollTop) {
low = mid;
} else {
high = mid - 1;
}
}
return low;
}
_onResize() {
if (this.destroyed) {
return;
}
this.containerHeight = this.container.clientHeight;
this._render();
}
_onScroll() {
if (this.destroyed) {
return;
}
this.scrollTop = this.container.scrollTop;
this._render();
}
_getVisibleRange() {
const startIndex = Math.max(0, this._findIndexAtPosition(this.scrollTop) - this.overscan);
const endIndex = Math.min(
this.totalCount,
this._findIndexAtPosition(this.scrollTop + this.containerHeight) + this.overscan + 1
);
return { start: startIndex, end: endIndex };
}
_render() {
if (this.destroyed || !this.inner) {
return;
}
const { start, end } = this._getVisibleRange();
if (start === this.lastVisibleRange.start && end === this.lastVisibleRange.end) {
return;
}
this.lastVisibleRange = { start, end };
const visible = new Set();
for (let i = start; i < end; i++) {
visible.add(i);
if (!this.items.has(i)) {
const element = this.renderItem(i);
element.style.position = 'absolute';
element.style.top = `${this._getItemPosition(i)}px`;
element.style.left = '0';
element.style.right = '0';
element.dataset.virtualIndex = i;
this.inner.appendChild(element);
this.items.set(i, element);
requestAnimationFrame(() => {
if (this.destroyed) {
return;
}
this._measureItem(i, element);
});
}
}
for (const [index, element] of this.items) {
if (!visible.has(index)) {
element.remove();
this.items.delete(index);
}
}
console.debug(`[VariableVirtualList] Rendering ${this.items.size} of ${this.totalCount} items`);
}
_measureItem(index, element) {
if (this.destroyed || !this.inner || !element?.isConnected) {
return;
}
const measuredHeight = element.offsetHeight;
const previousHeight = this.heights.get(index);
if (previousHeight !== measuredHeight) {
this.heights.set(index, measuredHeight);
for (let i = index; i < this.totalCount; i++) {
const height = this.heights.get(i) ?? this.estimatedHeight;
this.positions[i + 1] = this.positions[i] + height;
}
this._updateTotalHeight();
for (const [idx, el] of this.items) {
if (idx > index) {
el.style.top = `${this._getItemPosition(idx)}px`;
}
}
}
}
scrollToIndex(index, align = 'start') {
let targetTop = this._getItemPosition(index);
const itemHeight = this._getItemHeight(index);
if (align === 'center') {
targetTop = targetTop - (this.containerHeight / 2) + (itemHeight / 2);
} else if (align === 'end') {
targetTop = targetTop - this.containerHeight + itemHeight;
}
this.container.scrollTop = Math.max(0, targetTop);
this.scrollTop = this.container.scrollTop;
this._render();
}
updateTotalCount(newCount) {
this.totalCount = newCount;
this._calculatePositions();
this._updateTotalHeight();
this.lastVisibleRange = { start: -1, end: -1 };
this._render();
}
refresh() {
for (const [, element] of this.items) {
element.remove();
}
this.items.clear();
this.lastVisibleRange = { start: -1, end: -1 };
this._render();
}
destroy() {
this.destroyed = true;
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
if (this._scrollHandler) {
this.container.removeEventListener('scroll', this._scrollHandler);
}
if (this._scrollFrameId !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this._scrollFrameId);
this._scrollFrameId = null;
}
this._scrollFramePending = false;
for (const [, element] of this.items) {
element.remove();
}
this.items.clear();
if (this.inner) {
this.inner.remove();
this.inner = null;
}
console.debug('[VariableVirtualList] Destroyed');
}
}
export default {
VirtualList,
VariableHeightVirtualList,
};