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);
});
});