refactor(web): Extract VirtualScrollManager base class from TimelineManager (#23017)
Some checks failed
CLI Build / CLI Publish (push) Has been cancelled
CLI Build / Docker (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Docker / pre-job (push) Has been cancelled
Docker / Re-Tag ML (push) Has been cancelled
Docker / Re-Tag Server (push) Has been cancelled
Docker / Build and Push ML (push) Has been cancelled
Docker / Build and Push Server (push) Has been cancelled
Docker / Docker Build & Push Server Success (push) Has been cancelled
Docker / Docker Build & Push ML Success (push) Has been cancelled
Docs build / pre-job (push) Has been cancelled
Docs build / Docs Build (push) Has been cancelled
Zizmor / Zizmor (push) Has been cancelled
Static Code Analysis / pre-job (push) Has been cancelled
Static Code Analysis / Run Dart Code Analysis (push) Has been cancelled
Test / pre-job (push) Has been cancelled
Test / Test & Lint Server (push) Has been cancelled
Test / Unit Test CLI (push) Has been cancelled
Test / Unit Test CLI (Windows) (push) Has been cancelled
Test / Lint Web (push) Has been cancelled
Test / Test Web (push) Has been cancelled
Test / Test i18n (push) Has been cancelled
Test / End-to-End Lint (push) Has been cancelled
Test / Medium Tests (Server) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (push) Has been cancelled
Test / End-to-End Tests (Web) (push) Has been cancelled
Test / End-to-End Tests Success (push) Has been cancelled
Test / Unit Test Mobile (push) Has been cancelled
Test / Unit Test ML (push) Has been cancelled
Test / .github Files Formatting (push) Has been cancelled
Test / ShellCheck (push) Has been cancelled
Test / OpenAPI Clients (push) Has been cancelled
Test / SQL Schema Checks (push) Has been cancelled

Extract common virtual scrolling functionality from TimelineManager into
a new abstract VirtualScrollManager base class. This refactoring improves
code organization and enables reuse of virtual scrolling logic.

Changes:
- Create new VirtualScrollManager abstract base class with common virtual
  scrolling state and methods
- Refactor TimelineManager to extend VirtualScrollManager
- Rename 'assetsHeight' to 'bodySectionHeight' for semantic clarity
- Convert methods to use override keyword where appropriate
- Enable noImplicitOverride in tsconfig for better type safety
- Fix ApiError and AbortError class definitions with override keywords
This commit is contained in:
Min Idzelis 2025-10-17 13:37:54 -04:00 committed by GitHub
parent e7d6a066f8
commit 3174a27902
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 215 additions and 173 deletions

View file

@ -250,7 +250,7 @@
scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent); scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent);
} else if (leadOut) { } else if (leadOut) {
scrollToSegmentPercentage( scrollToSegmentPercentage(
timelineManager.topSectionHeight + timelineManager.assetsHeight, timelineManager.topSectionHeight + timelineManager.bodySectionHeight,
timelineManager.bottomSectionHeight, timelineManager.bottomSectionHeight,
scrubberMonthScrollPercent, scrubberMonthScrollPercent,
); );
@ -611,7 +611,7 @@
style:position="absolute" style:position="absolute"
style:left="0" style:left="0"
style:right="0" style:right="0"
style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.assetsHeight}px,0)`} style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`}
></div> ></div>
</section> </section>
</section> </section>

View file

@ -0,0 +1,168 @@
import { debounce } from 'lodash-es';
type LayoutOptions = {
headerHeight: number;
rowHeight: number;
gap: number;
};
export abstract class VirtualScrollManager {
topSectionHeight = $state(0);
bodySectionHeight = $state(0);
bottomSectionHeight = $state(0);
totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight);
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.by(() => ({
spacing: 2,
heightTolerance: 0.15,
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 updateIntersections(): 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.updateIntersections();
}
}
refreshLayout() {
this.updateIntersections();
}
destroy(): void {}
}

View file

