mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user