mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -05:00
190 lines
5.7 KiB
JavaScript
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; |