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": {
"@angular/animations": "19.2.1",
"@angular/cdk": "^19.2.19",
"@angular/common": "19.2.1",
"@angular/compiler": "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 { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -38,9 +40,11 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
],
bootstrap: [AppComponent], imports: [
BrowserModule,
CommonModule,
AppRoutingModule,
FormsModule,
ReactiveFormsModule,
ScrollingModule,
], providers: [provideHttpClient(withInterceptorsFromDi())],
})
export class AppModule { }

View File

@@ -1,51 +1,66 @@
<div
#resultTableDiv
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"
(scroll)="tableScrolled()">
<table id="resultTable" class="table table-zebra table-pin-rows" [class.table-xs]="settingsService.isCompactTable">
<thead>
<tr>
<th class="collapsing" id="checkboxColumn">
<input type="checkbox" class="checkbox" [(ngModel)]="allSelected" />
</th>
<th [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('name')">
Name <i *ngIf="sortColumn === 'name'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('artist')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('artist')">
Artist
<i *ngIf="sortColumn === 'artist'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('album')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('album')">
Album <i *ngIf="sortColumn === 'album'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('genre')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('genre')">
Genre <i *ngIf="sortColumn === 'genre'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('year')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('year')">
Year <i *ngIf="sortColumn === 'year'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('charter')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('charter')">
Charter <i *ngIf="sortColumn === 'charter'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('length')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('length')">
Length (min) <i *ngIf="sortColumn === 'length'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('difficulty')" [ngClass]="sortDirection" class="cursor-pointer">Difficulty</th>
<th *ngIf="hasColumn('uploaded')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('modifiedTime')">
Upload Date <i *ngIf="sortColumn === 'modifiedTime'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
</tr>
</thead>
<tbody>
@for (song of songs; track song) {
<div class="flex-1 flex flex-col overflow-hidden">
<div class="flex-shrink-0">
<table class="table table-zebra w-full" [class.table-xs]="settingsService.isCompactTable" style="table-layout: fixed">
<thead class="bg-base-100">
<ng-container *ngTemplateOutlet="headerTemplate; context: { isVisible: true }"></ng-container>
</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>
</tbody>
</table>
</cdk-virtual-scroll-viewport>
</div>
<ng-template #headerTemplate>
<tr>
<th class="collapsing" id="checkboxColumn">
<input type="checkbox" class="checkbox" [(ngModel)]="allSelected" />
</th>
<th [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('name')">
Name <i *ngIf="sortColumn === 'name'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('artist')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('artist')">
Artist
<i *ngIf="sortColumn === 'artist'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('album')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('album')">
Album <i *ngIf="sortColumn === 'album'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('genre')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('genre')">
Genre <i *ngIf="sortColumn === 'genre'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('year')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('year')">
Year <i *ngIf="sortColumn === 'year'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('charter')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('charter')">
Charter <i *ngIf="sortColumn === 'charter'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('length')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('length')">
Length (min) <i *ngIf="sortColumn === 'length'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
<th *ngIf="hasColumn('difficulty')" [ngClass]="sortDirection" class="cursor-pointer">Difficulty</th>
<th *ngIf="hasColumn('uploaded')" [ngClass]="sortDirection" class="cursor-pointer" (click)="onColClicked('modifiedTime')">
Upload Date <i *ngIf="sortColumn === 'modifiedTime'" class="bi bi-caret-{{ sortDirection === 'asc' ? 'down' : 'up' }}-fill"></i>
</th>
</tr>
</ng-template>

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 { Subscription } from 'rxjs'
import { SettingsService } from 'src-angular/app/core/services/settings.service'
import { ChartData } from 'src-shared/interfaces/search.interface'
import { SearchService } from '../../../core/services/search.service'
import { SelectionService } from '../../../core/services/selection.service'
import { ResultTableRowComponent } from './result-table-row/result-table-row.component'
@Component({
selector: 'app-result-table',
@@ -18,12 +19,14 @@ export class ResultTableComponent implements OnInit {
@Output() rowClicked = new EventEmitter<ChartData[]>()
@ViewChild('resultTableDiv', { static: true }) resultTableDiv: ElementRef
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
@ViewChild('viewport', { static: true }) viewport: CdkVirtualScrollViewport
activeSong: ChartData[] | null = null
sortDirection: 'asc' | 'desc' = 'asc'
sortColumn: 'name' | 'artist' | 'album' | 'genre' | 'year' | 'charter' | 'length' | 'modifiedTime' | null = null
isLoadingMore = false
songs: ChartData[][] = []
subscription: Subscription[] = []
constructor(
public searchService: SearchService,
@@ -33,18 +36,48 @@ export class ResultTableComponent implements OnInit {
) { }
ngOnInit() {
this.searchService.newSearch.subscribe(() => {
this.resultTableDiv.nativeElement.scrollTop = 0
this.activeSong = null
setTimeout(() => this.tableScrolled(), 0)
})
this.searchService.updateSearch.subscribe(() => {
setTimeout(() => this.tableScrolled(), 0)
})
this.subscription.push(
this.searchService.newSearch.subscribe(() => {
if (this.viewport) {
this.viewport.scrollToIndex(0)
}
this.activeSong = null
this.isLoadingMore = false
this.songs = [...this.searchService.groupedSongs]
})
)
this.subscription.push(
this.searchService.updateSearch.subscribe(() => {
this.isLoadingMore = false
this.songs = [...this.searchService.groupedSongs]
})
)
}
get songs() {
return this.searchService.groupedSongs
onViewportScroll(): void {
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) {
@@ -88,13 +121,8 @@ export class ResultTableComponent implements OnInit {
@HostListener('window:resize', ['$event'])
onResize() {
this.tableScrolled()
}
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()
if (this.viewport) {
this.viewport.checkViewportSize()
}
}
}