diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 8b06d9b72b..4ecf71f517 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -1,5 +1,6 @@
- {#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} + {#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)} {@const position = viewerAsset.position!} {@const asset = viewerAsset.asset!} diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index 91073a0a5f..c0b20b17bb 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -3,7 +3,7 @@ import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; + import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte'; import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { uploadAssetsStore } from '$lib/stores/upload'; @@ -14,7 +14,16 @@ import type { Snippet } from 'svelte'; type Props = { - thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>; + thumbnail: Snippet< + [ + { + asset: TimelineAsset; + position: CommonPosition; + dayGroup: DayGroup; + groupIndex: number; + }, + ] + >; customThumbnailLayout?: Snippet<[TimelineAsset]>; singleSelect: boolean; assetInteraction: AssetInteraction; @@ -37,10 +46,6 @@ const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); - const filterIntersecting = (intersectables: T[]) => { - return intersectables.filter(({ intersecting }) => intersecting); - }; - const getDayGroupFullDate = (dayGroup: DayGroup): string => { const { month, year } = dayGroup.monthGroup.yearMonth; const date = fromTimelinePlainDate({ @@ -52,7 +57,7 @@ }; -{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} +{#each filterIsInOrNearViewport(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
{#each timelineManager.months as monthGroup (monthGroup.viewId)} - {@const display = monthGroup.intersecting} + {@const isInOrNearViewport = monthGroup.isInOrNearViewport} {@const absoluteHeight = monthGroup.top} {#if !monthGroup.isLoaded} @@ -654,7 +654,7 @@ >
- {:else if display} + {:else if isInOrNearViewport}
this.viewerAssets.some((viewAsset) => viewAsset.intersecting)); + isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport)); #top: number = $state(0); #start: number = $state(0); @@ -137,7 +137,7 @@ export class DayGroup { } layout(options: CommonLayoutOptions, noDefer: boolean) { - if (!noDefer && !this.monthGroup.intersecting && !this.monthGroup.timelineManager.isScrollingOnLoad) { + if (!noDefer && !this.monthGroup.isInOrNearViewport && !this.monthGroup.timelineManager.isScrollingOnLoad) { this.#deferredLayout = true; return; } 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 3c6f2d8256..6fa8ab88c0 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 @@ -6,68 +6,64 @@ const { TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, } = TUNABLES; -export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) { - const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0); - let preIntersecting = false; - if (!actuallyIntersecting) { - preIntersecting = calculateMonthGroupIntersecting( - timelineManager, - month, - INTERSECTION_EXPAND_TOP, - INTERSECTION_EXPAND_BOTTOM, - ); +export function isIntersecting(regionTop: number, regionBottom: number, otherTop: number, otherBottom: number) { + return ( + (regionTop >= otherTop && regionTop < otherBottom) || + (regionBottom >= otherTop && regionBottom < otherBottom) || + (regionTop < otherTop && regionBottom >= otherBottom) + ); +} + +export enum ViewportProximity { + FarFromViewport, + NearViewport, + InViewport, +} + +export function isInViewport(state: ViewportProximity): boolean { + return state === ViewportProximity.InViewport; +} + +export function isInOrNearViewport(state: ViewportProximity): boolean { + return state !== ViewportProximity.FarFromViewport; +} + +function calculateViewportProximity(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) { + if (regionBottom < windowTop - INTERSECTION_EXPAND_TOP || regionTop >= windowBottom + INTERSECTION_EXPAND_BOTTOM) { + return ViewportProximity.FarFromViewport; } - month.intersecting = actuallyIntersecting || preIntersecting; - month.actuallyIntersecting = actuallyIntersecting; - if (preIntersecting || actuallyIntersecting) { + + if (regionBottom < windowTop || regionTop >= windowBottom) { + return ViewportProximity.NearViewport; + } + + return ViewportProximity.InViewport; +} + +export function updateMonthGroupViewportProximity(timelineManager: TimelineManager, month: MonthGroup) { + const proximity = calculateViewportProximity( + month.top, + month.top + month.height, + timelineManager.visibleWindow.top, + timelineManager.visibleWindow.bottom, + ); + + month.viewportProximity = proximity; + if (isInOrNearViewport(proximity)) { 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 calculateMonthGroupIntersecting( - timelineManager: TimelineManager, - monthGroup: MonthGroup, - expandTop: number, - expandBottom: number, -) { - const monthGroupTop = monthGroup.top; - const monthGroupBottom = monthGroupTop + monthGroup.height; - const topWindow = timelineManager.visibleWindow.top - expandTop; - const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom; - - return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow); -} - -/** - * Calculate intersection for viewer assets with additional parameters like header height - */ -export function calculateViewerAssetIntersecting( +export function calculateViewerAssetViewportProximity( 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); + const headerHeight = timelineManager.headerHeight; + return calculateViewportProximity( + positionTop, + positionTop + positionHeight, + timelineManager.visibleWindow.top - headerHeight, + timelineManager.visibleWindow.bottom + headerHeight, + ); } 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 b41deb5785..d23dc1b801 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -17,6 +17,11 @@ import { import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; +import { + ViewportProximity, + isInOrNearViewport as isInOrNearViewportUtil, + isInViewport as isInViewportUtil, +} from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { SvelteSet } from 'svelte/reactivity'; import { DayGroup } from './day-group.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte'; @@ -25,8 +30,7 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ import { ViewerAsset } from './viewer-asset.svelte'; export class MonthGroup { - #intersecting: boolean = $state(false); - actuallyIntersecting: boolean = $state(false); + #viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport); isLoaded: boolean = $state(false); dayGroups: DayGroup[] = $state([]); readonly timelineManager: TimelineManager; @@ -78,21 +82,25 @@ export class MonthGroup { } } - set intersecting(newValue: boolean) { - const old = this.#intersecting; + set viewportProximity(newValue: ViewportProximity) { + const old = this.#viewportProximity; if (old === newValue) { return; } - this.#intersecting = newValue; - if (newValue) { + this.#viewportProximity = newValue; + if (isInOrNearViewportUtil(newValue)) { void this.timelineManager.loadMonthGroup(this.yearMonth); } else { this.cancel(); } } - get intersecting() { - return this.#intersecting; + get isInOrNearViewport() { + return isInOrNearViewportUtil(this.#viewportProximity); + } + + get isInViewport() { + return isInViewportUtil(this.#viewportProximity); } get lastDayGroup() { diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 38c593bd00..9ab884b059 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -2,7 +2,7 @@ import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/Virtual import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte'; -import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; +import { updateMonthGroupViewportProximity } 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 { @@ -91,7 +91,7 @@ export class TimelineManager extends VirtualScrollManager { static #INIT_OPTIONS = {}; #websocketSupport: WebsocketSupport | undefined; #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; - #updatingIntersections = false; + #updatingViewportProximities = false; #scrollableElement: HTMLElement | undefined = $state(); #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false); #unsubscribes: Array<() => void> = []; @@ -198,17 +198,21 @@ export class TimelineManager extends VirtualScrollManager { return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1); } - override updateIntersections() { - if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { + override updateViewportProximities() { + if ( + this.#updatingViewportProximities || + !this.isInitialized || + this.visibleWindow.bottom === this.visibleWindow.top + ) { return; } - this.#updatingIntersections = true; + this.#updatingViewportProximities = true; for (const month of this.months) { - updateIntersectionMonthGroup(this, month); + updateMonthGroupViewportProximity(this, month); } - const month = this.months.find((month) => month.actuallyIntersecting); + const month = this.months.find((month) => month.isInViewport); const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month); const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month); @@ -218,7 +222,7 @@ export class TimelineManager extends VirtualScrollManager { viewportTopRatioInMonth, }; - this.#updatingIntersections = false; + this.#updatingViewportProximities = false; } clearDeferredLayout(month: MonthGroup) { @@ -317,7 +321,7 @@ export class TimelineManager extends VirtualScrollManager { for (const month of this.months) { updateGeometry(this, month, { invalidateHeight: changedWidth }); } - this.updateIntersections(); + this.updateViewportProximities(); if (changedWidth) { this.#createScrubberMonths(); } @@ -353,7 +357,7 @@ export class TimelineManager extends VirtualScrollManager { }, cancelable); if (executionStatus === 'LOADED') { updateGeometry(this, monthGroup, { invalidateHeight: false }); - this.updateIntersections(); + this.updateViewportProximities(); } } @@ -538,7 +542,7 @@ export class TimelineManager extends VirtualScrollManager { updateGeometry(this, month, { invalidateHeight: true }); } if (changedGeometry) { - this.updateIntersections(); + this.updateViewportProximities(); } return { updated, notUpdated, changedGeometry }; } @@ -547,7 +551,7 @@ export class TimelineManager extends VirtualScrollManager { for (const month of this.months) { updateGeometry(this, month, { invalidateHeight: true }); } - this.updateIntersections(); + this.updateViewportProximities(); } getFirstAsset(): TimelineAsset | undefined { @@ -626,6 +630,6 @@ export class TimelineManager extends VirtualScrollManager { month.sortDayGroups(); updateGeometry(this, month, { invalidateHeight: true }); } - this.updateIntersections(); + this.updateViewportProximities(); } } diff --git a/web/src/lib/managers/timeline-manager/utils.svelte.ts b/web/src/lib/managers/timeline-manager/utils.svelte.ts index 2aba6470ee..efc94206ea 100644 --- a/web/src/lib/managers/timeline-manager/utils.svelte.ts +++ b/web/src/lib/managers/timeline-manager/utils.svelte.ts @@ -2,3 +2,7 @@ import type { TimelineAsset } from './types'; export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); + +export function filterIsInOrNearViewport(items: T[]) { + return items.filter(({ isInOrNearViewport }) => isInOrNearViewport); +} 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 161cc049f1..e0d8e1f5b5 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -1,23 +1,31 @@ import type { CommonPosition } from '$lib/utils/layout-utils'; import type { DayGroup } from './day-group.svelte'; -import { calculateViewerAssetIntersecting } from './internal/intersection-support.svelte'; +import { + ViewportProximity, + calculateViewerAssetViewportProximity, + isInOrNearViewport, +} from './internal/intersection-support.svelte'; import type { TimelineAsset } from './types'; export class ViewerAsset { readonly #group: DayGroup; - intersecting = $derived.by(() => { + #viewportProximity = $derived.by(() => { if (!this.position) { - return false; + return ViewportProximity.FarFromViewport; } const store = this.#group.monthGroup.timelineManager; const positionTop = this.#group.absoluteDayGroupTop + this.position.top; - return calculateViewerAssetIntersecting(store, positionTop, this.position.height); + return calculateViewerAssetViewportProximity(store, positionTop, this.position.height); }); + get isInOrNearViewport() { + return isInOrNearViewport(this.#viewportProximity); + } + position: CommonPosition | undefined = $state.raw(); asset: TimelineAsset = $state(); id: string = $derived(this.asset.id);