refactor(web): replace intersection booleans with enum (#27306)

Change-Id: I0c9703d5960031142ae47fef23805e0a6a6a6964
This commit is contained in:
Min Idzelis 2026-03-27 08:37:12 -04:00 committed by GitHub
parent 4b9ebc2cff
commit 2d950db940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 121 additions and 99 deletions

View File

@ -1,5 +1,6 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
@ -30,15 +31,11 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}

View File

@ -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 = <T extends { intersecting: boolean }>(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 @@
};
</script>
{#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)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section

View File

@ -642,7 +642,7 @@
</section>
{#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 @@
>
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
</div>
{:else if display}
{:else if isInOrNearViewport}
<div
class="month-group"
style:height={monthGroup.height + 'px'}

View File

@ -138,7 +138,7 @@ export abstract class VirtualScrollManager {
return this.viewportWidth === 0 || this.viewportHeight === 0;
}
protected updateIntersections(): void {}
protected updateViewportProximities(): void {}
protected updateViewportGeometry(_: boolean) {}
@ -156,12 +156,12 @@ export abstract class VirtualScrollManager {
const scrollTop = this.scrollTop;
if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop;
this.updateIntersections();
this.updateViewportProximities();
}
}
refreshLayout() {
this.updateIntersections();
this.updateViewportProximities();
}
destroy(): void {}

View File

@ -18,7 +18,7 @@ export class DayGroup {
height = $state(0);
width = $state(0);
intersecting = $derived.by(() => 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;
}

View File

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

View File

@ -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() {

View File

@ -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<boolean>('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();
}
}

View File

@ -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<T extends { isInOrNearViewport: boolean }>(items: T[]) {
return items.filter(({ isInOrNearViewport }) => isInOrNearViewport);
}

View File

@ -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 = <TimelineAsset>$state();
id: string = $derived(this.asset.id);