diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 0b99e55fa5..76f360a8c0 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -250,7 +250,7 @@ scrollToSegmentPercentage(0, timelineManager.topSectionHeight, scrubberMonthScrollPercent); } else if (leadOut) { scrollToSegmentPercentage( - timelineManager.topSectionHeight + timelineManager.assetsHeight, + timelineManager.topSectionHeight + timelineManager.bodySectionHeight, timelineManager.bottomSectionHeight, scrubberMonthScrollPercent, ); @@ -611,7 +611,7 @@ style:position="absolute" style:left="0" style:right="0" - style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.assetsHeight}px,0)`} + style:transform={`translate3d(0,${timelineManager.topSectionHeight + timelineManager.bodySectionHeight}px,0)`} > diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts new file mode 100644 index 0000000000..c5f48f735a --- /dev/null +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -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 = {}) { + 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 {} +} diff --git a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts index 9ba09c5a45..434b2d1847 100644 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts @@ -28,7 +28,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG let dayGroupRow = 0; let dayGroupCol = 0; - const options = timelineManager.createLayoutOptions(); + const options = timelineManager.justifiedLayoutOptions; for (const dayGroup of month.dayGroups) { dayGroup.layout(options, noDefer); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index c9da480b5e..9f879fe1b1 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -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 { 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 { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; @@ -24,6 +16,11 @@ import { retrieveRange as retrieveRangeUtil, } from '$lib/managers/timeline-manager/internal/search-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 { isMismatched, updateObject } from './internal/utils.svelte'; import { MonthGroup } from './month-group.svelte'; @@ -33,7 +30,6 @@ import type { Direction, ScrubberMonth, TimelineAsset, - TimelineManagerLayoutOptions, TimelineManagerOptions, Viewport, } from './types'; @@ -45,28 +41,32 @@ type ViewportTopMonthIntersection = { // Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom) 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); 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 = new SvelteSet(); - scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); - viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined; - - visibleWindow = $derived.by(() => ({ - top: this.#scrollTop, - bottom: this.#scrollTop + this.viewportHeight, - })); limitedScroll = $derived(this.maxScrollPercent < 0.5); - initTask = new CancellableTask( () => { this.isInitialized = true; @@ -83,32 +83,17 @@ export class TimelineManager { ); static #INIT_OPTIONS = {}; - #viewportHeight = $state(0); - #viewportWidth = $state(0); - #scrollTop = $state(0); #websocketSupport: WebsocketSupport | undefined; - - #rowHeight = $state(235); - #headerHeight = $state(48); - #gap = $state(12); - #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; #scrollableElement: HTMLElement | undefined = $state(); - setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) { - let changed = false; - changed ||= this.#setHeaderHeight(headerHeight); - changed ||= this.#setGap(gap); - changed ||= this.#setRowHeight(rowHeight); - if (changed) { - this.refreshLayout(); - } + constructor() { + super(); + } + + override get scrollTop(): number { + return this.#scrollableElement?.scrollTop ?? 0; } set scrollableElement(element: HTMLElement | undefined) { @@ -125,87 +110,6 @@ export class TimelineManager { 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?: { startMonthGroup?: MonthGroup; startDayGroup?: DayGroup; @@ -251,14 +155,6 @@ export class TimelineManager { this.#websocketSupport = undefined; } - updateSlidingWindow() { - const scrollTop = this.#scrollableElement?.scrollTop ?? 0; - if (this.#scrollTop !== scrollTop) { - this.#scrollTop = scrollTop; - this.updateIntersections(); - } - } - #calculateMonthBottomViewportRatio(month: MonthGroup | undefined) { if (!month) { return 0; @@ -276,7 +172,7 @@ export class TimelineManager { 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) { return; } @@ -325,7 +221,7 @@ export class TimelineManager { ); }); this.albumAssets.clear(); - this.#updateViewportGeometry(false); + this.updateViewportGeometry(false); } async updateOptions(options: TimelineManagerOptions) { @@ -337,7 +233,7 @@ export class TimelineManager { } await this.initTask.reset(); await this.#init(options); - this.#updateViewportGeometry(false); + this.updateViewportGeometry(false); } async #init(options: TimelineManagerOptions) { @@ -350,9 +246,10 @@ export class TimelineManager { }, true); } - public destroy() { + public override destroy() { this.disconnect(); this.isInitialized = false; + super.destroy(); } async updateViewport(viewport: Viewport) { @@ -371,14 +268,11 @@ export class TimelineManager { const changedWidth = viewport.width !== this.viewportWidth; this.viewportHeight = viewport.height; this.viewportWidth = viewport.width; - this.#updateViewportGeometry(changedWidth); + this.updateViewportGeometry(changedWidth); } - #updateViewportGeometry(changedWidth: boolean) { - if (!this.isInitialized) { - return; - } - if (this.viewportWidth === 0 || this.viewportHeight === 0) { + protected override updateViewportGeometry(changedWidth: boolean) { + if (!this.isInitialized || this.hasEmptyViewport) { return; } for (const month of this.months) { @@ -401,27 +295,6 @@ export class TimelineManager { 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 { let cancelable = true; if (options) { @@ -559,7 +432,7 @@ export class TimelineManager { return [...unprocessedIds]; } - refreshLayout() { + override refreshLayout() { for (const month of this.months) { updateGeometry(this, month, { invalidateHeight: true }); } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c35fb6a893..9604ecbbc9 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -60,14 +60,14 @@ interface UploadRequestOptions { } export class AbortError extends Error { - name = 'AbortError'; + override name = 'AbortError'; } class ApiError extends Error { - name = 'ApiError'; + override name = 'ApiError'; constructor( - public message: string, + public override message: string, public statusCode: number, public details: string, ) { diff --git a/web/tsconfig.json b/web/tsconfig.json index c7bc16f52b..afebee51ad 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -6,6 +6,7 @@ "forceConsistentCasingInFileNames": true, "module": "es2022", "moduleResolution": "bundler", + "noImplicitOverride": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true,