Files
thrillwiki_django_no_react/static/js/components/virtual-scroller.js

190 lines
5.7 KiB
JavaScript

// Virtual Scroller Component
// Implements efficient scrolling for large lists by only rendering visible items
class VirtualScroller {
constructor(options) {
this.container = options.container;
this.itemHeight = options.itemHeight;
this.bufferSize = options.bufferSize || 5;
this.renderItem = options.renderItem;
this.items = [];
this.scrollTop = 0;
this.visibleItems = new Map();
this.observer = new IntersectionObserver(
this._handleIntersection.bind(this),
{ threshold: 0.1 }
);
this._setupContainer();
this._bindEvents();
}
_setupContainer() {
if (!this.container) {
throw new Error('Container element is required');
}
this.container.style.position = 'relative';
this.container.style.height = '600px'; // Default height
this.container.style.overflowY = 'auto';
// Create spacer element to maintain scroll height
this.spacer = document.createElement('div');
this.spacer.style.position = 'absolute';
this.spacer.style.top = '0';
this.spacer.style.left = '0';
this.spacer.style.width = '1px';
this.container.appendChild(this.spacer);
}
_bindEvents() {
this.container.addEventListener(
'scroll',
this._debounce(this._handleScroll.bind(this), 16)
);
// Handle container resize
if (window.ResizeObserver) {
const resizeObserver = new ResizeObserver(this._debounce(() => {
this._render();
}, 16));
resizeObserver.observe(this.container);
}
}
setItems(items) {
this.items = items;
this.spacer.style.height = `${items.length * this.itemHeight}px`;
this._render();
}
_handleScroll() {
this.scrollTop = this.container.scrollTop;
this._render();
}
_handleIntersection(entries) {
entries.forEach(entry => {
const itemId = entry.target.dataset.itemId;
if (!entry.isIntersecting) {
this.visibleItems.delete(itemId);
entry.target.remove();
}
});
}
_render() {
const visibleRange = this._getVisibleRange();
const itemsToRender = new Set();
// Calculate which items should be visible
for (let i = visibleRange.start; i <= visibleRange.end; i++) {
if (i >= 0 && i < this.items.length) {
itemsToRender.add(i);
}
}
// Remove items that are no longer visible
for (const [itemId] of this.visibleItems) {
const index = parseInt(itemId);
if (!itemsToRender.has(index)) {
const element = this.container.querySelector(`[data-item-id="${itemId}"]`);
if (element) {
this.observer.unobserve(element);
element.remove();
}
this.visibleItems.delete(itemId);
}
}
// Add new visible items
for (const index of itemsToRender) {
if (!this.visibleItems.has(index.toString())) {
this._renderItem(index);
}
}
// Update performance metrics
this._updateMetrics(itemsToRender.size);
}
_renderItem(index) {
const item = this.items[index];
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.top = `${index * this.itemHeight}px`;
element.style.left = '0';
element.style.width = '100%';
element.dataset.itemId = index.toString();
// Render content
element.innerHTML = this.renderItem(item);
// Add to container and observe
this.container.appendChild(element);
this.observer.observe(element);
this.visibleItems.set(index.toString(), element);
// Adjust actual height if needed
const actualHeight = element.offsetHeight;
if (actualHeight !== this.itemHeight) {
element.style.height = `${this.itemHeight}px`;
}
}
_getVisibleRange() {
const start = Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize;
const visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
const end = start + visibleCount + 2 * this.bufferSize;
return { start, end };
}
_updateMetrics(visibleCount) {
const metrics = {
totalItems: this.items.length,
visibleItems: visibleCount,
scrollPosition: this.scrollTop,
containerHeight: this.container.clientHeight,
renderTime: performance.now() // You can use this with the previous render time
};
// Dispatch metrics event
this.container.dispatchEvent(new CustomEvent('virtualScroller:metrics', {
detail: metrics
}));
}
_debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Public methods
scrollToIndex(index) {
if (index >= 0 && index < this.items.length) {
this.container.scrollTop = index * this.itemHeight;
}
}
refresh() {
this._render();
}
destroy() {
this.observer.disconnect();
this.container.innerHTML = '';
this.items = [];
this.visibleItems.clear();
}
}
export default VirtualScroller;