refactor: remove scroll compensation logic from photostream components

pull/22434/head
midzelis 2025-09-28 00:32:38 +00:00
parent 8943242b7f
commit 674def4088
11 changed files with 74 additions and 144 deletions

View File

@ -58,10 +58,8 @@
<Skeleton height={segment.height - segment.timelineManager.headerHeight} {stylePaddingHorizontalPx} /> <Skeleton height={segment.height - segment.timelineManager.headerHeight} {stylePaddingHorizontalPx} />
{/snippet} {/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })} {#snippet segment({ segment })}
<SelectableSegment <SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
timelineManager={searchResultsManager} timelineManager={searchResultsManager}
{assetInteraction} {assetInteraction}
isSelectionMode={false} isSelectionMode={false}

View File

@ -15,7 +15,6 @@
{ {
segment: PhotostreamSegment; segment: PhotostreamSegment;
scrollToFunction: (top: number) => void; scrollToFunction: (top: number) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}, },
] ]
>; >;
@ -107,44 +106,13 @@
updateSlidingWindow(); 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) => { export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId); const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) { if (!monthGroup) {
return false; return false;
} }
const height = getAssetHeight(assetId, monthGroup); const height = monthGroup.findAssetAbsolutePosition(assetId);
scrollTo(height); scrollTo(height);
return true; return true;
}; };
@ -274,7 +242,6 @@
{@render segment({ {@render segment({
segment: monthGroup, segment: monthGroup,
scrollToFunction: scrollTo, scrollToFunction: scrollTo,
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
})} })}
{/if} {/if}
</div> </div>

View File

@ -24,7 +24,6 @@
[ [
{ {
segment: PhotostreamSegment; segment: PhotostreamSegment;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}, },
] ]
>; >;

View File

@ -5,7 +5,6 @@
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; 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 { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
@ -21,28 +20,17 @@
}, },
] ]
>; >;
segment: PhotostreamSegment;
isSelectionMode: boolean; isSelectionMode: boolean;
singleSelect: boolean; singleSelect: boolean;
timelineManager: PhotostreamManager; timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void; onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void; onAssetSelect?: (asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
} }
let { let { content, isSelectionMode, singleSelect, assetInteraction, timelineManager, onAssetOpen, onAssetSelect }: Props =
segment, $props();
content,
isSelectionMode,
singleSelect,
assetInteraction,
timelineManager,
onAssetOpen,
onAssetSelect,
onScrollCompensationMonthInDOM,
}: Props = $props();
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0); let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
@ -189,12 +177,6 @@
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset)); const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets); assetInteraction.setAssetSelectionCandidates(assets);
}; };
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === segment) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script> </script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} /> <svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />

View File

@ -99,10 +99,8 @@
title={(segment as MonthGroup).monthGroupTitle} title={(segment as MonthGroup).monthGroupTitle}
/> />
{/snippet} {/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })} {#snippet segment({ segment })}
<SelectableSegment <SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager} {timelineManager}
{assetInteraction} {assetInteraction}
{isSelectionMode} {isSelectionMode}

View File

