@progress/kendo-angular-grid
Version:
Kendo UI Grid for Angular - high performance data grid with paging, filtering, virtualization, CRUD, and more.
255 lines (254 loc) • 9.1 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { isFirefox, firefoxMaxHeight, isDocumentAvailable } from '@progress/kendo-angular-common';
import { Observable, BehaviorSubject } from 'rxjs';
/**
* @hidden
*/
export class ScrollAction {
offset;
changeVirtualData;
constructor(offset, changeVirtualData) {
this.offset = offset;
this.changeVirtualData = changeVirtualData;
}
}
/**
* @hidden
*/
export class PageAction {
skip;
take;
constructor(skip, take) {
this.skip = skip;
this.take = take;
}
}
/**
* @hidden
*/
export class ScrollBottomAction {
}
const SCROLL_BOTTOM_THRESHOLD = 2;
/**
* @hidden
*/
export class ScrollerService {
scrollObservable;
ctx;
total = 0;
rowHeightService;
table = null;
lockedTable = null;
tableBody = null;
container = null;
scrollHeightContainer = null;
scrollableVirtual = false;
tableTransformOffset = 0;
virtualSkip = 0;
virtualPageSize = 0;
firstToLoad = 0;
lastLoaded = 0;
scrollSyncing = false;
scrollSubscription;
subscription;
lastScrollTop = 0;
firstLoaded = 0;
expandedRows = {};
take;
constructor(scrollObservable, ctx) {
this.scrollObservable = scrollObservable;
this.ctx = ctx;
}
create(rowHeightService, skip, take, total) {
this.rowHeightService = rowHeightService;
this.firstLoaded = skip;
this.lastLoaded = skip + take;
this.take = take;
this.total = total;
this.lastScrollTop = 0;
const offset = this.rowHeightService.offset(skip);
const subject = new BehaviorSubject(new ScrollAction(offset, this.scrollableVirtual && (Boolean(this.ctx.grid?.pageable) || Boolean(this.ctx.grid?.group?.length))));
this.subscription = Observable.create(observer => {
this.unsubscribe();
this.scrollSubscription = this.scrollObservable.subscribe(x => this.onScroll(x, observer));
}).subscribe(x => subject.next(x));
return subject;
}
reset(skipScroll = false) {
if (!skipScroll) {
this.firstToLoad = 0;
this.firstLoaded = 0;
this.lastLoaded = 0;
this.virtualSkip = 0;
}
this.rowHeightService = undefined;
if (skipScroll) {
this.scrollSyncing = true;
}
if (!skipScroll && this.container && this.container.scrollTop !== 0) {
this.scrollSyncing = true;
this.container.scrollTop = 0;
this.lastScrollTop = 0;
this.translate(0, true);
this.tableTransformOffset = 0;
}
}
update(skipAdjust = false) {
const itemHeights = this.getItemHeights();
if (this.firstLoaded > this.firstToLoad) {
// Scrolled up
const count = Math.min(this.firstLoaded - this.firstToLoad, this.take);
const newItemsHeight = this.getTotalHeight(count, itemHeights);
const newItemsExpectedHeight = this.getExpectedTotalHeight(count);
const diff = newItemsHeight - newItemsExpectedHeight;
if (!skipAdjust && diff !== 0) {
this.adjustScroll(diff);
}
}
this.rowHeightService?.update(this.firstToLoad, itemHeights);
this.scrollHeightContainer && this.setScrollHeightContainerHeight();
this.firstLoaded = this.firstToLoad;
this.lastLoaded = this.firstLoaded + itemHeights.length - 1;
}
destroy() {
this.unsubscribe();
if (this.subscription) {
this.subscription.unsubscribe();
}
}
onScroll({ scrollTop, offsetHeight, scrollHeight, clientHeight }, observer) {
if (this.scrollSyncing) {
this.scrollSyncing = false;
return;
}
if (!isDocumentAvailable() || (this.lastScrollTop === scrollTop)) {
return;
}
const up = this.lastScrollTop >= scrollTop;
const down = !up;
this.lastScrollTop = scrollTop;
let firstItemIndex = this.rowHeightService.index(scrollTop);
const lastItemIndex = this.rowHeightService.index(scrollTop + offsetHeight);
const overflow = Math.max(firstItemIndex + (this.virtualPageSize || this.take) - this.total, 0);
firstItemIndex = Math.max(firstItemIndex - overflow, 0);
if (down) {
const atBottom = scrollHeight - clientHeight - scrollTop < SCROLL_BOTTOM_THRESHOLD;
if (atBottom) {
observer.next(new ScrollBottomAction());
}
}
if (!this.scrollableVirtual) {
return;
}
if (down && lastItemIndex >= this.lastLoaded && this.lastLoaded < this.total - 1) {
this.firstToLoad = firstItemIndex;
this.loadPage(observer);
}
else if (up && firstItemIndex < this.firstLoaded) {
const nonVisibleBuffer = Math.max(Math.floor((this.virtualPageSize || this.take) * 0.3) - overflow, 0);
this.firstToLoad = Math.max(firstItemIndex - nonVisibleBuffer, 0);
this.loadPage(observer);
}
}
loadPage(observer) {
if (!this.rowHeightService) {
return;
}
this.translate(this.rowHeightService.offset(this.firstToLoad));
observer.next(new ScrollAction(this.rowHeightService.offset(this.firstToLoad)));
this.virtualPageChange(this.firstToLoad, observer);
}
unsubscribe() {
if (this.scrollSubscription) {
this.scrollSubscription.unsubscribe();
this.scrollSubscription = undefined;
}
}
translate(dY, forceSet) {
if (this.scrollableVirtual && this.table) {
if (forceSet) {
this.table.style.transform = 'translateY(' + dY + 'px)';
if (this.lockedTable) {
this.lockedTable.style.transform = 'translateY(' + dY + 'px)';
}
}
else {
this.tableTransformOffset = dY;
}
}
}
adjustScroll(scrollOffset, initialAdjust = false) {
if (Number.isNaN(scrollOffset)) {
return;
}
this.scrollSyncing = true;
if (this.container) {
if (initialAdjust) {
this.container.scrollTop = scrollOffset;
this.translate(scrollOffset, true);
this.tableTransformOffset = scrollOffset;
this.firstToLoad = this.rowHeightService.index(scrollOffset);
}
else {
this.container.scrollTop += scrollOffset;
}
}
}
isExpanded(rowIndex) {
return this.expandedRows[rowIndex] || false;
}
resetVirtualSkip = () => {
if (this.scrollableVirtual && this.virtualSkip) {
this.virtualSkip = 0;
}
};
setScrollHeightContainerHeight() {
if (this.scrollableVirtual) {
let containerHeight = this.rowHeightService?.totalHeight() || 0;
containerHeight = isFirefox ? Math.min(firefoxMaxHeight, containerHeight) : containerHeight;
this.scrollHeightContainer.style.height = containerHeight + 'px';
}
else {
this.scrollHeightContainer.style.height = '0';
}
}
getItemHeights() {
const result = [];
if (this.tableBody) {
Array.from(this.tableBody.children).forEach((item, index) => {
const itemHeight = item.getBoundingClientRect().height;
if (item.classList.contains('k-detail-row')) {
result[result.length - 1] += itemHeight;
this.expandedRows[index] = true;
}
else {
result.push(itemHeight);
}
});
}
return result;
}
getTotalHeight(count, itemHeights) {
return itemHeights.slice(0, count).reduce((sum, current) => sum + current, 0);
}
getExpectedTotalHeight(count) {
const service = this.rowHeightService;
if (!service) {
return 0;
}
const lastItemIndex = this.firstToLoad + (count - 1);
return service.offset(lastItemIndex) + service.height(lastItemIndex) - service.offset(this.firstToLoad);
}
virtualPageChange = (skip, observer) => {
if (this.ctx.grid.pageable || this.ctx.grid.group?.length) {
this.virtualSkip = skip;
observer.next(new ScrollAction(this.rowHeightService?.offset(skip) || 0, true));
}
else if (skip !== this.ctx.grid.skip) {
observer.next(new PageAction(Math.max(0, skip), this.take));
}
};
}