UNPKG

@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
/**----------------------------------------------------------------------------------------- * 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)); } }; }