diff --git a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts index c69503cf11..918988c83f 100644 --- a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts @@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => { await page.mouse.move(width / 2, height / 2); await page.mouse.wheel(0, -1); - const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]'); + const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]'); await expect(async () => { const transform = await imgLocator.evaluate((element) => { return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; @@ -252,7 +252,7 @@ test.describe('face overlay via edit faces side panel', () => { await ensureDetailPanelVisible(page); await page.getByLabel('Edit people').click(); - const faceThumbnail = page.locator('section div[role="button"]').first(); + const faceThumbnail = page.getByTestId('face-thumbnail').first(); await expect(faceThumbnail).toBeVisible(); const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); diff --git a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts index c3721b1c54..87f809de75 100644 --- a/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts +++ b/e2e/src/ui/specs/search/search-gallery.e2e-spec.ts @@ -6,6 +6,7 @@ import { generateTimelineData, TimelineAssetConfig, TimelineData, + toAssetResponseDto, } from 'src/ui/generators/timeline'; import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network'; import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network'; @@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => { }; test.beforeAll(async () => { + test.fail( + process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1', + 'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1', + ); adminUserId = faker.string.uuid(); testContext.adminId = adminUserId; timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId }); @@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => { await context.route('**/api/search/metadata', async (route, request) => { if (request.method() === 'POST') { - const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id)); + const searchAssets = assets + .slice(0, 5) + .filter((asset) => !changes.assetDeletions.includes(asset.id)) + .map((asset) => toAssetResponseDto(asset)); return route.fulfill({ status: 200, contentType: 'application/json', diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index b7003295cf..aff60df6af 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -164,12 +164,8 @@ export const assetViewerUtils = { }, async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { await page - .locator( - `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, - ) - .or( - page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), - ) + .locator(`img[data-testid="preview"][src*="${asset.id}"]`) + .or(page.locator(`video[poster*="${asset.id}"]`)) .waitFor(); }, async expectActiveAssetToBe(page: Page, assetId: string) { diff --git a/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts new file mode 100644 index 0000000000..1f5bdfdf2e --- /dev/null +++ b/e2e/src/web/specs/asset-viewer/asset-viewer.ui-spec.ts @@ -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]); + }); + }); +}); diff --git a/server/Dockerfile b/server/Dockerfile index 9cc53c1095..b7015072c7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -33,7 +33,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \ - pnpm --filter @immich/sdk --filter immich-web build + NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @immich/sdk --filter immich-web build FROM builder AS cli diff --git a/web/src/app.css b/web/src/app.css index 1ff3bec99b..1595bc8f30 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -75,6 +75,33 @@ --immich-dark-bg: 10 10 10; --immich-dark-fg: 229 231 235; --immich-dark-gray: 33 33 33; + + /* transitions */ + --immich-split-viewer-nav: enabled; + + /* view transition variables */ + /* Base animation duration for standard transitions (page fades, info panel) */ + --vt-duration-default: 250ms; + /* Duration for hero transitions (thumbnail to full viewer) */ + --vt-duration-hero: 280ms; + /* Duration for next/previous photo navigation */ + --vt-duration-viewer-navigation: 270ms; + /* Duration for slideshow mode transitions */ + --vt-duration-slideshow: 1s; + /* Easing function for slide animations (ease-out) */ + --vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1); + /* How far images slide in/out during navigation (% of viewport) */ + --vt-viewer-slide-distance: 15%; + /* Starting opacity for fly transitions (slide+fade effect) */ + --vt-viewer-opacity-start: 0.1; + /* Maximum blur during fly transitions (currently disabled) */ + --vt-viewer-blur-max: 0px; + + --vt-viewer-next-in: flyInRight; + --vt-viewer-next-out: flyOutLeft; + --vt-viewer-prev-in: flyInLeft; + --vt-viewer-prev-out: flyOutRight; + --vt-viewer-old-opacity: 1; } button:not(:disabled), @@ -176,3 +203,367 @@ @apply bg-subtle rounded-lg; } } + +@layer base { + ::view-transition { + background: var(--color-black); + animation-duration: var(--vt-duration-default); + } + + ::view-transition-old(*), + ::view-transition-new(*) { + mix-blend-mode: normal; + animation-duration: inherit; + } + + ::view-transition-old(*) { + animation-name: fadeOut; + animation-fill-mode: forwards; + } + ::view-transition-new(*) { + animation-name: fadeIn; + animation-fill-mode: forwards; + } + + ::view-transition-old(root) { + animation: var(--vt-duration-default) 0s fadeOut forwards; + } + ::view-transition-new(root) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + html:active-view-transition-type(slideshow) { + &::view-transition-old(*) { + animation: var(--vt-duration-slideshow) linear crossfadeOut forwards; + } + &::view-transition-new(*) { + animation: var(--vt-duration-slideshow) linear crossfadeIn forwards; + } + &::view-transition-image-pair(*) { + isolation: auto; + } + } + html:active-view-transition-type(viewer-nav) { + &::view-transition-old(root) { + animation: var(--vt-duration-hero) 0s fadeOut forwards; + } + &::view-transition-new(root) { + animation: var(--vt-duration-hero) 0s fadeIn forwards; + } + } + ::view-transition-image-pair(info) { + isolation: auto; + } + ::view-transition-old(info) { + animation: var(--vt-duration-default) 0s panelSlideOutRight forwards; + } + ::view-transition-new(info) { + 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(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + animation-duration: var(--vt-duration-viewer-navigation); + animation-timing-function: var(--vt-viewer-slide-easing); + z-index: 4; + } + + ::view-transition-image-pair(letterbox-left), + ::view-transition-image-pair(letterbox-right), + ::view-transition-image-pair(letterbox-top), + ::view-transition-image-pair(letterbox-bottom) { + isolation: auto; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + animation: none; + width: 100%; + height: 100%; + object-fit: fill; + background-color: var(--color-black); + } + + ::view-transition-group(exclude-leftbutton), + ::view-transition-group(exclude-rightbutton), + ::view-transition-group(exclude) { + animation: none; + z-index: 5; + } + ::view-transition-old(exclude-leftbutton), + ::view-transition-old(exclude-rightbutton), + ::view-transition-old(exclude) { + visibility: hidden; + } + ::view-transition-new(exclude-leftbutton), + ::view-transition-new(exclude-rightbutton), + ::view-transition-new(exclude) { + animation: none; + z-index: 5; + } + + ::view-transition-group(hero) { + animation-duration: var(--vt-duration-hero); + animation-timing-function: cubic-bezier(0.2, 0, 0, 1); + } + ::view-transition-old(hero), + ::view-transition-new(hero) { + animation: none; + align-content: center; + } + ::view-transition-old(next), + ::view-transition-old(next-old), + ::view-transition-new(next), + ::view-transition-new(next-new), + ::view-transition-old(previous), + ::view-transition-old(previous-old), + ::view-transition-new(previous), + ::view-transition-new(previous-new) { + animation-duration: var(--vt-duration-viewer-navigation); + animation-timing-function: var(--vt-viewer-slide-easing); + animation-fill-mode: forwards; + } + + ::view-transition-old(next), + ::view-transition-old(next-old), + ::view-transition-old(previous), + ::view-transition-old(previous-old) { + opacity: var(--vt-viewer-old-opacity); + } + + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation-name: var(--vt-viewer-next-out); + } + + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation-name: var(--vt-viewer-next-in); + } + + ::view-transition-old(previous), + ::view-transition-old(previous-old) { + animation-name: var(--vt-viewer-prev-out); + } + + ::view-transition-new(previous), + ::view-transition-new(previous-new) { + animation-name: var(--vt-viewer-prev-in); + } + + ::view-transition-old(next-old), + ::view-transition-new(next-new), + ::view-transition-old(previous-old), + ::view-transition-new(previous-new) { + overflow: hidden; + } + + ::view-transition-old(previous-old) { + z-index: -1; + } + + @keyframes flyInLeft { + from { + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutLeft { + from { + opacity: 1; + filter: blur(0); + } + to { + transform: translateX(calc(-1 * var(--vt-viewer-slide-distance))); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes flyInRight { + from { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + to { + opacity: 1; + filter: blur(0); + } + } + + @keyframes flyOutRight { + from { + opacity: 1; + filter: blur(0); + } + to { + transform: translateX(var(--vt-viewer-slide-distance)); + opacity: var(--vt-viewer-opacity-start); + filter: blur(var(--vt-viewer-blur-max)); + } + } + + @keyframes panelSlideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } + } + + @keyframes panelSlideOutRight { + from { + transform: translateX(0); + } + to { + transform: translateX(100%); + } + } + + /* cubic fade curves so combined opacity stays close to 1.0 during crossfade */ + @keyframes fadeIn { + from { + opacity: 0; + } + 50% { + opacity: 0.85; + } + to { + opacity: 1; + } + } + @keyframes fadeOut { + from { + opacity: 1; + } + 50% { + opacity: 0.85; + } + to { + opacity: 0; + } + } + + @keyframes crossfadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes crossfadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + @media (prefers-reduced-motion: reduce) { + ::view-transition-group(hero) { + animation-name: none; + } + + ::view-transition-old(hero) { + animation: none; + display: none; + } + + ::view-transition-new(hero) { + animation: none; + } + + html:active-view-transition-type(viewer) { + &::view-transition-old(hero) { + animation: none; + display: none; + } + &::view-transition-new(hero) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + } + + html:active-view-transition-type(timeline) { + &::view-transition-old(hero) { + animation: var(--vt-duration-default) 0s fadeOut forwards; + } + &::view-transition-new(hero) { + animation: var(--vt-duration-default) 0s fadeIn forwards; + } + } + + ::view-transition-group(letterbox-left), + ::view-transition-group(letterbox-right), + ::view-transition-group(letterbox-top), + ::view-transition-group(letterbox-bottom) { + z-index: 100; + } + + ::view-transition-old(letterbox-left), + ::view-transition-old(letterbox-right), + ::view-transition-old(letterbox-top), + ::view-transition-old(letterbox-bottom), + ::view-transition-new(letterbox-left), + ::view-transition-new(letterbox-right), + ::view-transition-new(letterbox-top), + ::view-transition-new(letterbox-bottom) { + background-color: transparent; + } + + ::view-transition-group(previous), + ::view-transition-group(previous-old), + ::view-transition-group(next), + ::view-transition-group(next-old) { + width: 100% !important; + height: 100% !important; + transform: none !important; + } + + ::view-transition-old(previous), + ::view-transition-old(previous-old), + ::view-transition-old(next), + ::view-transition-old(next-old) { + animation: var(--vt-duration-viewer-navigation) fadeOut forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + overflow: hidden; + } + + ::view-transition-new(previous), + ::view-transition-new(previous-new), + ::view-transition-new(next), + ::view-transition-new(next-new) { + animation: var(--vt-duration-viewer-navigation) fadeIn forwards; + transform-origin: center; + height: 100%; + width: 100%; + object-fit: contain; + } + } +} diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 901aa906b1..5831f15285 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,6 +1,7 @@ + -