UNPKG

ng-table-virtual-scroll

Version:
444 lines (436 loc) 20.7 kB
import * as i0 from '@angular/core'; import { Injectable, forwardRef, Directive, Input, ContentChild, NgModule } from '@angular/core'; import { VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling'; import { CdkTable } from '@angular/cdk/table'; import { Subject, BehaviorSubject, Subscription, ReplaySubject, merge, of, combineLatest, from } from 'rxjs'; import { distinctUntilChanged, map, startWith, delayWhen, tap, takeUntil, switchMap, take } from 'rxjs/operators'; import { MatTableDataSource } from '@angular/material/table'; import { DataSource } from '@angular/cdk/collections'; class FixedSizeTableVirtualScrollStrategy { constructor() { this.indexChange = new Subject(); this.stickyChange = new Subject(); this.renderedRangeStream = new BehaviorSubject({ start: 0, end: 0 }); this.scrolledIndexChange = this.indexChange.pipe(distinctUntilChanged()); this._dataLength = 0; } get dataLength() { return this._dataLength; } set dataLength(value) { if (value !== this._dataLength) { this._dataLength = value; this.onDataLengthChanged(); } } attach(viewport) { this.viewport = viewport; this.viewport.renderedRangeStream.subscribe(this.renderedRangeStream); this.stickyChange.next(0); this.onDataLengthChanged(); } detach() { this.indexChange.complete(); this.stickyChange.complete(); this.renderedRangeStream.complete(); } onContentScrolled() { this.updateContent(); } onDataLengthChanged() { if (this.viewport) { const contentSize = this.dataLength * this.rowHeight + this.headerHeight + this.footerHeight; this.viewport.setTotalContentSize(contentSize); const viewportSize = this.viewport.getViewportSize(); if (this.viewport.measureScrollOffset() + viewportSize >= contentSize) { this.viewport.scrollToOffset(contentSize - viewportSize); } } this.updateContent(); } onContentRendered() { } onRenderedOffsetChanged() { // no-op } scrollToIndex(index, behavior) { if (!this.viewport || !this.rowHeight) { return; } this.viewport.scrollToOffset((index - 1) * this.rowHeight + this.headerHeight, behavior); } setConfig(configs) { const { rowHeight, headerHeight, footerHeight, bufferMultiplier } = configs; if (this.rowHeight === rowHeight && this.headerHeight === headerHeight && this.footerHeight === footerHeight && this.bufferMultiplier === bufferMultiplier) { return; } this.rowHeight = rowHeight; this.headerHeight = headerHeight; this.footerHeight = footerHeight; this.bufferMultiplier = bufferMultiplier; this.onDataLengthChanged(); } updateContent() { if (!this.viewport || !this.rowHeight) { return; } const renderedOffset = this.viewport.getOffsetToRenderedContentStart(); const start = renderedOffset / this.rowHeight; const itemsDisplayed = Math.ceil(this.viewport.getViewportSize() / this.rowHeight); const bufferItems = Math.ceil(itemsDisplayed * this.bufferMultiplier); const end = start + itemsDisplayed + 2 * bufferItems; const bufferOffset = renderedOffset + bufferItems * this.rowHeight; const scrollOffset = this.viewport.measureScrollOffset(); // How far the scroll offset is from the lower buffer, which is usually where items start being displayed const relativeScrollOffset = scrollOffset - bufferOffset; const rowsScrolled = relativeScrollOffset / this.rowHeight; const displayed = scrollOffset / this.rowHeight; this.indexChange.next(displayed); // Only bother updating the displayed information if we've scrolled more than a row const rowSensitivity = 1.0; if (Math.abs(rowsScrolled) < rowSensitivity) { this.viewport.setRenderedContentOffset(renderedOffset); this.viewport.setRenderedRange({ start, end }); return; } // Special case for the start of the table. // At the top of the table, the first few rows are first rendered because they're visible, and then still rendered // Because they move into the buffer. So we only need to change what's rendered once the user scrolls far enough down. if (renderedOffset === 0 && rowsScrolled < 0) { this.viewport.setRenderedContentOffset(renderedOffset); this.viewport.setRenderedRange({ start, end }); return; } const rowsToMove = Math.sign(rowsScrolled) * Math.floor(Math.abs(rowsScrolled)); const adjustedRenderedOffset = Math.max(0, renderedOffset + rowsToMove * this.rowHeight); this.viewport.setRenderedContentOffset(adjustedRenderedOffset); const adjustedStart = Math.max(0, start + rowsToMove); const adjustedEnd = adjustedStart + itemsDisplayed + 2 * bufferItems; this.viewport.setRenderedRange({ start: adjustedStart, end: adjustedEnd }); this.stickyChange.next(adjustedRenderedOffset); } } FixedSizeTableVirtualScrollStrategy.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); FixedSizeTableVirtualScrollStrategy.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: FixedSizeTableVirtualScrollStrategy, decorators: [{ type: Injectable }] }); function isTVSDataSource(dataSource) { return dataSource instanceof CdkTableVirtualScrollDataSource || dataSource instanceof TableVirtualScrollDataSource; } class CdkTableVirtualScrollDataSource extends DataSource { constructor(initialData = []) { super(); /** Stream emitting render data to the table (depends on ordered data changes). */ this._renderData = new BehaviorSubject([]); /** * Subscription to the changes that should trigger an update to the table's rendered rows, such * as filtering, sorting, pagination, or base data changes. */ this._renderChangesSubscription = null; this._data = new BehaviorSubject(initialData); this._updateChangeSubscription(); } /** Array of data that should be rendered by the table, where each object represents one row. */ get data() { return this._data.value; } set data(data) { data = Array.isArray(data) ? data : []; this._data.next(data); } _updateChangeSubscription() { this.initStreams(); this._renderChangesSubscription?.unsubscribe(); this._renderChangesSubscription = new Subscription(); this._renderChangesSubscription.add(this._data.subscribe(data => this.dataToRender$.next(data))); this._renderChangesSubscription.add(this.dataOfRange$.subscribe(data => this._renderData.next(data))); } connect() { if (!this._renderChangesSubscription) { this._updateChangeSubscription(); } return this._renderData; } disconnect() { this._renderChangesSubscription?.unsubscribe(); this._renderChangesSubscription = null; } initStreams() { if (!this.streamsReady) { this.dataToRender$ = new ReplaySubject(1); this.dataOfRange$ = new ReplaySubject(1); this.streamsReady = true; } } } class TableVirtualScrollDataSource extends MatTableDataSource { _updateChangeSubscription() { this.initStreams(); const _sort = this['_sort']; const _paginator = this['_paginator']; const _internalPageChanges = this['_internalPageChanges']; const _filter = this['_filter']; const _renderData = this['_renderData']; const sortChange = _sort ? merge(_sort.sortChange, _sort.initialized) : of(null); const pageChange = _paginator ? merge(_paginator.page, _internalPageChanges, _paginator.initialized) : of(null); const dataStream = this['_data']; const filteredData = combineLatest([dataStream, _filter]) .pipe(map(([data]) => this._filterData(data))); const orderedData = combineLatest([filteredData, sortChange]) .pipe(map(([data]) => this._orderData(data))); const paginatedData = combineLatest([orderedData, pageChange]) .pipe(map(([data]) => this._pageData(data))); this._renderChangesSubscription?.unsubscribe(); this._renderChangesSubscription = new Subscription(); this._renderChangesSubscription.add(paginatedData.subscribe(data => this.dataToRender$.next(data))); this._renderChangesSubscription.add(this.dataOfRange$.subscribe(data => _renderData.next(data))); } initStreams() { if (!this.streamsReady) { this.dataToRender$ = new ReplaySubject(1); this.dataOfRange$ = new ReplaySubject(1); this.streamsReady = true; } } } function _tableVirtualScrollDirectiveStrategyFactory(tableDir) { return tableDir.scrollStrategy; } function combineSelectors(...pairs) { return pairs.map((selectors) => `${selectors.join(' ')}, ${selectors.join('')}`).join(', '); } const stickyHeaderSelector = combineSelectors(['.mat-mdc-header-row', '.mat-mdc-table-sticky'], ['.mat-header-row', '.mat-table-sticky'], ['.cdk-header-row', '.cdk-table-sticky']); const stickyFooterSelector = combineSelectors(['.mat-mdc-footer-row', '.mat-mdc-table-sticky'], ['.mat-footer-row', '.mat-table-sticky'], ['.cdk-footer-row', '.cdk-table-sticky']); function isMatTable(table) { return table instanceof CdkTable && table['stickyCssClass'].includes('mat'); } function isCdkTable(table) { return table instanceof CdkTable && table['stickyCssClass'].includes('cdk'); } const defaults = { rowHeight: 48, headerHeight: 56, headerEnabled: true, footerHeight: 48, footerEnabled: false, bufferMultiplier: 0.7 }; class TableItemSizeDirective { constructor(zone) { this.zone = zone; this.destroyed$ = new Subject(); // eslint-disable-next-line @angular-eslint/no-input-rename this.rowHeight = defaults.rowHeight; this.headerEnabled = defaults.headerEnabled; this.headerHeight = defaults.headerHeight; this.footerEnabled = defaults.footerEnabled; this.footerHeight = defaults.footerHeight; this.bufferMultiplier = defaults.bufferMultiplier; this.scrollStrategy = new FixedSizeTableVirtualScrollStrategy(); this.dataSourceChanges = new Subject(); this.resetStickyPositions = new Subject(); this.stickyEnabled = { header: false, footer: false }; } ngOnDestroy() { this.destroyed$.next(); this.destroyed$.complete(); this.dataSourceChanges.complete(); } ngAfterContentInit() { const switchDataSourceOrigin = this.table['_switchDataSource']; this.table['_switchDataSource'] = (dataSource) => { switchDataSourceOrigin.call(this.table, dataSource); this.connectDataSource(dataSource); }; const updateStickyColumnStylesOrigin = this.table.updateStickyColumnStyles; this.table.updateStickyColumnStyles = () => { const stickyColumnStylesNeedReset = this.table['_stickyColumnStylesNeedReset']; updateStickyColumnStylesOrigin.call(this.table); if (stickyColumnStylesNeedReset) { this.resetStickyPositions.next(); } }; this.connectDataSource(this.table.dataSource); combineLatest([ this.scrollStrategy.stickyChange, this.resetStickyPositions.pipe(startWith(void 0), delayWhen(() => this.getScheduleObservable()), tap(() => { this.stickyPositions = null; })) ]) .pipe(takeUntil(this.destroyed$)) .subscribe(([stickyOffset]) => { if (!this.stickyPositions) { this.initStickyPositions(); } if (this.stickyEnabled.header) { this.setStickyHeader(stickyOffset); } if (this.stickyEnabled.footer) { this.setStickyFooter(stickyOffset); } }); } connectDataSource(dataSource) { this.dataSourceChanges.next(); if (!isTVSDataSource(dataSource)) { throw new Error('[tvsItemSize] requires TableVirtualScrollDataSource or CdkTableVirtualScrollDataSource be set as [dataSource] of the table'); } if (isMatTable(this.table) && !(dataSource instanceof TableVirtualScrollDataSource)) { throw new Error('[tvsItemSize] requires TableVirtualScrollDataSource be set as [dataSource] of [mat-table]'); } if (isCdkTable(this.table) && !(dataSource instanceof CdkTableVirtualScrollDataSource)) { throw new Error('[tvsItemSize] requires CdkTableVirtualScrollDataSource be set as [dataSource] of [cdk-table]'); } dataSource .dataToRender$ .pipe(distinctUntilChanged(), takeUntil(this.dataSourceChanges), takeUntil(this.destroyed$), tap(data => this.scrollStrategy.dataLength = data.length), switchMap(data => this.scrollStrategy .renderedRangeStream .pipe(map(({ start, end }) => typeof start !== 'number' || typeof end !== 'number' ? data : data.slice(start, end))))) .subscribe(data => { this.zone.run(() => { dataSource.dataOfRange$.next(data); }); }); } ngOnChanges() { const config = { rowHeight: +this.rowHeight || defaults.rowHeight, headerHeight: this.headerEnabled ? +this.headerHeight || defaults.headerHeight : 0, footerHeight: this.footerEnabled ? +this.footerHeight || defaults.footerHeight : 0, bufferMultiplier: +this.bufferMultiplier || defaults.bufferMultiplier }; this.scrollStrategy.setConfig(config); } setStickyEnabled() { if (!this.scrollStrategy.viewport) { this.stickyEnabled = { header: false, footer: false }; return; } const isEnabled = (rowDefs) => rowDefs .map(def => def.sticky) .reduce((prevState, state) => prevState && state, true); this.stickyEnabled = { header: isEnabled(this.table['_headerRowDefs']), footer: isEnabled(this.table['_footerRowDefs']), }; } setStickyHeader(offset) { this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyHeaderSelector) .forEach((el) => { const parent = el.parentElement; let baseOffset = 0; if (this.stickyPositions.has(parent)) { baseOffset = this.stickyPositions.get(parent); } el.style.top = `${baseOffset - offset}px`; }); } setStickyFooter(offset) { this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyFooterSelector) .forEach((el) => { const parent = el.parentElement; let baseOffset = 0; if (this.stickyPositions.has(parent)) { baseOffset = this.stickyPositions.get(parent); } el.style.bottom = `${-baseOffset + offset}px`; }); } initStickyPositions() { this.stickyPositions = new Map(); this.setStickyEnabled(); if (this.stickyEnabled.header) { this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyHeaderSelector) .forEach(el => { const parent = el.parentElement; if (!this.stickyPositions.has(parent)) { this.stickyPositions.set(parent, parent.offsetTop); } }); } if (this.stickyEnabled.footer) { this.scrollStrategy.viewport.elementRef.nativeElement.querySelectorAll(stickyFooterSelector) .forEach(el => { const parent = el.parentElement; if (!this.stickyPositions.has(parent)) { this.stickyPositions.set(parent, -parent.offsetTop); } }); } } getScheduleObservable() { // Use onStable when in the context of an ongoing change detection cycle so that we // do not accidentally trigger additional cycles. return this.zone.isStable ? from(Promise.resolve(undefined)) : this.zone.onStable.pipe(take(1)); } } TableItemSizeDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableItemSizeDirective, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Directive }); TableItemSizeDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "15.1.4", type: TableItemSizeDirective, selector: "cdk-virtual-scroll-viewport[tvsItemSize]", inputs: { rowHeight: ["tvsItemSize", "rowHeight"], headerEnabled: "headerEnabled", headerHeight: "headerHeight", footerEnabled: "footerEnabled", footerHeight: "footerHeight", bufferMultiplier: "bufferMultiplier" }, providers: [{ provide: VIRTUAL_SCROLL_STRATEGY, useFactory: _tableVirtualScrollDirectiveStrategyFactory, deps: [forwardRef(() => TableItemSizeDirective)] }], queries: [{ propertyName: "table", first: true, predicate: CdkTable, descendants: true }], usesOnChanges: true, ngImport: i0 }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableItemSizeDirective, decorators: [{ type: Directive, args: [{ selector: 'cdk-virtual-scroll-viewport[tvsItemSize]', providers: [{ provide: VIRTUAL_SCROLL_STRATEGY, useFactory: _tableVirtualScrollDirectiveStrategyFactory, deps: [forwardRef(() => TableItemSizeDirective)] }] }] }], ctorParameters: function () { return [{ type: i0.NgZone }]; }, propDecorators: { rowHeight: [{ type: Input, args: ['tvsItemSize'] }], headerEnabled: [{ type: Input }], headerHeight: [{ type: Input }], footerEnabled: [{ type: Input }], footerHeight: [{ type: Input }], bufferMultiplier: [{ type: Input }], table: [{ type: ContentChild, args: [CdkTable, { static: false }] }] } }); class TableVirtualScrollModule { } TableVirtualScrollModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); TableVirtualScrollModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, declarations: [TableItemSizeDirective], exports: [TableItemSizeDirective] }); TableVirtualScrollModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.1.4", ngImport: i0, type: TableVirtualScrollModule, decorators: [{ type: NgModule, args: [{ declarations: [TableItemSizeDirective], imports: [], exports: [TableItemSizeDirective] }] }] }); /* * Public API Surface of ng-fixed-size-table-virtual-scroll */ /** * Generated bundle index. Do not edit. */ export { CdkTableVirtualScrollDataSource, FixedSizeTableVirtualScrollStrategy, TableItemSizeDirective, TableVirtualScrollDataSource, TableVirtualScrollModule, _tableVirtualScrollDirectiveStrategyFactory, isTVSDataSource }; //# sourceMappingURL=ng-table-virtual-scroll.mjs.map