feat: timeline performance ()

* Squash - feature complete

* remove need to init assetstore

* More optimizations. No need to init. Fix tests

* lint

* add missing selector for e2e

* e2e selectors again

* Update: fully reactive store, some transitions, bugfixes

* merge fallout

* Test fallout

* safari quirk

* security

* lint

* lint

* Bug fixes

* lint/format

* accidental commit

* lock

* null check, more throttle

* revert long duration

* Fix intersection bounds

* Fix bugs in intersection calculation

* lint, tweak scrubber ui a tiny bit

* bugfix - deselecting asset doesnt work

* fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
Min Idzelis 2025-03-18 10:14:46 -04:00 committed by GitHub
parent dd263b010c
commit e96ffd43e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2318 additions and 2764 deletions
web/src/lib/components/photos-page

View file

@ -4,38 +4,26 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import {
formatGroupTitle,
splitBucketIntoDateGroups,
type ScrubberListener,
type ScrollTargetListener,
} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { throttle } from 'lodash-es';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onMount, type Snippet } from 'svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver } from '$lib/actions/resize-observer';
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
import { intersectionObserver } from '$lib/actions/intersection-observer';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import { generateId } from '$lib/utils/generate-id';
import { isTimelineScrolling } from '$lib/stores/timeline.store';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props {
@ -81,64 +69,41 @@
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
const componentId = generateId();
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
let showShortcuts = $state(false);
let showSkeleton = $state(true);
let internalScroll = false;
let navigating = false;
let preMeasure: AssetBucket[] = $state([]);
let lastIntersectedBucketDate: string | undefined;
let scrubBucketPercent = $state(0);
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
let scrubOverallPercent: number = $state(0);
let topSectionHeight = $state(0);
let topSectionOffset = $state(0);
// 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60;
let leadout = $state(false);
const {
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
BUCKET: {
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
},
THUMBNAIL: {
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
},
} = TUNABLES;
const isViewportOrigin = () => {
return viewport.height === 0 && viewport.width === 0;
};
const isEqual = (a: ViewportXY, b: ViewportXY) => {
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
};
const completeNav = () => {
navigating = false;
if (internalScroll) {
internalScroll = false;
return;
}
const completeNav = async () => {
if ($gridScrollTarget?.at) {
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
try {
const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at);
if (bucket) {
const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at);
if (height) {
element?.scrollTo({ top: height });
showSkeleton = false;
assetStore.updateIntersections();
}
}
} catch {
element?.scrollTo({ top: 0 });
showSkeleton = false;
});
}
} else {
element?.scrollTo({ top: 0 });
showSkeleton = false;
}
};
beforeNavigate(() => (assetStore.suspendTransitions = true));
afterNavigate((nav) => {
const { complete, type } = nav;
if (type === 'enter') {
@ -147,10 +112,6 @@
complete.then(completeNav, completeNav);
});
beforeNavigate(() => {
navigating = true;
});
const hmrSupport = () => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
@ -165,7 +126,6 @@
if (assetGridUpdate) {
setTimeout(() => {
void assetStore.updateViewport(safeViewport, true);
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
@ -193,94 +153,60 @@
return () => void 0;
};
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
if (lastIntersectedBucketDate) {
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
if (deltaIndex < currentIndex) {
element?.scrollBy(0, delta);
}
}
};
const bucketListener: BucketListener = (event) => {
const { type } = event;
if (type === 'bucket-height') {
const { bucket, delta } = event;
scrollTolastIntersectedBucket(bucket, delta);
}
};
const updateIsScrolling = () => (assetStore.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
onMount(() => {
void assetStore
.init({ bucketListener })
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
assetStore.setCompensateScrollCallback(compensateScrollCallback);
if (!enableRouting) {
showSkeleton = false;
}
const dispose = hmrSupport();
const disposeHmr = hmrSupport();
return () => {
assetStore.disconnect();
assetStore.destroy();
dispose();
assetStore.setCompensateScrollCallback();
disposeHmr();
};
});
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
const updateViewport = throttle(_updateViewport, 16);
function getOffset(bucketDate: string) {
let offset = 0;
for (let a = 0; a < assetStore.buckets.length; a++) {
if (assetStore.buckets[a].bucketDate === bucketDate) {
break;
}
offset += assetStore.buckets[a].bucketHeight;
}
return offset;
}
const getMaxScrollPercent = () =>
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
const getMaxScrollPercent = () => {
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
return (totalHeight - assetStore.viewportHeight) / totalHeight;
};
const getMaxScroll = () => {
if (!element || !timelineElement) {
return 0;
}
return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
};
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
const topOffset = bucket.top;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
const scrollTop = (topOffset + delta) * maxScrollPercent;
if (!element) {
return;
if (element) {
element.scrollTop = scrollTop;
}
element.scrollTop = scrollTop;
};
const _onScrub: ScrubberListener = (
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = (
bucketDate: string | undefined,
scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
if (!element) {
return;
}
element.scrollTop = offset;
} else {
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
@ -290,47 +216,16 @@
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
const stopScrub: ScrubberListener = async (
bucketDate: string | undefined,
_scrollPercent: number,
bucketScrollPercent: number,
) => {
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
return;
}
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
if (!bucket) {
return;
}
if (bucket && !bucket.measured) {
preMeasure.push(bucket);
await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
await bucket.measuredPromise;
scrollToBucketAndOffset(bucket, bucketScrollPercent);
}
};
let scrollObserverTimer: NodeJS.Timeout;
const _handleTimelineScroll = () => {
$isTimelineScrolling = true;
if (scrollObserverTimer) {
clearTimeout(scrollObserverTimer);
}
scrollObserverTimer = setTimeout(() => {
$isTimelineScrolling = false;
}, 1000);
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => {
leadout = false;
if (!element) {
return;
}
if (assetStore.timelineHeight < safeViewport.height * 2) {
if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll();
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
@ -338,8 +233,8 @@
scrubBucket = undefined;
scrubBucketPercent = 0;
} else {
let top = element?.scrollTop;
if (top < topSectionHeight) {
let top = element.scrollTop;
if (top < assetStore.topSectionHeight) {
// in the lead-in area
scrubBucket = undefined;
scrubBucketPercent = 0;
@ -352,18 +247,24 @@
let maxScrollPercent = getMaxScrollPercent();
let found = false;
// create virtual buckets....
const vbuckets = [
{ bucketHeight: topSectionHeight, bucketDate: undefined },
...assetStore.buckets,
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
];
for (const bucket of vbuckets) {
let next = top - bucket.bucketHeight * maxScrollPercent;
const bucketsLength = assetStore.buckets.length;
for (let i = -1; i < bucketsLength + 1; i++) {
let bucket: { bucketDate: string | undefined } | undefined;
let bucketHeight = 0;
if (i === -1) {
// lead-in
bucketHeight = assetStore.topSectionHeight;
} else if (i === bucketsLength) {
// lead-out
bucketHeight = bottomSectionHeight;
} else {
bucket = assetStore.buckets[i];
bucketHeight = assetStore.buckets[i].bucketHeight;
}
let next = top - bucketHeight * maxScrollPercent;
if (next < 0) {
scrubBucket = bucket;
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
found = true;
break;
}
@ -377,34 +278,6 @@
}
}
};
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
const _onAssetInGrid = async (asset: AssetResponseDto) => {
if (!enableRouting || navigating || internalScroll) {
return;
}
$gridScrollTarget = { at: asset.id };
internalScroll = true;
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ replaceState: true, forceNavigate: true },
);
};
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
: () => void 0;
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
element?.scrollTo({ top: offset });
if (!bucket.measured) {
preMeasure.push(bucket);
}
showSkeleton = false;
assetStore.clearPendingScroll();
// set intersecting true manually here, to reduce flicker that happens when
// clearing pending scroll, but the intersection observer hadn't yet had time to run
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
@ -439,11 +312,9 @@
};
const toggleArchive = async () => {
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
if (ids) {
assetStore.removeAssets(ids);
deselectAllAssets();
}
await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
assetStore.updateAssets(assetInteraction.selectedAssetsArray);
deselectAllAssets();
};
const focusElement = () => {
@ -458,23 +329,6 @@
}
};
function handleIntersect(bucket: AssetBucket) {
// updateLastIntersectedBucketDate();
const task = () => {
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void assetStore.loadBucket(bucket.bucketDate);
};
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
}
function handleSeparate(bucket: AssetBucket) {
const task = () => {
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
assetStore.taskManager.separatedBucket(componentId, bucket, task);
}
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
@ -610,7 +464,6 @@
if (!asset) {
return;
}
onSelect(asset);
if (singleSelect && element) {
@ -619,7 +472,7 @@
}
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
const deselect = assetInteraction.selectedAssets.has(asset);
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
@ -637,39 +490,48 @@
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) {
if (startBucket === null || endBucket === null) {
return;
}
if (endBucketIndex < startBucketIndex) {
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
}
// Select/deselect assets in all intermediate buckets
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
const bucket = assetStore.buckets[bucketIndex];
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.assets) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
} else {
handleSelectAsset(asset);
// Select/deselect assets in range (start,end]
let started = false;
for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
}
if (bucket === endBucket) {
break;
}
if (started) {
await assetStore.loadBucket(bucket.bucketDate);
for (const asset of bucket.getAssets()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset);
} else {
handleSelectAsset(asset);
}
}
}
}
// Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
const bucket = assetStore.buckets[bucketIndex];
started = false;
for (const bucket of assetStore.buckets) {
if (bucket === startBucket) {
started = true;
}
if (bucket === endBucket) {
break;
}
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
for (const dateGroup of assetsGroupByDate) {
const dateGroupTitle = formatGroupTitle(dateGroup.date);
if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
for (const dateGroup of bucket.dateGroups) {
const dateGroupTitle = dateGroup.groupTitle;
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
@ -691,14 +553,16 @@
return;
}
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
const assets = assetStore.getAssets();
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
};
const onSelectStart = (e: Event) => {
@ -710,14 +574,14 @@
const focusNextAsset = async () => {
if (assetInteraction.focussedAssetId === null) {
const firstAsset = assetStore.getFirstAsset();
if (firstAsset !== null) {
if (firstAsset) {
assetInteraction.focussedAssetId = firstAsset.id;
}
} else {
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const nextAsset = await assetStore.getNextAsset(focussedAsset);
if (nextAsset !== null) {
if (nextAsset) {
assetInteraction.focussedAssetId = nextAsset.id;
}
}
@ -726,7 +590,7 @@
const focusPreviousAsset = async () => {
if (assetInteraction.focussedAssetId !== null) {
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
if (focussedAsset) {
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
if (previousAsset) {
@ -736,11 +600,8 @@
}
};
onDestroy(() => {
assetStore.taskManager.removeAllTasksForComponent(componentId);
});
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
$effect(() => {
@ -749,23 +610,6 @@
}
});
$effect(() => {
if (element && isViewportOrigin()) {
const rect = element.getBoundingClientRect();
viewport.height = rect.height;
viewport.width = rect.width;
viewport.x = rect.x;
viewport.y = rect.y;
}
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
safeViewport.height = viewport.height;
safeViewport.width = viewport.width;
safeViewport.x = viewport.x;
safeViewport.y = viewport.y;
updateViewport();
}
});
let shortcutList = $derived(
(() => {
if ($isSearchEnabled || $showAssetViewer) {
@ -829,19 +673,34 @@
{#if showShortcuts}
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if assetStore.buckets.length > 0}
<Scrubber
invisible={showSkeleton}
{assetStore}
height={safeViewport.height}
timelineTopOffset={topSectionHeight}
height={assetStore.viewportHeight}
timelineTopOffset={assetStore.topSectionHeight}
timelineBottomOffset={bottomSectionHeight}
{leadout}
{scrubOverallPercent}
{scrubBucketPercent}
{scrubBucket}
{onScrub}
{stopScrub}
onScrubKeyDown={(evt) => {
evt.preventDefault();
let amount = 50;
if (shiftKeyIsDown) {
amount = 500;
}
if (evt.key === 'ArrowUp') {
amount = -amount;
if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
} else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' });
}
}}
/>
{/if}
@ -850,90 +709,67 @@
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1"
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
bind:clientHeight={assetStore.viewportHeight}
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
bind:this={element}
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
class:invisible={showSkeleton}
>
{@render children?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={assetStore.timelineHeight + 'px'}
>
<section
use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton}
style:position="absolute"
style:left="0"
style:right="0"
>
{@render children?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
{#each assetStore.buckets as bucket (bucket.viewId)}
{@const isPremeasure = preMeasure.includes(bucket)}
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
{@const display = bucket.intersecting}
{@const absoluteHeight = bucket.top}
<div
class="bucket"
style:overflow={bucket.measured ? 'visible' : 'clip'}
use:intersectionObserver={[
{
key: bucket.viewId,
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,
},
{
key: bucket.viewId + '.bucketintersection',
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
top: '0px',
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
left: '0px',
right: '0px',
},
]}
data-bucket-display={bucket.intersecting}
data-bucket-date={bucket.bucketDate}
style:height={bucket.bucketHeight + 'px'}
>
{#if display && !bucket.measured}
<MeasureDateGroup
{bucket}
{assetStore}
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
></MeasureDateGroup>
{/if}
{#if !display || !bucket.measured}
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
{/if}
{#if display && bucket.measured}
{#if !bucket.isLoaded}
<div
style:height={bucket.bucketHeight + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
</div>
{:else if display}
<div
class="bucket"
style:height={bucket.bucketHeight + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<AssetDateGroup
assetGridElement={element}
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteraction}
{isSelectionMode}
{singleSelect}
{onScrollTarget}
{onAssetInGrid}
{bucket}
viewport={safeViewport}
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
/>
{/if}
</div>
</div>
{/if}
{/each}
<div class="h-[60px]"></div>
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
</section>
</section>
@ -965,6 +801,9 @@
}
.bucket {
contain: layout size;
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</style>