mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix(web): address review feedback on hero view transitions
Change-Id: I9f12e1616ddcf124a9926d54868b5e166a6a6964
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
#### View Transitions
|
||||
|
||||
This page describes the architecture behind hero view transitions between the timeline grid and the asset viewer.
|
||||
|
||||
##### View Transitions 101
|
||||
|
||||
The [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) lets the browser animate between two DOM states automatically. The basic flow:
|
||||
|
||||
1. **Tag elements with names**: You assign `view-transition-name: hero` (via CSS or inline style) to a DOM element on the current page, such as a thumbnail.
|
||||
2. **Capture old snapshot**: The browser takes a screenshot of every named element (position, size, appearance).
|
||||
3. **Update the DOM**: You make your changes: navigate to a new page, swap components, update state. The browser holds the old screenshot on screen while this happens, so the user sees no flash.
|
||||
4. **Tag the new element**: A completely different element on the new page can be given the same `view-transition-name: hero` (which is the case here: the image element in `AssetViewer`).
|
||||
5. **Capture new snapshot**: The browser screenshots the new named elements.
|
||||
6. **Animate**: The browser automatically performs a [FLIP-style animation](https://aerotwist.com/blog/flip-your-animations/) (First, Last, Invert, Play). It calculates the position/size delta between old and new snapshots and animates between them. The thumbnail smoothly morphs into the viewer image.
|
||||
|
||||
The animation is customizable via CSS pseudo-elements (`::view-transition-old(hero)`, `::view-transition-new(hero)`). Any element without a `view-transition-name` gets cross-faded as part of the page-level `::view-transition-group(root)` transition.
|
||||
|
||||
The key challenge is **timing**: the browser needs both snapshots tagged at exactly the right moments, but the thumbnail and viewer live in different components on different routes. We solve this with a lightweight event protocol between the participating components.
|
||||
|
||||
##### Why events?
|
||||
|
||||
The View Transition API itself is simple, but in our case the elements being animated (`Timeline` thumbnails and `AssetViewer` images) are owned by components spread across different routes and subtrees. Props and bindings can't reach across these boundaries, but a shared event bus can. Events let any component signal "I'm ready" and any other component await that signal, regardless of where they live in the tree.
|
||||
|
||||
##### BaseEventManager + `untilNext`
|
||||
|
||||
`BaseEventManager` is a typed event bus (`on`, `emit`, `once`, `hasListeners`). The key addition is `untilNext(event)`: it returns a promise that resolves the next time that event fires. This turns event-driven coordination into sequential async code:
|
||||
|
||||
```typescript
|
||||
// Instead of callback nesting:
|
||||
manager.on({
|
||||
SomeEvent: (...args) => {
|
||||
doNextThing(args);
|
||||
},
|
||||
});
|
||||
|
||||
// You can write:
|
||||
const args = await manager.untilNext('SomeEvent');
|
||||
doNextThing(args);
|
||||
```
|
||||
|
||||
It also supports a `signal` option. If the signal aborts before the event fires, the promise **resolves** (not rejects) with `undefined`. This allows graceful fallback: "wait for this event, but if nobody responds in time, move on."
|
||||
|
||||
##### ViewTransitionManager
|
||||
|
||||
Wraps the View Transition API into a request-based model with named lifecycle callbacks:
|
||||
|
||||
```typescript
|
||||
viewTransitionManager.startTransition({
|
||||
// CSS transition type filters
|
||||
types: ['viewer'],
|
||||
// Set up view-transition-names BEFORE old snapshot
|
||||
prepareOldSnapshot: () => {},
|
||||
// Do DOM changes (navigation, state updates, set up names for new snapshot)
|
||||
performUpdate: async (signal) => {},
|
||||
// Last-chance adjustments before new snapshot
|
||||
prepareNewSnapshot: () => {},
|
||||
// Cleanup after animation completes
|
||||
onFinished: () => {},
|
||||
});
|
||||
```
|
||||
|
||||
When `viewTransitionManager.startTransition()` is called, the following sequence occurs:
|
||||
|
||||
1. Emits `PrepareOldSnapshot` event. Calls `prepareOldSnapshot` callback (e.g. assign `view-transition-name: hero` to the thumbnail). `await tick()` flushes the DOM.
|
||||
2. Calls `document.startViewTransition()`. Browser captures old state, then invokes the transition's update callback.
|
||||
3. Inside the update callback: calls `performUpdate(signal)` (e.g. navigate to viewer, wait for image to load).
|
||||
4. After `performUpdate` returns: emits `PrepareNewSnapshot` event, then calls `prepareNewSnapshot` callback. This gives both event listeners and the caller a chance to tag elements for the new snapshot (e.g. `AssetViewer` listens for this to set exclusion names on its nav bar and buttons). `await tick()` flushes the DOM.
|
||||
5. The update callback returns. Browser captures new state. `updateCallbackDone` resolves.
|
||||
6. `transition.ready` resolves. Animation plays.
|
||||
7. `transition.finished` resolves. Emits `Finished` event, then calls `onFinished` callback. Listeners use this to clean up all `view-transition-name` values.
|
||||
|
||||
The three events (`PrepareOldSnapshot`, `PrepareNewSnapshot`, `Finished`) are broadcast with the transition's `types` array, so listeners can filter by transition type (e.g. only act on `'viewer'` or `'timeline'` transitions).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant M as ViewTransitionManager
|
||||
participant L as Event Listeners
|
||||
participant B as Browser
|
||||
|
||||
C->>M: startTransition({ callbacks })
|
||||
M->>L: emit('PrepareOldSnapshot', types)
|
||||
M->>C: prepareOldSnapshot()
|
||||
M->>B: document.startViewTransition()
|
||||
B->>B: Capture old state
|
||||
M->>C: performUpdate(signal)
|
||||
C-->>M: returns
|
||||
M->>L: emit('PrepareNewSnapshot', types)
|
||||
M->>C: prepareNewSnapshot()
|
||||
B->>B: Capture new state
|
||||
B->>B: Animation plays
|
||||
M->>L: emit('Finished', types)
|
||||
M->>C: onFinished()
|
||||
```
|
||||
|
||||
The manager also handles a few edge cases:
|
||||
|
||||
- **Browser compatibility**: The View Transition API has two calling conventions. The newer form `startViewTransition({ update, types })` accepts an object with a `types` array that lets you target specific transitions with different CSS animations. Older browsers only support the function form `startViewTransition(update)`. The manager tries the object form first and falls back to the function form if it throws.
|
||||
- **Overlapping transitions**: If a new transition starts while one is already active, the active transition is skipped via `skipTransition()`.
|
||||
- **Abort signal**: An `AbortSignal` is created and passed to `performUpdate`. It aborts if `transition.ready` rejects, which is usually caused by coding errors like duplicate `view-transition-name` values on the same page.
|
||||
|
||||
##### Timeline visibility
|
||||
|
||||
The timeline is always rendered, even when the asset viewer is open. It is hidden using CSS `visibility: hidden` (Tailwind's `invisible` class) rather than `display: none`. The difference matters: `display: none` removes the element from the layout tree entirely (dimensions are 0), while `visibility: hidden` keeps the element fully laid out but unpainted.
|
||||
|
||||
The timeline's virtualization pipeline depends on real viewport dimensions:
|
||||
|
||||
```svelte
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
```
|
||||
|
||||
With `display: none`, `viewportHeight`/`viewportWidth` would be 0 and the entire virtualization would break. No months would be "near viewport," nothing would load, no positions would be calculated, and no `Month` components would mount.
|
||||
|
||||
With `visibility: hidden`, the timeline stays fully functional while hidden: months load, layout is computed, scroll position tracks the viewer (via `scrollAfterNavigate`), and `Month` components mount/unmount based on viewport proximity as usual. This means:
|
||||
|
||||
- **Closing the viewer is instant** because the timeline is already laid out (no bootstrap needed)
|
||||
- **Direct navigation to `/photos/{id}` doesn't flicker** because the timeline renders silently behind the viewer
|
||||
- **`Month` components are mounted** and can receive `ViewerCloseTransition` events to start the hero animation
|
||||
|
||||
##### View transition name assignments
|
||||
|
||||
Two elements participate in the hero animation:
|
||||
|
||||
- **Timeline thumbnail** (`AssetLayout.svelte`): When `heroTransitionAssetId` matches an asset, that thumbnail's wrapper gets `style:view-transition-name="hero"`
|
||||
- **Viewer image** (`AssetViewer.svelte`): `assetViewerManager.transitionName` is set to `'hero'` during transitions
|
||||
|
||||
Other viewer elements get their own unique transition names during transitions (`'exclude'` for the navigation bar, `'exclude-previousbutton'` and `'exclude-nextbutton'` for the nav buttons, `'info'` for the detail panel). Without these, the browser would cross-fade them as part of the default page-level `::view-transition-group(root)` animation, creating a messy visual. Assigning unique names isolates them into separate transition groups that can be styled independently via CSS (e.g. faded out or held static). They're `undefined` outside of transitions so they don't affect normal rendering.
|
||||
|
||||
##### Open protocol (thumbnail to viewer)
|
||||
|
||||
Participants: `Timeline`, `ViewTransitionManager`, `AssetViewer`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Timeline
|
||||
participant VTM as ViewTransitionManager
|
||||
participant VT as Browser
|
||||
participant AV as AssetViewer
|
||||
|
||||
Note over T: User clicks thumbnail
|
||||
T->>VTM: startTransition()
|
||||
VTM->>AV: emit('PrepareOldSnapshot', ['viewer'])
|
||||
Note over T: prepareOldSnapshot()
|
||||
T->>T: Assign view-transition-name: "hero" to thumbnail
|
||||
VTM->>VT: startViewTransition()
|
||||
VT->>VT: Capture OLD snapshot
|
||||
Note over T: performUpdate()
|
||||
T->>T: Remove "hero" from thumbnail
|
||||
T->>AV: navigate to /photos/{id}
|
||||
AV->>AV: Mount, load image
|
||||
AV-->>T: emit(ViewerOpenTransitionReady)
|
||||
T->>AV: emit(ViewerOpenTransition)
|
||||
AV->>AV: Assign view-transition-name: "hero"<br/>to viewer image
|
||||
VTM->>AV: emit('PrepareNewSnapshot', ['viewer'])
|
||||
AV->>AV: Assign exclusion names to nav bar,<br/>buttons
|
||||
Note over T: performUpdate() returns
|
||||
VT->>VT: Capture NEW snapshot
|
||||
VT->>VT: Animate thumbnail to image
|
||||
VT-->>VTM: transition.finished
|
||||
VTM->>AV: emit('Finished')
|
||||
AV->>AV: Clear all view-transition-names
|
||||
Note over T: onFinished()
|
||||
```
|
||||
|
||||
##### Close protocol (viewer to thumbnail)
|
||||
|
||||
The close is more complex than the open: `TimelineAssetViewer` knows the asset but needs to find which mounted `Month` owns it, and the timeline must scroll into position and become visible before the new snapshot can be captured.
|
||||
|
||||
Participants: `TimelineAssetViewer`, `Month`, `ViewTransitionManager`, `AssetViewer`, `Timeline`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant TAV as TimelineAssetViewer
|
||||
participant M as Month
|
||||
participant VTM as ViewTransitionManager
|
||||
participant VT as Browser
|
||||
participant AV as AssetViewer
|
||||
participant TL as Timeline
|
||||
|
||||
Note over TAV: User closes viewer
|
||||
TAV->>TAV: untilNext('ViewerCloseTransitionReady',<br/>signal: timeout(200ms))
|
||||
TAV->>M: emit(ViewerCloseTransition, {id})
|
||||
M->>M: Find asset in this month<br/>(early return if not found)
|
||||
M->>VTM: startTransition()
|
||||
VTM->>AV: emit('PrepareOldSnapshot', ['timeline'])
|
||||
AV->>AV: Assign exclusion names to nav bar,<br/>buttons
|
||||
Note over M: prepareOldSnapshot()
|
||||
VTM->>VT: startViewTransition()
|
||||
VT->>VT: Capture OLD snapshot
|
||||
Note over M: performUpdate()
|
||||
M-->>TAV: emit(ViewerCloseTransitionReady)
|
||||
Note over TAV: untilNext resolves
|
||||
TAV->>TAV: Set timeline to invisible
|
||||
TAV->>TL: navigate(close viewer)
|
||||
TL->>TL: afterNavigate: scroll to asset,<br/>set timeline to visible
|
||||
TL-->>M: emit(TimelineLoaded, {id})
|
||||
M->>M: Assign view-transition-name: "hero"<br/>to thumbnail
|
||||
VTM->>AV: emit('PrepareNewSnapshot', ['timeline'])
|
||||
Note over M: performUpdate() returns
|
||||
VT->>VT: Capture NEW snapshot
|
||||
VT->>VT: Animate image to thumbnail
|
||||
VT-->>VTM: transition.finished
|
||||
VTM->>AV: emit('Finished')
|
||||
AV->>AV: Clear all view-transition-names
|
||||
Note over M: onFinished()
|
||||
M->>M: Focus asset
|
||||
```
|
||||
|
||||
##### Timeout and error handling
|
||||
|
||||
`untilNext` has a default 10s timeout. If the awaited event never fires, the promise **rejects**, which causes `performUpdate` to throw. By the View Transition spec, a failed update callback aborts the transition. No animation plays; the browser just shows the current DOM state.
|
||||
|
||||
**Open timeout (10s default)**: If `ViewerOpenTransitionReady` never fires, `performUpdate` rejects and the hero animation is skipped, but the navigation to the viewer already happened (`openViewer()` fired before the `await`). The viewer opens normally, just without the animation. The likely cause would be something preventing the viewer from mounting. Every viewer type (photo, video, panorama, editor) emits `ViewerOpenTransitionReady` on both success and error, so even a failed image load or network error still emits. The 10s timeout is defensive code, just in case.
|
||||
|
||||
**Close timeout (200ms, explicit `AbortSignal.timeout`)**: If no mounted `Month` claims the asset, the signal aborts and `untilNext` **resolves** (not rejects) with `undefined`. `handleClose` continues normally: viewer closes, timeline appears, no hero animation. This is a shorter, intentional timeout because month virtualization creates a known (if rare) structural gap where the event can't fire.
|
||||
|
||||
In both cases, the navigation always succeeds. State cleanup always happens (`transition.finished` fires regardless, emitting `Finished` and clearing all `view-transition-name` values), and the app is in a consistent state afterward. The hero animation is a visual enhancement; its failure is invisible beyond the missing animation.
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
toc_max_heading_level: 4
|
||||
---
|
||||
|
||||
import AppArchitecture from './img/app-architecture.webp';
|
||||
import MobileArchitecture from './img/immich_mobile_architecture.svg';
|
||||
import ViewTransitions from './_view-transitions.md';
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -42,6 +44,8 @@ The Repositories should be the only place where other data classes are used inte
|
||||
|
||||
The web app is a [TypeScript](https://www.typescriptlang.org/) project that uses [SvelteKit](https://kit.svelte.dev) and [Tailwindcss](https://tailwindcss.com/).
|
||||
|
||||
<ViewTransitions />
|
||||
|
||||
### CLI
|
||||
|
||||
The Immich CLI is an [npm](https://www.npmjs.com/) package that lets users control their Immich instance from the command line. It uses the API to perform various tasks, especially uploading assets. See the [CLI documentation](/features/command-line-interface.md) for more information.
|
||||
|
||||
-5344
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-16
@@ -1,16 +0,0 @@
|
||||
import { HttpError } from '@oazapfts/runtime';
|
||||
export interface ApiValidationError {
|
||||
code: string;
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
}
|
||||
export interface ApiExceptionResponse {
|
||||
message: string;
|
||||
error?: string;
|
||||
statusCode: number;
|
||||
errors?: ApiValidationError[];
|
||||
}
|
||||
export interface ApiHttpError extends HttpError {
|
||||
data: ApiExceptionResponse;
|
||||
}
|
||||
export declare function isHttpError(error: unknown): error is ApiHttpError;
|
||||
@@ -1,4 +0,0 @@
|
||||
import { HttpError } from '@oazapfts/runtime';
|
||||
export function isHttpError(error) {
|
||||
return error instanceof HttpError;
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
export * from './fetch-client.js';
|
||||
export * from './fetch-errors.js';
|
||||
export interface InitOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
export declare const init: ({ baseUrl, apiKey, headers }: InitOptions) => void;
|
||||
export declare const getBaseUrl: () => string;
|
||||
export declare const setBaseUrl: (baseUrl: string) => void;
|
||||
export declare const setApiKey: (apiKey: string) => void;
|
||||
export declare const setHeader: (key: string, value: string) => void;
|
||||
export declare const setHeaders: (headers: Record<string, string>) => void;
|
||||
export declare const getAssetOriginalPath: (id: string) => string;
|
||||
export declare const getAssetThumbnailPath: (id: string) => string;
|
||||
export declare const getAssetPlaybackPath: (id: string) => string;
|
||||
export declare const getUserProfileImagePath: (userId: string) => string;
|
||||
export declare const getPeopleThumbnailPath: (personId: string) => string;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { defaults } from './fetch-client.js';
|
||||
export * from './fetch-client.js';
|
||||
export * from './fetch-errors.js';
|
||||
export const init = ({ baseUrl, apiKey, headers }) => {
|
||||
setBaseUrl(baseUrl);
|
||||
setApiKey(apiKey);
|
||||
if (headers) {
|
||||
setHeaders(headers);
|
||||
}
|
||||
};
|
||||
export const getBaseUrl = () => defaults.baseUrl;
|
||||
export const setBaseUrl = (baseUrl) => {
|
||||
defaults.baseUrl = baseUrl;
|
||||
};
|
||||
export const setApiKey = (apiKey) => {
|
||||
defaults.headers = defaults.headers || {};
|
||||
defaults.headers['x-api-key'] = apiKey;
|
||||
};
|
||||
export const setHeader = (key, value) => {
|
||||
assertNoApiKey(key);
|
||||
defaults.headers = defaults.headers || {};
|
||||
defaults.headers[key] = value;
|
||||
};
|
||||
export const setHeaders = (headers) => {
|
||||
defaults.headers = defaults.headers || {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
assertNoApiKey(key);
|
||||
defaults.headers[key] = value;
|
||||
}
|
||||
};
|
||||
const assertNoApiKey = (headerKey) => {
|
||||
if (headerKey.toLowerCase() === 'x-api-key') {
|
||||
throw new Error('The API key header can only be set using setApiKey().');
|
||||
}
|
||||
};
|
||||
export const getAssetOriginalPath = (id) => `/assets/${id}/original`;
|
||||
export const getAssetThumbnailPath = (id) => `/assets/${id}/thumbnail`;
|
||||
export const getAssetPlaybackPath = (id) => `/assets/${id}/video/playback`;
|
||||
export const getUserProfileImagePath = (userId) => `/users/${userId}/profile-image`;
|
||||
export const getPeopleThumbnailPath = (personId) => `/people/${personId}/thumbnail`;
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.7.5",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@
|
||||
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
@@ -68,19 +69,21 @@
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
performUpdate: async () => {
|
||||
assetViewerManager.emit('ViewerCloseTransitionReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineHeroAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineHeroAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
});
|
||||
handlePromiseError(
|
||||
viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
performUpdate: async () => {
|
||||
assetViewerManager.emit('ViewerCloseTransitionReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineHeroAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineHeroAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => assetViewerManager.on({ ViewerCloseTransition: handleViewerCloseTransition }));
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
@@ -581,11 +582,13 @@
|
||||
}
|
||||
|
||||
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
startViewerTransition(
|
||||
asset.id,
|
||||
openViewer,
|
||||
(id) => (toViewerHeroAssetId = id),
|
||||
() => (toViewerHeroAssetId = null),
|
||||
handlePromiseError(
|
||||
startViewerTransition(
|
||||
asset.id,
|
||||
openViewer,
|
||||
(id) => (toViewerHeroAssetId = id),
|
||||
() => (toViewerHeroAssetId = null),
|
||||
),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
|
||||
const handleClose = async (assetId: string) => {
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
const transitionReady = assetViewerManager.untilNext('ViewerCloseTransitionReady');
|
||||
const transitionReady = assetViewerManager.untilNext('ViewerCloseTransitionReady', {
|
||||
signal: AbortSignal.timeout(200),
|
||||
});
|
||||
assetViewerManager.emit('ViewerCloseTransition', { id: assetId });
|
||||
await transitionReady;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { tick } from 'svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
|
||||
export function startViewerTransition(
|
||||
export async function startViewerTransition(
|
||||
heroAssetId: string,
|
||||
openViewer: () => void,
|
||||
activateHeroAsset: (assetId: string) => void,
|
||||
deactivateHeroAsset: () => void,
|
||||
) {
|
||||
void viewTransitionManager.startTransition({
|
||||
await viewTransitionManager.startTransition({
|
||||
types: ['viewer'],
|
||||
prepareOldSnapshot: () => {
|
||||
activateHeroAsset(heroAssetId);
|
||||
|
||||
Reference in New Issue
Block a user