feat(web): add hero transition for 'View in Timeline' action

Adds a smooth view transition when navigating from the asset viewer or
memory page to the timeline via 'View in Timeline'. Extracts the shared
hero overlay logic into navigateToTimeline() in transition-utils.

Change-Id: If96174ab5c34b5ecf454bb2c09c7a5866a6a6964
This commit is contained in:
midzelis
2026-03-21 03:10:53 +00:00
parent e79a98fa82
commit 2dc89e2f15
5 changed files with 111 additions and 4 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
@@ -17,15 +18,19 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { navigateToTimeline } from '$lib/utils/transition-utils';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { isPhotosRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -184,7 +189,21 @@
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
onClick={async () => {
const assetId = stack?.primaryAssetId ?? asset.id;
if (isPhotosRoute(page.route.id) && viewTransitionManager.isSupported()) {
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
eventManager.emit('ViewerCloseTransition', { id: assetId });
await transitionReady;
await goto(Route.photos({ at: assetId }));
return;
}
navigateToTimeline(assetId, {
types: ['timeline'],
prepareOldSnapshot: () => eventManager.emit('ViewerOpenTransition'),
});
}}
text={$t('view_in_timeline')}
/>
{/if}
@@ -11,9 +11,10 @@
interface Props {
asset: TimelineAsset;
onImageLoad: () => void;
transitionName?: string;
}
const { asset, onImageLoad }: Props = $props();
const { asset, onImageLoad, transitionName }: Props = $props();
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
@@ -52,6 +53,7 @@
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
style:view-transition-name={transitionName}
/>
</div>
{/if}
@@ -30,6 +30,7 @@
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { navigateToTimeline } from '$lib/utils/transition-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
@@ -226,6 +227,20 @@
handlePromiseError(handleAction('galleryInView', 'pause'));
};
let memoryTransitionName = $state<string | undefined>(undefined);
const viewInTimeline = (assetId: string) => {
navigateToTimeline(assetId, {
types: ['memory-enter'],
prepareOldSnapshot: () => {
memoryTransitionName = 'hero';
},
onFinished: () => {
memoryTransitionName = undefined;
},
});
};
const handleGalleryScrollsOutOfView = () => {
galleryInView = false;
// only call play after the first page load. When page first loads the gallery will not be visible
@@ -505,7 +520,11 @@
videoViewerVolume={$videoViewerVolume}
/>
{:else}
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
<MemoryPhotoViewer
asset={current.asset}
onImageLoad={resetAndPlay}
transitionName={memoryTransitionName}
/>
{/if}
{/key}
@@ -560,6 +579,10 @@
color="secondary"
variant="ghost"
shape="round"
onclick={(event: MouseEvent) => {
event.preventDefault();
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
}}
/>
{/if}
{/await}
@@ -218,11 +218,14 @@
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
if (scrollTarget) {
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
}
} else if (scrollTarget) {
await tick();
focusAsset(scrollTarget);
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
}
invisible = isAssetViewerRoute(page) ? true : false;
};
+60
View File
@@ -1,5 +1,7 @@
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { tick } from 'svelte';
export function startViewerTransition(
@@ -32,6 +34,64 @@ export function removeCrossfadeOverlay() {
}
}
export function navigateToTimeline(
assetId: string,
options: { types: string[]; prepareOldSnapshot?: () => void; onFinished?: () => void },
) {
let heroOverlay: HTMLElement | null = null;
let hiddenElement: HTMLElement | null = null;
void viewTransitionManager.startTransition({
types: options.types,
prepareOldSnapshot: options.prepareOldSnapshot,
performUpdate: async () => {
const scrolled = eventManager.untilNext('TimelineScrolledToAsset');
await goto(Route.photos({ at: assetId }));
await scrolled;
await tick();
const element = document.querySelector<HTMLElement>(`[data-asset-id="${CSS.escape(assetId)}"]`);
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
const img = element.querySelector('img');
hiddenElement = element;
element.style.visibility = 'hidden';
heroOverlay = document.createElement('div');
heroOverlay.style.cssText = `
position: fixed;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
view-transition-name: hero;
pointer-events: none;
z-index: -1;
overflow: hidden;
`;
if (img?.src) {
heroOverlay.style.backgroundImage = `url("${CSS.escape(img.src)}")`;
heroOverlay.style.backgroundSize = 'cover';
heroOverlay.style.backgroundPosition = 'center';
}
document.body.append(heroOverlay);
},
onFinished: () => {
heroOverlay?.remove();
heroOverlay = null;
if (hiddenElement) {
hiddenElement.style.visibility = '';
hiddenElement = null;
}
options.onFinished?.();
},
});
}
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
if (!viewerContent) {