Compare commits

..

3 Commits

Author SHA1 Message Date
midzelis
79adb016e8 feat: photostream can have scrollbar, style options, standardize small/large layout sizes
- Add configurable header height props for responsive layouts
  (smallHeaderHeight/largeHeaderHeight)
  - Add style customization props: styleMarginContentHorizontal,
  styleMarginTop, alwaysShowScrollbar
  - Replace hardcoded layout values with configurable props
  - Change root element from <section> to custom <photostream> element for
  better semantic structure
  - Move viewport width binding to inner timeline element for more accurate
  measurements
  - Simplify HMR handler by removing file-specific checks
  - Add segment loading check to prevent rendering unloaded segments
  - Add spacing margin between month groups using layout options
  - Change scrollbar-width from 'auto' to 'thin' for consistency
  - Remove unused UpdatePayload type import
2025-10-07 00:56:15 +00:00
midzelis
1b60c9d32f feat: skeleton title is optional
feat: skeleton title optional
2025-10-07 00:56:15 +00:00
midzelis
113082dcaa fix: back/forward navigation won't reset scroll in timeline 2025-10-07 00:56:15 +00:00
10 changed files with 180 additions and 111 deletions

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
@@ -9,7 +8,6 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
segment: Snippet<
@@ -36,6 +34,7 @@
enableRouting: boolean;
timelineManager: PhotostreamManager;
alwaysShowScrollbar?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string;
@@ -44,6 +43,18 @@
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
smallHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
largeHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
styleMarginContentHorizontal?: string;
styleMarginTop?: string;
}
let {
@@ -52,14 +63,27 @@
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false),
showScrollbar,
styleMarginRightOverride,
styleMarginContentHorizontal = '0px',
styleMarginTop = '0px',
alwaysShowScrollbar,
isShowDeleteConfirmation = $bindable(false),
children,
skeleton,
empty,
header,
handleTimelineScroll = () => {},
smallHeaderHeight = {
rowHeight: 100,
headerHeight: 32,
},
largeHeaderHeight = {
rowHeight: 235,
headerHeight: 48,
},
}: Props = $props();
let { gridScrollTarget } = assetViewingStore;
@@ -71,15 +95,7 @@
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
timelineManager.setLayoutOptions(layoutOptions);
});
@@ -121,51 +137,41 @@
return height;
};
const assetIsVisible = (assetTop: number): boolean => {
if (!element) {
return false;
}
const { clientHeight, scrollTop } = element;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
};
export const scrollToAssetId = (assetId: string) => {
const monthGroup = timelineManager.getSegmentForAssetId(assetId);
export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
return true;
}
scrollTo(height);
return true;
};
const completeNav = () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = scrollToAssetId(scrollTarget);
export const completeAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
// this can happen if you do the following navigation order
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
const rect = element?.getBoundingClientRect();
if (rect) {
timelineManager.viewportHeight = rect.height;
timelineManager.viewportWidth = rect.width;
}
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
if (scrollToAssetQueryParam) {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
}
}
showSkeleton = false;
};
beforeNavigate(() => (timelineManager.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
});
const updateIsScrolling = () => (timelineManager.scrolling = true);
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
// by explicitly invoking element.scrollX functions, there may be a delay with enough time to
@@ -184,51 +190,54 @@
</script>
<HotModuleReload
onAfterUpdate={(payload: UpdatePayload) => {
onAfterUpdate={() => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
const finishHmr = () => {
const asset = $page.url.searchParams.get('at');
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void completeNav();
void completeAfterNavigate({ scrollToAssetQueryParam: true });
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte'));
if (assetGridUpdate) {
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}}
/>
{@render header?.(scrollTo)}
<!-- Right margin MUST be equal to the width of scrubber -->
<section
<photostream
id="asset-grid"
class={[
'h-full overflow-y-auto outline-none',
'overflow-y-auto outline-none',
{ 'scrollbar-hidden': !showScrollbar },
{ 'overflow-y-scroll': alwaysShowScrollbar },
{ 'm-0': isEmpty },
{ 'ms-0': !isEmpty },
]}
style:height={`calc(100% - ${styleMarginTop})`}
style:margin-top={styleMarginTop}
style:margin-right={styleMarginRightOverride}
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
style:margin-left={styleMarginContentHorizontal}
style:margin-right={styleMarginContentHorizontal}
class:invisible={showSkeleton}
style:height={timelineManager.timelineHeight + 'px'}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
>
<section
use:resizeObserver={topSectionResizeObserver}
@@ -245,13 +254,15 @@
</section>
{#each timelineManager.months as monthGroup (monthGroup.id)}
{@const shouldDisplay = monthGroup.intersecting}
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
{@const absoluteHeight = monthGroup.top}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:height={`${monthGroup.height}px`}
style:width="100%"
>
{#if !shouldDisplay}
@@ -274,9 +285,12 @@
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
></div>
</section>
</section>
</photostream>
<style>
photostream {
display: block;
}
#asset-grid {
contain: strict;
scrollbar-width: none;

View File

@@ -43,7 +43,7 @@
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
showSkeleton = true,
isShowDeleteConfirmation = $bindable(false),
segment,
skeleton,
@@ -160,7 +160,10 @@
handleScrollTop?.(scrollToTop);
};
let baseTimelineViewer: Photostream | undefined = $state();
export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAssetId(asset.id) ?? false;
export const scrollToAsset = async (asset: TimelineAsset) =>
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
baseTimelineViewer?.completeAfterNavigate(args);
</script>
<Photostream

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
@@ -14,6 +16,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -71,11 +74,51 @@
customThumbnailLayout,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let viewer: PhotostreamWithScrubber | undefined = $state();
let showSkeleton: boolean = $state(true);
// tri-state boolean
let initialLoadWasAssetViewer: boolean | null = null;
let hasNavigatedToOrFromAssetViewer: boolean = false;
let timelineScrollPositionInitialized = false;
beforeNavigate(({ from, to }) => {
timelineManager.suspendTransitions = true;
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
});
const completeAfterNavigate = () => {
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
let isInitial = false;
// Set initial load state only once
if (initialLoadWasAssetViewer === null) {
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
isInitial = true;
}
let scrollToAssetQueryParam = false;
if (
!timelineScrollPositionInitialized &&
((isInitial && !assetViewerPage) || // Direct timeline load
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
) {
scrollToAssetQueryParam = true;
timelineScrollPositionInitialized = true;
}
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
};
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
const onViewerClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
@@ -85,7 +128,7 @@
</script>
<TimelineKeyboardActions
scrollToAsset={(asset) => viewer?.scrollToAsset(asset) ?? false}
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{timelineManager}
{assetInteraction}
bind:isShowDeleteConfirmation
@@ -165,6 +208,6 @@
<Portal target="body">
{#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
{/if}
</Portal>

View File

@@ -9,15 +9,16 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
let { asset: viewingAsset, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
onViewerClose?: (asset: { id: string }) => Promise<void>;
removeAction?:
| AssetAction.UNARCHIVE
@@ -30,12 +31,12 @@
let {
timelineManager,
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
onViewerClose = () => Promise.resolve(void 0),
}: Props = $props();
const handlePrevious = async () => {
@@ -79,13 +80,6 @@
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
@@ -97,7 +91,7 @@
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
(await handleNext()) || (await handlePrevious()) || (await onViewerClose?.(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
@@ -172,6 +166,6 @@
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
onClose={onViewerClose}
/>
{/await}

View File

@@ -7,8 +7,10 @@
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
setFocusToAsset as setFocusAssetUtil,
setFocusTo as setFocusToUtil,
type FocusDirection,
type FocusInterval,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -32,7 +34,7 @@
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>;
}
let {
@@ -147,8 +149,10 @@
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
const setFocusTo = (direction: FocusDirection, interval: FocusInterval) =>
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
let shortcutList = $derived(
(() => {
@@ -212,7 +216,7 @@
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
void setFocusAsset(asset);
}
}
}}

View File

@@ -21,19 +21,26 @@ export const focusPreviousAsset = () =>
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
const scrolled = scrollToAsset(asset);
export const setFocusToAsset = async (
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
asset: TimelineAsset,
) => {
const scrolled = await scrollToAsset(asset);
if (scrolled) {
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
element?.focus();
}
};
export type FocusDirection = 'earlier' | 'later';
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
export const setFocusTo = async (
scrollToAsset: (asset: TimelineAsset) => boolean,
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
store: TimelineManager,
direction: 'earlier' | 'later',
interval: 'day' | 'month' | 'year' | 'asset',
direction: FocusDirection,
interval: FocusInterval,
) => {
if (tracker.isActive()) {
// there are unfinished running invocations, so return early
@@ -65,7 +72,10 @@ export const setFocusTo = async (
return;
}
const scrolled = scrollToAsset(asset);
const scrolled = await scrollToAsset(asset);
if (!invocation.isStillValid()) {
return;
}
if (scrolled) {
await tick();
if (!invocation.isStillValid()) {

View File

@@ -1,18 +1,20 @@
<script lang="ts">
interface Props {
height: number;
title: string;
title?: string;
}
let { height = 0, title }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{#if title}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{/if}
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"

View File

@@ -274,13 +274,14 @@ export abstract class PhotostreamManager {
return this.months.find((segment) => identifier.matches(segment));
}
getSegmentForAssetId(assetId: string) {
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
for (const month of this.months) {
const asset = month.assets.find((asset) => asset.id === assetId);
if (asset) {
return month;
return Promise.resolve(month);
}
}
return Promise.resolve(void 0);
}
refreshLayout() {

View File

@@ -1,5 +1,8 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import {
findMonthGroupForAsset,
getMonthGroupByDate,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
@@ -549,10 +552,10 @@ describe('TimelineManager', () => {
);
timelineManager.upsertAssets([assetOne, assetTwo]);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
it('ignores removed months', () => {
@@ -569,8 +572,8 @@ describe('TimelineManager', () => {
timelineManager.upsertAssets([assetOne, assetTwo]);
timelineManager.removeAssets([assetTwo.id]);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
});
});
});

View File

@@ -171,7 +171,7 @@ export class TimelineManager extends PhotostreamManager {
this.scrubberTimelineHeight = this.timelineHeight;
}
async findMonthGroupForAsset(id: string) {
async findSegmentForAssetId(id: string) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
}
@@ -202,11 +202,6 @@ export class TimelineManager extends PhotostreamManager {
return getMonthGroupByDate(this, yearMonth);
}
getMonthGroupByAssetId(assetId: string) {
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
return monthGroupInfo?.monthGroup;
}
async getRandomMonthGroup() {
const random = Math.floor(Math.random() * this.months.length);
const month = this.months[random];