From f1da027a8c94969cfb304b6e5b620921f4c1e59c Mon Sep 17 00:00:00 2001 From: midzelis Date: Fri, 17 Oct 2025 13:49:05 +0000 Subject: [PATCH] refactor(web): extract VirtualScrollManager base class Extract a reusable VirtualScrollManager base class from TimelineManager to enable code reuse for virtual scrolling behavior. Changes: - Add VirtualScrollManager base class with common scrolling logic - Add ScrollSegment base class for month/segment abstraction - Migrate TimelineMonth to extend ScrollSegment - Migrate TimelineManager to extend VirtualScrollManager - Move intersection/layout/load logic from internal/ to base classes - Update components to work with new architecture - Delete timeline-manager/internal/{intersection,layout,load}-support.ts No functional changes - pure refactoring. --- .../lib/components/timeline/Timeline.svelte | 44 +- .../timeline/TimelineDateGroup.svelte | 97 +++-- .../actions/TimelineKeyboardActions.svelte | 2 +- .../ScrollSegment.svelte.ts | 213 ++++++++- .../VirtualScrollManager.svelte.ts | 150 ++++++- .../timeline-manager/TimelineDay.svelte.ts | 12 +- .../TimelineManager.svelte.spec.ts | 142 +++--- .../TimelineManager.svelte.ts | 410 +++++++----------- .../timeline-manager/TimelineMonth.svelte.ts | 339 ++++++++------- .../internal/intersection-support.svelte.ts | 73 ---- .../internal/layout-support.svelte.ts | 70 --- .../internal/load-support.svelte.ts | 57 --- .../internal/search-support.svelte.ts | 6 +- .../timeline-manager/viewer-asset.svelte.ts | 31 +- web/src/lib/utils/asset-utils.ts | 9 +- web/src/lib/utils/cancellable-task.ts | 23 +- web/src/lib/utils/timeline-util.ts | 28 ++ .../[[assetId=id]]/+page.svelte | 2 +- 18 files changed, 923 insertions(+), 785 deletions(-) delete mode 100644 web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts delete mode 100644 web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts delete mode 100644 web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index e380b0b6c8..99bc1879b2 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -2,14 +2,14 @@ import { afterNavigate, beforeNavigate } from '$app/navigation'; import { page } from '$app/state'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; - import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; + import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import { AssetAction } from '$lib/constants'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import Portal from '$lib/elements/Portal.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte'; - import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; + import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; @@ -20,7 +20,7 @@ import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { isAssetViewerRoute } from '$lib/utils/navigation'; - import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; + import { getSegmentIdentifier, getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { onDestroy, onMount, type Snippet } from 'svelte'; @@ -109,7 +109,7 @@ let timelineScrollPercent: number = $state(0); let scrubberWidth = $state(0); - const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); + const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0); const maxMd = $derived(mobileDevice.maxMd); const usingMobileDevice = $derived(mobileDevice.pointerCoarse); @@ -141,7 +141,7 @@ // Need to update window positions/intersections because may have // gone from invisible to visible. - timelineManager.updateSlidingWindow(); + timelineManager.updateVisibleWindow(); const assetTop = position.top; const assetBottom = position.top + position.height; @@ -296,7 +296,7 @@ scrubberMonthScrollPercent, ); } else { - const month = timelineManager.months.find( + const month = timelineManager.segments.find( ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ); if (!month) { @@ -325,7 +325,7 @@ let top = scrollableElement.scrollTop; let maxScrollPercent = timelineManager.maxScrollPercent; - const monthsLength = timelineManager.months.length; + const monthsLength = timelineManager.segments.length; for (let i = -1; i < monthsLength + 1; i++) { let month: ViewportTopMonth; let monthHeight = 0; @@ -338,8 +338,8 @@ month = 'lead-out'; monthHeight = timelineManager.bottomSectionHeight; } else { - month = timelineManager.months[i].yearMonth; - monthHeight = timelineManager.months[i].height; + month = timelineManager.segments[i].yearMonth; + monthHeight = timelineManager.segments[i].height; } let next = top - monthHeight * maxScrollPercent; @@ -352,7 +352,7 @@ // compensate for lost precision/rounding errors advance to the next bucket, if present if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) { - viewportTopMonth = timelineManager.months[i + 1].yearMonth; + viewportTopMonth = timelineManager.segments[i + 1].yearMonth; viewportTopMonthScrollPercent = 0; } break; @@ -442,21 +442,23 @@ assetInteraction.clearAssetSelectionCandidates(); if (assetInteraction.assetSelectionStart && rangeSelection) { - let startBucket = timelineManager.getMonthByAssetId(assetInteraction.assetSelectionStart.id); - let endBucket = timelineManager.getMonthByAssetId(asset.id); + let startBucket = timelineManager.getSegmentForAssetId(assetInteraction.assetSelectionStart.id) as + | TimelineMonth + | undefined; + let endBucket = timelineManager.getSegmentForAssetId(asset.id) as TimelineMonth | undefined; - if (startBucket === null || endBucket === null) { + if (!startBucket || !endBucket) { return; } // Select/deselect assets in range (start,end) let started = false; - for (const month of timelineManager.months) { + for (const month of timelineManager.segments) { if (month === endBucket) { break; } if (started) { - await timelineManager.loadMonth(month.yearMonth); + await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth)); for (const asset of month.assetsIterator()) { if (deselect) { assetInteraction.removeAssetFromMultiselectGroup(asset.id); @@ -472,7 +474,7 @@ // Update date group selection in range [start,end] started = false; - for (const month of timelineManager.months) { + for (const month of timelineManager.segments) { if (month === startBucket) { started = true; } @@ -531,7 +533,7 @@ $effect(() => { if ($showAssetViewer) { const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60); - void timelineManager.loadMonth({ year: localDateTime.year, month: localDateTime.month }); + void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month })); } }); @@ -562,7 +564,7 @@ {onEscape} /> -{#if timelineManager.months.length > 0} +{#if timelineManager.segments.length > 0} (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())} + onscroll={() => (handleTimelineScroll(), timelineManager.updateVisibleWindow(), updateIsScrolling())} >
- {#each timelineManager.months as month (month.viewId)} + {#each timelineManager.segments as month (month.identifier.id)} {@const display = month.intersecting} {@const absoluteHeight = month.top} - {#if !month.isLoaded} + {#if !month.loaded}
(month.timelineManager.suspendTransitions && !$isUploading ? 0 : 150)); + const transitionDuration = $derived.by(() => (month.scrollManager.suspendTransitions && !$isUploading ? 0 : 150)); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const _onClick = ( timelineManager: TimelineManager, @@ -135,8 +135,8 @@
- {#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)} - {@const position = viewerAsset.position!} - {@const asset = viewerAsset.asset!} +
+ {#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)} + {@const position = viewerAsset.position!} + {@const asset = viewerAsset.asset!} - - -
- { - if (typeof onThumbnailClick === 'function') { - onThumbnailClick(asset, timelineManager, day, _onClick); - } else { - _onClick(timelineManager, day.getAssets(), day.dayTitle, asset); - } - }} - onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)} - onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))} - selected={assetInteraction.hasSelectedAsset(asset.id) || - day.month.timelineManager.albumAssets.has(asset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} - disabled={day.month.timelineManager.albumAssets.has(asset.id)} - thumbnailWidth={position.width} - thumbnailHeight={position.height} - /> - {#if customLayout} - {@render customLayout(asset)} - {/if} -
- - {/each} + + +
+ { + if (typeof onThumbnailClick === 'function') { + onThumbnailClick(asset, timelineManager, day, _onClick); + } else { + _onClick(timelineManager, day.getAssets(), day.dayTitle, asset); + } + }} + onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)} + onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))} + selected={assetInteraction.hasSelectedAsset(asset.id) || + day.month.scrollManager.albumAssets.has(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + disabled={day.month.scrollManager.albumAssets.has(asset.id)} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {#if customLayout} + {@render customLayout(asset)} + {/if} +
+ + {/each} +
{/each} diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte index 9e452e2406..bed52f5806 100644 --- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte +++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte @@ -122,7 +122,7 @@ }; const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); - const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); + const isEmpty = $derived(timelineManager.isInitialized && timelineManager.segments.length === 0); const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id)); let isShortcutModalOpen = false; diff --git a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts index 2244712bf9..144c896590 100644 --- a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts @@ -1 +1,212 @@ -export class ScrollSegment {} +import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types'; +import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; +import type { + VirtualScrollManager, + VisibleWindow, +} from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; +import { CancellableTask, TaskStatus } from '$lib/utils/cancellable-task'; +import { handleError } from '$lib/utils/handle-error'; +import { TUNABLES } from '$lib/utils/tunables'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; +const { + TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, +} = TUNABLES; + +export type SegmentIdentifier = { + get id(): string; + matches(segment: ScrollSegment): boolean; +}; +export abstract class ScrollSegment { + #intersecting = $state(false); + #actuallyIntersecting = $state(false); + #isLoaded = $state(false); + #height = $state(0); + #top = $state(0); + #assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset)); + + initialCount = $state(0); + percent = $state(0); + isHeightActual = $state(false); + assetsCount = $derived.by(() => (this.loaded ? this.viewerAssets.length : this.initialCount)); + loader = new CancellableTask( + () => (this.loaded = true), + () => (this.loaded = false), + () => this.handleLoadError, + ); + + abstract get scrollManager(): VirtualScrollManager; + + abstract get identifier(): SegmentIdentifier; + + abstract get viewerAssets(): ViewerAsset[]; + + abstract findAssetAbsolutePosition(assetId: string): { top: number; height: number } | undefined; + + protected abstract fetch(signal: AbortSignal): Promise; + + get loaded() { + return this.#isLoaded; + } + + protected set loaded(newValue: boolean) { + this.#isLoaded = newValue; + } + + get intersecting() { + return this.#intersecting; + } + + set intersecting(newValue: boolean) { + const old = this.#intersecting; + if (old === newValue) { + return; + } + this.#intersecting = newValue; + if (newValue) { + void this.load(true); + } else { + this.cancel(); + } + } + + get actuallyIntersecting() { + return this.#actuallyIntersecting; + } + + get assets(): TimelineAsset[] { + return this.#assets; + } + + get height() { + return this.#height; + } + + set height(height: number) { + if (this.#height === height) { + return; + } + const scrollManager = this.scrollManager; + const index = scrollManager.segments.indexOf(this); + const heightDelta = height - this.#height; + this.#height = height; + const prevSegment = scrollManager.segments[index - 1]; + if (prevSegment) { + const newTop = prevSegment.#top + prevSegment.#height; + if (this.#top !== newTop) { + this.#top = newTop; + } + } + if (heightDelta === 0) { + return; + } + for (let cursor = index + 1; cursor < scrollManager.segments.length; cursor++) { + const segment = this.scrollManager.segments[cursor]; + const newTop = segment.#top + heightDelta; + if (segment.#top !== newTop) { + segment.#top = newTop; + } + } + if (!scrollManager.viewportTopSegmentIntersection) { + return; + } + + const { segment, viewportTopSegmentRatio, segmentBottomViewportRatio } = + scrollManager.viewportTopSegmentIntersection; + + const currentIndex = segment ? scrollManager.segments.indexOf(segment) : -1; + if (!segment || currentIndex <= 0 || index > currentIndex) { + return; + } + if (index < currentIndex || segmentBottomViewportRatio < 1) { + scrollManager.scrollBy(heightDelta); + } else if (index === currentIndex) { + const scrollTo = this.top + heightDelta * viewportTopSegmentRatio; + scrollManager.scrollTo(scrollTo); + } + } + + get top(): number { + return this.#top + this.scrollManager.topSectionHeight; + } + + async load(cancelable: boolean): Promise { + const result = await this.loader?.execute(async (signal: AbortSignal) => { + await this.fetch(signal); + }, cancelable); + if (result === TaskStatus.LOADED) { + this.updateGeometry({ invalidateHeight: false }); + this.calculateAndUpdateIntersection(this.scrollManager.visibleWindow); + } + return result; + } + + protected handleLoadError(error: unknown) { + const _$t = get(t); + handleError(error, _$t('errors.failed_to_load_assets')); + } + + cancel() { + this.loader?.cancel(); + } + + updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) { + this.intersecting = intersecting; + this.#actuallyIntersecting = actuallyIntersecting; + } + + updateGeometry(options: UpdateGeometryOptions) { + const { invalidateHeight = true, noDefer = false } = options; + if (invalidateHeight) { + this.isHeightActual = false; + } + if (!this.loaded) { + const viewportWidth = this.scrollManager.viewportWidth; + if (!this.isHeightActual) { + const unwrappedWidth = (3 / 2) * this.assetsCount * this.scrollManager.rowHeight * (7 / 10); + const rows = Math.ceil(unwrappedWidth / viewportWidth); + const height = 51 + Math.max(1, rows) * this.scrollManager.rowHeight; + this.height = height; + } + return; + } + this.layout(noDefer); + } + + layout(_: boolean) {} + + protected calculateSegmentIntersecting(visibleWindow: VisibleWindow, expandTop: number, expandBottom: number) { + const segmentTop = this.top; + const segmentBottom = segmentTop + this.height; + const topWindow = visibleWindow.top - expandTop; + const bottomWindow = visibleWindow.bottom + expandBottom; + + return isIntersecting(segmentTop, segmentBottom, topWindow, bottomWindow); + } + + calculateAndUpdateIntersection(visibleWindow: VisibleWindow) { + const actuallyIntersecting = this.calculateSegmentIntersecting(visibleWindow, 0, 0); + let preIntersecting = false; + if (!actuallyIntersecting) { + preIntersecting = this.calculateSegmentIntersecting( + visibleWindow, + INTERSECTION_EXPAND_TOP, + INTERSECTION_EXPAND_BOTTOM, + ); + } + this.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting }); + } +} + +/** + * General function to check if a segment region intersects with a window region. + * @param regionTop - Top position of the region to check + * @param regionBottom - Bottom position of the region to check + * @param windowTop - Top position of the window + * @param windowBottom - Bottom position of the window + * @returns true if the region intersects with the window + */ +export const isIntersecting = (regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) => + (regionTop >= windowTop && regionTop < windowBottom) || + (regionBottom >= windowTop && regionBottom < windowBottom) || + (regionTop < windowTop && regionBottom >= windowBottom); diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts index 12526527b7..1a385b6c11 100644 --- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -1,20 +1,58 @@ -import { debounce } from 'lodash-es'; +import type { Viewport } from '$lib/managers/timeline-manager/types'; +import type { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; + +import { clamp, debounce } from 'lodash-es'; type LayoutOptions = { headerHeight: number; rowHeight: number; gap: number; }; + +export type VisibleWindow = { + top: number; + bottom: number; +}; + +type ViewportTopSegmentIntersection = { + segment: ScrollSegment | null; + // Where viewport top intersects segment (0 = segment top, 1 = segment bottom) + viewportTopSegmentRatio: number; + // Where first segment bottom is in viewport (0 = viewport top, 1 = viewport bottom) + segmentBottomViewportRatio: number; +}; + export abstract class VirtualScrollManager { topSectionHeight = $state(0); - bodySectionHeight = $state(0); + bodySectionHeight = $derived.by(() => { + let height = 0; + for (const segment of this.segments) { + height += segment.height; + } + return height; + }); bottomSectionHeight = $state(0); totalViewerHeight = $derived.by(() => this.topSectionHeight + this.bodySectionHeight + this.bottomSectionHeight); - - visibleWindow = $derived.by(() => ({ + isInitialized = $state(false); + streamViewerHeight = $derived.by(() => { + let height = this.topSectionHeight; + for (const segment of this.segments) { + height += segment.height; + } + return height; + }); + assetCount = $derived.by(() => { + let count = 0; + for (const segment of this.segments) { + count += segment.assetsCount; + } + return count; + }); + visibleWindow: VisibleWindow = $derived.by(() => ({ top: this.#scrollTop, bottom: this.#scrollTop + this.viewportHeight, })); + viewportTopSegmentIntersection: ViewportTopSegmentIntersection | undefined; #viewportHeight = $state(0); #viewportWidth = $state(0); @@ -24,14 +62,15 @@ export abstract class VirtualScrollManager { #gap = $state(12); #scrolling = $state(false); #suspendTransitions = $state(false); - #resetScrolling = debounce(() => (this.#scrolling = false), 1000); - #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); + #resumeTransitionsAfterDelay = debounce(() => (this.suspendTransitions = false), 1000); + #resumeScrollingStatusAfterDelay = debounce(() => (this.#scrolling = false), 1000); #justifiedLayoutOptions = $derived({ spacing: 2, heightTolerance: 0.5, rowHeight: this.#rowHeight, rowWidth: Math.floor(this.viewportWidth), }); + #updatingIntersections = false; constructor() { this.setLayoutOptions(); @@ -45,6 +84,8 @@ export abstract class VirtualScrollManager { return this.#justifiedLayoutOptions; } + abstract get segments(): ScrollSegment[]; + get maxScrollPercent() { const totalHeight = this.totalViewerHeight; return (totalHeight - this.viewportHeight) / totalHeight; @@ -94,7 +135,7 @@ export abstract class VirtualScrollManager { this.#scrolling = value; if (value) { this.suspendTransitions = true; - this.#resetScrolling(); + this.#resumeScrollingStatusAfterDelay(); } } @@ -105,7 +146,7 @@ export abstract class VirtualScrollManager { set suspendTransitions(value: boolean) { this.#suspendTransitions = value; if (value) { - this.#resetSuspendTransitions(); + this.#resumeTransitionsAfterDelay(); } } @@ -114,10 +155,11 @@ export abstract class VirtualScrollManager { } set viewportWidth(value: number) { - const changed = value !== this.#viewportWidth; + const oldViewport = this.viewportSnapshot; this.#viewportWidth = value; this.suspendTransitions = true; - void this.updateViewportGeometry(changed); + const newViewport = this.viewportSnapshot; + void this.onUpdateViewport(oldViewport, newViewport); } get viewportWidth() { @@ -125,22 +167,78 @@ export abstract class VirtualScrollManager { } set viewportHeight(value: number) { + const oldViewport = this.viewportSnapshot; this.#viewportHeight = value; this.#suspendTransitions = true; - void this.updateViewportGeometry(false); + const newViewport = this.viewportSnapshot; + void this.onUpdateViewport(oldViewport, newViewport); } get viewportHeight() { return this.#viewportHeight; } - get hasEmptyViewport() { - return this.viewportWidth === 0 || this.viewportHeight === 0; + get viewportSnapshot(): Viewport { + return { + width: $state.snapshot(this.#viewportWidth), + height: $state.snapshot(this.#viewportHeight), + }; } - protected updateIntersections(): void {} + scrollTo(_: number) {} - protected updateViewportGeometry(_: boolean) {} + scrollBy(_: number) {} + + #calculateSegmentBottomViewportRatio(segment: ScrollSegment | null) { + if (!segment) { + return 0; + } + const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top; + const bottomOfSegment = segment.top + segment.height; + const bottomOfSegmentInViewport = bottomOfSegment - this.visibleWindow.top; + return clamp(bottomOfSegmentInViewport / windowHeight, 0, 1); + } + + #calculateViewportTopRatioInMonth(month: ScrollSegment | null) { + if (!month) { + return 0; + } + return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1); + } + + protected updateIntersections() { + if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { + return; + } + + this.#updatingIntersections = true; + let topSegment: ScrollSegment | null = null; + for (const segment of this.segments) { + segment.calculateAndUpdateIntersection(this.visibleWindow); + if (segment.actuallyIntersecting && topSegment === null) { + topSegment = segment; + } + } + + const viewportTopSegmentRatio = this.#calculateViewportTopRatioInMonth(topSegment); + const segmentBottomViewportRatio = this.#calculateSegmentBottomViewportRatio(topSegment); + + this.viewportTopSegmentIntersection = { + segment: topSegment, + viewportTopSegmentRatio, + segmentBottomViewportRatio, + }; + + this.#updatingIntersections = false; + } + + protected onUpdateViewport(oldViewport: Viewport, newViewport: Viewport) { + if (!this.isInitialized || isEmptyViewport(newViewport)) { + return; + } + const changedWidth = oldViewport.width !== newViewport.width || isEmptyViewport(oldViewport); + this.refreshLayout({ invalidateHeight: changedWidth }); + } setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: Partial = {}) { let changed = false; @@ -152,7 +250,7 @@ export abstract class VirtualScrollManager { } } - updateSlidingWindow() { + updateVisibleWindow() { const scrollTop = this.scrollTop; if (this.#scrollTop !== scrollTop) { this.#scrollTop = scrollTop; @@ -160,9 +258,25 @@ export abstract class VirtualScrollManager { } } - refreshLayout() { + protected refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) { + for (const segment of this.segments) { + segment.updateGeometry({ invalidateHeight }); + } this.updateIntersections(); } - destroy(): void {} + destroy() { + this.isInitialized = false; + } + + getSegmentForAssetId(assetId: string) { + for (const segment of this.segments) { + const asset = segment.assets.find((asset) => asset.id === assetId); + if (asset) { + return segment; + } + } + } } + +export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0; diff --git a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts index aa1291686f..e76be28041 100644 --- a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts @@ -11,6 +11,8 @@ export class TimelineDay { readonly month: TimelineMonth; readonly index: number; readonly dayTitle: string; + readonly dayTitleFull: string; + readonly day: number; viewerAssets: ViewerAsset[] = $state([]); @@ -24,11 +26,13 @@ export class TimelineDay { #col = $state(0); #deferredLayout = false; - constructor(month: TimelineMonth, index: number, day: number, dayTitle: string) { + constructor(month: TimelineMonth, index: number, day: number, groupTitle: string, groupTitleFull: string) { this.index = index; this.month = month; this.day = day; - this.dayTitle = dayTitle; + this.dayTitle = groupTitle; + this.dayTitleFull = groupTitleFull; + if (import.meta.env.DEV) { onCreateDay(this); } @@ -142,7 +146,7 @@ export class TimelineDay { } unprocessedIds.delete(assetId); processedIds.add(assetId); - if (remove || this.month.timelineManager.isExcluded(asset)) { + if (remove || this.month.scrollManager.isExcluded(asset)) { this.viewerAssets.splice(index, 1); changedGeometry = true; } @@ -165,7 +169,7 @@ export class TimelineDay { } } - get absoluteTop() { + get topAbsolute() { return this.month.top + this.#top; } } diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts index bf1b54e63d..679d70c490 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.spec.ts @@ -6,7 +6,7 @@ import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager. import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { AbortError } from '$lib/utils'; -import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; +import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util'; import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { tick } from 'svelte'; @@ -78,7 +78,7 @@ describe('TimelineManager', () => { }); it('calculates month height', () => { - const plainMonths = timelineManager.months.map((month) => ({ + const plainMonths = timelineManager.segments.map((month) => ({ year: month.yearMonth.year, month: month.yearMonth.month, height: month.height, @@ -98,7 +98,7 @@ describe('TimelineManager', () => { }); }); - describe('loadMonth', () => { + describe('loadSegment', () => { let timelineManager: TimelineManager; const bucketAssets: Record = { '2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) => @@ -134,48 +134,48 @@ describe('TimelineManager', () => { }); it('loads a month', async () => { - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); - await timelineManager.loadMonth({ year: 2024, month: 1 }); + expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('ignores invalid months', async () => { - await timelineManager.loadMonth({ year: 2023, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(0); }); it('cancels month loading', async () => { const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!; - void timelineManager.loadMonth({ year: 2024, month: 1 }); + void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort'); month?.cancel(); expect(abortSpy).toBeCalledTimes(1); - await timelineManager.loadMonth({ year: 2024, month: 1 }); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3); }); it('prevents loading months multiple times', async () => { await Promise.all([ - timelineManager.loadMonth({ year: 2024, month: 1 }), - timelineManager.loadMonth({ year: 2024, month: 1 }), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), + timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })), ]); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); - await timelineManager.loadMonth({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); expect(sdkMock.getTimeBucket).toBeCalledTimes(1); }); it('allows loading a canceled month', async () => { const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!; - const loadPromise = timelineManager.loadMonth({ year: 2024, month: 1 }); + const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); month.cancel(); await loadPromise; - expect(month?.getAssets().length).toEqual(0); + expect(month?.assets.length).toEqual(0); - await timelineManager.loadMonth({ year: 2024, month: 1 }); - expect(month!.getAssets().length).toEqual(3); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + expect(month!.assets.length).toEqual(3); }); }); @@ -190,7 +190,7 @@ describe('TimelineManager', () => { }); it('is empty initially', () => { - expect(timelineManager.months.length).toEqual(0); + expect(timelineManager.segments.length).toEqual(0); expect(timelineManager.assetCount).toEqual(0); }); @@ -202,12 +202,12 @@ describe('TimelineManager', () => { ); timelineManager.upsertAssets([asset]); - expect(timelineManager.months.length).toEqual(1); + expect(timelineManager.segments.length).toEqual(1); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); - expect(timelineManager.months[0].yearMonth.year).toEqual(2024); - expect(timelineManager.months[0].yearMonth.month).toEqual(1); - expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id); + expect(timelineManager.segments[0].assets.length).toEqual(1); + expect(timelineManager.segments[0].yearMonth.year).toEqual(2024); + expect(timelineManager.segments[0].yearMonth.month).toEqual(1); + expect(timelineManager.segments[0].getFirstAsset().id).toEqual(asset.id); }); it('adds assets to existing month', () => { @@ -219,11 +219,11 @@ describe('TimelineManager', () => { timelineManager.upsertAssets([assetOne]); timelineManager.upsertAssets([assetTwo]); - expect(timelineManager.months.length).toEqual(1); + expect(timelineManager.segments.length).toEqual(1); expect(timelineManager.assetCount).toEqual(2); - expect(timelineManager.months[0].getAssets().length).toEqual(2); - expect(timelineManager.months[0].yearMonth.year).toEqual(2024); - expect(timelineManager.months[0].yearMonth.month).toEqual(1); + expect(timelineManager.segments[0].assets.length).toEqual(2); + expect(timelineManager.segments[0].yearMonth.year).toEqual(2024); + expect(timelineManager.segments[0].yearMonth.month).toEqual(1); }); it('orders assets in months by descending date', () => { @@ -246,10 +246,10 @@ describe('TimelineManager', () => { const month = getMonthByDate(timelineManager, { year: 2024, month: 1 }); expect(month).not.toBeNull(); - expect(month?.getAssets().length).toEqual(3); - expect(month?.getAssets()[0].id).toEqual(assetOne.id); - expect(month?.getAssets()[1].id).toEqual(assetThree.id); - expect(month?.getAssets()[2].id).toEqual(assetTwo.id); + expect(month?.assets.length).toEqual(3); + expect(month?.assets[0].id).toEqual(assetOne.id); + expect(month?.assets[1].id).toEqual(assetThree.id); + expect(month?.assets[2].id).toEqual(assetTwo.id); }); it('orders months by descending date', () => { @@ -270,15 +270,15 @@ describe('TimelineManager', () => { ); timelineManager.upsertAssets([assetOne, assetTwo, assetThree]); - expect(timelineManager.months.length).toEqual(3); - expect(timelineManager.months[0].yearMonth.year).toEqual(2024); - expect(timelineManager.months[0].yearMonth.month).toEqual(4); + expect(timelineManager.segments.length).toEqual(3); + expect(timelineManager.segments[0].yearMonth.year).toEqual(2024); + expect(timelineManager.segments[0].yearMonth.month).toEqual(4); - expect(timelineManager.months[1].yearMonth.year).toEqual(2024); - expect(timelineManager.months[1].yearMonth.month).toEqual(1); + expect(timelineManager.segments[1].yearMonth.year).toEqual(2024); + expect(timelineManager.segments[1].yearMonth.month).toEqual(1); - expect(timelineManager.months[2].yearMonth.year).toEqual(2023); - expect(timelineManager.months[2].yearMonth.month).toEqual(1); + expect(timelineManager.segments[2].yearMonth.year).toEqual(2023); + expect(timelineManager.segments[2].yearMonth.month).toEqual(1); }); it('updates existing asset', () => { @@ -435,11 +435,11 @@ describe('TimelineManager', () => { timelineManager.upsertAssets([asset]); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false); + expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(false); timelineManager.upsertAssets([updatedAsset]); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true); + expect(timelineManager.segments[0].getFirstAsset().isFavorite).toEqual(true); }); it('asset moves months when asset date changes', () => { @@ -454,16 +454,16 @@ describe('TimelineManager', () => { }); timelineManager.upsertAssets([asset]); - expect(timelineManager.months.length).toEqual(1); + expect(timelineManager.segments.length).toEqual(1); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1); + expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1); timelineManager.upsertAssets([updatedAsset]); - expect(timelineManager.months.length).toEqual(2); + expect(timelineManager.segments.length).toEqual(2); expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0); + expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0); expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); - expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); + expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1); }); it('asset is removed during upsert when TimelineManager if visibility changes', async () => { @@ -551,8 +551,8 @@ describe('TimelineManager', () => { timelineManager.removeAssets(['', 'invalid', '4c7d9acc']); expect(timelineManager.assetCount).toEqual(2); - expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(2); + expect(timelineManager.segments.length).toEqual(1); + expect(timelineManager.segments[0].assets.length).toEqual(2); }); it('removes asset from month', () => { @@ -565,8 +565,8 @@ describe('TimelineManager', () => { timelineManager.removeAssets([assetOne.id]); expect(timelineManager.assetCount).toEqual(1); - expect(timelineManager.months.length).toEqual(1); - expect(timelineManager.months[0].getAssets().length).toEqual(1); + expect(timelineManager.segments.length).toEqual(1); + expect(timelineManager.segments[0].assets.length).toEqual(1); }); it('does not remove month when empty', () => { @@ -579,7 +579,7 @@ describe('TimelineManager', () => { timelineManager.removeAssets(assets.map((asset) => asset.id)); expect(timelineManager.assetCount).toEqual(0); - expect(timelineManager.months.length).toEqual(1); + expect(timelineManager.segments.length).toEqual(1); }); }); @@ -655,45 +655,45 @@ describe('TimelineManager', () => { }); it('returns previous assetId', async () => { - await timelineManager.loadMonth({ year: 2024, month: 1 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); const month = getMonthByDate(timelineManager, { year: 2024, month: 1 }); - const a = month!.getAssets()[0]; - const b = month!.getAssets()[1]; + const a = month!.assets[0]; + const b = month!.assets[1]; const previous = await timelineManager.getLaterAsset(b); expect(previous).toEqual(a); }); it('returns previous assetId spanning multiple months', async () => { - await timelineManager.loadMonth({ year: 2024, month: 2 }); - await timelineManager.loadMonth({ year: 2024, month: 3 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); const month = getMonthByDate(timelineManager, { year: 2024, month: 2 }); const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 }); - const a = month!.getAssets()[0]; - const b = previousMonth!.getAssets()[0]; + const a = month!.assets[0]; + const b = previousMonth!.assets[0]; const previous = await timelineManager.getLaterAsset(a); expect(previous).toEqual(b); }); it('loads previous month', async () => { - await timelineManager.loadMonth({ year: 2024, month: 2 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); const month = getMonthByDate(timelineManager, { year: 2024, month: 2 }); const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 }); const a = month!.getFirstAsset(); const b = previousMonth!.getFirstAsset(); - const loadMonthSpy = vi.spyOn(month!.loader!, 'execute'); + const loadmonthSpy = vi.spyOn(month!.loader!, 'execute'); const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute'); const previous = await timelineManager.getLaterAsset(a); expect(previous).toEqual(b); - expect(loadMonthSpy).toBeCalledTimes(0); + expect(loadmonthSpy).toBeCalledTimes(0); expect(previousMonthSpy).toBeCalledTimes(0); }); it('skips removed assets', async () => { - await timelineManager.loadMonth({ year: 2024, month: 1 }); - await timelineManager.loadMonth({ year: 2024, month: 2 }); - await timelineManager.loadMonth({ year: 2024, month: 3 }); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 })); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager); timelineManager.removeAssets([assetTwo.id]); @@ -701,8 +701,8 @@ describe('TimelineManager', () => { }); it('returns null when no more assets', async () => { - await timelineManager.loadMonth({ year: 2024, month: 3 }); - expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined(); + await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 })); + expect(await timelineManager.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined(); }); }); @@ -734,10 +734,10 @@ describe('TimelineManager', () => { ); timelineManager.upsertAssets([assetOne, assetTwo]); - expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024); - expect(timelineManager.getMonthByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2); - expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); - expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1); + expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); + expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.month).toEqual(2); + expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); + expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1); }); it('ignores removed months', () => { @@ -754,8 +754,8 @@ describe('TimelineManager', () => { timelineManager.upsertAssets([assetOne, assetTwo]); timelineManager.removeAssets([assetTwo.id]); - expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024); - expect(timelineManager.getMonthByAssetId(assetOne.id)?.yearMonth.month).toEqual(1); + expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024); + expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1); }); }); diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts index 46713d2cfa..6463795e31 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts @@ -1,11 +1,9 @@ +import type { SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; -import { updateIntersectionMonth } 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'; import { findClosestGroupForDate, findMonthForAsset as findMonthForAssetUtil, @@ -27,47 +25,24 @@ import type { } from '$lib/managers/timeline-manager/types'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { + getSegmentIdentifier, setDifferenceInPlace, toTimelineAsset, type TimelineDateTime, type TimelineYearMonth, } from '$lib/utils/timeline-util'; import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; -import { clamp, isEqual } from 'lodash-es'; +import { isEqual } from 'lodash-es'; import { SvelteDate, SvelteSet } from 'svelte/reactivity'; -type ViewportTopMonthIntersection = { - month: TimelineMonth | undefined; - // Where viewport top intersects month (0 = month top, 1 = month bottom) - viewportTopRatioInMonth: number; - // Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom) - monthBottomViewportRatio: number; -}; 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: TimelineMonth[] = $state([]); + segments: TimelineMonth[] = $state([]); albumAssets: Set = new SvelteSet(); scrubberMonths: ScrubberMonth[] = $state([]); scrubberTimelineHeight: number = $state(0); - viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined; + limitedScroll = $derived(this.maxScrollPercent < 0.5); initTask = new CancellableTask( () => { @@ -87,13 +62,17 @@ export class TimelineManager extends VirtualScrollManager { static #INIT_OPTIONS = {}; #websocketSupport: WebsocketSupport | undefined; #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; - #updatingIntersections = false; + #scrollableElement: HTMLElement | undefined = $state(); constructor() { super(); } + get options() { + return this.#options; + } + override get scrollTop(): number { return this.#scrollableElement?.scrollTop ?? 0; } @@ -102,43 +81,61 @@ export class TimelineManager extends VirtualScrollManager { this.#scrollableElement = element; } - scrollTo(top: number) { + override scrollTo(top: number) { this.#scrollableElement?.scrollTo({ top }); - this.updateSlidingWindow(); + this.updateVisibleWindow(); } - scrollBy(y: number) { + override scrollBy(y: number) { this.#scrollableElement?.scrollBy(0, y); - this.updateSlidingWindow(); + this.updateVisibleWindow(); } - async *assetsIterator(options?: { - startMonth?: TimelineMonth; - startDay?: TimelineDay; - startAsset?: TimelineAsset; - direction?: Direction; - }) { - const direction = options?.direction ?? 'earlier'; - let { startDay, startAsset } = options ?? {}; - for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) { - await this.loadMonth(month.yearMonth, { cancelable: false }); - yield* month.assetsIterator({ startDay, startAsset, direction }); - startDay = startAsset = undefined; + protected override refreshLayout({ invalidateHeight = true }: { invalidateHeight?: boolean } = {}) { + super.refreshLayout({ invalidateHeight }); + if (invalidateHeight) { + this.#createScrubberMonths(); } } - *monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) { - const isEarlier = options?.direction === 'earlier'; - let startIndex = options?.startMonth - ? this.months.indexOf(options.startMonth) - : isEarlier - ? 0 - : this.months.length - 1; + public override destroy() { + this.disconnect(); + super.destroy(); + } - while (startIndex >= 0 && startIndex < this.months.length) { - yield this.months[startIndex]; - startIndex += isEarlier ? 1 : -1; + async updateOptions(options: TimelineManagerOptions) { + if (options.deferInit) { + return; } + if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) { + return; + } + await this.initTask.reset(); + await this.#init(options); + this.refreshLayout(); + } + + async updateViewport(viewport: Viewport) { + if (viewport.height === 0 && viewport.width === 0) { + return; + } + + if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) { + return; + } + + if (!this.initTask.executed) { + await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options)); + } + + const oldViewport: Viewport = { + width: this.viewportWidth, + height: this.viewportHeight, + }; + + this.viewportHeight = viewport.height; + this.viewportWidth = viewport.width; + this.onUpdateViewport(oldViewport, viewport); } connect() { @@ -157,171 +154,6 @@ export class TimelineManager extends VirtualScrollManager { this.#websocketSupport = undefined; } - #calculateMonthBottomViewportRatio(month: TimelineMonth | undefined) { - if (!month) { - return 0; - } - const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top; - const bottomOfMonth = month.top + month.height; - const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top; - return clamp(bottomOfMonthInViewport / windowHeight, 0, 1); - } - - #calculateVewportTopRatioInMonth(month: TimelineMonth | undefined) { - if (!month) { - return 0; - } - return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1); - } - - override updateIntersections() { - if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { - return; - } - this.#updatingIntersections = true; - - for (const month of this.months) { - updateIntersectionMonth(this, month); - } - - const month = this.months.find((month) => month.actuallyIntersecting); - const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month); - const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month); - - this.viewportTopMonthIntersection = { - month, - monthBottomViewportRatio, - viewportTopRatioInMonth, - }; - - this.#updatingIntersections = false; - } - - clearDeferredLayout(month: TimelineMonth) { - const hasDeferred = month.days.some((group) => group.deferredLayout); - if (hasDeferred) { - updateGeometry(this, month, { invalidateHeight: true, noDefer: true }); - for (const group of month.days) { - group.deferredLayout = false; - } - } - } - - async #initializeMonths() { - const timebuckets = await getTimeBuckets({ - ...authManager.params, - ...this.#options, - }); - - this.months = timebuckets.map((timeBucket) => { - const date = new SvelteDate(timeBucket.timeBucket); - return new TimelineMonth( - this, - { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, - timeBucket.count, - false, - this.#options.order, - ); - }); - this.albumAssets.clear(); - this.updateViewportGeometry(false); - } - - async updateOptions(options: TimelineManagerOptions) { - if (options.deferInit) { - return; - } - if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) { - return; - } - await this.initTask.reset(); - await this.#init(options); - this.updateViewportGeometry(false); - this.#createScrubberMonths(); - } - - async #init(options: TimelineManagerOptions) { - this.isInitialized = false; - this.months = []; - this.albumAssets.clear(); - await this.initTask.execute(async () => { - this.#options = options; - await this.#initializeMonths(); - }, true); - } - - public override destroy() { - this.disconnect(); - this.isInitialized = false; - super.destroy(); - } - - async updateViewport(viewport: Viewport) { - if (viewport.height === 0 && viewport.width === 0) { - return; - } - - if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) { - return; - } - - if (!this.initTask.executed) { - await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options)); - } - - const changedWidth = viewport.width !== this.viewportWidth; - this.viewportHeight = viewport.height; - this.viewportWidth = viewport.width; - this.updateViewportGeometry(changedWidth); - } - - protected override updateViewportGeometry(changedWidth: boolean) { - if (!this.isInitialized || this.hasEmptyViewport) { - return; - } - for (const month of this.months) { - updateGeometry(this, month, { invalidateHeight: changedWidth }); - } - this.updateIntersections(); - if (changedWidth) { - this.#createScrubberMonths(); - } - } - - #createScrubberMonths() { - this.scrubberMonths = this.months.map((month) => ({ - assetCount: month.assetsCount, - year: month.yearMonth.year, - month: month.yearMonth.month, - title: month.monthTitle, - height: month.height, - })); - this.scrubberTimelineHeight = this.totalViewerHeight; - } - - async loadMonth(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise { - let cancelable = true; - if (options) { - cancelable = options.cancelable; - } - const month = getMonthByDate(this, yearMonth); - if (!month) { - return; - } - - if (month.loader?.executed) { - return; - } - - const executionStatus = await month.loader?.execute(async (signal: AbortSignal) => { - await loadFromTimeBuckets(this, month, this.#options, signal); - }, cancelable); - if (executionStatus === 'LOADED') { - updateGeometry(this, month, { invalidateHeight: false }); - this.updateIntersections(); - } - } - upsertAssets(assets: TimelineAsset[]) { const notUpdated = this.#updateAssets(assets); const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset)); @@ -354,8 +186,18 @@ export class TimelineManager extends VirtualScrollManager { } } + async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise { + const { cancelable = true } = options ?? {}; + const segment = this.segments.find((segment) => identifier.matches(segment)); + if (!segment || segment.loader?.executed) { + return; + } + + await segment.load(cancelable); + } + async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) { - await this.loadMonth(yearMonth, options); + await this.loadSegment(getSegmentIdentifier(yearMonth), options); return getMonthByDate(this, yearMonth); } @@ -373,7 +215,7 @@ export class TimelineManager extends VirtualScrollManager { let accumulatedCount = 0; let randomMonth: TimelineMonth | undefined = undefined; - for (const month of this.months) { + for (const month of this.segments) { if (randomAssetIndex < accumulatedCount + month.assetsCount) { randomMonth = month; break; @@ -384,7 +226,7 @@ export class TimelineManager extends VirtualScrollManager { if (!randomMonth) { return; } - await this.loadMonth(randomMonth.yearMonth, { cancelable: false }); + await this.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false }); let randomDay: TimelineDay | undefined = undefined; for (const day of randomMonth.days) { @@ -447,7 +289,7 @@ export class TimelineManager extends VirtualScrollManager { if (!month) { month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order); - this.months.push(month); + this.segments.push(month); } month.addTimelineAsset(asset, context); @@ -458,11 +300,11 @@ export class TimelineManager extends VirtualScrollManager { return; } const context = this.createUpsertContext(); - const monthCount = this.months.length; + const monthCount = this.segments.length; for (const asset of assets) { this.upsertAssetIntoSegment(asset, context); } - if (this.months.length !== monthCount) { + if (this.segments.length !== monthCount) { this.postCreateSegments(); } this.postUpsert(context); @@ -482,7 +324,7 @@ export class TimelineManager extends VirtualScrollManager { // eslint-disable-next-line svelte/prefer-svelte-reactivity const idsProcessed = new Set(); const combinedMoveAssets: TimelineAsset[] = []; - for (const month of this.months) { + for (const month of this.segments) { if (idsToProcess.size > 0) { const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); if (moveAssets.length > 0) { @@ -502,7 +344,7 @@ export class TimelineManager extends VirtualScrollManager { } const changedGeometry = changedMonths.size > 0; for (const month of changedMonths) { - updateGeometry(this, month, { invalidateHeight: true }); + month.updateGeometry({ invalidateHeight: true }); } if (changedGeometry) { this.updateIntersections(); @@ -510,15 +352,20 @@ export class TimelineManager extends VirtualScrollManager { return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; } - override refreshLayout() { - for (const month of this.months) { - updateGeometry(this, month, { invalidateHeight: true }); - } - this.updateIntersections(); + isExcluded(asset: TimelineAsset) { + return ( + isMismatched(this.#options.visibility, asset.visibility) || + isMismatched(this.#options.isFavorite, asset.isFavorite) || + isMismatched(this.#options.isTrashed, asset.isTrashed) + ); + } + + getAssetOrder() { + return this.#options.order ?? AssetOrder.Desc; } getFirstAsset(): TimelineAsset | undefined { - return this.months[0]?.getFirstAsset(); + return this.segments[0]?.getFirstAsset(); } async getLaterAsset( @@ -538,13 +385,12 @@ export class TimelineManager extends VirtualScrollManager { async getClosestAssetToDate(dateTime: TimelineDateTime) { let month = findMonthForDate(this, dateTime); if (!month) { - // if exact match not found, find closest - month = findClosestGroupForDate(this.months, dateTime); + month = findClosestGroupForDate(this.segments, dateTime); if (!month) { return; } } - await this.loadMonth(dateTime, { cancelable: false }); + await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false }); const asset = month.findClosest(dateTime); if (asset) { return asset; @@ -558,20 +404,86 @@ export class TimelineManager extends VirtualScrollManager { return retrieveRangeUtil(this, start, end); } - isExcluded(asset: TimelineAsset) { - return ( - isMismatched(this.#options.visibility, asset.visibility) || - isMismatched(this.#options.isFavorite, asset.isFavorite) || - isMismatched(this.#options.isTrashed, asset.isTrashed) - ); + clearDeferredLayout(month: TimelineMonth) { + const hasDeferred = month.days.some((group) => group.deferredLayout); + if (hasDeferred) { + month.updateGeometry({ invalidateHeight: true, noDefer: true }); + for (const group of month.days) { + group.deferredLayout = false; + } + } } - getAssetOrder() { - return this.#options.order ?? AssetOrder.Desc; + async *assetsIterator(options?: { + startMonth?: TimelineMonth; + startDay?: TimelineDay; + startAsset?: TimelineAsset; + direction?: Direction; + }) { + const direction = options?.direction ?? 'earlier'; + let { startDay, startAsset } = options ?? {}; + for (const month of this.monthIterator({ direction, startMonth: options?.startMonth })) { + await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false }); + yield* month.assetsIterator({ startDay, startAsset, direction }); + startDay = startAsset = undefined; + } + } + + *monthIterator(options?: { direction?: Direction; startMonth?: TimelineMonth }) { + const isEarlier = options?.direction === 'earlier'; + let startIndex = options?.startMonth + ? this.segments.indexOf(options.startMonth) + : isEarlier + ? 0 + : this.segments.length - 1; + + while (startIndex >= 0 && startIndex < this.segments.length) { + yield this.segments[startIndex]; + startIndex += isEarlier ? 1 : -1; + } + } + + async #init(options: TimelineManagerOptions) { + this.isInitialized = false; + this.segments = []; + this.albumAssets.clear(); + await this.initTask.execute(async () => { + this.#options = options; + const timebuckets = await getTimeBuckets({ + ...authManager.params, + ...this.#options, + }); + + for (const timeBucket of timebuckets) { + const date = new SvelteDate(timeBucket.timeBucket); + this.segments.push( + new TimelineMonth( + this, + { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, + timeBucket.count, + false, + this.#options.order, + ), + ); + } + this.albumAssets.clear(); + }, true); + this.refreshLayout(); + } + + #createScrubberMonths() { + this.scrubberMonths = this.segments.map((month) => ({ + assetCount: month.assetsCount, + year: month.yearMonth.year, + month: month.yearMonth.month, + title: month.monthTitle, + height: month.height, + })); + this.scrubberTimelineHeight = this.totalViewerHeight; } protected postCreateSegments(): void { - this.months.sort((a, b) => { + this.segments.sort((a, b) => { return a.yearMonth.year === b.yearMonth.year ? b.yearMonth.month - a.yearMonth.month : b.yearMonth.year - a.yearMonth.year; @@ -589,7 +501,7 @@ export class TimelineManager extends VirtualScrollManager { for (const month of context.updatedMonths) { month.sortDays(); - updateGeometry(this, month, { invalidateHeight: true }); + month.updateGeometry({ invalidateHeight: true }); } } } diff --git a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts index 91990aaf1b..d49c832a0f 100644 --- a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts @@ -1,49 +1,36 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; -import { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; -import { CancellableTask } from '$lib/utils/cancellable-task'; -import { handleError } from '$lib/utils/handle-error'; +import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import { formatDayTitle, + formatDayTitleFull, formatMonthTitle, fromTimelinePlainDate, fromTimelinePlainDateTime, fromTimelinePlainYearMonth, + getSegmentIdentifier, getTimes, setDifferenceInPlace, + toISOYearMonthUTC, type TimelineDateTime, type TimelineYearMonth, } from '$lib/utils/timeline-util'; -import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; -import { t } from 'svelte-i18n'; -import { get } from 'svelte/store'; +import { AssetOrder, getTimeBucket, type TimeBucketAssetResponseDto } from '@immich/sdk'; export class TimelineMonth extends ScrollSegment { - #intersecting: boolean = $state(false); - actuallyIntersecting: boolean = $state(false); - isLoaded: boolean = $state(false); days: TimelineDay[] = $state([]); - readonly timelineManager: TimelineManager; - #height: number = $state(0); - #top: number = $state(0); - - #initialCount: number = 0; #sortOrder: AssetOrder = AssetOrder.Desc; - percent: number = $state(0); - - assetsCount: number = $derived( - this.isLoaded ? this.days.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0) : this.#initialCount, - ); - loader: CancellableTask | undefined; - isHeightActual: boolean = $state(false); + #yearMonth: TimelineYearMonth; + #identifier: SegmentIdentifier; + #timelineManager: TimelineManager; readonly monthTitle: string; - readonly yearMonth: TimelineYearMonth; constructor( timelineManager: TimelineManager, @@ -53,46 +40,162 @@ export class TimelineMonth extends ScrollSegment { order: AssetOrder = AssetOrder.Desc, ) { super(); - this.timelineManager = timelineManager; - this.#initialCount = initialCount; + this.initialCount = initialCount; + this.#yearMonth = yearMonth; + this.#identifier = getSegmentIdentifier(yearMonth); + this.#timelineManager = timelineManager; this.#sortOrder = order; - - this.yearMonth = yearMonth; this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth)); - - this.loader = new CancellableTask( - () => { - this.isLoaded = true; - }, - () => { - this.days = []; - this.isLoaded = false; - }, - this.#handleLoadError, - ); - if (loaded) { - this.isLoaded = true; - } + this.loaded = loaded; if (import.meta.env.DEV) { onCreateMonth(this); } } - set intersecting(newValue: boolean) { - const old = this.#intersecting; - if (old === newValue) { - return; + get identifier() { + return this.#identifier; + } + + get scrollManager(): TimelineManager { + return this.#timelineManager; + } + + get viewerAssets() { + const assets: ViewerAsset[] = []; + for (const day of this.days) { + assets.push(...day.viewerAssets); } - this.#intersecting = newValue; - if (newValue) { - void this.timelineManager.loadMonth(this.yearMonth); - } else { - this.cancel(); + return assets; + } + + override findAssetAbsolutePosition(assetId: string) { + this.#clearDeferredLayout(); + for (const group of this.days) { + const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); + if (viewerAsset) { + if (!viewerAsset.position) { + console.warn('No position for asset'); + return; + } + return { + top: this.top + group.top + viewerAsset.position.top + this.scrollManager.headerHeight, + height: viewerAsset.position.height, + }; + } } } - get intersecting() { - return this.#intersecting; + protected async fetch(signal: AbortSignal): Promise { + if (this.getFirstAsset()) { + return; + } + const timelineManager = this.#timelineManager; + const options = timelineManager.options; + const timeBucket = toISOYearMonthUTC(this.yearMonth); + const bucketResponse = await getTimeBucket( + { + ...authManager.params, + ...options, + timeBucket, + }, + { signal }, + ); + + if (!bucketResponse) { + return; + } + + if (options.timelineAlbumId) { + const albumAssets = await getTimeBucket( + { + ...authManager.params, + albumId: options.timelineAlbumId, + timeBucket, + }, + { signal }, + ); + for (const id of albumAssets.id) { + timelineManager.albumAssets.add(id); + } + } + + const unprocessedAssets = this.addAssets(bucketResponse, true); + if (unprocessedAssets.length > 0) { + console.error( + `Warning: getTimeBucket API returning assets not in requested month: ${this.yearMonth.month}, ${JSON.stringify( + unprocessedAssets.map((unprocessed) => ({ + id: unprocessed.id, + localDateTime: unprocessed.localDateTime, + })), + )}`, + ); + } + } + + override layout(noDefer: boolean) { + let cumulativeHeight = 0; + let cumulativeWidth = 0; + let currentRowHeight = 0; + + let dayRow = 0; + let dayCol = 0; + + const options = this.scrollManager.justifiedLayoutOptions; + for (const day of this.days) { + day.layout(options, noDefer); + + // Calculate space needed for this item (including gap if not first in row) + const spaceNeeded = day.width + (dayCol > 0 ? this.scrollManager.gap : 0); + const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= this.scrollManager.viewportWidth; + + if (fitsInCurrentRow) { + day.row = dayRow; + day.col = dayCol++; + day.left = cumulativeWidth; + day.top = cumulativeHeight; + + cumulativeWidth += day.width + this.scrollManager.gap; + } else { + // Move to next row + cumulativeHeight += currentRowHeight; + cumulativeWidth = 0; + dayRow++; + dayCol = 0; + + // Position at start of new row + day.row = dayRow; + day.col = dayCol; + day.left = 0; + day.top = cumulativeHeight; + + dayCol++; + cumulativeWidth += day.width + this.scrollManager.gap; + } + currentRowHeight = day.height + this.scrollManager.headerHeight; + } + + // Add the height of the final row + cumulativeHeight += currentRowHeight; + + this.height = cumulativeHeight; + this.isHeightActual = true; + } + + override updateIntersection({ + intersecting, + actuallyIntersecting, + }: { + intersecting: boolean; + actuallyIntersecting: boolean; + }) { + super.updateIntersection({ intersecting, actuallyIntersecting }); + if (intersecting) { + this.#clearDeferredLayout(); + } + } + + get yearMonth() { + return this.#yearMonth; } get lastDay() { @@ -103,19 +206,6 @@ export class TimelineMonth extends ScrollSegment { return this.days[0]?.getFirstAsset(); } - getAssets() { - // eslint-disable-next-line unicorn/no-array-reduce - return this.days.reduce((accumulator: TimelineAsset[], g: TimelineDay) => accumulator.concat(g.getAssets()), []); - } - - sortDays() { - if (this.#sortOrder === AssetOrder.Asc) { - return this.days.sort((a, b) => a.day - b.day); - } - - return this.days.sort((a, b) => b.day - a.day); - } - runAssetOperation(ids: Set, operation: AssetOperation) { if (ids.size === 0) { return { @@ -215,6 +305,14 @@ export class TimelineMonth extends ScrollSegment { return addContext.unprocessedAssets; } + sortDays() { + if (this.#sortOrder === AssetOrder.Asc) { + return this.days.sort((a, b) => a.day - b.day); + } + + return this.days.sort((a, b) => b.day - a.day); + } + addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) { const { localDateTime } = timelineAsset; @@ -229,7 +327,8 @@ export class TimelineMonth extends ScrollSegment { addContext.setDay(day, localDateTime); } else { const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime)); - day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle); + const dayTitleFull = formatDayTitleFull(fromTimelinePlainDate(localDateTime)); + day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle, dayTitleFull); this.days.push(day); addContext.setDay(day, localDateTime); addContext.newDays.add(day); @@ -240,94 +339,6 @@ export class TimelineMonth extends ScrollSegment { addContext.changedDays.add(day); } - get viewId() { - const { year, month } = this.yearMonth; - return year + '-' + month; - } - - set height(height: number) { - if (this.#height === height) { - return; - } - const timelineManager = this.timelineManager; - const index = timelineManager.months.indexOf(this); - const heightDelta = height - this.#height; - this.#height = height; - const prevMonth = timelineManager.months[index - 1]; - if (prevMonth) { - const newTop = prevMonth.#top + prevMonth.#height; - if (this.#top !== newTop) { - this.#top = newTop; - } - } - if (heightDelta === 0) { - return; - } - for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) { - const month = this.timelineManager.months[cursor]; - const newTop = month.#top + heightDelta; - if (month.#top !== newTop) { - month.#top = newTop; - } - } - if (!timelineManager.viewportTopMonthIntersection) { - return; - } - const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection; - const currentIndex = month ? timelineManager.months.indexOf(month) : -1; - if (!month || currentIndex <= 0 || index > currentIndex) { - return; - } - if (index < currentIndex || monthBottomViewportRatio < 1) { - timelineManager.scrollBy(heightDelta); - } else if (index === currentIndex) { - const scrollTo = this.top + height * viewportTopRatioInMonth; - timelineManager.scrollTo(scrollTo); - } - } - - get height() { - return this.#height; - } - - get top(): number { - return this.#top + this.timelineManager.topSectionHeight; - } - - #handleLoadError(error: unknown) { - const _$t = get(t); - handleError(error, _$t('errors.failed_to_load_assets')); - } - - findDayForAsset(asset: TimelineAsset) { - for (const group of this.days) { - if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { - return group; - } - } - } - - findDayByDay(day: number) { - return this.days.find((group) => group.day === day); - } - - findAssetAbsolutePosition(assetId: string) { - this.timelineManager.clearDeferredLayout(this); - for (const group of this.days) { - const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId); - if (viewerAsset) { - if (!viewerAsset.position) { - console.warn('No position for asset'); - return; - } - return { - top: this.top + group.top + viewerAsset.position.top + this.timelineManager.headerHeight, - height: viewerAsset.position.height, - }; - } - } - } - *assetsIterator(options?: { startDay?: TimelineDay; startAsset?: TimelineAsset; direction?: Direction }) { const direction = options?.direction ?? 'earlier'; let { startAsset } = options ?? {}; @@ -342,6 +353,18 @@ export class TimelineMonth extends ScrollSegment { } } + findDayForAsset(asset: TimelineAsset) { + for (const group of this.days) { + if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) { + return group; + } + } + } + + findDayByDay(day: number) { + return this.days.find((group) => group.day === day); + } + findAssetById(assetDescriptor: AssetDescriptor) { for (const asset of this.assetsIterator()) { if (asset.id === assetDescriptor.id) { @@ -365,7 +388,13 @@ export class TimelineMonth extends ScrollSegment { return closest; } - cancel() { - this.loader?.cancel(); + #clearDeferredLayout() { + const hasDeferred = this.days.some((group) => group.deferredLayout); + if (hasDeferred) { + this.updateGeometry({ invalidateHeight: true, noDefer: true }); + for (const group of this.days) { + group.deferredLayout = false; + } + } } } diff --git a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts deleted file mode 100644 index 8026c69d49..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; -import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import { TUNABLES } from '$lib/utils/tunables'; - -const { - TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, -} = TUNABLES; - -export function updateIntersectionMonth(timelineManager: TimelineManager, month: TimelineMonth) { - const actuallyIntersecting = calculateMonthIntersecting(timelineManager, month, 0, 0); - let preIntersecting = false; - if (!actuallyIntersecting) { - preIntersecting = calculateMonthIntersecting( - timelineManager, - month, - INTERSECTION_EXPAND_TOP, - INTERSECTION_EXPAND_BOTTOM, - ); - } - month.intersecting = actuallyIntersecting || preIntersecting; - month.actuallyIntersecting = actuallyIntersecting; - if (preIntersecting || actuallyIntersecting) { - timelineManager.clearDeferredLayout(month); - } -} - -/** - * General function to check if a rectangular region intersects with a window. - * @param regionTop - Top position of the region to check - * @param regionBottom - Bottom position of the region to check - * @param windowTop - Top position of the window - * @param windowBottom - Bottom position of the window - * @returns true if the region intersects with the window - */ -export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) { - return ( - (regionTop >= windowTop && regionTop < windowBottom) || - (regionBottom >= windowTop && regionBottom < windowBottom) || - (regionTop < windowTop && regionBottom >= windowBottom) - ); -} - -export function calculateMonthIntersecting( - timelineManager: TimelineManager, - month: TimelineMonth, - expandTop: number, - expandBottom: number, -) { - const monthTop = month.top; - const monthBottom = monthTop + month.height; - const topWindow = timelineManager.visibleWindow.top - expandTop; - const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom; - - return isIntersecting(monthTop, monthBottom, topWindow, bottomWindow); -} - -/** - * Calculate intersection for viewer assets with additional parameters like header height - */ -export function calculateViewerAssetIntersecting( - timelineManager: TimelineManager, - positionTop: number, - positionHeight: number, - expandTop: number = INTERSECTION_EXPAND_TOP, - expandBottom: number = INTERSECTION_EXPAND_BOTTOM, -) { - const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop; - const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom; - - const positionBottom = positionTop + positionHeight; - - return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow); -} 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 deleted file mode 100644 index 533294d8ea..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/layout-support.svelte.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; -import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import type { UpdateGeometryOptions } from '$lib/managers/timeline-manager/types'; - -export function updateGeometry(timelineManager: TimelineManager, month: TimelineMonth, options: UpdateGeometryOptions) { - const { invalidateHeight, noDefer = false } = options; - if (invalidateHeight) { - month.isHeightActual = false; - } - if (!month.isLoaded) { - const viewportWidth = timelineManager.viewportWidth; - if (!month.isHeightActual) { - const unwrappedWidth = (3 / 2) * month.assetsCount * timelineManager.rowHeight * (7 / 10); - const rows = Math.ceil(unwrappedWidth / viewportWidth); - const height = 51 + Math.max(1, rows) * timelineManager.rowHeight; - month.height = height; - } - return; - } - layoutMonth(timelineManager, month, noDefer); -} - -export function layoutMonth(timelineManager: TimelineManager, month: TimelineMonth, noDefer: boolean = false) { - let cumulativeHeight = 0; - let cumulativeWidth = 0; - let currentRowHeight = 0; - - let dayRow = 0; - let dayCol = 0; - - const options = timelineManager.justifiedLayoutOptions; - for (const day of month.days) { - day.layout(options, noDefer); - - // Calculate space needed for this item (including gap if not first in row) - const spaceNeeded = day.width + (dayCol > 0 ? timelineManager.gap : 0); - const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= timelineManager.viewportWidth; - - if (fitsInCurrentRow) { - day.row = dayRow; - day.col = dayCol++; - day.left = cumulativeWidth; - day.top = cumulativeHeight; - - cumulativeWidth += day.width + timelineManager.gap; - } else { - // Move to next row - cumulativeHeight += currentRowHeight; - cumulativeWidth = 0; - dayRow++; - dayCol = 0; - - // Position at start of new row - day.row = dayRow; - day.col = dayCol; - day.left = 0; - day.top = cumulativeHeight; - - dayCol++; - cumulativeWidth += day.width + timelineManager.gap; - } - currentRowHeight = day.height + timelineManager.headerHeight; - } - - // Add the height of the final row - cumulativeHeight += currentRowHeight; - - month.height = cumulativeHeight; - month.isHeightActual = true; -} diff --git a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts deleted file mode 100644 index 0c91a13d78..0000000000 --- a/web/src/lib/managers/timeline-manager/internal/load-support.svelte.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { authManager } from '$lib/managers/auth-manager.svelte'; -import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; -import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; -import type { TimelineManagerOptions } from '$lib/managers/timeline-manager/types'; -import { toISOYearMonthUTC } from '$lib/utils/timeline-util'; -import { getTimeBucket } from '@immich/sdk'; - -export async function loadFromTimeBuckets( - timelineManager: TimelineManager, - month: TimelineMonth, - options: TimelineManagerOptions, - signal: AbortSignal, -): Promise { - if (month.getFirstAsset()) { - return; - } - - const timeBucket = toISOYearMonthUTC(month.yearMonth); - const bucketResponse = await getTimeBucket( - { - ...authManager.params, - ...options, - timeBucket, - }, - { signal }, - ); - - if (!bucketResponse) { - return; - } - - if (options.timelineAlbumId) { - const albumAssets = await getTimeBucket( - { - ...authManager.params, - albumId: options.timelineAlbumId, - timeBucket, - }, - { signal }, - ); - for (const id of albumAssets.id) { - timelineManager.albumAssets.add(id); - } - } - - const unprocessedAssets = month.addAssets(bucketResponse, true); - if (unprocessedAssets.length > 0) { - console.error( - `Warning: getTimeBucket API returning assets not in requested month: ${month.yearMonth.month}, ${JSON.stringify( - unprocessedAssets.map((unprocessed) => ({ - id: unprocessed.id, - localDateTime: unprocessed.localDateTime, - })), - )}`, - ); - } -} diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts index 375f7d9ff8..e121ec8a04 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts @@ -33,7 +33,7 @@ export async function getAssetWithOffset( } export function findMonthForAsset(timelineManager: TimelineManager, id: string) { - for (const month of timelineManager.months) { + for (const month of timelineManager.segments) { const asset = month.findAssetById({ id }); if (asset) { return { month, asset }; @@ -45,7 +45,7 @@ export function getMonthByDate( timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth, ): TimelineMonth | undefined { - return timelineManager.months.find( + return timelineManager.segments.find( (month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month, ); } @@ -137,7 +137,7 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass } export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) { - for (const month of timelineManager.months) { + for (const month of timelineManager.segments) { const { year, month: monthNum } = month.yearMonth; if (monthNum === targetYearMonth.month && year === targetYearMonth.year) { return month; diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index 6ae5f4a2bc..545f9204cd 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -1,7 +1,14 @@ -import { calculateViewerAssetIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; +import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; + import type { CommonPosition } from '$lib/utils/layout-utils'; +import { TUNABLES } from '$lib/utils/tunables'; + +const { + TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, +} = TUNABLES; export class ViewerAsset { readonly #day: TimelineDay; @@ -11,10 +18,10 @@ export class ViewerAsset { return false; } - const store = this.#day.month.timelineManager; - const positionTop = this.#day.absoluteTop + this.position.top; + const scrollManager = this.#day.month.scrollManager; + const positionTop = this.#day.topAbsolute + this.position.top; - return calculateViewerAssetIntersecting(store, positionTop, this.position.height); + return calculateViewerAssetIntersecting(scrollManager, positionTop, this.position.height); }); position: CommonPosition | undefined = $state.raw(); @@ -26,3 +33,19 @@ export class ViewerAsset { this.asset = asset; } } + +/** + * Calculate intersection for viewer assets with additional parameters like header height + */ +function calculateViewerAssetIntersecting( + scrollManager: VirtualScrollManager, + positionTop: number, + positionHeight: number, + expandTop: number = INTERSECTION_EXPAND_TOP, + expandBottom: number = INTERSECTION_EXPAND_BOTTOM, +) { + const topWindow = scrollManager.visibleWindow.top - scrollManager.headerHeight - expandTop; + const bottomWindow = scrollManager.visibleWindow.bottom + scrollManager.headerHeight + expandBottom; + const positionBottom = positionTop + positionHeight; + return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow); +} diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index ea4646ba2d..ce23f2cddf 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -14,6 +14,7 @@ import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; +import { getSegmentIdentifier } from '$lib/utils/timeline-util'; import { addAssetsToAlbum as addAssets, addAssetsToAlbums as addToAlbums, @@ -490,8 +491,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt isSelectingAllAssets.set(true); try { - for (const month of timelineManager.months) { - await timelineManager.loadMonth(month.yearMonth); + for (const month of timelineManager.segments) { + await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth)); if (!get(isSelectingAllAssets)) { assetInteraction.clearMultiselect(); @@ -499,8 +500,8 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt } assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()])); - for (const dateGroup of month.days) { - assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle); + for (const day of month.days) { + assetInteraction.addGroupToMultiselectGroup(day.dayTitle); } } } catch (error) { diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts index cf6335977a..f05603bb4c 100644 --- a/web/src/lib/utils/cancellable-task.ts +++ b/web/src/lib/utils/cancellable-task.ts @@ -1,3 +1,10 @@ +export enum TaskStatus { + DONE, + WAITED, + CANCELED, + LOADED, + ERRORED, +} export class CancellableTask { cancelToken: AbortController | null = null; cancellable: boolean = true; @@ -32,18 +39,18 @@ export class CancellableTask { async waitUntilCompletion() { if (this.executed) { - return 'DONE'; + return TaskStatus.DONE; } // if there is a cancel token, task is currently executing, so wait on the promise. If it // isn't, then the task is in new state, it hasn't been loaded, nor has it been executed. // in either case, we wait on the promise. await this.complete; - return 'WAITED'; + return TaskStatus.WAITED; } async execute Promise>(f: F, cancellable: boolean) { if (this.executed) { - return 'DONE'; + return TaskStatus.DONE; } // if promise is pending, wait on previous request instead. @@ -54,7 +61,7 @@ export class CancellableTask { this.cancellable = cancellable; } await this.complete; - return 'WAITED'; + return TaskStatus.WAITED; } this.cancellable = cancellable; const cancelToken = (this.cancelToken = new AbortController()); @@ -62,18 +69,18 @@ export class CancellableTask { try { await f(cancelToken.signal); if (cancelToken.signal.aborted) { - return 'CANCELED'; + return TaskStatus.CANCELED; } this.#transitionToExecuted(); - return 'LOADED'; + return TaskStatus.LOADED; } catch (error) { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((error as any).name === 'AbortError') { // abort error is not treated as an error, but as a cancellation. - return 'CANCELED'; + return TaskStatus.CANCELED; } this.#transitionToErrored(error); - return 'ERRORED'; + return TaskStatus.ERRORED; } finally { this.cancelToken = null; } diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 9d8e9d274e..75b24b34ec 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,4 +1,6 @@ +import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte'; import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; +import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; import { locale } from '$lib/stores/preferences.store'; import { getAssetRatio } from '$lib/utils/asset-utils'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; @@ -245,3 +247,29 @@ export function setDifferenceInPlace(setA: Set, setB: Set): Set { } return setA; } + +export const formatDayTitleFull = (_date: DateTime): string => { + if (!_date.isValid) { + return _date.toString(); + } + const date = _date as DateTime; + return getDateLocaleString(date); +}; + +/** + * Creates a segment identifier for a given year/month or date/time. + * This is used to uniquely identify and match timeline segments (MonthGroups). + */ +export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime): SegmentIdentifier => ({ + get id() { + return yearMonth.year + '-' + yearMonth.month; + }, + matches: (segment: ScrollSegment) => { + const monthSegment = segment as TimelineMonth; + return ( + monthSegment.yearMonth && + monthSegment.yearMonth.year === yearMonth.year && + monthSegment.yearMonth.month === yearMonth.month + ); + }, +}); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6d17547d32..5c10c5aaac 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -141,7 +141,7 @@ const asset = $slideshowNavigation === SlideshowNavigation.Shuffle ? await timelineManager.getRandomAsset() - : timelineManager.months[0]?.days[0]?.viewerAssets[0]?.asset; + : timelineManager.getFirstAsset(); if (asset) { handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow))); }