perf: Add virtual scrolling

Attempting to improve table performance by only render visible dom elements.
This commit is contained in:
2025-08-06 01:18:35 +02:00
parent b5658ce37f
commit 8885b7c0db
4 changed files with 112 additions and 64 deletions

View File

@@ -26,6 +26,7 @@
}, },
"dependencies": { "dependencies": {
"@angular/animations": "19.2.1", "@angular/animations": "19.2.1",
"@angular/cdk": "^19.2.19",
"@angular/common": "19.2.1", "@angular/common": "19.2.1",
"@angular/compiler": "19.2.1", "@angular/compiler": "19.2.1",
"@angular/core": "19.2.1", "@angular/core": "19.2.1",

View File

@@ -1,3 +1,5 @@
import { ScrollingModule } from '@angular/cdk/scrolling'
import { CommonModule } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -38,9 +40,11 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
], ],
bootstrap: [AppComponent], imports: [ bootstrap: [AppComponent], imports: [
BrowserModule, BrowserModule,
CommonModule,
AppRoutingModule, AppRoutingModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
ScrollingModule,
], providers: [provideHttpClient(withInterceptorsFromDi())], ], providers: [provideHttpClient(withInterceptorsFromDi())],
}) })
export class AppModule { } export class AppModule { }

View File

@@ -1,9 +1,37 @@
<div <div class="flex-1 flex flex-col overflow-hidden">
#resultTableDiv <div class="flex-shrink-0">
class="basis-2/3 flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full" <table class="table table-zebra w-full" [class.table-xs]="settingsService.isCompactTable" style="table-layout: fixed">
(scroll)="tableScrolled()"> <thead class="bg-base-100">
<table id="resultTable" class="table table-zebra table-pin-rows" [class.table-xs]="settingsService.isCompactTable"> <ng-container *ngTemplateOutlet="headerTemplate; context: { isVisible: true }"></ng-container>
<thead> </thead>
</table>
</div>
<cdk-virtual-scroll-viewport
#viewport
class="flex-1 overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full"
[itemSize]="tableRowHeight"
(scroll)="onViewportScroll()">
<table class="table table-zebra w-full" [class.table-xs]="settingsService.isCompactTable" style="table-layout: fixed">
<!-- Invisible header for column alignment -->
<thead class="invisible absolute opacity-0" style="height: 0; overflow: hidden">
<ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
</thead>
<tbody>
<tr
*cdkVirtualFor="let song of songs; trackBy: trackByFn"
app-result-table-row
(click)="onRowClicked(song)"
(rowFocused)="onRowClicked(song)"
[class.!bg-neutral]="activeSong === song"
[class.!text-neutral-content]="activeSong === song"
[song]="song"></tr>
</tbody>
</table>
</cdk-virtual-scroll-viewport>
</div>
<ng-template #headerTemplate>
<tr> <tr>
<th class="collapsing" id="checkboxColumn"> <th class="collapsing" id="checkboxColumn">
<input type="checkbox" class="checkbox" [(ngModel)]="allSelected" /> <input type="checkbox" class="checkbox" [(ngModel)]="allSelected" />
@@ -35,17 +63,4 @@
Upload Date <i *ngIf="sortColumn === 'modifiedTime'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i> Upload Date <i *ngIf="sortColumn === 'modifiedTime'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th> </th>
</tr> </tr>
</thead> </ng-template>
<tbody>
@for (song of songs; track song) {
<tr
app-result-table-row
(click)="onRowClicked(song)"
(rowFocused)="onRowClicked(song)"
[class.!bg-neutral]="activeSong === song"
[class.!text-neutral-content]="activeSong === song"
[song]="song"></tr>
}
</tbody>
</table>
</div>

View File

@@ -1,12 +1,13 @@
import { Component, ElementRef, EventEmitter, HostBinding, HostListener, OnInit, Output, QueryList, ViewChild, ViewChildren } from '@angular/core' import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'
import { Component, EventEmitter, HostBinding, HostListener, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Subscription } from 'rxjs'
import { SettingsService } from 'src-angular/app/core/services/settings.service' import { SettingsService } from 'src-angular/app/core/services/settings.service'
import { ChartData } from 'src-shared/interfaces/search.interface' import { ChartData } from 'src-shared/interfaces/search.interface'
import { SearchService } from '../../../core/services/search.service' import { SearchService } from '../../../core/services/search.service'
import { SelectionService } from '../../../core/services/selection.service' import { SelectionService } from '../../../core/services/selection.service'
import { ResultTableRowComponent } from './result-table-row/result-table-row.component'
@Component({ @Component({
selector: 'app-result-table', selector: 'app-result-table',
@@ -18,12 +19,14 @@ export class ResultTableComponent implements OnInit {
@Output() rowClicked = new EventEmitter<ChartData[]>() @Output() rowClicked = new EventEmitter<ChartData[]>()
@ViewChild('resultTableDiv', { static: true }) resultTableDiv: ElementRef @ViewChild('viewport', { static: true }) viewport: CdkVirtualScrollViewport
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
activeSong: ChartData[] | null = null activeSong: ChartData[] | null = null
sortDirection: 'asc' | 'desc' = 'asc' sortDirection: 'asc' | 'desc' = 'asc'
sortColumn: 'name' | 'artist' | 'album' | 'genre' | 'year' | 'charter' | 'length' | 'modifiedTime' | null = null sortColumn: 'name' | 'artist' | 'album' | 'genre' | 'year' | 'charter' | 'length' | 'modifiedTime' | null = null
isLoadingMore = false
songs: ChartData[][] = []
subscription: Subscription[] = []
constructor( constructor(
public searchService: SearchService, public searchService: SearchService,
@@ -33,18 +36,48 @@ export class ResultTableComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.subscription.push(
this.searchService.newSearch.subscribe(() => { this.searchService.newSearch.subscribe(() => {
this.resultTableDiv.nativeElement.scrollTop = 0 if (this.viewport) {
this.viewport.scrollToIndex(0)
}
this.activeSong = null this.activeSong = null
setTimeout(() => this.tableScrolled(), 0) this.isLoadingMore = false
this.songs = [...this.searchService.groupedSongs]
}) })
)
this.subscription.push(
this.searchService.updateSearch.subscribe(() => { this.searchService.updateSearch.subscribe(() => {
setTimeout(() => this.tableScrolled(), 0) this.isLoadingMore = false
this.songs = [...this.searchService.groupedSongs]
}) })
)
} }
get songs() { onViewportScroll(): void {
return this.searchService.groupedSongs if (!this.viewport || this.router.url !== '/browse' || this.isLoadingMore) {
return
}
const viewportElement = this.viewport.elementRef.nativeElement
const scrollTop = viewportElement.scrollTop
const scrollHeight = viewportElement.scrollHeight
const clientHeight = viewportElement.clientHeight
const threshold = 100
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
this.isLoadingMore = true
this.searchService.getNextSearchPage()
}
}
trackByFn(_: number, song: ChartData[]): number {
return song[0].groupId
}
get tableRowHeight(): number {
return this.settingsService.isCompactTable ? 32 : 48
} }
hasColumn(column: string) { hasColumn(column: string) {
@@ -88,13 +121,8 @@ export class ResultTableComponent implements OnInit {
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
onResize() { onResize() {
this.tableScrolled() if (this.viewport) {
} this.viewport.checkViewportSize()
tableScrolled(): void {
const table = this.resultTableDiv.nativeElement
if (this.router.url === '/browse' && table.scrollHeight - (table.scrollTop + table.clientHeight) < 100) {
// Scrolled near the bottom of the table
this.searchService.getNextSearchPage()
} }
} }
} }