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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
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 UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.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 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 { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
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 { getGlobalActions } from '$lib/services/app.service';
|
||||||
import { getAssetActions } from '$lib/services/asset.service';
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
|
import { isPhotosRoute } from '$lib/utils/navigation';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
@@ -184,7 +189,21 @@
|
|||||||
{#if !asset.isArchived && !asset.isTrashed}
|
{#if !asset.isArchived && !asset.isTrashed}
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiImageSearch}
|
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')}
|
text={$t('view_in_timeline')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: TimelineAsset;
|
||||||
onImageLoad: () => void;
|
onImageLoad: () => void;
|
||||||
|
transitionName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset, onImageLoad }: Props = $props();
|
const { asset, onImageLoad, transitionName }: Props = $props();
|
||||||
|
|
||||||
let assetFileUrl: string = $state('');
|
let assetFileUrl: string = $state('');
|
||||||
let imageLoaded: boolean = $state(false);
|
let imageLoaded: boolean = $state(false);
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(asset)}
|
alt={$getAltText(asset)}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
|
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
||||||
@@ -226,6 +227,20 @@
|
|||||||
handlePromiseError(handleAction('galleryInView', 'pause'));
|
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 = () => {
|
const handleGalleryScrollsOutOfView = () => {
|
||||||
galleryInView = false;
|
galleryInView = false;
|
||||||
// only call play after the first page load. When page first loads the gallery will not be visible
|
// only call play after the first page load. When page first loads the gallery will not be visible
|
||||||
@@ -505,7 +520,11 @@
|
|||||||
videoViewerVolume={$videoViewerVolume}
|
videoViewerVolume={$videoViewerVolume}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
<MemoryPhotoViewer
|
||||||
|
asset={current.asset}
|
||||||
|
onImageLoad={resetAndPlay}
|
||||||
|
transitionName={memoryTransitionName}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
@@ -560,6 +579,10 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
shape="round"
|
shape="round"
|
||||||
|
onclick={(event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -218,11 +218,14 @@
|
|||||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||||
}
|
}
|
||||||
if (!scrolled) {
|
if (!scrolled) {
|
||||||
// if the asset is not found, scroll to the top
|
|
||||||
timelineManager.scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
|
if (scrollTarget) {
|
||||||
|
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||||
|
}
|
||||||
} else if (scrollTarget) {
|
} else if (scrollTarget) {
|
||||||
await tick();
|
await tick();
|
||||||
focusAsset(scrollTarget);
|
focusAsset(scrollTarget);
|
||||||
|
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||||
}
|
}
|
||||||
invisible = isAssetViewerRoute(page) ? true : false;
|
invisible = isAssetViewerRoute(page) ? true : false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
export function startViewerTransition(
|
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) {
|
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
||||||
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
||||||
if (!viewerContent) {
|
if (!viewerContent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user