feat(web): nav/slideshow view transitions and crossfade
Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964push-zpwsovysllvn
parent
4957bb15d3
commit
682bbce88e
|
|
@ -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;
|
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-previousbutton),
|
||||||
::view-transition-group(exclude-nextbutton),
|
::view-transition-group(exclude-nextbutton),
|
||||||
::view-transition-group(exclude) {
|
::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">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
|
@ -25,6 +31,7 @@
|
||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
|
|
@ -95,6 +102,7 @@
|
||||||
slideshowNavigation,
|
slideshowNavigation,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
slideshowTransition,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
|
|
@ -149,6 +157,7 @@
|
||||||
let navigationBarTransitionName = $state<string | undefined>();
|
let navigationBarTransitionName = $state<string | undefined>();
|
||||||
let previousButtonTransitionName = $state<string | undefined>();
|
let previousButtonTransitionName = $state<string | undefined>();
|
||||||
let nextButtonTransitionName = $state<string | undefined>();
|
let nextButtonTransitionName = $state<string | undefined>();
|
||||||
|
let letterboxTransitionName = $state<string | undefined>();
|
||||||
|
|
||||||
const activateViewTransitionNames = () => {
|
const activateViewTransitionNames = () => {
|
||||||
detailPanelTransitionName = 'info';
|
detailPanelTransitionName = 'info';
|
||||||
|
|
@ -228,26 +237,88 @@
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeNavigation = async (order: 'previous' | 'next') => {
|
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||||
preloadManager.cancelBeforeNavigation(order);
|
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) {
|
if (slideShowPlaying && slideShowShuffle) {
|
||||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
navigate = async () => {
|
||||||
if (!next) {
|
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
const asset = await onRandom?.();
|
if (!next) {
|
||||||
if (asset) {
|
const asset = await onRandom?.();
|
||||||
slideshowHistory.queue(asset);
|
if (asset) {
|
||||||
next = true;
|
slideshowHistory.queue(asset);
|
||||||
|
next = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return next;
|
||||||
hasNext = next;
|
};
|
||||||
|
types = ['slideshow'];
|
||||||
|
targetTransition = null;
|
||||||
} else {
|
} else {
|
||||||
const target = order === 'previous' ? previousAsset : nextAsset;
|
navigate = async () => {
|
||||||
hasNext = await navigateToAsset(target);
|
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) {
|
if (!slideShowPlaying) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +339,7 @@
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
const tracker = new InvocationTracker();
|
||||||
let navigating = $state(false);
|
let navigating = $state(false);
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if (slideShowPlaying) {
|
if (slideShowPlaying) {
|
||||||
order = slideShowAscending ? 'previous' : 'next';
|
order = slideShowAscending ? 'previous' : 'next';
|
||||||
|
|
@ -283,7 +354,7 @@
|
||||||
|
|
||||||
navigating = true;
|
navigating = true;
|
||||||
void tracker
|
void tracker
|
||||||
.invoke(() => completeNavigation(order), $t('error_while_navigating'))
|
.invoke(() => completeNavigation(order, skipTransition), $t('error_while_navigating'))
|
||||||
.finally(() => (navigating = false));
|
.finally(() => (navigating = false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -324,8 +395,22 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
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) => {
|
const handlePreAction = (action: Action) => {
|
||||||
|
|
@ -652,7 +737,12 @@
|
||||||
|
|
||||||
{#if stack && withStacked && $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
{#if stack && withStacked && $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||||
{@const stackedAssets = stack.assets}
|
{@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">
|
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -665,10 +755,11 @@
|
||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
removeCrossfadeOverlay();
|
||||||
cursor.current = stackedAsset;
|
cursor.current = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
showStackedIcon={false}
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue