Skip to content

Commit 1709f6e

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 02b8ea9 commit 1709f6e

File tree

5 files changed

+519
-416
lines changed

5 files changed

+519
-416
lines changed
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
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 assetIsVisible = (assetTop: number): boolean => {
135+
if (!element) {
136+
return false;
137+
}
138+
139+
const { clientHeight, scrollTop } = element;
140+
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
141+
};
142+
143+
const scrollToAssetId = async (assetId: string) => {
144+
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
145+
if (!monthGroup) {
146+
return false;
147+
}
148+
const height = getAssetHeight(assetId, monthGroup);
149+
150+
// If the asset is already visible, then don't scroll.
151+
if (assetIsVisible(height)) {
152+
return true;
153+
}
154+
155+
scrollTo(height);
156+
updateSlidingWindow();
157+
return true;
158+
};
159+
160+
const scrollToAsset = (asset: TimelineAsset) => {
161+
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
162+
if (!monthGroup) {
163+
return false;
164+
}
165+
const height = getAssetHeight(asset.id, monthGroup);
166+
scrollTo(height);
167+
updateSlidingWindow();
168+
return true;
169+
};
170+
171+
const completeNav = async () => {
172+
const scrollTarget = $gridScrollTarget?.at;
173+
let scrolled = false;
174+
if (scrollTarget) {
175+
scrolled = await scrollToAssetId(scrollTarget);
176+
}
177+
if (!scrolled) {
178+
// if the asset is not found, scroll to the top
179+
scrollToTop();
180+
}
181+
showSkeleton = false;
182+
};
183+
184+
beforeNavigate(() => (timelineManager.suspendTransitions = true));
185+
186+
afterNavigate((nav) => {
187+
const { complete } = nav;
188+
complete.then(completeNav, completeNav);
189+
});
190+
191+
const hmrSupport = () => {
192+
// when hmr happens, skeleton is initialized to true by default
193+
// normally, loading asset-grid is part of a navigation event, and the completion of
194+
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
195+
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
196+
// preventing skeleton from showing after hmr
197+
if (import.meta && import.meta.hot) {
198+
const afterApdate = (payload: UpdatePayload) => {
199+
const assetGridUpdate = payload.updates.some(
200+
(update) => update.path.endsWith('asset-grid.svelte') || update.path.endsWith('assets-store.ts'),
201+
);
202+
203+
if (assetGridUpdate) {
204+
setTimeout(() => {
205+
const asset = $page.url.searchParams.get('at');
206+
if (asset) {
207+
$gridScrollTarget = { at: asset };
208+
void navigate(
209+
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
210+
{ replaceState: true, forceNavigate: true },
211+
);
212+
} else {
213+
scrollToTop();
214+
}
215+
showSkeleton = false;
216+
}, 500);
217+
}
218+
};
219+
import.meta.hot?.on('vite:afterUpdate', afterApdate);
220+
import.meta.hot?.on('vite:beforeUpdate', (payload) => {
221+
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('asset-grid.svelte'));
222+
if (assetGridUpdate) {
223+
timelineManager.destroy();
224+
}
225+
});
226+
227+
return () => import.meta.hot?.off('vite:afterUpdate', afterApdate);
228+
}
229+
return () => void 0;
230+
};
231+
232+
const updateIsScrolling = () => (timelineManager.scrolling = true);
233+
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
234+
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
235+
236+
const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
237+
if (heightDelta !== undefined) {
238+
scrollBy(heightDelta);
239+
} else if (scrollTop !== undefined) {
240+
scrollTo(scrollTop);
241+
}
242+
// Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
243+
// the above calls. However, this delay is enough time to set the intersecting property
244+
// of the monthGroup to false, then true, which causes the DOM nodes to be recreated,
245+
// causing bad perf, and also, disrupting focus of those elements.
246+
updateSlidingWindow();
247+
};
248+
249+
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
250+
251+
onMount(() => {
252+
if (!enableRouting) {
253+
showSkeleton = false;
254+
}
255+
const disposeHmr = hmrSupport();
256+
return () => {
257+
disposeHmr();
258+
};
259+
});
260+
261+
let onDateGroupSelect = <({ title, assets }: { title: string; assets: TimelineAsset[] }) => void>$state();
262+
let onSelectAssets = <(asset: TimelineAsset) => Promise<void>>$state();
263+
let onSelectAssetCandidates = <(asset: TimelineAsset | null) => void>$state();
264+
</script>
265+
266+
<AssetDateGroupActions
267+
{timelineManager}
268+
{assetInteraction}
269+
handleScrollTop={scrollTop}
270+
{onSelect}
271+
bind:onDateGroupSelect
272+
bind:onSelectAssets
273+
bind:onSelectAssetCandidates
274+
></AssetDateGroupActions>
275+
276+
<AssetGridActions {scrollToAsset} {timelineManager} {assetInteraction} bind:isShowDeleteConfirmation {onEscape}
277+
></AssetGridActions>
278+
279+
{@render header?.(scrollTop)}
280+
281+
<!-- Right margin MUST be equal to the width of scrubber -->
282+
<section
283+
id="asset-grid"
284+
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
285+
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
286+
tabindex="-1"
287+
bind:clientHeight={timelineManager.viewportHeight}
288+
bind:clientWidth={null, (v) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
289+
bind:this={element}
290+
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
291+
>
292+
<section
293+
bind:this={timelineElement}
294+
id="virtual-timeline"
295+
class:invisible={showSkeleton}
296+
style:height={timelineManager.timelineHeight + 'px'}
297+
>
298+
<section
299+
use:resizeObserver={topSectionResizeObserver}
300+
class:invisible={showSkeleton}
301+
style:position="absolute"
302+
style:left="0"
303+
style:right="0"
304+
>
305+
{@render children?.()}
306+
{#if isEmpty}
307+
<!-- (optional) empty placeholder -->
308+
{@render empty?.()}
309+
{/if}
310+
</section>
311+
312+
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
313+
{@const display = monthGroup.intersecting}
314+
{@const absoluteHeight = monthGroup.top}
315+
316+
{#if !monthGroup.isLoaded}
317+
<div
318+
style:height={monthGroup.height + 'px'}
319+
style:position="absolute"
320+
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
321+
style:width="100%"
322+
>
323+
<Skeleton
324+
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
325+
title={monthGroup.monthGroupTitle}
326+
/>
327+
</div>
328+
{:else if display}
329+
<div
330+
class="month-group"
331+
style:height={monthGroup.height + 'px'}
332+
style:position="absolute"
333+
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
334+
style:width="100%"
335+
>
336+
<AssetDateGroup
337+
{withStacked}
338+
{showArchiveIcon}
339+
{assetInteraction}
340+
{timelineManager}
341+
{isSelectionMode}
342+
{singleSelect}
343+
{monthGroup}
344+
onSelect={onDateGroupSelect}
345+
{onSelectAssetCandidates}
346+
{onSelectAssets}
347+
onScrollCompensation={handleScrollCompensation}
348+
/>
349+
</div>
350+
{/if}
351+
{/each}
352+
<!-- spacer for leadout -->
353+
<div
354+
class="h-[60px]"
355+
style:position="absolute"
356+
style:left="0"
357+
style:right="0"
358+
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
359+
></div>
360+
</section>
361+
</section>
362+
363+
<Portal target="body">
364+
{#if $showAssetViewer}
365+
<AssetViewerAndActions
366+
bind:showSkeleton
367+
{timelineManager}
368+
{removeAction}
369+
{withStacked}
370+
{isShared}
371+
{album}
372+
{person}
373+
{isShowDeleteConfirmation}
374+
></AssetViewerAndActions>
375+
{/if}
376+
</Portal>
377+
378+
<style>
379+
#asset-grid {
380+
contain: strict;
381+
scrollbar-width: none;
382+
}
383+
384+
.month-group {
385+
contain: layout size paint;
386+
transform-style: flat;
387+
backface-visibility: hidden;
388+
transform-origin: center center;
389+
}
390+
</style>

0 commit comments

Comments
 (0)