import { BLOCK_ROWS, CLUSTER_BLOCKS } from '../constants.js'; import type { HtmlStruct, VirtualCache, VirtualScrollOption } from '../models/interfaces.js'; import { convertItemRowToHtml, createDomElement, emptyElement } from '../utils/domUtils.js'; export class VirtualScroll { protected clusterRows?: number; protected cache: VirtualCache; protected scrollEl: HTMLElement; protected blockHeight?: number; protected clusterHeight?: number; protected contentEl: HTMLElement; protected parentEl: HTMLElement | null; protected itemHeight?: number; protected lastCluster: number; protected scrollTop: number; dataStart!: number; dataEnd!: number; rows: HtmlStruct[]; destroy: () => void; callback: () => void; sanitizer?: (dirtyHtml: string) => string; constructor(options: VirtualScrollOption) { this.rows = options.rows; this.scrollEl = options.scrollEl; this.contentEl = options.contentEl; this.parentEl = options.contentEl?.parentElement; this.callback = options.callback; this.cache = Object.create(null) as VirtualCache; this.scrollTop = this.scrollEl.scrollTop; this.initDOM(this.rows); this.scrollEl.scrollTop = this.scrollTop; this.lastCluster = 0; const onScroll = () => { if (this.lastCluster !== (this.lastCluster = this.getNum())) { this.initDOM(this.rows); this.callback(); } }; this.scrollEl.addEventListener('scroll', onScroll, false); this.destroy = () => { this.scrollEl.removeEventListener('scroll', onScroll, false); emptyElement(this.contentEl); }; } reset(rows: HtmlStruct[]) { this.rows = rows; this.cache = Object.create(null) as VirtualCache; emptyElement(this.contentEl); this.initDOM(rows); this.lastCluster = this.getNum(); } protected initDOM(rows: HtmlStruct[]) { if (typeof this.clusterHeight === 'undefined' && rows.length) { this.cache.scrollTop = this.scrollEl.scrollTop; const firstRowElm = convertItemRowToHtml(rows[0]); emptyElement(this.contentEl); this.contentEl.appendChild(firstRowElm); this.cache.data = [rows[0]]; this.getRowsHeight(); } const data = this.initData(rows, this.getNum()); const cachedRows = this.cache.data; const dataChanged = !cachedRows || cachedRows.length !== data.rows.length || data.rows.some((row, i) => row !== cachedRows[i]); this.cache.data = data.rows; const topOffsetChanged = this.checkChanges('top', data.topOffset); const bottomOffsetChanged = this.checkChanges('bottom', data.bottomOffset); if (dataChanged || topOffsetChanged) { emptyElement(this.contentEl); if (data.topOffset) { this.contentEl.appendChild(this.getExtra('top', data.topOffset)); } data.rows.forEach(h => this.contentEl.appendChild(convertItemRowToHtml(h))); if (data.bottomOffset) { this.contentEl.appendChild(this.getExtra('bottom', data.bottomOffset)); } } else if (bottomOffsetChanged) { const bottomElm = this.contentEl.querySelector('.virtual-scroll-bottom'); if (!data.bottomOffset) { bottomElm?.remove(); } else if (bottomElm) { bottomElm.style.height = `${data.bottomOffset}px`; } else { this.contentEl.appendChild(this.getExtra('bottom', data.bottomOffset)); } } } protected getRowsHeight() { if (typeof this.itemHeight === 'undefined') { // make sure parent is not hidden before reading item list height const prevParentDisplay = this.parentEl?.style.display || ''; if (this.parentEl && (prevParentDisplay === '' || prevParentDisplay === 'none')) { this.parentEl.style.display = 'block'; } const nodes = this.contentEl.children; const node = nodes[Math.floor(nodes.length / 2)]; this.itemHeight = (node as HTMLElement).offsetHeight; if (this.parentEl) { this.parentEl.style.display = prevParentDisplay; } } this.blockHeight = this.itemHeight * BLOCK_ROWS; this.clusterRows = BLOCK_ROWS * CLUSTER_BLOCKS; this.clusterHeight = this.blockHeight * CLUSTER_BLOCKS; } protected getNum() { this.scrollTop = this.scrollEl.scrollTop; const blockSize = (this.clusterHeight || 0) - (this.blockHeight || 0); return blockSize ? Math.floor(this.scrollTop / blockSize) || 0 : 0; } protected initData(rows: HtmlStruct[], num: number) { if (rows.length < BLOCK_ROWS) { this.dataStart = 0; this.dataEnd = rows.length; return { topOffset: 0, bottomOffset: 0, rowsAbove: 0, rows, }; } const start = Math.max((this.clusterRows! - BLOCK_ROWS) * num, 0); const end = start + this.clusterRows!; const topOffset = Math.max(start * this.itemHeight!, 0); const bottomOffset = Math.max((rows.length - end) * this.itemHeight!, 0); const thisRows: HtmlStruct[] = []; let rowsAbove = start; if (topOffset < 1) { rowsAbove++; } for (let i = start; i < end; i++) { rows[i] && thisRows.push(rows[i]); } this.dataStart = start; this.dataEnd = end; return { topOffset, bottomOffset, rowsAbove, rows: thisRows, }; } protected checkChanges(type: T, value: VirtualCache[T]) { const changed = value !== this.cache[type]; this.cache[type] = value; return changed; } protected getExtra(className: string, height: number) { const tag = createDomElement('li', { className: `virtual-scroll-${className}` }); if (height) { tag.style.height = `${height}px`; } return tag; } }