Skip to content

Commit f7c987f

Browse files
committed
refactor(web): extract asset-grid-without-scrubber from asset-grid component
Separated scrubber logic into asset-grid component, creating a asset-grid-without-scrubber for modularity. Names not final yet, much more left to do.
1 parent 1de2152 commit f7c987f

File tree

5 files changed

+505
-393
lines changed

5 files changed

+505
-393
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
<script lang="ts">
2+
import { afterNavigate, beforeNavigate } from '$app/navigation';
3+
import { page } from '$app/stores';
4+
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
5+
import AssetDateGroupActions from '$lib/components/photos-page/asset-date-group-actions.svelte';
6+
import AssetGridActions from '$lib/components/photos-page/asset-grid-actions.svelte';
7+
import AssetViewerAndActions from '$lib/components/photos-page/asset-viewer-and-actions.svelte';
8+
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
9+
import { AssetAction } from '$lib/constants';
10+
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
11+
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
12+
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
13+
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
14+
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
15+
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
16+
import { navigate } from '$lib/utils/navigation';
17+
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
18+
import { onMount, type Snippet } from 'svelte';
19+
import type { UpdatePayload } from 'vite';
20+
import Portal from '../shared-components/portal/portal.svelte';
21+
import AssetDateGroup from './asset-date-group.svelte';
22+
23+
interface Props {
24+
isSelectionMode?: boolean;
25+
singleSelect?: boolean;
26+
/** `true` if this asset grid is responds to navigation events; if `true`, then look at the
27+
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
28+
additionally, update the page location/url with the asset as the asset-grid is scrolled */
29+
enableRouting: boolean;
30+
timelineManager: TimelineManager;
31+
assetInteraction: AssetInteraction;
32+
removeAction?:
33+
| AssetAction.UNARCHIVE
34+
| AssetAction.ARCHIVE
35+
| AssetAction.FAVORITE
36+
| AssetAction.UNFAVORITE
37+
| AssetAction.SET_VISIBILITY_TIMELINE;
38+
withStacked?: boolean;
39+
showArchiveIcon?: boolean;
40+
isShared?: boolean;
41+
album?: AlbumResponseDto | null;
42+
person?: PersonResponseDto | null;
43+
isShowDeleteConfirmation?: boolean;
44+
onSelect?: (asset: TimelineAsset) => void;
45+
onEscape?: () => void;
46+
header?: Snippet<[handleScrollTop: (top: number) => void]>;
47+
children?: Snippet;
48+
empty?: Snippet;
49+
handleTimelineScroll?: () => void;
50+
}
51+
52+
let {
53+
isSelectionMode = false,
54+
singleSelect = false,
55+
enableRouting,
56+
timelineManager = $bindable(),
57+
assetInteraction,
58+
removeAction,
59+
withStacked = false,
60+
showArchiveIcon = false,
61+
isShared = false,
62+
album = null,
63+
person = null,
64+
isShowDeleteConfirmation = $bindable(false),
65+
onSelect = () => {},
66+
onEscape = () => {},
67+
children,
68+
empty,
69+
header,
70+
handleTimelineScroll = () => {},
71+
}: Props = $props();
72+
73+
let { isViewing: showAssetViewer, gridScrollTarget } = assetViewingStore;
74+
75+
let element: HTMLElement | undefined = $state();
76+
77+
let timelineElement: HTMLElement | undefined = $state();
78+
let showSkeleton = $state(true);
79+
80+
let scrubberWidth = $state(0);
81+
82+
// 60 is the bottom spacer element at 60px
83+
let bottomSectionHeight = 60;
84+
let leadout = $state(false);
85+
86+
const maxMd = $derived(mobileDevice.maxMd);
87+
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
88+
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
89+
90+
$effect(() => {
91+
const layoutOptions = maxMd
92+
? {
93+
rowHeight: 100,
94+
headerHeight: 32,
95+
}
96+
: {
97+
rowHeight: 235,
98+
headerHeight: 48,
99+
};
100+
timelineManager.setLayoutOptions(layoutOptions);
101+
});
102+
103+
const scrollTo = (top: number) => {
104+
if (element) {
105+
element.scrollTo({ top });
106+
}
107+
};
108+
const scrollTop = (top: number) => {
109+
if (element) {
110+
element.scrollTop = top;
111+
}
112+
};
113+
const scrollBy = (y: number) => {
114+
if (element) {
115+
element.scrollBy(0, y);
116+
}
117+
};
118+
const scrollToTop = () => {
119+
scrollTo(0);
120+
};
121+
122+
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
123+
// the following method may trigger any layouts, so need to
124+
// handle any scroll compensation that may have been set
125+
const height = monthGroup!.findAssetAbsolutePosition(assetId);
126+
127+
while (timelineManager.scrollCompensation.monthGroup) {
128+
handleScrollCompensation(timelineManager.scrollCompensation);
129+
timelineManager.clearScrollCompensation();
130+
}
131+
return height;
132+
};
133+
134+
const scrollToAssetId = async (assetId: string) => {
135+
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
136+
if (!monthGroup) {
137+
return false;
138+
}
139+
const height = getAssetHeight(assetId, monthGroup);
140+
scrollTo(height);
141+
updateSlidingWindow();
142+
return true;
143+
};
144+
145+
const scrollToAsset = (asset: TimelineAsset) => {
146+
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
147+
if (!monthGroup) {
148+
return false;
149+
}
150+
const height = getAssetHeight(asset.id, monthGroup);
151+
scrollTo(height);
152+
updateSlidingWindow();
153+
return true;
154+
};
155+
156+
const completeNav = async () => {
157+
const scrollTarget = $gridScrollTarget?.at;
158+
let scrolled = false;
159+
if (scrollTarget) {
160+
scrolled = await scrollToAssetId(scrollTarget);
161+
}
162+
if (!scrolled) {
163+
// if the asset is not found, scroll to the top
164+
scrollToTop();
165+
}
166+
showSkeleton = false;
167+
};
168+
169+
beforeNavigate(() => (timelineManager.suspendTransitions = true));
170+
171+
afterNavigate((nav) => {
172+
const { complete } = nav;
173+
complete.then(completeNav, completeNav);
174+
});
175+
176+
const hmrSupport = () => {
177+
// when hmr happens, skeleton is initialized to true by default
178+
// normally, loading asset-grid is part of a navigation event, and the completion of
179+
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
180+
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
181+
// preventing skeleton from showing after hmr
182+
if (import.meta && import.meta.hot) {
183+
const afterApdate = (payload: UpdatePayload) => {
184+
const assetGridUpdate = payload.updates.some(
185+
(update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
186+
);
187+
188+
if (assetGridUpdate) {
189+
setTimeout(() => {
190+
const asset = $page.url.searchParams.get('at');
191+
if (asset) {
192+
$gridScrollTarget = { at: asset };
193+
void navigate(
194+
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
195+
{ replaceState: true, forceNavigate: true },
196+
);
197+
} else {
198+
scrollToTop();
199+
}
200+
showSkeleton = false;
201+
}, 500);
202+
}
203+
};
204+
import.meta.hot?.on('vite:afterUpdate', afterApdate);
205+
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
206+
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
207+
if (assetGridUpdate) {
208+
timelineManager.destroy();
209+
}
210+
});
211+
212+
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
213+
}
214+
return () => void 0;
215+
};
216+
217+
const updateIsScrolling = () => (timelineManager.scrolling = true);
218+
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
219+
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
220+
221+
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
222+
if (heightDelta !== undefined) {
223+
scrollBy(heightDelta);
224+
} else if (scrollTop !== undefined) {
225+
scrollTo(scrollTop);
226+
}
227+
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
228+
// the above calls. However, this delay is enough time to set the intersecting property
229+
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
230+
// causing bad perf, and also, disrupting focus of those elements.
231+
updateSlidingWindow();
232+
};
233+
234+
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
235+
236+
onMount(() => {
237+
if (!enableRouting) {
238+
showSkeleton = false;
239+
}
240+
const disposeHmr = hmrSupport();
241+
return () => {
242+
disposeHmr();
243+
};
244+
});
245+
246+
let onDateGroupSelect = <({ title, assets }: { title: string; assets: TimelineAsset[] }) => void>$state();
247+
let onSelectAssets = <(asset: TimelineAsset) => Promise<void>>$state();
248+
let onSelectAssetCandidates = <(asset: TimelineAsset | null) => void>$state();
249+
</script>
250+
251+
<AssetDateGroupActions
252+
{timelineManager}
253+
{assetInteraction}
254+
handleScrollTop={scrollTop}
255+
{onSelect}
256+
bind:onDateGroupSelect
257+
bind:onSelectAssets
258+
bind:onSelectAssetCandidates
259+
></AssetDateGroupActions>
260+
261+
<AssetGridActions {scrollToAsset} {timelineManager} {assetInteraction} bind:isShowDeleteConfirmation {onEscape}
262+
></AssetGridActions>
263+
264+
{@render header?.(scrollTop)}
265+
266+
<!-- Right margin MUST be equal to the width of scrubber -->
267+
<section
268+
id="asset-grid"
269+
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
270+
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
271+
tabindex="-1"
272+
bind:clientHeight={timelineManager.viewportHeight}
273+
bind:clientWidth={null, (v) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
274+
bind:this={element}
275+
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
276+
>
277+
<section
278+
bind:this={timelineElement}
279+
id="virtual-timeline"
280+
class:invisible={showSkeleton}
281+
style:height={timelineManager.timelineHeight + 'px'}
282+
>
283+
<section
284+
use:resizeObserver={topSectionResizeObserver}
285+
class:invisible={showSkeleton}
286+
style:position="absolute"
287+
style:left="0"
288+
style:right="0"
289+
>
290+
{@render children?.()}
291+
{#if isEmpty}
292+
<!-- (optional) empty placeholder -->
293+
{@render empty?.()}
294+
{/if}
295+
</section>
296+
297+
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
298+
{@const display = monthGroup.intersecting}
299+
{@const absoluteHeight = monthGroup.top}
300+
301+
{#if !monthGroup.isLoaded}
302+
<div
303+
style:height={monthGroup.height + 'px'}
304+
style:position="absolute"
305+
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
306+
style:width="100%"
307+
>
308+
<Skeleton
309+
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
310+
title={monthGroup.monthGroupTitle}
311+
/>
312+
</div>
313+
{:else if display}
314+
<div
315+
class="month-group"
316+
style:height={monthGroup.height + 'px'}
317+
style:position="absolute"
318+
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
319+
style:width="100%"
320+
>
321+
<AssetDateGroup
322+
{withStacked}
323+
{showArchiveIcon}
324+
{assetInteraction}
325+
{timelineManager}
326+
{isSelectionMode}
327+
{singleSelect}
328+
{monthGroup}
329+
onSelect={onDateGroupSelect}
330+
{onSelectAssetCandidates}
331+
{onSelectAssets}
332+
onScrollCompensation={handleScrollCompensation}
333+
/>
334+
</div>
335+
{/if}
336+
{/each}
337+
<!-- spacer for leadout -->
338+
<div
339+
class="h-[60px]"
340+
style:position="absolute"
341+
style:left="0"
342+
style:right="0"
343+
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
344+
></div>
345+
</section>
346+
</section>
347+
348+
<Portal target="body">
349+
{#if $showAssetViewer}
350+
<AssetViewerAndActions
351+
bind:showSkeleton
352+
{timelineManager}
353+
{removeAction}
354+
{withStacked}
355+
{isShared}
356+
{album}
357+
{person}
358+
{isShowDeleteConfirmation}
359+
></AssetViewerAndActions>
360+
{/if}
361+
</Portal>
362+
363+
<style>
364+
#asset-grid {
365+
contain: strict;
366+
scrollbar-width: none;
367+
}
368+
369+
.month-group {
370+
contain: layout size paint;
371+
transform-style: flat;
372+
backface-visibility: hidden;
373+
transform-origin: center center;
374+
}
375+
</style>

0 commit comments

Comments
 (0)