cover grid refactor

-split component into several files
This commit is contained in:
2026-02-24 09:49:34 -05:00
parent 352fa9da61
commit 1221a403cf
13 changed files with 3055 additions and 3193 deletions
@@ -0,0 +1,907 @@
import type { LitElement } from 'lit';
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';
/**
* Grid spacing constants shared between the scroll
* manager and the host component.
*/
export interface GridConstants {
readonly GRID_GAP: number;
readonly GRID_PADDING: number;
}
/**
* Read-only interface into the cover-grid component
* that the scroll manager needs.
*/
export interface ScrollManagerHost extends LitElement {
readonly libraryCtrl: LibraryController;
readonly cachedFilteredAlbums: library.Album[];
readonly expandedAlbumId: number | null;
readonly expandedTracks: library.Track[];
readonly splitMode: boolean;
readonly splitIndex: number;
readonly cardWidth: number;
readonly cardHeight: number;
}
/**
* Manages scroll position persistence, resize-aware
* scroll preservation, transition overlays, and
* split/single mode geometry for the cover grid.
*
* This is a plain class (not a ReactiveController)
* because scroll management is imperative and async,
* not reactive.
*/
export class ScrollManager {
private host: ScrollManagerHost;
private gc: GridConstants;
// Scroll position debounce.
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
// Resize-aware scroll preservation.
private resizeObserver: ResizeObserver | null = null;
private resizeDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
private pendingFocus: {
albumIndex: number;
viewportOffset: number;
} | null = null;
private currentColumnCount = 0;
/** True while a resize reflow is in progress. */
isResizing = false;
// Scroll restoration across single/split mode
// transitions.
savedScrollTop = 0;
needsScrollRestore = false;
showDropdownAfterRestore = false;
/**
* Monotonically increasing counter used to cancel
* stale scroll-restore async blocks.
*/
private scrollRestoreGeneration = 0;
/**
* Set to the generation value when an async
* scroll-restore block finishes or is cancelled.
*/
private scrollRestoreResolved = 0;
/**
* When switching albums, the pixel distance from
* the newly-expanded album's top edge to the
* viewport top.
*/
savedAlbumViewportOffset: number | null = null;
/** Overlay element showing the old grid state
* while a mode transition is in flight. */
private transitionOverlay: HTMLDivElement | null =
null;
/** Cached index of the expanded album in the
* filtered list. -1 when no album is expanded
* or the album isn't in the filtered list. */
private expandedAlbumIndex = -1;
/** The expanded album ID that corresponds to the
* cached index. Used to detect invalidation. */
private expandedAlbumIndexId: number | null = null;
/** The filtered-albums reference used to compute
* the cached index. Used to detect invalidation. */
private expandedAlbumIndexAlbums:
library.Album[] = [];
constructor(
host: ScrollManagerHost,
gc: GridConstants,
) {
this.host = host;
this.gc = gc;
}
// ================================================================
// Expanded album index cache (improvement 6c)
// ================================================================
/**
* Return the index of the expanded album in the
* filtered list. Cached and invalidated when
* `expandedAlbumId` or `cachedFilteredAlbums`
* changes.
*/
getExpandedAlbumIndex(): number {
const id = this.host.expandedAlbumId;
const albums = this.host.cachedFilteredAlbums;
if (
id === this.expandedAlbumIndexId &&
albums === this.expandedAlbumIndexAlbums
) {
return this.expandedAlbumIndex;
}
this.expandedAlbumIndexId = id;
this.expandedAlbumIndexAlbums = albums;
if (id === null) {
this.expandedAlbumIndex = -1;
} else {
this.expandedAlbumIndex = albums.findIndex(
(a) => a.ID === id,
);
}
return this.expandedAlbumIndex;
}
// ================================================================
// Lifecycle
// ================================================================
/** Clean up timers and observers. */
teardown(): void {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
if (this.resizeDebounceTimer !== null) {
clearTimeout(this.resizeDebounceTimer);
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.removeOverlay();
}
// ================================================================
// Scroll position (index-based)
// ================================================================
/**
* Restore scroll position from the library store
* after initial album load.
*/
restoreScrollPosition(
virtualizer: LitVirtualizer | undefined,
): void {
const saved =
this.host.libraryCtrl.getScrollPosition(
'albums',
);
if (saved <= 0 || !virtualizer) return;
const safeIndex = Math.min(
saved,
this.host.cachedFilteredAlbums.length - 1,
);
if (safeIndex <= 0) return;
virtualizer.scrollToIndex(safeIndex, 'start');
}
/**
* 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.
*/
onVisibilityChanged(
first: number,
getEntries: () => GridEntry[],
): void {
if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => {
const entries = getEntries();
const entry = entries[first];
if (entry) {
this.host.libraryCtrl.setScrollPosition(
'albums',
entry.albumIndex,
);
}
}, SCROLL_DEBOUNCE_MS);
}
// ================================================================
// Resize-aware scroll preservation
// ================================================================
/**
* Set up a ResizeObserver on the scroll container
* to preserve scroll position across width changes.
*/
setupResizeObserver(
container: HTMLElement,
onSplitResize: () => Promise<void>,
): void {
// Guard against stacked observers.
this.resizeObserver?.disconnect();
this.currentColumnCount =
this.getColumnCount(container);
const restoreScroll = () => {
const pending = this.pendingFocus;
this.pendingFocus = null;
this.isResizing = false;
if (!pending) return;
const newColumns =
this.getColumnCount(container);
this.currentColumnCount = newColumns;
// If a dropdown is open, delegate to the
// host for split recomputation.
if (
this.host.splitMode &&
this.host.expandedAlbumId !== null
) {
void onSplitResize();
return;
}
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const rowStep =
this.host.cardHeight + gap;
const newRow = Math.floor(
pending.albumIndex / newColumns,
);
const newY = pad + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
};
this.resizeObserver = new ResizeObserver(
() => {
const rowStep =
this.host.cardHeight +
this.gc.GRID_GAP;
if (this.pendingFocus === null) {
this.isResizing = true;
this.captureFocusPoint(
container,
rowStep,
);
}
const newColumns =
this.getColumnCount(container);
if (
newColumns !==
this.currentColumnCount
) {
if (
this.resizeDebounceTimer !==
null
) {
clearTimeout(
this.resizeDebounceTimer,
);
this.resizeDebounceTimer =
null;
}
restoreScroll();
return;
}
if (
this.resizeDebounceTimer !== null
) {
clearTimeout(
this.resizeDebounceTimer,
);
}
this.resizeDebounceTimer = setTimeout(
restoreScroll,
100,
);
},
);
this.resizeObserver.observe(container);
}
/**
* Determine the focus point for scroll restoration.
*/
private captureFocusPoint(
container: HTMLElement,
rowStep: number,
): void {
const pad = this.gc.GRID_PADDING;
const cols = this.currentColumnCount;
const filtered =
this.host.cachedFilteredAlbums;
// Prefer the expanded album as focus.
if (this.host.expandedAlbumId !== null) {
const idx = this.getExpandedAlbumIndex();
if (idx >= 0) {
const albumRow = Math.floor(
idx / cols,
);
const albumY =
pad + albumRow * rowStep;
this.pendingFocus = {
albumIndex: idx,
viewportOffset:
albumY - container.scrollTop,
};
return;
}
}
const centerY =
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - pad) / rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
Math.max(0, filtered.length - 1),
);
const albumY = pad + centerRow * rowStep;
this.pendingFocus = {
albumIndex,
viewportOffset:
albumY - container.scrollTop,
};
}
// ================================================================
// Column count / geometry helpers
// ================================================================
/**
* Compute the number of columns that fit in the
* given container.
*/
getColumnCount(
container?: HTMLElement,
): number {
if (!container) return 1;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const availableWidth =
container.clientWidth - pad * 2;
return Math.max(
1,
Math.floor(
(availableWidth + gap) /
(this.host.cardWidth + gap),
),
);
}
/** Container width in pixels. */
getContainerWidth(
container?: HTMLElement,
): number {
return container?.clientWidth ?? 800;
}
/**
* Width of the album row (left of leftmost card to
* right of rightmost card).
*/
getGridRowWidth(
container?: HTMLElement,
): number {
const cols = this.getColumnCount(container);
const gap = this.gc.GRID_GAP;
return (
cols * this.host.cardWidth +
(cols - 1) * gap
);
}
/**
* Horizontal offset of the carat so it points at
* the center of the expanded album card.
*/
getCaratOffset(
container?: HTMLElement,
): number {
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return 0;
const cols = this.getColumnCount(container);
const colIndex = idx % cols;
const gap = this.gc.GRID_GAP;
return (
colIndex *
(this.host.cardWidth + gap) +
this.host.cardWidth / 2
);
}
// ================================================================
// Split-mode helpers
// ================================================================
/**
* Compute the split point and return it. The
* component assigns this to its `splitIndex` state.
*/
computeSplitIndex(
container?: HTMLElement,
): number {
const filtered =
this.host.cachedFilteredAlbums;
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return filtered.length;
const columns =
this.getColumnCount(container);
return Math.min(
(Math.floor(idx / columns) + 1) * columns,
filtered.length,
);
}
// ================================================================
// Transition overlay
// ================================================================
/**
* Capture the current scroll container as a static
* overlay.
*/
captureOverlay(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): void {
if (!container || this.transitionOverlay) {
return;
}
const scrollY = container.scrollTop;
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute;inset:0;z-index:10;' +
'overflow:hidden;pointer-events:none;';
const inner = document.createElement('div');
inner.style.cssText =
'position:relative;height:100%;' +
'pointer-events:none;';
for (const child of Array.from(
container.childNodes,
)) {
inner.appendChild(child.cloneNode(true));
}
inner.style.transform =
`translateY(-${scrollY}px)`;
overlay.appendChild(inner);
shadowRoot?.appendChild(overlay);
this.transitionOverlay = overlay;
container.style.visibility = 'hidden';
}
/**
* Remove the snapshot overlay and reveal the real
* scroll container.
*/
removeOverlay(): void {
if (this.transitionOverlay) {
this.transitionOverlay.remove();
this.transitionOverlay = null;
}
}
/**
* Reveal the real scroll container (call separately
* when the overlay has already been removed or was
* never created).
*/
revealContainer(
container: HTMLElement | undefined,
): void {
if (container) {
container.style.visibility = '';
}
}
// ================================================================
// Dropdown scroll positioning
// ================================================================
/**
* Wait for the "before" virtualizer to finish its
* layout pass.
*/
async awaitBeforeLayout(
shadowRoot: ShadowRoot | null,
): Promise<void> {
const virt = shadowRoot?.querySelector(
'#grid-before',
) as LitVirtualizer | null;
await virt?.layoutComplete;
}
/**
* Return the current scrollTop converted to
* single-mode (dropdown-free) coordinates.
*/
computeAdjustedScrollTop(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): number {
if (!container) return 0;
const raw = container.scrollTop;
if (!this.host.splitMode) return raw;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const beforeRows = Math.ceil(
this.host.splitIndex / columns,
);
const dropdownTop =
pad + beforeRows * rowStep;
if (raw <= dropdownTop) return raw;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
const dropdownHeight =
(dropdown as HTMLElement)?.offsetHeight ??
0;
return raw - dropdownHeight;
}
/**
* Set scrollTop on the scroll container with
* retry logic for virtualizer expansion.
*/
async restoreScrollTop(
container: HTMLElement | undefined,
target: number,
): Promise<void> {
if (!container) return;
const maxAttempts = 10;
for (let i = 0; i < maxAttempts; i++) {
container.scrollTop = target;
if (
container.scrollTop >= target ||
target <= 0
) {
return;
}
await new Promise<void>((r) =>
requestAnimationFrame(() => r()),
);
}
console.warn(
'[restoreScrollTop] gave up after max attempts',
{
target,
actual: container.scrollTop,
scrollHeight: container.scrollHeight,
},
);
}
/**
* Scroll the container so the expanded album card
* and its dropdown are visible with minimal movement.
*/
async scrollToShowDropdown(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): Promise<void> {
if (
!container ||
this.host.expandedAlbumId === null
) {
return;
}
const expandedIndex =
this.getExpandedAlbumIndex();
if (expandedIndex < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const albumRow = Math.floor(
expandedIndex / columns,
);
const albumTop =
pad + albumRow * rowStep - gap / 2;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
if (!dropdown) return;
await (dropdown as LitElement).updateComplete;
const beforeRows = Math.ceil(
this.host.splitIndex / columns,
);
const dropdownTop =
pad + beforeRows * rowStep;
const dropdownBottom =
dropdownTop +
(dropdown as HTMLElement).offsetHeight;
const viewTop = container.scrollTop;
const viewHeight = container.clientHeight;
const minScroll = dropdownBottom - viewHeight;
const maxScroll = albumTop;
let newScrollTop: number;
if (minScroll <= maxScroll) {
newScrollTop = Math.max(
minScroll,
Math.min(viewTop, maxScroll),
);
} else {
newScrollTop = albumTop;
}
if (newScrollTop !== viewTop) {
await this.restoreScrollTop(
container,
newScrollTop,
);
}
}
// ================================================================
// willUpdate / updated helpers
//
// Called from the component's lifecycle methods to
// compute scroll-related state transitions.
// ================================================================
/**
* Check whether a scroll-restore async block is
* currently in flight.
*/
get restoreInFlight(): boolean {
return (
this.scrollRestoreGeneration >
this.scrollRestoreResolved
);
}
/**
* Prepare the anchor capture for an exit-split
* transition when switching albums (not closing).
* Records the viewport offset of the newly-expanded
* album in the old split layout.
*/
captureAnchorOffset(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): void {
if (this.host.expandedAlbumId === null) {
this.savedAlbumViewportOffset = null;
return;
}
const rawScrollTop =
container?.scrollTop ?? 0;
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const cols =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const row = Math.floor(idx / cols);
const albumY = pad + row * rowStep;
const oldBeforeRows = Math.ceil(
this.host.splitIndex / cols,
);
const oldDropdownTop =
pad + oldBeforeRows * rowStep;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
const oldDropdownHeight =
(dropdown as HTMLElement)?.offsetHeight ??
0;
const albumYOldSplit =
albumY >= oldDropdownTop
? albumY + oldDropdownHeight
: albumY;
this.savedAlbumViewportOffset =
albumYOldSplit - rawScrollTop;
}
/**
* Run the async scroll-restore sequence from the
* component's `updated()` callback.
*/
runScrollRestore(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
expandedAlbumId: number | null,
updateComplete: Promise<boolean>,
): void {
this.needsScrollRestore = false;
const saved = this.savedScrollTop;
const showDropdown =
this.showDropdownAfterRestore;
const switching =
!showDropdown &&
expandedAlbumId !== null;
const gen = ++this.scrollRestoreGeneration;
void (async () => {
await updateComplete;
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
await this.restoreScrollTop(
container,
saved,
);
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
if (showDropdown) {
if (
this.savedAlbumViewportOffset !==
null &&
expandedAlbumId !== null
) {
const idx =
this.getExpandedAlbumIndex();
if (idx >= 0) {
const gap = this.gc.GRID_GAP;
const pad =
this.gc.GRID_PADDING;
const cols =
this.getColumnCount(
container,
);
const rowStep =
this.host.cardHeight +
gap;
const row = Math.floor(
idx / cols,
);
const albumY =
pad + row * rowStep;
const anchor =
albumY -
this
.savedAlbumViewportOffset!;
await this.restoreScrollTop(
container,
anchor,
);
}
this.savedAlbumViewportOffset =
null;
}
if (
gen !==
this.scrollRestoreGeneration
) {
this.scrollRestoreResolved = gen;
return;
}
await this.scrollToShowDropdown(
container,
shadowRoot,
);
}
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
if (!switching) {
this.removeOverlay();
this.revealContainer(container);
}
this.scrollRestoreResolved = gen;
})();
}
}