@ -1,7 +1,7 @@
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { import {
PhotostreamSegment, PhotostreamSegment,
@ -21,15 +21,14 @@ import { setDifference } from '$lib/utils/timeline-util';
export abstract class PhotostreamManager { export abstract class PhotostreamManager {
isInitialized = $state(false); isInitialized = $state(false);
topSectionHeight = $state(0); topSectionHeight = $state(0);
bottomSectionHeight = $state(60); bottomSectionHeight = $state(60);
abstract get months(): PhotostreamSegment[];
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
timelineHeight = $derived.by( timelineHeight = $derived.by(
() => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight, () => 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(() => ({ visibleWindow = $derived.by(() => ({
top: this.#scrollTop, top: this.#scrollTop,
@ -54,17 +53,9 @@ export abstract class PhotostreamManager {
#suspendTransitions = $state(false); #suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: { #updatingIntersections = false;
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: PhotostreamSegment | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
constructor() {} abstract get months(): PhotostreamSegment[];
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) { setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false; let changed = false;
@ -163,39 +154,17 @@ export abstract class PhotostreamManager {
} }
} }
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() { updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return; return;
} }
let topIntersectingMonthGroup = undefined; this.#updatingIntersections = true;
for (const month of this.months) { for (const month of this.months) {
updateIntersectionMonthGroup(this, month); 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() { async init() {

View File

@ -4,6 +4,7 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte'; import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { getTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte';
import type { AssetOperation, MoveAsset, TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { AssetOperation, MoveAsset, TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; 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)); #assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0); initialCount = $state(0);
percent = $state(0);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount)); assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask( loader = new CancellableTask(
@ -30,6 +30,10 @@ export abstract class PhotostreamSegment {
); );
isHeightActual = $state(false); isHeightActual = $state(false);
constructor() {
getTestHook()?.hookSegment(this);
}
abstract get timelineManager(): PhotostreamManager; abstract get timelineManager(): PhotostreamManager;
abstract get identifier(): SegmentIdentifier; abstract get identifier(): SegmentIdentifier;
@ -66,9 +70,13 @@ export abstract class PhotostreamSegment {
} }
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> { 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); await this.fetch(signal);
}, cancelable); }, cancelable);
if (executionStatus === 'LOADED') {
this.layout();
}
return executionStatus;
} }
protected abstract fetch(signal: AbortSignal): Promise<void>; protected abstract fetch(signal: AbortSignal): Promise<void>;
@ -88,42 +96,34 @@ export abstract class PhotostreamSegment {
if (this.#height === height) { if (this.#height === height) {
return; 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; const heightDelta = height - this.#height;
this.#height = height; this.#height = height;
const prevMonthGroup = store.months[index - 1]; const prevMonthGroup = timelineManager.months[index - 1];
if (prevMonthGroup) { if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height; const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) { if (this.#top !== newTop) {
this.#top = newTop; this.#top = newTop;
} }
} }
for (let cursor = index + 1; cursor < store.months.length; cursor++) { if (heightDelta === 0) {
const monthGroup = this.timelineManager.months[cursor]; return;
}
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const monthGroup = timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta; const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) { if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop; monthGroup.#top = newTop;
needsIntersectionUpdate = true;
} }
} }
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup); if (needsIntersectionUpdate) {
if (currentIndex > 0) { timelineManager.updateIntersections();
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,
};
}
}
} }
} }

View File

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

View File

@ -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( export function calculateViewerAssetIntersecting(
timelineManager: PhotostreamManager, timelineManager: PhotostreamManager,
@ -60,13 +60,8 @@ export function calculateViewerAssetIntersecting(
expandTop: number = INTERSECTION_EXPAND_TOP, expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM, expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) { ) {
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0; const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
const topWindow =
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
const bottomWindow =
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
const positionBottom = positionTop + positionHeight; const positionBottom = positionTop + positionHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow); return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);

View File

@ -73,6 +73,10 @@ export class MonthGroup extends PhotostreamSegment {
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal); return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
} }
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
get lastDayGroup() { get lastDayGroup() {
return this.dayGroups.at(-1); return this.dayGroups.at(-1);
} }
@ -306,10 +310,6 @@ export class MonthGroup extends PhotostreamSegment {
this.loader?.cancel(); this.loader?.cancel();
} }
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
#clearDeferredLayout() { #clearDeferredLayout() {
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout); const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) { if (hasDeferred) {

View File

@ -1,4 +1,6 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; 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 { import {
findMonthGroupForAsset, findMonthGroupForAsset,
getMonthGroupByDate, getMonthGroupByDate,
@ -7,6 +9,7 @@ import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util'; import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import type { MockedFunction } from 'vitest';
import { TimelineManager } from './timeline-manager.svelte'; import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
@ -57,8 +60,16 @@ describe('TimelineManager', () => {
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
); );
const spys: { segment: PhotostreamSegment; cancelSpy: MockedFunction<() => void> }[] = [];
beforeEach(async () => { beforeEach(async () => {
timelineManager = new TimelineManager(); timelineManager = new TimelineManager();
setTestHook({
hookSegment: (segment) => {
spys.push({ segment, cancelSpy: vi.spyOn(segment, 'cancel') as MockedFunction<() => void> });
},
});
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01' }, { count: 1, timeBucket: '2024-03-01' },
{ count: 100, timeBucket: '2024-02-01' }, { count: 100, timeBucket: '2024-02-01' },
@ -71,7 +82,7 @@ describe('TimelineManager', () => {
it('should load months in viewport', () => { it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); expect(spys[2].cancelSpy).toHaveBeenCalled();
}); });
it('calculates month height', () => { it('calculates month height', () => {
@ -85,13 +96,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }), expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }), 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', () => { it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_447.5); expect(timelineManager.timelineHeight).toBe(12_209.5);
}); });
}); });