Compare commits

...

1 Commits

Author SHA1 Message Date
midzelis d93ef7e0fc fix(web): align gallery-viewer viewport zones and skip thumbhash fade for offscreen thumbnails
When thumbnails load outside the viewport (e.g. during fast scrolling),
skip the thumbhash-to-image fade transition to avoid visual flicker for
already-loaded images.

Expands the gallery-viewer sliding window to use the same
INTERSECTION_EXPAND_TOP/BOTTOM tunables as the timeline, enabling
consistent near-viewport preloading of assets across both views.

Adds isInViewport prop to Thumbnail, threaded through AssetLayout, Month,
and Timeline. ViewerAsset gains an isInViewport getter backed by
ViewportProximity.

Also aligns gallery-viewer naming with ViewportProximity terminology:
- isRenderable -> isInOrNearViewport
- isIntersecting -> isInViewport
- onIntersected -> onReachedEnd

Change-Id: Iad30716dc2a45f4883701bbbd1412e106a6a6964
2026-05-04 14:24:56 +00:00
7 changed files with 61 additions and 31 deletions
@@ -33,6 +33,7 @@
thumbnailSize?: number;
thumbnailWidth?: number;
thumbnailHeight?: number;
isInViewport?: boolean;
selected?: boolean;
selectionCandidate?: boolean;
disabled?: boolean;
@@ -56,6 +57,7 @@
thumbnailSize = undefined,
thumbnailWidth = undefined,
thumbnailHeight = undefined,
isInViewport = true,
selected = false,
selectionCandidate = false,
disabled = false,
@@ -78,6 +80,7 @@
let mouseOver = $state(false);
let loaded = $state(false);
let thumbError = $state(false);
let skipFade = $state(false);
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
@@ -264,7 +267,11 @@
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
onComplete={(errored) => {
skipFade = !isInViewport;
loaded = true;
thumbError = errored;
}}
/>
{#if asset.isVideo}
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
@@ -309,7 +316,10 @@
<Thumbhash
base64ThumbHash={asset.thumbhash}
data-testid="thumbhash"
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
class={[
'absolute top-0 object-cover group-focus-visible:rounded-lg',
{ 'rounded-xl': selected, hidden: skipFade },
]}
style="width: {width}px; height: {height}px"
draggable="false"
fadeOut
@@ -22,11 +22,16 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
type Props = {
assets: AssetResponseDto[];
viewerAssets?: AssetResponseDto[];
@@ -34,7 +39,7 @@
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
onIntersected?: (() => void) | undefined;
onEndReached?: (() => void) | undefined;
showAssetName?: boolean;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number;
@@ -50,7 +55,7 @@
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
onIntersected = undefined,
onEndReached = undefined,
showAssetName = false,
onReload = undefined,
slidingWindowOffset = 0,
@@ -70,24 +75,31 @@
}),
);
const getStyle = (i: number) => {
const geo = geometry;
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
const getStyle = (index: number) => {
return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`;
};
const isIntersecting = (i: number) => {
const geo = geometry;
const isInOrNearViewport = (index: number) => {
const window = slidingWindow;
const top = geo.getTop(i);
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
const top = geometry.getTop(index);
return top + pageHeaderOffset < window.bottom && top + geometry.getHeight(index) > window.top;
};
const isInViewport = (index: number) => {
const top = geometry.getTop(index) + pageHeaderOffset;
const bottom = top + geometry.getHeight(index);
const viewportTop = (scrollTop || 0) - slidingWindowOffset;
const viewportBottom = viewportTop + viewport.height + slidingWindowOffset;
return top < viewportBottom && bottom > viewportTop;
};
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset;
const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP;
const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM;
return {
top,
bottom,
@@ -101,17 +113,15 @@
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
const debouncedOnEndReached = debounce(() => onEndReached?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0;
let lastEndReachedHeight = 0;
$effect(() => {
// Intersect if there's only one viewport worth of assets left to scroll.
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
// Notify we got to (near) the end of scroll.
const intersectedHeight = geometry.containerHeight;
if (lastIntersectedHeight !== intersectedHeight) {
debouncedOnIntersected();
lastIntersectedHeight = intersectedHeight;
const contentHeight = geometry.containerHeight;
if (lastEndReachedHeight !== contentHeight) {
debouncedOnEndReached();
lastEndReachedHeight = contentHeight;
}
}
});
@@ -362,10 +372,10 @@
style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'}
>
{#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)}
{#each assets as asset, index (asset.id + '-' + index)}
{#if isInOrNearViewport(index)}
{@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
<div class="absolute" style:overflow="clip" style={getStyle(index)}>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
@@ -382,8 +392,9 @@
asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={geometry.getWidth(i)}
thumbnailHeight={geometry.getHeight(i)}
isInViewport={isInViewport(index)}
thumbnailWidth={geometry.getWidth(index)}
thumbnailHeight={geometry.getHeight(index)}
/>
{#if showAssetName && !isTimelineAsset(asset)}
<div
@@ -21,6 +21,7 @@
{
asset: TimelineAsset;
position: CommonPosition;
isInViewport: boolean;
},
]
>;
@@ -38,6 +39,7 @@
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const isInViewport = viewerAsset.isInViewport!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
@@ -50,7 +52,7 @@
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render thumbnail({ asset, position, isInViewport })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
+3 -2
View File
@@ -21,6 +21,7 @@
position: CommonPosition;
timelineDay: TimelineDay;
groupIndex: number;
isInViewport: boolean;
},
]
>;
@@ -105,8 +106,8 @@
width={timelineDay.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex })}
{#snippet thumbnail({ asset, position, isInViewport })}
{@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex, isInViewport })}
{/snippet}
</AssetLayout>
</section>
@@ -673,7 +673,7 @@
manager={timelineManager}
onTimelineDaySelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{#snippet thumbnail({ asset, position, timelineDay, groupIndex, isInViewport })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
@@ -684,6 +684,7 @@
{asset}
{albumUsers}
{groupIndex}
{isInViewport}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
@@ -3,6 +3,7 @@ import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
isInViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
@@ -25,6 +26,10 @@ export class ViewerAsset {
return isInOrNearViewport(this.#viewportProximity);
}
get isInViewport() {
return isInViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id);
@@ -292,7 +292,7 @@
<GalleryViewer
assets={searchResultAssets}
assetInteraction={assetMultiSelectManager}
onIntersected={loadNextPage}
onEndReached={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}