mirror of
https://github.com/immich-app/immich.git
synced 2026-05-31 03:05:22 -04:00
feat(web): nav/slideshow view transitions and crossfade
Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
const rng = new SeededRandom(529);
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos/:id', () => {
|
||||
test('Navigate to next asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
// Navigate forward 3 times
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Navigate backward 3 times to return to original
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Verify we're back at the original asset
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
});
|
||||
|
||||
test('Verify no next button on last asset', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${lastAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
|
||||
// Verify next button doesn't exist
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Verify no previous button on first asset', async ({ page }) => {
|
||||
const firstAsset = assets[0];
|
||||
await page.goto(`/photos/${firstAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||
|
||||
// Verify previous button doesn't exist
|
||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Delete photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete last photo advances to prev', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
});
|
||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||
});
|
||||
});
|
||||
test.describe('/trash/photos/:id', () => {
|
||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,14 @@
|
||||
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(detail-panel) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(detail-panel),
|
||||
::view-transition-new(detail-panel) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-previousbutton),
|
||||
::view-transition-group(exclude-nextbutton),
|
||||
::view-transition-group(exclude) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<script module lang="ts">
|
||||
const useSplitNavTransitions =
|
||||
typeof document !== 'undefined' &&
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
@@ -25,6 +31,7 @@
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAssetInfo,
|
||||
@@ -95,6 +102,7 @@
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowRepeat,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
@@ -149,6 +157,7 @@
|
||||
let navigationBarTransitionName = $state<string | undefined>();
|
||||
let previousButtonTransitionName = $state<string | undefined>();
|
||||
let nextButtonTransitionName = $state<string | undefined>();
|
||||
let letterboxTransitionName = $state<string | undefined>();
|
||||
|
||||
const activateViewTransitionNames = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
@@ -228,26 +237,88 @@
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const completeNavigation = async (order: 'previous' | 'next') => {
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||
if (direction === 'previous' || direction === 'next') {
|
||||
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
|
||||
}
|
||||
return direction ?? undefined;
|
||||
};
|
||||
|
||||
let hasNext: boolean;
|
||||
const clearTransitionNames = () => {
|
||||
detailPanelTransitionName = undefined;
|
||||
assetViewerManager.transitionName = undefined;
|
||||
letterboxTransitionName = undefined;
|
||||
};
|
||||
|
||||
const startTransition = async (
|
||||
types: string[],
|
||||
targetTransition: string | null,
|
||||
navigateFn: () => Promise<boolean>,
|
||||
) => {
|
||||
const oldName = getTransitionName('old', targetTransition);
|
||||
const newName = getTransitionName('new', targetTransition);
|
||||
|
||||
let result = false;
|
||||
|
||||
await viewTransitionManager.startTransition({
|
||||
types,
|
||||
prepareOldSnapshot: () => {
|
||||
assetViewerManager.transitionName = oldName;
|
||||
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
|
||||
detailPanelTransitionName = 'detail-panel';
|
||||
},
|
||||
performUpdate: async (signal) => {
|
||||
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||
result = await navigateFn();
|
||||
await ready;
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
assetViewerManager.transitionName = newName;
|
||||
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
|
||||
},
|
||||
onFinished: clearTransitionNames,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
const skipped = viewTransitionManager.skipTransitions();
|
||||
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
|
||||
|
||||
let navigate: () => Promise<boolean>;
|
||||
let types: string[];
|
||||
let targetTransition: string | null;
|
||||
|
||||
if (slideShowPlaying && slideShowShuffle) {
|
||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!next) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
next = true;
|
||||
navigate = async () => {
|
||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!next) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
next = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
hasNext = next;
|
||||
return next;
|
||||
};
|
||||
types = ['slideshow'];
|
||||
targetTransition = null;
|
||||
} else {
|
||||
const target = order === 'previous' ? previousAsset : nextAsset;
|
||||
hasNext = await navigateToAsset(target);
|
||||
navigate = async () => {
|
||||
const target = order === 'previous' ? previousAsset : nextAsset;
|
||||
return navigateToAsset(target);
|
||||
};
|
||||
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
|
||||
targetTransition = slideShowPlaying ? null : order;
|
||||
}
|
||||
|
||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
||||
|
||||
if (!slideShowPlaying) {
|
||||
return;
|
||||
}
|
||||
@@ -268,7 +339,7 @@
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
let navigating = $state(false);
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||
if (!order) {
|
||||
if (slideShowPlaying) {
|
||||
order = slideShowAscending ? 'previous' : 'next';
|
||||
@@ -283,7 +354,7 @@
|
||||
|
||||
navigating = true;
|
||||
void tracker
|
||||
.invoke(() => completeNavigation(order), $t('error_while_navigating'))
|
||||
.invoke(() => completeNavigation(order, skipTransition), $t('error_while_navigating'))
|
||||
.finally(() => (navigating = false));
|
||||
};
|
||||
|
||||
@@ -324,8 +395,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
|
||||
return;
|
||||
}
|
||||
assetViewerManager.closeFaceEditMode();
|
||||
void crossfadeViewerContent(() => {
|
||||
previewStackedAsset = stackedAsset;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseLeave = () => {
|
||||
if (!previewStackedAsset) {
|
||||
return;
|
||||
}
|
||||
removeCrossfadeOverlay();
|
||||
previewStackedAsset = undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
@@ -652,7 +737,12 @@
|
||||
|
||||
{#if stack && withStacked && $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
id="stack-slideshow"
|
||||
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
|
||||
onmouseleave={handleStackedAssetMouseLeave}
|
||||
>
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
@@ -665,10 +755,11 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
removeCrossfadeOverlay();
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
|
||||
@@ -23,3 +23,63 @@ export function startViewerTransition(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let activeOverlay: HTMLElement | undefined;
|
||||
|
||||
export function removeCrossfadeOverlay() {
|
||||
if (activeOverlay) {
|
||||
activeOverlay.remove();
|
||||
activeOverlay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
||||
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
||||
if (!viewerContent) {
|
||||
await updateFn();
|
||||
return;
|
||||
}
|
||||
|
||||
removeCrossfadeOverlay();
|
||||
|
||||
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||
Object.assign(clone.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
zIndex: '1',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
delete clone.dataset.viewerContent;
|
||||
if (!viewerContent.parentElement) {
|
||||
await updateFn();
|
||||
return;
|
||||
}
|
||||
viewerContent.parentElement.append(clone);
|
||||
activeOverlay = clone;
|
||||
|
||||
const ready = eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
await updateFn();
|
||||
|
||||
try {
|
||||
await ready;
|
||||
} catch {
|
||||
clone.remove();
|
||||
if (activeOverlay === clone) {
|
||||
activeOverlay = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fadeOut = clone.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration,
|
||||
easing: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
fill: 'forwards',
|
||||
});
|
||||
|
||||
void fadeOut.finished.then(() => {
|
||||
clone.remove();
|
||||
if (activeOverlay === clone) {
|
||||
activeOverlay = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user