Files
immich/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts
T
midzelis f61b7a8a15 refactor(web): fixed-size scroll plane for timeline virtual scroll
Replaces the timeline's growing virtual scroll height with a fixed
500K-pixel scroll plane that recycles around an anchor month. Removes
the browser max-height ceiling and the O(N) layout cascade that ran on
every month height change.

- Months are positioned by planeTop, derived on demand by walking
  outward from the anchor in positionMonthsOnPlane.
- Soft repoint (trackAnchorToViewportTop) runs on every scroll; hard
  repoint (recenterPlane) slides the plane back toward PLANE_CENTER on
  idle or near plane edges.
- Height changes shift the anchor instead of scrollTop, fixing Safari
  momentum-scroll stutter when a viewport-top month settles.

Change-Id: I39cb61e7c4ff6cd5b0d59a7cc9c65b4e6a6a6964
2026-04-20 17:13:44 +00:00

188 lines
4.2 KiB
TypeScript

import { debounce } from 'lodash-es';
type LayoutOptions = {
headerHeight: number;
rowHeight: number;
gap: number;
};
export abstract class VirtualScrollManager {
static readonly PLANE_SIZE = 500_000;
static readonly PLANE_CENTER = 250_000;
planeHeight = $state(VirtualScrollManager.PLANE_SIZE);
#topSectionHeight = $state(0);
bodySectionHeight = $state(0);
bottomSectionHeight = $state(0);
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
get topSectionHeight() {
return this.#topSectionHeight;
}
set topSectionHeight(value: number) {
if (this.#topSectionHeight === value) {
return;
}
const oldValue = this.#topSectionHeight;
this.#topSectionHeight = value;
this.onTopSectionHeightChanged(oldValue, value);
}
protected onTopSectionHeightChanged(_oldHeight: number, _newHeight: number) {}
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
#justifiedLayoutOptions = $derived({
spacing: 2,
heightTolerance: 0.5,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(this.viewportWidth),
});
constructor() {
this.setLayoutOptions();
}
get scrollTop() {
return 0;
}
get justifiedLayoutOptions() {
return this.#justifiedLayoutOptions;
}
get maxScrollPercent() {
const totalHeight = this.totalViewerHeight;
return (totalHeight - this.viewportHeight) / totalHeight;
}
get maxScroll() {
return this.totalViewerHeight - this.viewportHeight;
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) {
return false;
}
this.#headerHeight = value;
return true;
}
get headerHeight() {
return this.#headerHeight;
}
#setGap(value: number) {
if (this.#gap == value) {
return false;
}
this.#gap = value;
return true;
}
get gap() {
return this.#gap;
}
#setRowHeight(value: number) {
if (this.#rowHeight == value) {
return false;
}
this.#rowHeight = value;
return true;
}
get rowHeight() {
return this.#rowHeight;
}
set scrolling(value: boolean) {
this.#scrolling = value;
if (value) {
this.suspendTransitions = true;
this.#resetScrolling();
}
}
get scrolling() {
return this.#scrolling;
}
set suspendTransitions(value: boolean) {
this.#suspendTransitions = value;
if (value) {
this.#resetSuspendTransitions();
}
}
get suspendTransitions() {
return this.#suspendTransitions;
}
set viewportWidth(value: number) {
const changed = value !== this.#viewportWidth;
this.#viewportWidth = value;
this.suspendTransitions = true;
void this.updateViewportGeometry(changed);
}
get viewportWidth() {
return this.#viewportWidth;
}
set viewportHeight(value: number) {
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.updateViewportGeometry(false);
}
get viewportHeight() {
return this.#viewportHeight;
}
get hasEmptyViewport() {
return this.viewportWidth === 0 || this.viewportHeight === 0;
}
protected updateViewportProximities(): void {}
protected updateViewportGeometry(_: boolean) {}
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: Partial<LayoutOptions> = {}) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
updateSlidingWindow() {
const scrollTop = this.scrollTop;
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateViewportProximities();
}
}
refreshLayout() {
this.updateViewportProximities();
}
destroy(): void {}
}