perf(14-04): RAF-throttle scroll position saves and add overflow-anchor to queue panel
- Replace 100ms debounced scroll save in cover grid with requestAnimationFrame throttling - Position now saves continuously during scrolling (~16ms) instead of only after stop - Cancel pending RAF in teardown() to prevent leaks - Add overflow-anchor: none CSS to queue panel lit-virtualizer - Keep monkey-patch for lit-virtualizer _correctScrollError with expanded comment explaining why CSS alone is insufficient
This commit is contained in:
@@ -3,9 +3,6 @@ import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
||||
import type { library } from '@go/models';
|
||||
import type { LibraryController } from '@store/controllers/library-controller';
|
||||
|
||||
import {
|
||||
SCROLL_DEBOUNCE_MS,
|
||||
} from './cover-grid-types.js';
|
||||
import type { GridEntry } from './cover-grid-types.js';
|
||||
|
||||
/**
|
||||
@@ -45,10 +42,8 @@ export class ScrollManager {
|
||||
private host: ScrollManagerHost;
|
||||
private gc: GridConstants;
|
||||
|
||||
// Scroll position debounce.
|
||||
private scrollDebounceTimer: ReturnType<
|
||||
typeof setTimeout
|
||||
> | null = null;
|
||||
// RAF-throttled scroll position saving.
|
||||
private scrollRAFId: number | null = null;
|
||||
|
||||
// Resize-aware scroll preservation.
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
@@ -157,8 +152,8 @@ export class ScrollManager {
|
||||
|
||||
/** Clean up timers and observers. */
|
||||
teardown(): void {
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
if (this.scrollRAFId !== null) {
|
||||
cancelAnimationFrame(this.scrollRAFId);
|
||||
}
|
||||
|
||||
if (this.resizeDebounceTimer !== null) {
|
||||
@@ -202,6 +197,12 @@ export class ScrollManager {
|
||||
* Save scroll position from the first visible album.
|
||||
* In split mode we use the before-entries; in single
|
||||
* mode we use the full grid entries.
|
||||
*
|
||||
* Uses requestAnimationFrame throttling: saves at most
|
||||
* once per frame (~16ms at 60fps). Unlike debouncing,
|
||||
* this captures position continuously during scrolling
|
||||
* (not just after it stops) and naturally aligns with
|
||||
* the browser's paint cycle.
|
||||
*/
|
||||
onVisibilityChanged(
|
||||
first: number,
|
||||
@@ -209,21 +210,23 @@ export class ScrollManager {
|
||||
): void {
|
||||
if (this.isResizing) return;
|
||||
|
||||
if (this.scrollDebounceTimer !== null) {
|
||||
clearTimeout(this.scrollDebounceTimer);
|
||||
}
|
||||
if (this.scrollRAFId !== null) return;
|
||||
|
||||
this.scrollDebounceTimer = setTimeout(() => {
|
||||
const entries = getEntries();
|
||||
const entry = entries[first];
|
||||
this.scrollRAFId = requestAnimationFrame(
|
||||
() => {
|
||||
this.scrollRAFId = null;
|
||||
|
||||
if (entry) {
|
||||
this.host.libraryCtrl.setScrollPosition(
|
||||
'albums',
|
||||
entry.albumIndex,
|
||||
);
|
||||
}
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
const entries = getEntries();
|
||||
const entry = entries[first];
|
||||
|
||||
if (entry) {
|
||||
this.host.libraryCtrl.setScrollPosition(
|
||||
'albums',
|
||||
entry.albumIndex,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
||||
@@ -303,6 +303,7 @@ export class QueuePanel
|
||||
overflow-y: auto;
|
||||
contain: paint;
|
||||
will-change: transform;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
@@ -490,6 +491,12 @@ export class QueuePanel
|
||||
// scrollTo() to "correct" sub-pixel estimation errors, which fights the
|
||||
// browser's native scrollbar drag gesture and causes the thumb to
|
||||
// desync from the mouse on large lists (20k+ items).
|
||||
//
|
||||
// NOTE: CSS `overflow-anchor: none` (set on lit-virtualizer above)
|
||||
// disables the *browser's* native scroll anchoring, but does NOT
|
||||
// affect lit-virtualizer's own _correctScrollError() method which
|
||||
// calls scrollTo() internally. This monkey-patch is still needed
|
||||
// to suppress that internal correction during scrollbar drag.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const virt = (this.virtualizer as any)?.[virtualizerRef];
|
||||
if (virt) {
|
||||
@@ -509,6 +516,17 @@ export class QueuePanel
|
||||
'mousedown',
|
||||
this.onVirtualizerMouseDown,
|
||||
);
|
||||
|
||||
// Event delegation: attach stable handlers to the virtualizer
|
||||
// so renderTrackItem creates zero per-item closures.
|
||||
const virtEl = this.virtualizer;
|
||||
if (virtEl) {
|
||||
virtEl.addEventListener('click', this.onDelegatedClick);
|
||||
virtEl.addEventListener('dblclick', this.onDelegatedDblClick);
|
||||
virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
|
||||
virtEl.addEventListener('dragend', this.onTrackDragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
@@ -581,6 +599,16 @@ export class QueuePanel
|
||||
'mousedown',
|
||||
this.onVirtualizerMouseDown,
|
||||
);
|
||||
|
||||
// Remove delegated event handlers from virtualizer.
|
||||
const virtEl = this.virtualizer;
|
||||
if (virtEl) {
|
||||
virtEl.removeEventListener('click', this.onDelegatedClick);
|
||||
virtEl.removeEventListener('dblclick', this.onDelegatedDblClick);
|
||||
virtEl.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
|
||||
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
@@ -655,6 +683,70 @@ export class QueuePanel
|
||||
this.closePlaylistPicker();
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Delegated event handlers (stable references, zero per-item closures)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Walk up from the event target to find the nearest
|
||||
* `.track-item` and extract the index via `data-index`.
|
||||
*/
|
||||
private resolveTrackIndexFromEvent(
|
||||
e: Event,
|
||||
): number | null {
|
||||
const row = (e.target as HTMLElement).closest(
|
||||
'.track-item',
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const idx = Number(row.dataset.index);
|
||||
|
||||
if (Number.isNaN(idx)) return null;
|
||||
|
||||
return idx;
|
||||
}
|
||||
|
||||
private onDelegatedClick = (e: MouseEvent) => {
|
||||
const idx = this.resolveTrackIndexFromEvent(e);
|
||||
|
||||
if (idx === null) return;
|
||||
|
||||
// Check if click was on the remove button
|
||||
const removeBtn = (e.target as HTMLElement).closest(
|
||||
'.remove-button',
|
||||
);
|
||||
|
||||
if (removeBtn) {
|
||||
e.stopPropagation();
|
||||
this.queue.removeFromQueue(idx);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const track = this.queue.tracks[idx];
|
||||
|
||||
if (track) this.handleTrackClick(e, track, idx);
|
||||
};
|
||||
|
||||
private onDelegatedDblClick = (e: MouseEvent) => {
|
||||
const idx = this.resolveTrackIndexFromEvent(e);
|
||||
|
||||
if (idx !== null) this.handleTrackDblClick(idx);
|
||||
};
|
||||
|
||||
private onDelegatedContextMenu = (e: MouseEvent) => {
|
||||
const idx = this.resolveTrackIndexFromEvent(e);
|
||||
|
||||
if (idx !== null) this.handleTrackContextMenu(e, idx);
|
||||
};
|
||||
|
||||
private onDelegatedDragStart = (e: DragEvent) => {
|
||||
const idx = this.resolveTrackIndexFromEvent(e);
|
||||
|
||||
if (idx !== null) this.onTrackDragStart(e, idx);
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Selection & click handlers
|
||||
// =================================================================
|
||||
|
||||
Reference in New Issue
Block a user