@ -28,7 +28,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
let dayGroupRow = 0; let dayGroupRow = 0;
let dayGroupCol = 0; let dayGroupCol = 0;
const options = timelineManager.createLayoutOptions(); const options = timelineManager.justifiedLayoutOptions;
for (const dayGroup of month.dayGroups) { for (const dayGroup of month.dayGroups) {
dayGroup.layout(options, noDefer); dayGroup.layout(options, noDefer);

View file

@ -1,13 +1,5 @@
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
@ -24,6 +16,11 @@ import {
retrieveRange as retrieveRangeUtil, retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte'; } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte'; import { MonthGroup } from './month-group.svelte';
@ -33,7 +30,6 @@ import type {
Direction, Direction,
ScrubberMonth, ScrubberMonth,
TimelineAsset, TimelineAsset,
TimelineManagerLayoutOptions,
TimelineManagerOptions, TimelineManagerOptions,
Viewport, Viewport,
} from './types'; } from './types';
@ -45,28 +41,32 @@ type ViewportTopMonthIntersection = {
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom) // Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
monthBottomViewportRatio: number; monthBottomViewportRatio: number;
}; };
export class TimelineManager { export class TimelineManager extends VirtualScrollManager {
override bottomSectionHeight = $state(60);
override bodySectionHeight = $derived.by(() => {
let height = 0;
for (const month of this.months) {
height += month.height;
}
return height;
});
assetCount = $derived.by(() => {
let count = 0;
for (const month of this.months) {
count += month.assetsCount;
}
return count;
});
isInitialized = $state(false); isInitialized = $state(false);
months: MonthGroup[] = $state([]); months: MonthGroup[] = $state([]);
topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
assetsHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0));
totalViewerHeight = $derived(this.topSectionHeight + this.assetsHeight + this.bottomSectionHeight);
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
albumAssets: Set<string> = new SvelteSet(); albumAssets: Set<string> = new SvelteSet();
scrubberMonths: ScrubberMonth[] = $state([]); scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0); scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined; viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
limitedScroll = $derived(this.maxScrollPercent < 0.5); limitedScroll = $derived(this.maxScrollPercent < 0.5);
initTask = new CancellableTask( initTask = new CancellableTask(
() => { () => {
this.isInitialized = true; this.isInitialized = true;
@ -83,32 +83,17 @@ export class TimelineManager {
); );
static #INIT_OPTIONS = {}; static #INIT_OPTIONS = {};
#viewportHeight = $state(0);
#viewportWidth = $state(0);
#scrollTop = $state(0);
#websocketSupport: WebsocketSupport | undefined; #websocketSupport: WebsocketSupport | undefined;
#rowHeight = $state(235);
#headerHeight = $state(48);
#gap = $state(12);
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#scrolling = $state(false);
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
#updatingIntersections = false; #updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state(); #scrollableElement: HTMLElement | undefined = $state();
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) { constructor() {
let changed = false; super();
changed ||= this.#setHeaderHeight(headerHeight); }
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight); override get scrollTop(): number {
if (changed) { return this.#scrollableElement?.scrollTop ?? 0;
this.refreshLayout();
}
} }
set scrollableElement(element: HTMLElement | undefined) { set scrollableElement(element: HTMLElement | undefined) {
@ -125,87 +110,6 @@ export class TimelineManager {
this.updateSlidingWindow(); this.updateSlidingWindow();
} }
#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;
this.#updateViewportGeometry(changed);
this.updateSlidingWindow();
}
get viewportWidth() {
return this.#viewportWidth;
}
set viewportHeight(value: number) {
this.#viewportHeight = value;
this.#suspendTransitions = true;
void this.#updateViewportGeometry(false);
}
get viewportHeight() {
return this.#viewportHeight;
}
async *assetsIterator(options?: { async *assetsIterator(options?: {
startMonthGroup?: MonthGroup; startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup; startDayGroup?: DayGroup;
@ -251,14 +155,6 @@ export class TimelineManager {
this.#websocketSupport = undefined; this.#websocketSupport = undefined;
} }
updateSlidingWindow() {
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
}
}
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) { #calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
if (!month) { if (!month) {
return 0; return 0;
@ -276,7 +172,7 @@ export class TimelineManager {
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1); return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
} }
updateIntersections() { override updateIntersections() {
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return; return;
} }
@ -325,7 +221,7 @@ export class TimelineManager {
); );
}); });
this.albumAssets.clear(); this.albumAssets.clear();
this.#updateViewportGeometry(false); this.updateViewportGeometry(false);
} }
async updateOptions(options: TimelineManagerOptions) { async updateOptions(options: TimelineManagerOptions) {
@ -337,7 +233,7 @@ export class TimelineManager {
} }
await this.initTask.reset(); await this.initTask.reset();
await this.#init(options); await this.#init(options);
this.#updateViewportGeometry(false); this.updateViewportGeometry(false);
} }
async #init(options: TimelineManagerOptions) { async #init(options: TimelineManagerOptions) {
@ -350,9 +246,10 @@ export class TimelineManager {
}, true); }, true);
} }
public destroy() { public override destroy() {
this.disconnect(); this.disconnect();
this.isInitialized = false; this.isInitialized = false;
super.destroy();
} }
async updateViewport(viewport: Viewport) { async updateViewport(viewport: Viewport) {
@ -371,14 +268,11 @@ export class TimelineManager {
const changedWidth = viewport.width !== this.viewportWidth; const changedWidth = viewport.width !== this.viewportWidth;
this.viewportHeight = viewport.height; this.viewportHeight = viewport.height;
this.viewportWidth = viewport.width; this.viewportWidth = viewport.width;
this.#updateViewportGeometry(changedWidth); this.updateViewportGeometry(changedWidth);
} }
#updateViewportGeometry(changedWidth: boolean) { protected override updateViewportGeometry(changedWidth: boolean) {
if (!this.isInitialized) { if (!this.isInitialized || this.hasEmptyViewport) {
return;
}
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
return; return;
} }
for (const month of this.months) { for (const month of this.months) {
@ -401,27 +295,6 @@ export class TimelineManager {
this.scrubberTimelineHeight = this.totalViewerHeight; this.scrubberTimelineHeight = this.totalViewerHeight;
} }
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
get maxScrollPercent() {
const totalHeight = this.totalViewerHeight;
const max = (totalHeight - this.viewportHeight) / totalHeight;
return max;
}
get maxScroll() {
return this.totalViewerHeight - this.viewportHeight;
}
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> { async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true; let cancelable = true;
if (options) { if (options) {
@ -559,7 +432,7 @@ export class TimelineManager {
return [...unprocessedIds]; return [...unprocessedIds];
} }
refreshLayout() { override refreshLayout() {
for (const month of this.months) { for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true }); updateGeometry(this, month, { invalidateHeight: true });
} }

View file

@ -60,14 +60,14 @@ interface UploadRequestOptions {
} }
export class AbortError extends Error { export class AbortError extends Error {
name = 'AbortError'; override name = 'AbortError';
} }
class ApiError extends Error { class ApiError extends Error {
name = 'ApiError'; override name = 'ApiError';
constructor( constructor(
public message: string, public override message: string,
public statusCode: number, public statusCode: number,
public details: string, public details: string,
) { ) {

View file

@ -6,6 +6,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "es2022", "module": "es2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"noImplicitOverride": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,