diff --git a/web/src/lib/components/search/SearchResults.svelte b/web/src/lib/components/search/SearchResults.svelte index eb5f58860c..8b43e43317 100644 --- a/web/src/lib/components/search/SearchResults.svelte +++ b/web/src/lib/components/search/SearchResults.svelte @@ -58,10 +58,8 @@ {/snippet} - {#snippet segment({ segment, onScrollCompensationMonthInDOM })} + {#snippet segment({ segment })} void; - onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; }, ] >; @@ -107,44 +106,13 @@ updateSlidingWindow(); }; - const scrollBy = (y: number) => { - if (element) { - element.scrollBy(0, y); - } - updateSlidingWindow(); - }; - - const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => { - const { heightDelta, scrollTop } = compensation; - if (heightDelta !== undefined) { - scrollBy(heightDelta); - } else if (scrollTop !== undefined) { - scrollTo(scrollTop); - } - timelineManager.clearScrollCompensation(); - }; - - const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => { - // the following method may trigger any layouts, so need to - // handle any scroll compensation that may have been set - const height = monthGroup.findAssetAbsolutePosition(assetId); - - // this is in a while loop, since scrollCompensations invoke scrolls - // which may load months, triggering more scrollCompensations. Call - // this in a loop, until no more layouts occur. - while (timelineManager.scrollCompensation.monthGroup) { - handleTriggeredScrollCompensation(timelineManager.scrollCompensation); - } - return height; - }; - export const scrollToAssetId = async (assetId: string) => { const monthGroup = await timelineManager.findSegmentForAssetId(assetId); if (!monthGroup) { return false; } - const height = getAssetHeight(assetId, monthGroup); + const height = monthGroup.findAssetAbsolutePosition(assetId); scrollTo(height); return true; }; @@ -274,7 +242,6 @@ {@render segment({ segment: monthGroup, scrollToFunction: scrollTo, - onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation, })} {/if} diff --git a/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte b/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte index 9bfd797c04..9818cf0bbf 100644 --- a/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte +++ b/web/src/lib/components/timeline/PhotostreamWithScrubber.svelte @@ -24,7 +24,6 @@ [ { segment: PhotostreamSegment; - onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; }, ] >; diff --git a/web/src/lib/components/timeline/SelectableSegment.svelte b/web/src/lib/components/timeline/SelectableSegment.svelte index 55dfaa371d..91a1230eab 100644 --- a/web/src/lib/components/timeline/SelectableSegment.svelte +++ b/web/src/lib/components/timeline/SelectableSegment.svelte @@ -5,7 +5,6 @@ import { navigate } from '$lib/utils/navigation'; import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; - import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { searchStore } from '$lib/stores/search.svelte'; @@ -21,28 +20,17 @@ }, ] >; - segment: PhotostreamSegment; + isSelectionMode: boolean; singleSelect: boolean; timelineManager: PhotostreamManager; assetInteraction: AssetInteraction; onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void; onAssetSelect?: (asset: TimelineAsset) => void; - - onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void; } - let { - segment, - content, - isSelectionMode, - singleSelect, - assetInteraction, - timelineManager, - onAssetOpen, - onAssetSelect, - onScrollCompensationMonthInDOM, - }: Props = $props(); + let { content, isSelectionMode, singleSelect, assetInteraction, timelineManager, onAssetOpen, onAssetSelect }: Props = + $props(); let shiftKeyIsDown = $state(false); let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); @@ -189,12 +177,6 @@ const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset)); assetInteraction.setAssetSelectionCandidates(assets); }; - - $effect.root(() => { - if (timelineManager.scrollCompensation.monthGroup === segment) { - onScrollCompensationMonthInDOM(timelineManager.scrollCompensation); - } - }); diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 07580489b3..c563e8b615 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -99,10 +99,8 @@ title={(segment as MonthGroup).monthGroupTitle} /> {/snippet} - {#snippet segment({ segment, onScrollCompensationMonthInDOM })} + {#snippet segment({ segment })} this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); timelineHeight = $derived.by( () => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight, ); - assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); - - topIntersectingMonthGroup: PhotostreamSegment | undefined = $state(); visibleWindow = $derived.by(() => ({ top: this.#scrollTop, @@ -49,17 +48,9 @@ export abstract class PhotostreamManager { #suspendTransitions = $state(false); #resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); - scrollCompensation: { - heightDelta: number | undefined; - scrollTop: number | undefined; - monthGroup: PhotostreamSegment | undefined; - } = $state({ - heightDelta: 0, - scrollTop: 0, - monthGroup: undefined, - }); + #updatingIntersections = false; - constructor() {} + abstract get months(): PhotostreamSegment[]; setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) { let changed = false; @@ -158,39 +149,17 @@ export abstract class PhotostreamManager { } } - clearScrollCompensation() { - this.scrollCompensation = { - heightDelta: undefined, - scrollTop: undefined, - monthGroup: undefined, - }; - } - updateIntersections() { - if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { + if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { return; } - let topIntersectingMonthGroup = undefined; + this.#updatingIntersections = true; + for (const month of this.months) { updateIntersectionMonthGroup(this, month); - if (!topIntersectingMonthGroup && month.actuallyIntersecting) { - topIntersectingMonthGroup = month; - } - } - if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) { - this.topIntersectingMonthGroup = topIntersectingMonthGroup; - } - for (const month of this.months) { - if (month === this.topIntersectingMonthGroup) { - this.topIntersectingMonthGroup.percent = clamp( - (this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height, - 0, - 1, - ); - } else { - month.percent = 0; - } } + + this.#updatingIntersections = false; } async init() { diff --git a/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts b/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts index f473139aed..f9a958a12c 100644 --- a/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts +++ b/web/src/lib/managers/photostream-manager/PhotostreamSegment.svelte.ts @@ -4,6 +4,7 @@ import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; +import { getTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; @@ -20,7 +21,6 @@ export abstract class PhotostreamSegment { #assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset)); initialCount = $state(0); - percent = $state(0); assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount)); loader = new CancellableTask( @@ -30,6 +30,10 @@ export abstract class PhotostreamSegment { ); isHeightActual = $state(false); + constructor() { + getTestHook()?.hookSegment(this); + } + abstract get timelineManager(): PhotostreamManager; abstract get identifier(): SegmentIdentifier; @@ -66,9 +70,13 @@ export abstract class PhotostreamSegment { } async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> { - return await this.loader.execute(async (signal: AbortSignal) => { + const executionStatus = await this.loader.execute(async (signal: AbortSignal) => { await this.fetch(signal); }, cancelable); + if (executionStatus === 'LOADED') { + this.layout(); + } + return executionStatus; } protected abstract fetch(signal: AbortSignal): Promise; @@ -88,42 +96,34 @@ export abstract class PhotostreamSegment { if (this.#height === height) { return; } - const { timelineManager: store, percent } = this; - const index = store.months.indexOf(this); + + let needsIntersectionUpdate = false; + const timelineManager = this.timelineManager; + const index = timelineManager.months.indexOf(this); const heightDelta = height - this.#height; this.#height = height; - const prevMonthGroup = store.months[index - 1]; + const prevMonthGroup = timelineManager.months[index - 1]; if (prevMonthGroup) { const newTop = prevMonthGroup.#top + prevMonthGroup.#height; if (this.#top !== newTop) { this.#top = newTop; } } - for (let cursor = index + 1; cursor < store.months.length; cursor++) { - const monthGroup = this.timelineManager.months[cursor]; + if (heightDelta === 0) { + return; + } + + for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) { + const monthGroup = timelineManager.months[cursor]; const newTop = monthGroup.#top + heightDelta; if (monthGroup.#top !== newTop) { monthGroup.#top = newTop; + needsIntersectionUpdate = true; } } - if (store.topIntersectingMonthGroup) { - const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup); - if (currentIndex > 0) { - if (index < currentIndex) { - store.scrollCompensation = { - heightDelta, - scrollTop: undefined, - monthGroup: this, - }; - } else if (percent > 0) { - const top = this.top + height * percent; - store.scrollCompensation = { - heightDelta: undefined, - scrollTop: top, - monthGroup: this, - }; - } - } + + if (needsIntersectionUpdate) { + timelineManager.updateIntersections(); } } diff --git a/web/src/lib/managers/photostream-manager/TestHooks.svelte.ts b/web/src/lib/managers/photostream-manager/TestHooks.svelte.ts new file mode 100644 index 0000000000..ce24efd965 --- /dev/null +++ b/web/src/lib/managers/photostream-manager/TestHooks.svelte.ts @@ -0,0 +1,11 @@ +import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte'; + +let testHooks: { hookSegment: (segment: PhotostreamSegment) => void } | undefined = undefined; + +export function setTestHook(hooks: { hookSegment: (segment: PhotostreamSegment) => void }) { + testHooks = hooks; +} + +export function getTestHook() { + return testHooks; +} 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 index dc1d00ae6a..aef46d645c 100644 --- a/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/intersection-support.svelte.ts @@ -51,7 +51,7 @@ export function calculateSegmentIntersecting( } /** - * Calculate intersection for viewer assets with additional parameters like header height and scroll compensation + * Calculate intersection for viewer assets with additional parameters like header height */ export function calculateViewerAssetIntersecting( timelineManager: PhotostreamManager, @@ -60,13 +60,8 @@ export function calculateViewerAssetIntersecting( expandTop: number = INTERSECTION_EXPAND_TOP, expandBottom: number = INTERSECTION_EXPAND_BOTTOM, ) { - const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0; - - const topWindow = - timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta; - const bottomWindow = - timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta; - + 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/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 42349c0fb3..40ccfdd31b 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -73,6 +73,10 @@ export class MonthGroup extends PhotostreamSegment { return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal); } + layout(noDefer?: boolean) { + layoutMonthGroup(this.timelineManager, this, noDefer); + } + get lastDayGroup() { return this.dayGroups.at(-1); } @@ -306,10 +310,6 @@ export class MonthGroup extends PhotostreamSegment { this.loader?.cancel(); } - layout(noDefer?: boolean) { - layoutMonthGroup(this.timelineManager, this, noDefer); - } - #clearDeferredLayout() { const hasDeferred = this.dayGroups.some((group) => group.deferredLayout); if (hasDeferred) { diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 460888d841..6075332c6d 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -1,4 +1,6 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte'; +import { setTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte'; import { findMonthGroupForAsset, getMonthGroupByDate, @@ -7,6 +9,7 @@ import { AbortError } from '$lib/utils'; import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; +import type { MockedFunction } from 'vitest'; import { TimelineManager } from './timeline-manager.svelte'; import type { TimelineAsset } from './types'; @@ -57,8 +60,16 @@ describe('TimelineManager', () => { Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), ); + const spys: { segment: PhotostreamSegment; cancelSpy: MockedFunction<() => void> }[] = []; beforeEach(async () => { timelineManager = new TimelineManager(); + + setTestHook({ + hookSegment: (segment) => { + spys.push({ segment, cancelSpy: vi.spyOn(segment, 'cancel') as MockedFunction<() => void> }); + }, + }); + sdkMock.getTimeBuckets.mockResolvedValue([ { count: 1, timeBucket: '2024-03-01' }, { count: 100, timeBucket: '2024-02-01' }, @@ -71,7 +82,7 @@ describe('TimelineManager', () => { it('should load months in viewport', () => { expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); - expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); + expect(spys[2].cancelSpy).toHaveBeenCalled(); }); it('calculates month height', () => { @@ -85,13 +96,13 @@ describe('TimelineManager', () => { expect.arrayContaining([ expect.objectContaining({ year: 2024, month: 3, height: 165.5 }), expect.objectContaining({ year: 2024, month: 2, height: 11_996 }), - expect.objectContaining({ year: 2024, month: 1, height: 286 }), + expect.objectContaining({ year: 2024, month: 1, height: 48 }), ]), ); }); it('calculates timeline height', () => { - expect(timelineManager.timelineHeight).toBe(12_447.5); + expect(timelineManager.timelineHeight).toBe(12_209.5); }); });