mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 18:12:31 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 137ef8cf51 |
@@ -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[data-testid="preview"]');
|
||||
const imgLocator = page.getByTestId('preview');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const zoomIn = async (page: Page) => {
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
const getImageTransform = (page: Page) => {
|
||||
return page.getByTestId('preview').evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('zoom minimap', () => {
|
||||
const fixture = setupAssetViewerFixture(950);
|
||||
|
||||
test('minimap is not visible at 1x zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('minimap appears when zoomed in', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
|
||||
});
|
||||
|
||||
test('minimap contains thumbnail image', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const canvas = page.getByTestId('zoom-minimap-canvas');
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
const img = canvas.locator('img');
|
||||
await expect(img).toBeVisible();
|
||||
await expect(img).toHaveAttribute('src', /thumbnail/);
|
||||
});
|
||||
|
||||
test('viewport rect is visible when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const viewport = page.getByTestId('zoom-minimap-viewport');
|
||||
await expect(viewport).toBeVisible();
|
||||
|
||||
const box = await viewport.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
expect(box!.width).toBeGreaterThan(0);
|
||||
expect(box!.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clicking minimap pans the image', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const transformBefore = await getImageTransform(page);
|
||||
|
||||
const canvas = page.getByTestId('zoom-minimap-canvas');
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
expect(canvasBox).toBeTruthy();
|
||||
|
||||
// Click near the top-left corner of the minimap
|
||||
await page.mouse.click(canvasBox!.x + 20, canvasBox!.y + 20);
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const transformAfter = await getImageTransform(page);
|
||||
expect(transformAfter).not.toBe(transformBefore);
|
||||
});
|
||||
|
||||
test('zoom slider adjusts zoom level', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
|
||||
const slider = page.getByTestId('zoom-minimap-slider');
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
const sliderBox = await slider.boundingBox();
|
||||
expect(sliderBox).toBeTruthy();
|
||||
|
||||
const fillBefore = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
|
||||
return element.style.width;
|
||||
});
|
||||
|
||||
// Click near the right end of the slider to increase zoom
|
||||
await page.mouse.click(sliderBox!.x + sliderBox!.width * 0.8, sliderBox!.y + sliderBox!.height / 2);
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const fillAfter = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
|
||||
return element.style.width;
|
||||
});
|
||||
|
||||
expect(fillAfter).not.toBe(fillBefore);
|
||||
});
|
||||
|
||||
test('minimap auto-hides after inactivity', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await zoomIn(page);
|
||||
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
|
||||
|
||||
// Wait for the hide delay (1500ms) plus fade duration
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -31,10 +30,6 @@ 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 });
|
||||
@@ -49,10 +44,7 @@ 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))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -143,7 +143,7 @@ export const timelineUtils = {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await page.locator('#asset-grid[data-initialized]').waitFor();
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
@@ -163,17 +163,14 @@ export const assetViewerUtils = {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
|
||||
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
|
||||
await imgLocator.or(videoLocator).waitFor();
|
||||
|
||||
if ((await videoLocator.count()) === 0) {
|
||||
await expect
|
||||
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
|
||||
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"]`),
|
||||
)
|
||||
.waitFor();
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1013,7 +1013,6 @@
|
||||
"editor_reset_all_changes": "Reset changes",
|
||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||
"editor_rotate_right": "Rotate 90° clockwise",
|
||||
"editor_smart_crop": "Smart crop",
|
||||
"email": "Email",
|
||||
"email_notifications": "Email notifications",
|
||||
"empty_folder": "This folder is empty",
|
||||
@@ -2161,7 +2160,6 @@
|
||||
"skip_to_folders": "Skip to folders",
|
||||
"skip_to_tags": "Skip to tags",
|
||||
"slideshow": "Slideshow",
|
||||
"slideshow_ken_burns_effect": "Ken Burns effect",
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
|
||||
Generated
-8
@@ -824,9 +824,6 @@ importers:
|
||||
simple-icons:
|
||||
specifier: ^15.15.0
|
||||
version: 15.22.0
|
||||
smartcrop:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
socket.io-client:
|
||||
specifier: ~4.8.0
|
||||
version: 4.8.3
|
||||
@@ -10907,9 +10904,6 @@ packages:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
smartcrop@2.0.5:
|
||||
resolution: {integrity: sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==}
|
||||
|
||||
snake-case@3.0.4:
|
||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||
|
||||
@@ -24112,8 +24106,6 @@ snapshots:
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
smartcrop@2.0.5: {}
|
||||
|
||||
snake-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"pmtiles": "^4.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"simple-icons": "^15.15.0",
|
||||
"smartcrop": "^2.0.5",
|
||||
"socket.io-client": "~4.8.0",
|
||||
"svelte-gestures": "^5.2.2",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
|
||||
-609
@@ -75,35 +75,6 @@
|
||||
--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;
|
||||
/* Easing function for memory and hero morph transitions */
|
||||
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -205,583 +176,3 @@
|
||||
@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: var(--vt-memory-easing);
|
||||
}
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-old(memory-overlay),
|
||||
::view-transition-old(memory-controls),
|
||||
::view-transition-new(memory-overlay),
|
||||
::view-transition-new(memory-controls) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: none;
|
||||
object-position: left top;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory) {
|
||||
&::view-transition-group(hero),
|
||||
&::view-transition-group(hero-out) {
|
||||
animation-duration: var(--vt-duration-memory);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-group(memory-overlay-prev),
|
||||
&::view-transition-group(memory-overlay-next) {
|
||||
animation: none;
|
||||
z-index: 2;
|
||||
opacity: 0.25;
|
||||
}
|
||||
&::view-transition-image-pair(memory-overlay),
|
||||
&::view-transition-image-pair(memory-controls) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation: 120ms linear fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
&::view-transition-old(memory-overlay-prev),
|
||||
&::view-transition-old(memory-overlay-next) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(memory-overlay-prev),
|
||||
&::view-transition-new(memory-overlay-next) {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: none;
|
||||
object-position: left top;
|
||||
}
|
||||
&::view-transition-image-pair(hero) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(hero) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-image-pair(hero-out) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(hero-out) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero-out) {
|
||||
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-group(memory-departing) {
|
||||
animation: none;
|
||||
}
|
||||
&::view-transition-old(memory-departing) {
|
||||
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
|
||||
}
|
||||
&::view-transition-new(memory-departing) {
|
||||
animation: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-enter) {
|
||||
&::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
overflow: hidden;
|
||||
}
|
||||
&::view-transition-old(hero),
|
||||
&::view-transition-new(hero) {
|
||||
animation: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls),
|
||||
&::view-transition-group(memory-nav-buttons) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls),
|
||||
&::view-transition-old(memory-nav-buttons) {
|
||||
animation: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls),
|
||||
&::view-transition-new(memory-nav-buttons) {
|
||||
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(memory-fade-out) {
|
||||
animation: 500ms linear crossfadeOut forwards;
|
||||
}
|
||||
::view-transition-new(memory-fade-in) {
|
||||
animation: 500ms linear crossfadeIn forwards;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-nav-fast) {
|
||||
&::view-transition-old(memory-fade-out) {
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
&::view-transition-new(memory-fade-in) {
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation-duration: 100ms;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 100ms linear 150ms fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-nav) {
|
||||
&::view-transition-group(memory-overlay),
|
||||
&::view-transition-group(memory-controls) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
&::view-transition-image-pair(memory-overlay),
|
||||
&::view-transition-image-pair(memory-controls) {
|
||||
isolation: auto;
|
||||
}
|
||||
&::view-transition-old(memory-overlay),
|
||||
&::view-transition-old(memory-controls) {
|
||||
animation: 150ms linear fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(memory-overlay),
|
||||
&::view-transition-new(memory-controls) {
|
||||
animation: 200ms linear 300ms fadeIn forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
&::view-transition-group(memory-overlay-prev),
|
||||
&::view-transition-group(memory-overlay-next) {
|
||||
animation: none;
|
||||
opacity: 0.25;
|
||||
}
|
||||
&::view-transition-old(memory-overlay-prev),
|
||||
&::view-transition-old(memory-overlay-next) {
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(memory-overlay-prev),
|
||||
&::view-transition-new(memory-overlay-next) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
::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 fadeFromDim {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dimDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::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;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(memory-enter) {
|
||||
&::view-transition-group(hero) {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) fadeIn forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||
@@ -8,22 +9,66 @@ type TouchEventLike = {
|
||||
};
|
||||
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
|
||||
|
||||
export const MAX_ZOOM = 10;
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
let zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: MAX_ZOOM,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
let needsResync = false;
|
||||
|
||||
const createInstance = () => {
|
||||
zoomInstance.cleanup();
|
||||
zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: MAX_ZOOM,
|
||||
initialState: { ...assetViewerManager.zoomState, enable: true },
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
node.style.overflow = 'visible';
|
||||
unsubscribeStore?.();
|
||||
unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
|
||||
needsResync = false;
|
||||
};
|
||||
|
||||
const applyDirectTransform = (state: ZoomImageWheelState) => {
|
||||
const target = options?.zoomTarget ?? node.querySelector('img');
|
||||
if (target) {
|
||||
(target as HTMLElement).style.transformOrigin = '0 0';
|
||||
(target as HTMLElement).style.transform =
|
||||
`translate(${state.currentPositionX}px, ${state.currentPositionY}px) scale(${state.currentZoom})`;
|
||||
needsResync = true;
|
||||
}
|
||||
};
|
||||
|
||||
const resyncIfNeeded = () => {
|
||||
if (needsResync) {
|
||||
createInstance();
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
|
||||
|
||||
const unsubscribeManager = assetViewerManager.on({
|
||||
ZoomChange: (state) => zoomInstance.setState(state),
|
||||
DirectTransform: (state) => applyDirectTransform(state),
|
||||
ZoomEnabled: (enabled) => {
|
||||
if (enabled && needsResync) {
|
||||
createInstance();
|
||||
} else {
|
||||
zoomInstance.setState({ enable: enabled });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
|
||||
node.addEventListener('pointerdown', resyncIfNeeded, { signal });
|
||||
node.addEventListener('wheel', resyncIfNeeded, { signal });
|
||||
|
||||
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
|
||||
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
|
||||
@@ -141,9 +186,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = '';
|
||||
}
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
unsubscribeManager();
|
||||
unsubscribeStore?.();
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
@@ -19,10 +18,6 @@
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: Size;
|
||||
showLetterboxes?: boolean;
|
||||
transitionName?: string | null | undefined;
|
||||
letterboxTransitionName?: string | undefined;
|
||||
imageClass?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
@@ -46,10 +41,6 @@
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
showLetterboxes = true,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
imageClass,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
@@ -175,20 +166,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
|
||||
|
||||
<div
|
||||
class={['absolute inset-0 pointer-events-none', imageClass]}
|
||||
style:left
|
||||
style:top
|
||||
style:width
|
||||
style:height
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
>
|
||||
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
<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';
|
||||
@@ -19,7 +13,6 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -34,7 +27,6 @@
|
||||
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,
|
||||
@@ -48,7 +40,7 @@
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
@@ -97,14 +89,13 @@
|
||||
onRandom,
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowRepeat,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
@@ -118,10 +109,6 @@
|
||||
let sharedLink = getSharedLink();
|
||||
let fullscreenElement = $state<Element>();
|
||||
|
||||
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
|
||||
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
|
||||
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
|
||||
@@ -155,40 +142,35 @@
|
||||
}
|
||||
};
|
||||
|
||||
let transitionName = $state<string | undefined>('hero');
|
||||
let letterboxTransitionName = $state<string | undefined>(undefined);
|
||||
let detailPanelTransitionName = $state<string | undefined>(undefined);
|
||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
||||
if (asset.id === updatedAsset.id) {
|
||||
cursor = { ...cursor, current: updatedAsset };
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
const addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
transitionName = 'hero';
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
|
||||
unsubscribes.push(
|
||||
eventManager.on({
|
||||
ViewerOpenTransition: addInfoTransition,
|
||||
ViewerCloseTransition: addInfoTransition,
|
||||
}),
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -197,16 +179,10 @@
|
||||
isFaceEditMode.value = false;
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
transitionName = 'hero';
|
||||
const id = stack?.primaryAssetId ?? asset.id;
|
||||
onClose?.({ ...asset, id });
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
@@ -218,143 +194,65 @@
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||
if (direction === 'previous' || direction === 'next') {
|
||||
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
|
||||
}
|
||||
return direction ?? undefined;
|
||||
};
|
||||
|
||||
const clearTransitionNames = () => {
|
||||
detailPanelTransitionName = undefined;
|
||||
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: () => {
|
||||
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: () => {
|
||||
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) {
|
||||
navigate = async () => {
|
||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!next) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
next = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
types = ['slideshow'];
|
||||
targetTransition = null;
|
||||
} else {
|
||||
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 = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||
|
||||
let hasNext: boolean;
|
||||
if (slideShowPlaying && useTransition) {
|
||||
hasNext = false;
|
||||
await crossfadeViewerContent(async () => {
|
||||
hasNext = await navigate();
|
||||
}, 1000);
|
||||
} else if (canTransition && useTransition) {
|
||||
hasNext = await startTransition(types, targetTransition, navigate);
|
||||
} else {
|
||||
hasNext = await navigate();
|
||||
}
|
||||
|
||||
if (!slideShowPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
let navigating = $state(false);
|
||||
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if (slideShowPlaying) {
|
||||
order = slideShowAscending ? 'previous' : 'next';
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigating = true;
|
||||
void tracker.invoke(
|
||||
() => completeNavigation(order, skipTransition),
|
||||
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||
() => {
|
||||
navigating = false;
|
||||
eventManager.emit('ViewerAfterNavigate');
|
||||
},
|
||||
);
|
||||
void tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
if (isShuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
@@ -379,11 +277,10 @@
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
return;
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||
} finally {
|
||||
@@ -392,24 +289,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
|
||||
return;
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
if (isMouseOver) {
|
||||
isFaceEditMode.value = false;
|
||||
}
|
||||
|
||||
isFaceEditMode.value = false;
|
||||
void crossfadeViewerContent(() => {
|
||||
previewStackedAsset = stackedAsset;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStackSlideshowMouseLeave = () => {
|
||||
if (!previewStackedAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeCrossfadeOverlay();
|
||||
previewStackedAsset = undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
@@ -510,24 +394,15 @@
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastCursor) {
|
||||
previewStackedAsset = undefined;
|
||||
ocrManager.showOverlay = false;
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||
lastCursor = cursor;
|
||||
return;
|
||||
}
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||
if (asset.id === update.id) {
|
||||
cursor = { ...cursor, current: update };
|
||||
}
|
||||
};
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
@@ -598,17 +473,13 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||
class:invisible={$invisible}
|
||||
data-navigating={navigating || undefined}
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -619,7 +490,7 @@
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onClose={onClose ? closeViewer : undefined}
|
||||
onClose={onClose ? () => onClose(asset) : undefined}
|
||||
{playOriginalVideo}
|
||||
{setPlayOriginalVideo}
|
||||
/>
|
||||
@@ -640,21 +511,16 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div
|
||||
data-test-id="previous-asset"
|
||||
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
|
||||
style:view-transition-name="exclude-leftbutton"
|
||||
>
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
asset={previewStackedAsset!}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
@@ -667,7 +533,6 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
cacheKey={asset.thumbhash}
|
||||
@@ -679,20 +544,13 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
|
||||
<ImagePanoramaViewer {asset} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
@@ -726,20 +584,15 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div
|
||||
data-test-id="next-asset"
|
||||
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
|
||||
style:view-transition-name="exclude-rightbutton"
|
||||
>
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
@@ -755,14 +608,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<!-- 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={handleStackSlideshowMouseLeave}
|
||||
>
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
@@ -775,12 +623,11 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
removeCrossfadeOverlay();
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
isFaceEditMode.value = false;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -75,8 +74,6 @@
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full select-none transition-transform motion-reduce:transition-none"
|
||||
style:transform={imageTransform}
|
||||
onload={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
onerror={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { Button, HStack, Icon, IconButton } from '@immich/ui';
|
||||
import { mdiAutoFix, mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
||||
import { Button, HStack, IconButton } from '@immich/ui';
|
||||
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface AspectRatioOption {
|
||||
@@ -137,19 +137,5 @@
|
||||
<span class="text-sm text-white">{ratio.label}</span>
|
||||
</HStack>
|
||||
{/each}
|
||||
<HStack>
|
||||
<Button
|
||||
class="w-14 h-14 m-2"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
loading={transformManager.isApplyingSmartCrop}
|
||||
aria-label={$t('editor_smart_crop')}
|
||||
onclick={() => transformManager.applySmartCrop()}
|
||||
>
|
||||
<Icon icon={mdiAutoFix} size="1.75em" />
|
||||
</Button>
|
||||
<span class="text-sm text-white">{$t('editor_smart_crop')}</span>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
let { transitionName, letterboxTransitionName, asset }: Props = $props();
|
||||
let { asset }: Props = $props();
|
||||
|
||||
const assetId = $derived(asset.id);
|
||||
|
||||
@@ -21,16 +20,11 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
panorama={data}
|
||||
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
||||
/>
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
letterboxTransitionName?: string | undefined;
|
||||
show?: boolean;
|
||||
scaledDimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { letterboxTransitionName, show = true, scaledDimensions, container }: Props = $props();
|
||||
|
||||
const shouldShowLetterboxes = $derived(show && !!letterboxTransitionName);
|
||||
|
||||
const letterboxes = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const horizontalOffset = (container.width - width) / 2;
|
||||
const verticalOffset = (container.height - height) / 2;
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'letterbox-left',
|
||||
width: horizontalOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-right',
|
||||
width: horizontalOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: container.width - horizontalOffset + 'px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-top',
|
||||
width: width + 'px',
|
||||
height: verticalOffset + 'px',
|
||||
left: horizontalOffset + 'px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-bottom',
|
||||
width: width + 'px',
|
||||
height: verticalOffset + 'px',
|
||||
left: horizontalOffset + 'px',
|
||||
top: container.height - verticalOffset + 'px',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shouldShowLetterboxes}
|
||||
{#each letterboxes as box (box.name)}
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={box.name}
|
||||
style:left={box.left}
|
||||
style:top={box.top}
|
||||
style:width={box.width}
|
||||
style:height={box.height}
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
@@ -43,8 +41,6 @@
|
||||
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
@@ -52,21 +48,11 @@
|
||||
navbar?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
plugins = [],
|
||||
navbar = false,
|
||||
}: Props = $props();
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
|
||||
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
|
||||
|
||||
let animationInProgress: { cancel: () => void } | undefined;
|
||||
let previousFaces: Faces[] = [];
|
||||
|
||||
@@ -224,7 +210,6 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener('ready', () => eventManager.emit('ViewerOpenTransitionReady'), { once: true });
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
@@ -269,15 +254,7 @@
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div
|
||||
id="sphere"
|
||||
class="h-full w-full h-dvh w-dvw mb-0"
|
||||
bind:this={container}
|
||||
style:view-transition-name={transitionName}
|
||||
></div>
|
||||
|
||||
<!-- Zero-sized letterboxes for view transitions from/to regular photos -->
|
||||
<Letterboxes {letterboxTransitionName} scaledDimensions={fullscreenDimensions} container={fullscreenDimensions} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
|
||||
<style>
|
||||
/* Reset the default tooltip styling */
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import ZoomMinimap from '$lib/components/asset-viewer/zoom-minimap.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
@@ -18,12 +17,11 @@
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { KenBurnsAnimation } from '$lib/utils/ken-burns';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
@@ -32,33 +30,17 @@
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
sharedLink,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
onError,
|
||||
onSwipe,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
const kenBurns = new KenBurnsAnimation();
|
||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||
onMount(() =>
|
||||
eventManager.on({
|
||||
ViewTransitionOldSnapshotPending: () => kenBurns.freeze(),
|
||||
}),
|
||||
);
|
||||
|
||||
let previousAssetId: string | undefined;
|
||||
$effect.pre(() => {
|
||||
@@ -68,16 +50,13 @@
|
||||
}
|
||||
previousAssetId = id;
|
||||
untrack(() => {
|
||||
kenBurns.cancel();
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
$boundingBoxesArray = [];
|
||||
adaptiveImage?.style.removeProperty('transform');
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
kenBurns.cancel();
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
@@ -89,8 +68,6 @@
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
|
||||
|
||||
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
|
||||
@@ -158,6 +135,8 @@
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string | undefined>();
|
||||
@@ -176,47 +155,6 @@
|
||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
||||
|
||||
const unassignedFaces = $derived((asset.unassignedFaces ?? []) as Faces[]);
|
||||
|
||||
const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect);
|
||||
|
||||
$effect(() => {
|
||||
if (!kenBurnsActive || !visibleImageReady || !adaptiveImage || !assetViewerManager.imgRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false };
|
||||
|
||||
void kenBurns.startWithSmartCrop(adaptiveImage, {
|
||||
imgRef: assetViewerManager.imgRef,
|
||||
faces,
|
||||
fallbackFaces: unassignedFaces,
|
||||
contentWidth: overlaySize.width,
|
||||
contentHeight: overlaySize.height,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
slideshowLook: $slideshowLook,
|
||||
isCoverMode,
|
||||
slideshowDelay: $slideshowDelay,
|
||||
assetId: asset.id,
|
||||
});
|
||||
|
||||
return () => {
|
||||
kenBurns.cancel();
|
||||
if (viewTransitionManager.activeViewTransition === null) {
|
||||
assetViewerManager.resetZoomState();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (viewTransitionManager.activeViewTransition === null) {
|
||||
kenBurns.resume();
|
||||
} else {
|
||||
kenBurns.pause();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
@@ -244,23 +182,20 @@
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={isCoverMode ? 'cover' : 'contain'}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
onReady?.();
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:imgNaturalSize={imageDimensions}
|
||||
bind:imgScaledSize={scaledDimensions}
|
||||
bind:ref={adaptiveImage}
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
showLetterboxes={!blurredSlideshow}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
@@ -313,6 +248,8 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import {
|
||||
autoPlayVideo,
|
||||
@@ -20,7 +19,6 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
assetId: string;
|
||||
imageSize: Size;
|
||||
loopVideo: boolean;
|
||||
@@ -34,7 +32,6 @@
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
imageSize,
|
||||
loopVideo,
|
||||
@@ -64,6 +61,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
hasFocused = false;
|
||||
videoPlayer?.load();
|
||||
@@ -144,7 +142,6 @@
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
@@ -153,7 +150,6 @@
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
onloadedmetadata={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
const { asset, transitionName }: Props = $props();
|
||||
const { asset }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -19,12 +19,11 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full select-none place-content-center place-items-center">
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
assetId?: string;
|
||||
projectionType: string | null | undefined;
|
||||
@@ -20,7 +19,6 @@
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
assetId,
|
||||
projectionType,
|
||||
@@ -38,10 +36,9 @@
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {transitionName} {asset} />
|
||||
<VideoPanoramaViewer {asset} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { MAX_ZOOM } from '$lib/actions/zoom-image';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
}
|
||||
|
||||
let { containerWidth, containerHeight, asset, sharedLink }: Props = $props();
|
||||
|
||||
const MINIMAP_MAX = 192;
|
||||
const MINIMAP_MIN = 100;
|
||||
const minimapSize = $derived(clamp(Math.min(containerWidth, containerHeight) * 0.25, MINIMAP_MIN, MINIMAP_MAX));
|
||||
|
||||
const thumbnailUrl = $derived(getAssetUrls(asset, sharedLink).thumbnail);
|
||||
|
||||
const imageDimensions = $derived({
|
||||
width: asset.width && asset.width > 0 ? asset.width : 1,
|
||||
height: asset.height && asset.height > 0 ? asset.height : 1,
|
||||
});
|
||||
|
||||
const container = $derived({ width: containerWidth, height: containerHeight });
|
||||
|
||||
// Scale the full container into the minimap square
|
||||
const containerInMinimap = $derived(scaleToFit(container, { width: minimapSize, height: minimapSize }));
|
||||
const minimapContainerScale = $derived(containerInMinimap.width / containerWidth);
|
||||
const containerOffsetX = $derived((minimapSize - containerInMinimap.width) / 2);
|
||||
const containerOffsetY = $derived((minimapSize - containerInMinimap.height) / 2);
|
||||
|
||||
// Position the image within the minimap's container representation
|
||||
const imageInMinimap: ContentMetrics = $derived.by(() => {
|
||||
const fitted = scaleToFit(imageDimensions, containerInMinimap);
|
||||
return {
|
||||
contentWidth: fitted.width,
|
||||
contentHeight: fitted.height,
|
||||
offsetX: containerOffsetX + (containerInMinimap.width - fitted.width) / 2,
|
||||
offsetY: containerOffsetY + (containerInMinimap.height - fitted.height) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
const { FADE_DURATION, HIDE_DELAY } = TUNABLES.MINIMAP;
|
||||
|
||||
let isDragging = $state(false);
|
||||
let isDraggingZoom = $state(false);
|
||||
let isRecentActivity = $state(false);
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const resetHideTimer = () => {
|
||||
isRecentActivity = true;
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer);
|
||||
}
|
||||
hideTimer = setTimeout(() => {
|
||||
isRecentActivity = false;
|
||||
hideTimer = null;
|
||||
}, HIDE_DELAY);
|
||||
};
|
||||
|
||||
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||
const isVisible = $derived((isZoomed && isRecentActivity) || isDragging || isDraggingZoom);
|
||||
|
||||
$effect(() => {
|
||||
// Track zoom state changes to reset the hide timer
|
||||
const _state = assetViewerManager.zoomState;
|
||||
void _state;
|
||||
if (isZoomed) {
|
||||
resetHideTimer();
|
||||
}
|
||||
return () => {
|
||||
if (hideTimer !== null) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const zoomPercent = $derived(((assetViewerManager.zoom - 1) / (MAX_ZOOM - 1)) * 100);
|
||||
const zoomLabel = $derived(assetViewerManager.zoom.toFixed(1) + 'x');
|
||||
|
||||
const clampPanPosition = (positionX: number, positionY: number, zoom: number) => ({
|
||||
positionX: clamp(positionX, -(containerWidth * (zoom - 1)), 0),
|
||||
positionY: clamp(positionY, -(containerHeight * (zoom - 1)), 0),
|
||||
});
|
||||
|
||||
const minimapToContainerPosition = (minimapX: number, minimapY: number) => {
|
||||
const containerX = (minimapX - containerOffsetX) / minimapContainerScale;
|
||||
const containerY = (minimapY - containerOffsetY) / minimapContainerScale;
|
||||
const { currentZoom } = assetViewerManager.zoomState;
|
||||
const rawPositionX = containerWidth / 2 - containerX * currentZoom;
|
||||
const rawPositionY = containerHeight / 2 - containerY * currentZoom;
|
||||
return clampPanPosition(rawPositionX, rawPositionY, currentZoom);
|
||||
};
|
||||
|
||||
const panToMinimapPosition = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const minimapX = event.clientX - rect.left;
|
||||
const minimapY = event.clientY - rect.top;
|
||||
const { positionX, positionY } = minimapToContainerPosition(minimapX, minimapY);
|
||||
assetViewerManager.directTransform({ currentPositionX: positionX, currentPositionY: positionY });
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
assetViewerManager.setZoomEnabled(false);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
panToMinimapPosition(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
panToMinimapPosition(event);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDragging = false;
|
||||
assetViewerManager.setZoomEnabled(true);
|
||||
};
|
||||
|
||||
const zoomAroundCenter = (newZoom: number) => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const centerX = containerWidth / 2;
|
||||
const centerY = containerHeight / 2;
|
||||
const zoomTargetX = (centerX - currentPositionX) / currentZoom;
|
||||
const zoomTargetY = (centerY - currentPositionY) / currentZoom;
|
||||
const newPositionX = -zoomTargetX * newZoom + centerX;
|
||||
const newPositionY = -zoomTargetY * newZoom + centerY;
|
||||
|
||||
assetViewerManager.directTransform({
|
||||
currentZoom: newZoom,
|
||||
currentPositionX: clamp(newPositionX, -(containerWidth * (newZoom - 1)), 0),
|
||||
currentPositionY: clamp(newPositionY, -(containerHeight * (newZoom - 1)), 0),
|
||||
});
|
||||
};
|
||||
|
||||
const setZoomFromSlider = (event: PointerEvent) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const percent = clamp((event.clientX - rect.left) / rect.width, 0, 1);
|
||||
zoomAroundCenter(1 + percent * (MAX_ZOOM - 1));
|
||||
};
|
||||
|
||||
const WHEEL_ZOOM_RATIO = 0.1;
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const { currentZoom } = assetViewerManager.zoomState;
|
||||
const delta = -clamp(event.deltaY, -0.5, 0.5);
|
||||
const newZoom = clamp(currentZoom + delta * WHEEL_ZOOM_RATIO * currentZoom, 1, MAX_ZOOM);
|
||||
zoomAroundCenter(newZoom);
|
||||
};
|
||||
|
||||
const onZoomSliderPointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
isDraggingZoom = true;
|
||||
assetViewerManager.setZoomEnabled(false);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
setZoomFromSlider(event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onZoomSliderPointerMove = (event: PointerEvent) => {
|
||||
if (!isDraggingZoom) {
|
||||
return;
|
||||
}
|
||||
setZoomFromSlider(event);
|
||||
};
|
||||
|
||||
const onZoomSliderPointerUp = () => {
|
||||
isDraggingZoom = false;
|
||||
assetViewerManager.setZoomEnabled(true);
|
||||
};
|
||||
|
||||
const viewportRect = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
// Visible area in container coordinates
|
||||
const visibleLeft = -currentPositionX / currentZoom;
|
||||
const visibleTop = -currentPositionY / currentZoom;
|
||||
const visibleWidth = containerWidth / currentZoom;
|
||||
const visibleHeight = containerHeight / currentZoom;
|
||||
|
||||
// Map to minimap coordinates
|
||||
return {
|
||||
left: visibleLeft * minimapContainerScale + containerOffsetX,
|
||||
top: visibleTop * minimapContainerScale + containerOffsetY,
|
||||
width: visibleWidth * minimapContainerScale,
|
||||
height: visibleHeight * minimapContainerScale,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-[68px] right-14 md:right-4 z-10 rounded-lg border border-white/30 bg-black/60 p-1 backdrop-blur-sm"
|
||||
data-testid="zoom-minimap"
|
||||
transition:fade={{ duration: FADE_DURATION }}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative overflow-hidden rounded bg-black"
|
||||
class:cursor-grabbing={isDragging}
|
||||
class:cursor-pointer={!isDragging}
|
||||
data-testid="zoom-minimap-canvas"
|
||||
style="width: {minimapSize}px; height: {minimapSize}px;"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
onwheel={onWheel}
|
||||
>
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
class="absolute pointer-events-none"
|
||||
draggable="false"
|
||||
style="left: {imageInMinimap.offsetX}px; top: {imageInMinimap.offsetY}px; width: {imageInMinimap.contentWidth}px; height: {imageInMinimap.contentHeight}px;"
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
'absolute border-2 border-white bg-white/20 pointer-events-none rounded-sm',
|
||||
isDragging && 'border-white/80',
|
||||
]}
|
||||
data-testid="zoom-minimap-viewport"
|
||||
style="left: {viewportRect.left}px; top: {viewportRect.top}px; width: {viewportRect.width}px; height: {viewportRect.height}px;"
|
||||
></div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="relative mt-1 h-3 rounded-full bg-white/20 cursor-pointer"
|
||||
class:cursor-grabbing={isDraggingZoom}
|
||||
data-testid="zoom-minimap-slider"
|
||||
style="width: {minimapSize}px;"
|
||||
onpointerdown={onZoomSliderPointerDown}
|
||||
onpointermove={onZoomSliderPointerMove}
|
||||
onpointerup={onZoomSliderPointerUp}
|
||||
onpointercancel={onZoomSliderPointerUp}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-white/80 pointer-events-none"
|
||||
data-testid="zoom-minimap-slider-fill"
|
||||
style="width: {zoomPercent}%;"
|
||||
></div>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-[9px] font-semibold pointer-events-none select-none leading-none"
|
||||
style="color: #000; text-shadow: 0 0 3px rgba(255,255,255,0.8);"
|
||||
>
|
||||
{zoomLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -6,11 +6,10 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import { type Snippet } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -45,17 +44,12 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
{#if !hideNavbar}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -64,15 +58,13 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{#if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative w-full">
|
||||
<main class="relative">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
<script lang="ts">
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
transitionName?: string;
|
||||
asset: TimelineAsset;
|
||||
onImageLoad: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
const { asset, transitionName, onImageLoad, onError }: Props = $props();
|
||||
const { asset, onImageLoad }: Props = $props();
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
const container: Size = $derived({ width: containerWidth, height: containerHeight });
|
||||
const onLoadCallback = () => {
|
||||
imageLoaded = true;
|
||||
assetFileUrl = imageLoaderUrl;
|
||||
onImageLoad();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (loader?.complete) {
|
||||
onLoadCallback();
|
||||
}
|
||||
loader?.addEventListener('load', onLoadCallback);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onLoadCallback);
|
||||
};
|
||||
});
|
||||
|
||||
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if containerWidth > 0 && containerHeight > 0}
|
||||
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
|
||||
{:else}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
</div>
|
||||
{#if !imageLoaded}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -20,14 +20,11 @@
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
@@ -55,7 +52,6 @@
|
||||
} from '@mdi/js';
|
||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
@@ -68,7 +64,6 @@
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
const currentAssetId = $derived(current?.asset.id);
|
||||
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
|
||||
const currentMemoryAssetFull = $derived.by(async () =>
|
||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||
);
|
||||
@@ -81,14 +76,6 @@
|
||||
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let viewerHeight = $state(0);
|
||||
let transition = $state({
|
||||
name: undefined as string | undefined,
|
||||
previousPanel: undefined as string | undefined,
|
||||
nextPanel: undefined as string | undefined,
|
||||
active: false,
|
||||
});
|
||||
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
|
||||
const showNavButtonOverlay = $derived(transition.name === 'hero');
|
||||
|
||||
const { isViewing } = assetViewingStore;
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
@@ -99,6 +86,18 @@
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if ($isViewing) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(asHref(asset));
|
||||
};
|
||||
|
||||
const setProgressDuration = (asset: TimelineAsset) => {
|
||||
if (asset.isVideo) {
|
||||
const timeParts = asset.duration!.split(':').map(Number);
|
||||
@@ -113,177 +112,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (window.scrollY === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(resolve, 500);
|
||||
window.addEventListener(
|
||||
'scrollend',
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const withMemoryTransition = async (
|
||||
asset: { id: string } | undefined,
|
||||
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
|
||||
onFinished?: () => void;
|
||||
},
|
||||
) => {
|
||||
if ($isViewing || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
await scrollToTop();
|
||||
|
||||
transition.active = true;
|
||||
viewTransitionManager
|
||||
.startTransition({
|
||||
...config,
|
||||
onFinished: () => {
|
||||
transition.previousPanel = undefined;
|
||||
transition.nextPanel = undefined;
|
||||
transition.name = undefined;
|
||||
transition.active = false;
|
||||
config.onFinished?.();
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
|
||||
};
|
||||
|
||||
const navigateWithTransition = (asset?: { id: string }) =>
|
||||
withMemoryTransition(asset, {
|
||||
types: ['memory-nav'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'memory-fade-out';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
await goto(asHref(asset!));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
transition.name = 'memory-fade-in';
|
||||
},
|
||||
});
|
||||
|
||||
const handleNextAsset = () => {
|
||||
const next = current?.next;
|
||||
if (next && next.memory.id !== current?.memory.id) {
|
||||
void navigateToMemory('next', next.asset);
|
||||
} else {
|
||||
void navigateWithTransition(next?.asset);
|
||||
}
|
||||
};
|
||||
const handlePreviousAsset = () => {
|
||||
const previous = current?.previous;
|
||||
if (previous && previous.memory.id !== current?.memory.id) {
|
||||
void navigateToMemory('previous', previous.asset);
|
||||
} else {
|
||||
void navigateWithTransition(previous?.asset);
|
||||
}
|
||||
};
|
||||
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
|
||||
const isNext = direction === 'next';
|
||||
const useHeroMorph = !mediaQueryManager.reducedMotion;
|
||||
|
||||
return withMemoryTransition(asset, {
|
||||
types: ['memory'],
|
||||
prepareOldSnapshot: () => {
|
||||
if (useHeroMorph) {
|
||||
if (isNext) {
|
||||
transition.nextPanel = 'hero';
|
||||
transition.previousPanel = 'memory-departing';
|
||||
} else {
|
||||
transition.previousPanel = 'hero';
|
||||
transition.nextPanel = 'memory-departing';
|
||||
}
|
||||
transition.name = 'hero-out';
|
||||
} else {
|
||||
transition.name = 'memory-fade-out';
|
||||
}
|
||||
},
|
||||
performUpdate: async () => {
|
||||
transition.nextPanel = undefined;
|
||||
transition.previousPanel = undefined;
|
||||
if (useHeroMorph) {
|
||||
if (isNext) {
|
||||
transition.previousPanel = 'hero-out';
|
||||
} else {
|
||||
transition.nextPanel = 'hero-out';
|
||||
}
|
||||
}
|
||||
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
|
||||
await goto(asHref(asset!));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
|
||||
const closeMemoryViewer = () => {
|
||||
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
|
||||
const firstAsset = current.memory.assets[0];
|
||||
void withMemoryTransition(firstAsset, {
|
||||
types: ['memory-nav', 'memory-nav-fast'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'memory-fade-out';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
await goto(asHref(firstAsset));
|
||||
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
transition.name = 'memory-fade-in';
|
||||
},
|
||||
onFinished: () => closeToTimeline(),
|
||||
});
|
||||
} else {
|
||||
closeToTimeline();
|
||||
}
|
||||
};
|
||||
|
||||
const closeToTimeline = () => {
|
||||
const memoryId = current?.memory.id;
|
||||
let cardImage: HTMLElement | null | undefined;
|
||||
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['memory-enter'],
|
||||
prepareOldSnapshot: () => {
|
||||
transition.name = 'hero';
|
||||
},
|
||||
performUpdate: async () => {
|
||||
transition.name = undefined;
|
||||
await goto(Route.photos());
|
||||
await tick();
|
||||
|
||||
const memoryCard = memoryId
|
||||
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
|
||||
: null;
|
||||
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
|
||||
cardImage = memoryCard?.querySelector<HTMLElement>('img');
|
||||
if (cardImage) {
|
||||
cardImage.style.viewTransitionName = 'hero';
|
||||
await tick();
|
||||
}
|
||||
},
|
||||
onFinished: () => {
|
||||
if (cardImage) {
|
||||
cardImage.style.viewTransitionName = '';
|
||||
cardImage = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEscape = closeMemoryViewer;
|
||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const handleSelectAll = () =>
|
||||
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
@@ -327,17 +160,13 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgress = (progress: number) => {
|
||||
const handleProgress = async (progress: number) => {
|
||||
if (!progressBarController) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === 1 && !paused && !transition.active) {
|
||||
if (current?.next) {
|
||||
handleNextAsset();
|
||||
} else {
|
||||
handlePromiseError(handleAction('handleProgressLast', 'pause'));
|
||||
}
|
||||
if (progress === 1 && !paused) {
|
||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,18 +270,7 @@
|
||||
playerInitialized = false;
|
||||
};
|
||||
|
||||
const resolveTransitionIfPending = () => {
|
||||
if (viewTransitionManager.activeViewTransition) {
|
||||
transition.name = 'hero';
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
requestAnimationFrame(() => {
|
||||
transition.name = undefined;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemoryImageReady = () => {
|
||||
resolveTransitionIfPending();
|
||||
const resetAndPlay = () => {
|
||||
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
||||
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
||||
};
|
||||
@@ -467,7 +285,7 @@
|
||||
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
||||
} else if (isVideo) {
|
||||
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
||||
handleMemoryImageReady();
|
||||
resetAndPlay();
|
||||
}
|
||||
playerInitialized = true;
|
||||
};
|
||||
@@ -495,7 +313,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (progressBarController) {
|
||||
handleProgress(progressBarController.current);
|
||||
handlePromiseError(handleProgress(progressBarController.current));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -564,7 +382,7 @@
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
{#if current}
|
||||
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||
{#snippet leading()}
|
||||
{#if current}
|
||||
<p class="text-lg">
|
||||
@@ -640,11 +458,7 @@
|
||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
||||
>
|
||||
<!-- PREVIOUS MEMORY -->
|
||||
<div
|
||||
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
|
||||
? ''
|
||||
: 'opacity-0!'}"
|
||||
>
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
@@ -657,7 +471,6 @@
|
||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('previous_memory')}
|
||||
draggable="false"
|
||||
style:view-transition-name={transition.previousPanel}
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
@@ -670,10 +483,7 @@
|
||||
{/if}
|
||||
|
||||
{#if current.previousMemory}
|
||||
<div
|
||||
class="absolute bottom-4 end-4 text-start text-white"
|
||||
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
|
||||
>
|
||||
<div class="absolute bottom-4 end-4 text-start text-white">
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||
</div>
|
||||
@@ -682,42 +492,39 @@
|
||||
</div>
|
||||
|
||||
<!-- CURRENT MEMORY -->
|
||||
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else if currentAssetDto}
|
||||
<MemoryPhotoViewer
|
||||
asset={currentAssetDto}
|
||||
transitionName={transition.name}
|
||||
onImageLoad={handleMemoryImageReady}
|
||||
onError={resolveTransitionIfPending}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
<div
|
||||
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
||||
>
|
||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
||||
{#key current.asset.id}
|
||||
{#if current.asset.isVideo}
|
||||
<MemoryVideoViewer
|
||||
asset={current.asset}
|
||||
bind:videoPlayer
|
||||
videoViewerMuted={$videoViewerMuted}
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else}
|
||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
<div
|
||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory()}
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
icon={mdiShareVariantOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
@@ -725,46 +532,42 @@
|
||||
color="secondary"
|
||||
aria-label={$t('share')}
|
||||
/> -->
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||
direction="left"
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||
direction="left"
|
||||
size="medium"
|
||||
align="bottom-right"
|
||||
>
|
||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset()}
|
||||
text={$t('remove_photo_from_memory')}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{#if asset}
|
||||
<IconButton
|
||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
|
||||
>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
{#if current.previous}
|
||||
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
|
||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('previous_memory')}
|
||||
@@ -778,7 +581,7 @@
|
||||
{/if}
|
||||
|
||||
{#if current.next}
|
||||
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
|
||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('next_memory')}
|
||||
@@ -790,32 +593,25 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute start-8 top-4 text-sm font-medium text-white"
|
||||
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
|
||||
>
|
||||
<p>
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{asset?.exifInfo?.city || ''}
|
||||
{asset?.exifInfo?.country || ''}
|
||||
{/await}
|
||||
</p>
|
||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
||||
<p>
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{#await currentMemoryAssetFull then asset}
|
||||
{asset?.exifInfo?.city || ''}
|
||||
{asset?.exifInfo?.country || ''}
|
||||
{/await}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEXT MEMORY -->
|
||||
<div
|
||||
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
|
||||
? ''
|
||||
: 'opacity-0!'}"
|
||||
>
|
||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full w-full rounded-2xl"
|
||||
@@ -828,7 +624,6 @@
|
||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||
alt={$t('next_memory')}
|
||||
draggable="false"
|
||||
style:view-transition-name={transition.nextPanel}
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
@@ -841,10 +636,7 @@
|
||||
{/if}
|
||||
|
||||
{#if current.nextMemory}
|
||||
<div
|
||||
class="absolute bottom-4 start-4 text-start text-white"
|
||||
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
|
||||
>
|
||||
<div class="absolute bottom-4 start-4 text-start text-white">
|
||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||
</div>
|
||||
@@ -888,6 +680,8 @@
|
||||
|
||||
<style>
|
||||
.main-view {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
||||
box-shadow:
|
||||
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
||||
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
@@ -30,11 +27,9 @@
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -70,16 +65,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||
|
||||
$effect(() => {
|
||||
if ($isViewerOpen) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.style.overflow = '';
|
||||
};
|
||||
});
|
||||
|
||||
const navigationAssets = $derived(viewerAssets ?? assets);
|
||||
|
||||
const geometry = $derived(
|
||||
@@ -122,36 +107,6 @@
|
||||
|
||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
const scrollGalleryToAsset = async (assetId: string) => {
|
||||
const index = assets.findIndex((asset) => asset.id === assetId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const assetTopPage = geometry.getTop(index) + slidingWindowOffset;
|
||||
const assetBottomPage = assetTopPage + geometry.getHeight(index);
|
||||
const currentScrollTop = document.scrollingElement?.scrollTop ?? 0;
|
||||
const visibleTop = currentScrollTop + pageHeaderOffset;
|
||||
const visibleBottom = currentScrollTop + viewport.height;
|
||||
|
||||
if (assetTopPage >= visibleTop && assetBottomPage <= visibleBottom) {
|
||||
return;
|
||||
}
|
||||
const distanceToAlignTop = Math.abs(assetTopPage - pageHeaderOffset - currentScrollTop);
|
||||
const distanceToAlignBottom = Math.abs(assetBottomPage - viewport.height - currentScrollTop);
|
||||
const newScrollTop =
|
||||
distanceToAlignTop < distanceToAlignBottom ? assetTopPage - pageHeaderOffset : assetBottomPage - viewport.height;
|
||||
if (document.scrollingElement) {
|
||||
document.scrollingElement.scrollTop = newScrollTop;
|
||||
}
|
||||
updateSlidingWindow();
|
||||
await tick();
|
||||
};
|
||||
|
||||
const scrollToAndFocusAsset = async (assetId: string) => {
|
||||
await scrollGalleryToAsset(assetId);
|
||||
focusAsset(assetId);
|
||||
};
|
||||
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
|
||||
let lastIntersectedHeight = 0;
|
||||
@@ -401,63 +356,6 @@
|
||||
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
|
||||
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
|
||||
});
|
||||
|
||||
let toViewerTransitionId = $state<string | null>(null);
|
||||
let toGalleryTransitionId = $state<string | null>(null);
|
||||
const transitionTargetId = $derived(toViewerTransitionId ?? toGalleryTransitionId);
|
||||
|
||||
const handleThumbnailClick = (asset: AssetResponseDto, currentAsset: TimelineAsset) => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(currentAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
const doNavigate = () => void navigateToAsset(asset);
|
||||
|
||||
if (!viewTransitionManager.isSupported()) {
|
||||
doNavigate();
|
||||
return;
|
||||
}
|
||||
|
||||
startViewerTransition(asset.id, doNavigate, (id) => (toViewerTransitionId = id));
|
||||
};
|
||||
|
||||
const transitionToGalleryCallback = ({ id }: { id: string }) => {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
prepareOldSnapshot: () => {
|
||||
void scrollGalleryToAsset(id);
|
||||
},
|
||||
performUpdate: async () => {
|
||||
eventManager.emit('ViewerCloseTransitionReady');
|
||||
toGalleryTransitionId = id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toGalleryTransitionId = null;
|
||||
focusAsset(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToGalleryCallback }));
|
||||
}
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const useTransition = viewTransitionManager.isSupported();
|
||||
if (useTransition) {
|
||||
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||
eventManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||
await transitionReady;
|
||||
}
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
if (!useTransition) {
|
||||
await tick();
|
||||
await scrollToAndFocusAsset(asset.id);
|
||||
}
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -477,17 +375,16 @@
|
||||
{#each assets as asset, i (asset.id + '-' + i)}
|
||||
{#if isIntersecting(i)}
|
||||
{@const currentAsset = toTimelineAsset(asset)}
|
||||
{@const transitionName = transitionTargetId === asset.id ? 'hero' : undefined}
|
||||
<div
|
||||
class="absolute"
|
||||
style:overflow="clip"
|
||||
style={getStyle(i)}
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
>
|
||||
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={() => handleThumbnailClick(asset, currentAsset)}
|
||||
onClick={() => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(currentAsset);
|
||||
return;
|
||||
}
|
||||
void navigateToAsset(asset);
|
||||
}}
|
||||
onSelect={() => handleSelectAssets(currentAsset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
|
||||
{showArchiveIcon}
|
||||
@@ -519,7 +416,10 @@
|
||||
onAction={handleAction}
|
||||
onRandom={handleRandom}
|
||||
onAssetChange={updateCurrentAsset}
|
||||
onClose={(asset) => handleClose(asset)}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
</Portal>
|
||||
|
||||
@@ -50,11 +50,7 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<nav
|
||||
id="dashboard-navbar"
|
||||
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm relative z-10"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<div
|
||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
animationTargetAssetId?: string | null;
|
||||
suspendTransitions?: boolean;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
manager: VirtualScrollManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -25,20 +26,14 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const {
|
||||
animationTargetAssetId,
|
||||
suspendTransitions = false,
|
||||
viewerAssets,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
customThumbnailLayout,
|
||||
}: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Image grid -->
|
||||
@@ -46,14 +41,11 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
data-transition-name={transitionName}
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:inset-inline-start={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import { onMount, tick, type Snippet } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toAssetViewerTransitionId?: string | null;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
assetInteraction: AssetInteraction;
|
||||
monthGroup: MonthGroup;
|
||||
manager: VirtualScrollManager;
|
||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
toAssetViewerTransitionId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
monthGroup,
|
||||
manager,
|
||||
onDayGroupSelect,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -40,6 +37,10 @@
|
||||
|
||||
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
};
|
||||
|
||||
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
||||
const { month, year } = dayGroup.monthGroup.yearMonth;
|
||||
const date = fromTimelinePlainDate({
|
||||
@@ -49,32 +50,6 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||
|
||||
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||
const asset = monthGroup.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
performUpdate: async () => {
|
||||
eventManager.emit('ViewerCloseTransitionReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineTransitionAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineTransitionAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -118,8 +93,7 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{animationTargetAssetId}
|
||||
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
|
||||
{manager}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -40,7 +39,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -440,7 +438,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next?.focus();
|
||||
next.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,7 +509,6 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
@@ -104,7 +102,6 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
@@ -212,7 +209,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = getScrollTarget();
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -224,7 +221,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
invisible = false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -242,13 +239,10 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(async () => {
|
||||
void complete.finally(() => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -257,13 +251,8 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
void scrollAfterNavigate();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,7 +260,7 @@
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
if (!enableRouting) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -535,33 +524,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumbnailClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (asset: TimelineAsset, dayGroup: DayGroup) => {
|
||||
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, defaultThumbnailClick);
|
||||
} else {
|
||||
defaultThumbnailClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
startViewerTransition(asset.id, openViewer, (id) => (toAssetViewerTransitionId = id));
|
||||
};
|
||||
|
||||
const assetSelectHandler = (
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
@@ -582,6 +544,19 @@
|
||||
|
||||
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length;
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -612,7 +587,6 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -644,7 +618,6 @@
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
data-initialized={timelineManager.isInitialized || undefined}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
@@ -693,11 +666,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -711,7 +684,13 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -100,12 +98,6 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||
eventManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||
await transitionReady;
|
||||
}
|
||||
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
|
||||
describe('ViewTransitionManager', () => {
|
||||
let manager: ViewTransitionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ViewTransitionManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (document as Partial<typeof document> & { startViewTransition?: unknown }).startViewTransition;
|
||||
});
|
||||
|
||||
describe('when View Transition API is not supported', () => {
|
||||
it('should still call performUpdate', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.startTransition({ performUpdate });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should call onFinished after performUpdate', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const performUpdate = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('performUpdate');
|
||||
});
|
||||
const onFinished = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onFinished');
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate, onFinished });
|
||||
|
||||
expect(onFinished).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(['performUpdate', 'onFinished']);
|
||||
});
|
||||
|
||||
it('should not call prepareOldSnapshot or prepareNewSnapshot', async () => {
|
||||
const prepareOldSnapshot = vi.fn();
|
||||
const prepareNewSnapshot = vi.fn();
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.startTransition({ performUpdate, prepareOldSnapshot, prepareNewSnapshot });
|
||||
|
||||
expect(prepareOldSnapshot).not.toHaveBeenCalled();
|
||||
expect(prepareNewSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a transition is already active', () => {
|
||||
it('should skip the first transition and run the second', async () => {
|
||||
let resolveFirstUpdate!: () => void;
|
||||
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveFirstUpdate = resolve;
|
||||
});
|
||||
const firstFinished = new Promise<void>(() => {});
|
||||
const firstSkipTransition = vi.fn();
|
||||
|
||||
let callCount = 0;
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
callCount++;
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
updateCallbackDone: firstUpdateCallbackDone,
|
||||
finished: firstFinished,
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: firstSkipTransition,
|
||||
};
|
||||
}
|
||||
return {
|
||||
updateCallbackDone: Promise.resolve(),
|
||||
finished: Promise.resolve(),
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Start first — it will be blocked on updateCallbackDone
|
||||
const firstPromise = manager.startTransition({
|
||||
performUpdate: async () => {},
|
||||
});
|
||||
|
||||
// Flush microtasks so the first transition reaches the startViewTransition call
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
// While first is active, start a second — should skip the first and proceed
|
||||
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||
expect(firstSkipTransition).toHaveBeenCalledOnce();
|
||||
expect(secondPerformUpdate).toHaveBeenCalledOnce();
|
||||
|
||||
// Clean up first promise
|
||||
resolveFirstUpdate();
|
||||
await firstPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipTransitions', () => {
|
||||
it('should return false when no transition is active', () => {
|
||||
expect(manager.skipTransitions()).toBe(false);
|
||||
});
|
||||
|
||||
it('should call skipTransition on the active transition and return true', async () => {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
const skipTransition = vi.fn();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition };
|
||||
});
|
||||
|
||||
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
const skipped = manager.skipTransitions();
|
||||
expect(skipped).toBe(true);
|
||||
expect(skipTransition).toHaveBeenCalledOnce();
|
||||
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should allow a new transition after skipping', async () => {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
});
|
||||
|
||||
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
manager.skipTransitions();
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
await promise;
|
||||
|
||||
// Now start a second transition — it should NOT be skipped
|
||||
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const secondFinished = Promise.resolve();
|
||||
const secondUpdateDone = Promise.resolve();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return {
|
||||
updateCallbackDone: secondUpdateDone,
|
||||
finished: secondFinished,
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate: secondUpdate });
|
||||
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate error from performUpdate when API is not supported', async () => {
|
||||
const error = new Error('update failed');
|
||||
const performUpdate = vi.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
|
||||
});
|
||||
|
||||
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
|
||||
const error = new Error('update failed');
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
const updateCallbackDone = updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
|
||||
'update failed',
|
||||
);
|
||||
|
||||
// Simulate transition finishing after error
|
||||
resolveFinished();
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
// Manager should accept new transitions after cleanup
|
||||
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const secondFinished = Promise.resolve();
|
||||
const secondUpdateDone = Promise.resolve();
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return {
|
||||
updateCallbackDone: secondUpdateDone,
|
||||
finished: secondFinished,
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate: secondUpdate });
|
||||
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback path', () => {
|
||||
it('should fall back to function argument when object argument throws', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const prepareNewSnapshot = vi.fn();
|
||||
const finished = Promise.resolve();
|
||||
const updateCallbackDone = Promise.resolve();
|
||||
|
||||
let callCount = 0;
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
callCount++;
|
||||
if (callCount === 1 && typeof arg !== 'function') {
|
||||
throw new TypeError('object form not supported');
|
||||
}
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledOnce();
|
||||
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal', () => {
|
||||
it('should pass an AbortSignal to performUpdate', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return {
|
||||
updateCallbackDone: Promise.resolve(),
|
||||
finished: Promise.resolve(),
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it('should abort the signal when transition.ready rejects', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
|
||||
const readyError = new Error('Transition was aborted because of timeout in DOM update');
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return {
|
||||
updateCallbackDone,
|
||||
finished: Promise.reject(readyError),
|
||||
ready: Promise.reject(readyError),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const promise = manager.startTransition({ performUpdate });
|
||||
|
||||
// Flush microtasks so ready rejection fires and aborts the signal
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
|
||||
resolveUpdate();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not abort the signal when transition completes normally', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return {
|
||||
updateCallbackDone: Promise.resolve(),
|
||||
finished: Promise.resolve(),
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
await manager.startTransition({
|
||||
performUpdate: (signal) => {
|
||||
capturedSignal = signal;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
|
||||
await manager.startTransition({
|
||||
performUpdate: (signal) => {
|
||||
capturedSignal = signal;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupported', () => {
|
||||
it('should return false when startViewTransition is not in document', () => {
|
||||
expect(manager.isSupported()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when startViewTransition is in document', () => {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn();
|
||||
|
||||
expect(manager.isSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface TransitionRequest {
|
||||
types?: string[];
|
||||
prepareOldSnapshot?: () => void;
|
||||
performUpdate: (signal: AbortSignal) => Promise<void>;
|
||||
prepareNewSnapshot?: () => void;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export class ViewTransitionManager {
|
||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||
#activeOnFinished: (() => void) | undefined;
|
||||
|
||||
get activeViewTransition() {
|
||||
return this.#activeViewTransition;
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
skipTransitions() {
|
||||
const skipped = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#activeViewTransition = null;
|
||||
const onFinished = this.#activeOnFinished;
|
||||
this.#activeOnFinished = undefined;
|
||||
onFinished?.();
|
||||
return skipped;
|
||||
}
|
||||
|
||||
async startTransition({
|
||||
types,
|
||||
prepareOldSnapshot,
|
||||
performUpdate,
|
||||
prepareNewSnapshot,
|
||||
onFinished,
|
||||
}: TransitionRequest) {
|
||||
if (this.#activeViewTransition) {
|
||||
this.skipTransitions();
|
||||
}
|
||||
|
||||
if (!this.isSupported()) {
|
||||
await performUpdate(AbortSignal.timeout(10_000));
|
||||
onFinished?.();
|
||||
return;
|
||||
}
|
||||
|
||||
prepareOldSnapshot?.();
|
||||
await tick();
|
||||
|
||||
const abortController = new AbortController();
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition({
|
||||
update: async () => {
|
||||
await performUpdate(abortController.signal);
|
||||
prepareNewSnapshot?.();
|
||||
await tick();
|
||||
},
|
||||
types,
|
||||
});
|
||||
} catch {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition(async () => {
|
||||
await performUpdate(abortController.signal);
|
||||
prepareNewSnapshot?.();
|
||||
await tick();
|
||||
});
|
||||
}
|
||||
|
||||
this.#activeViewTransition = transition;
|
||||
this.#activeOnFinished = onFinished;
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.ready.catch((error: unknown) => {
|
||||
abortController.abort(error);
|
||||
});
|
||||
|
||||
// Let animation run in the background — don't block the caller.
|
||||
// This allows skipTransitions() to abort mid-animation for rapid navigation.
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.#activeViewTransition === transition) {
|
||||
this.#activeViewTransition = null;
|
||||
this.#activeOnFinished = undefined;
|
||||
onFinished?.();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait only until the DOM update completes (both snapshots captured),
|
||||
// not for the animation to finish.
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
await transition.updateCallbackDone;
|
||||
}
|
||||
}
|
||||
|
||||
export const viewTransitionManager = new ViewTransitionManager();
|
||||
@@ -1,5 +0,0 @@
|
||||
class AppManager {
|
||||
isAssetViewer = $state(false);
|
||||
}
|
||||
|
||||
export const appManager = new AppManager();
|
||||
@@ -18,6 +18,8 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
|
||||
export type Events = {
|
||||
Zoom: [];
|
||||
ZoomChange: [ZoomImageWheelState];
|
||||
DirectTransform: [ZoomImageWheelState];
|
||||
ZoomEnabled: [boolean];
|
||||
Copy: [];
|
||||
};
|
||||
|
||||
@@ -87,6 +89,15 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.#zoomState = state;
|
||||
}
|
||||
|
||||
directTransform(state: Partial<ZoomImageWheelState>) {
|
||||
this.#zoomState = { ...this.#zoomState, ...state };
|
||||
this.emit('DirectTransform', this.#zoomState);
|
||||
}
|
||||
|
||||
setZoomEnabled(enabled: boolean) {
|
||||
this.emit('ZoomEnabled', enabled);
|
||||
}
|
||||
|
||||
cancelZoomAnimation() {
|
||||
if (this.#animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.#animationFrameId);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
|
||||
import { clamp } from 'lodash-es';
|
||||
import smartcrop from 'smartcrop';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export type CropAspectRatio =
|
||||
@@ -57,12 +56,10 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
isInteracting = $state(false);
|
||||
isDragging = $state(false);
|
||||
isApplyingSmartCrop = $state(false);
|
||||
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
|
||||
dragAnchor = $state({ x: 0, y: 0 });
|
||||
resizeSide = $state(ResizeBoundary.None);
|
||||
imgElement = $state<HTMLImageElement | null>(null);
|
||||
asset = $state<AssetResponseDto | null>(null);
|
||||
cropAreaEl = $state<HTMLElement | null>(null);
|
||||
overlayEl = $state<HTMLElement | null>(null);
|
||||
cropFrame = $state<HTMLElement | null>(null);
|
||||
@@ -188,7 +185,6 @@ class TransformManager implements EditToolManager {
|
||||
}
|
||||
|
||||
async onActivate(asset: AssetResponseDto, edits: EditActions): Promise<void> {
|
||||
this.asset = asset;
|
||||
const originalSize = getDimensions(asset.exifInfo!);
|
||||
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
|
||||
|
||||
@@ -247,7 +243,6 @@ class TransformManager implements EditToolManager {
|
||||
this.cropImageScale = 1;
|
||||
this.cropAspectRatio = 'free';
|
||||
this.hasChanges = false;
|
||||
this.asset = null;
|
||||
}
|
||||
|
||||
mirror(axis: 'horizontal' | 'vertical') {
|
||||
@@ -824,72 +819,6 @@ class TransformManager implements EditToolManager {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
async applySmartCrop() {
|
||||
const img = this.imgElement;
|
||||
if (!img || !this.cropAreaEl || !this.asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isApplyingSmartCrop = true;
|
||||
try {
|
||||
const allFaces = [
|
||||
...(this.asset.people?.flatMap((person) => person.faces ?? []) ?? []),
|
||||
...(this.asset.unassignedFaces ?? []),
|
||||
];
|
||||
|
||||
const boosts =
|
||||
allFaces.length > 0
|
||||
? allFaces.map((face) => ({
|
||||
x: (face.boundingBoxX1 / face.imageWidth) * img.naturalWidth,
|
||||
y: (face.boundingBoxY1 / face.imageHeight) * img.naturalHeight,
|
||||
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * img.naturalWidth,
|
||||
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * img.naturalHeight,
|
||||
weight: 1,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
let requestWidth: number;
|
||||
let requestHeight: number;
|
||||
if (this.cropAspectRatio === 'free') {
|
||||
requestWidth = Math.round(this.region.width / this.cropImageScale);
|
||||
requestHeight = Math.round(this.region.height / this.cropImageScale);
|
||||
} else {
|
||||
const [aspectW, aspectH] = this.cropAspectRatio.split(':').map(Number);
|
||||
const fitScale = Math.min(img.naturalWidth / aspectW, img.naturalHeight / aspectH);
|
||||
requestWidth = Math.round(aspectW * fitScale);
|
||||
requestHeight = Math.round(aspectH * fitScale);
|
||||
}
|
||||
|
||||
const result = await smartcrop.crop(img, {
|
||||
width: requestWidth,
|
||||
height: requestHeight,
|
||||
boost: boosts ?? undefined,
|
||||
});
|
||||
|
||||
const { x, y, width, height } = result.topCrop;
|
||||
const displayRegion = this.constrainToBounds(
|
||||
{
|
||||
x: x * this.cropImageScale,
|
||||
y: y * this.cropImageScale,
|
||||
width: width * this.cropImageScale,
|
||||
height: height * this.cropImageScale,
|
||||
},
|
||||
{ width: this.cropAreaEl.clientWidth, height: this.cropAreaEl.clientHeight },
|
||||
);
|
||||
|
||||
this.hasChanges = true;
|
||||
this.region.x = displayRegion.x;
|
||||
this.region.y = displayRegion.y;
|
||||
this.region.width = displayRegion.width;
|
||||
this.region.height = displayRegion.height;
|
||||
this.draw();
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to apply smart crop');
|
||||
} finally {
|
||||
this.isApplyingSmartCrop = false;
|
||||
}
|
||||
}
|
||||
|
||||
resetCrop() {
|
||||
this.cropAspectRatio = 'free';
|
||||
this.region = {
|
||||
|
||||
@@ -89,16 +89,6 @@ export type Events = {
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
|
||||
WebsocketConnect: [];
|
||||
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
TimelineScrolledToAsset: [{ id: string }];
|
||||
|
||||
ViewerAfterNavigate: [];
|
||||
ViewerCloseTransition: [{ id: string }];
|
||||
ViewerCloseTransitionReady: [];
|
||||
ViewerOpenTransition: [];
|
||||
ViewerOpenTransitionReady: [];
|
||||
ViewTransitionOldSnapshotPending: [];
|
||||
};
|
||||
|
||||
export const eventManager = new BaseEventManager<Events>();
|
||||
|
||||
@@ -2,11 +2,3 @@ import type { TimelineAsset } from './types';
|
||||
|
||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||
|
||||
export function* filterIntersecting<T extends { intersecting: boolean }>(items: T[]) {
|
||||
for (const item of items) {
|
||||
if (item.intersecting) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
slideshowTransition,
|
||||
slideshowAutoplay,
|
||||
slideshowRepeat,
|
||||
kenBurnsEffect,
|
||||
slideshowState,
|
||||
} = slideshowStore;
|
||||
|
||||
@@ -39,7 +38,6 @@
|
||||
let tempSlideshowTransition = $state($slideshowTransition);
|
||||
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
||||
let tempSlideshowRepeat = $state($slideshowRepeat);
|
||||
let tempKenBurnsEffect = $state($kenBurnsEffect);
|
||||
|
||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||
@@ -72,7 +70,6 @@
|
||||
$slideshowTransition = tempSlideshowTransition;
|
||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||
$slideshowRepeat = tempSlideshowRepeat;
|
||||
$kenBurnsEffect = tempKenBurnsEffect;
|
||||
$slideshowState = SlideshowState.PlaySlideshow;
|
||||
onClose();
|
||||
};
|
||||
@@ -110,10 +107,6 @@
|
||||
<Switch bind:checked={tempSlideshowTransition} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('slideshow_ken_burns_effect')}>
|
||||
<Switch bind:checked={tempKenBurnsEffect} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
|
||||
<Switch bind:checked={tempSlideshowRepeat} />
|
||||
</Field>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const invisible = writable<boolean>(false);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
@@ -31,7 +30,6 @@ function createAssetViewingStore() {
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
invisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ function createSlideshowStore() {
|
||||
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
||||
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
||||
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
||||
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
|
||||
|
||||
return {
|
||||
restartProgress: {
|
||||
@@ -74,7 +73,6 @@ function createSlideshowStore() {
|
||||
slideshowTransition,
|
||||
slideshowAutoplay,
|
||||
slideshowRepeat,
|
||||
kenBurnsEffect,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -43,50 +43,6 @@ export class BaseEventManager<Events extends EventsBase> {
|
||||
};
|
||||
}
|
||||
|
||||
once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
|
||||
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
|
||||
unsubscribe();
|
||||
return callback(...args);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
untilNext<T extends keyof Events>(
|
||||
event: T,
|
||||
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<Events[T] extends [] ? void : Events[T][0]> {
|
||||
type Result = Events[T] extends [] ? void : Events[T][0];
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) {
|
||||
return false;
|
||||
}
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
return true;
|
||||
};
|
||||
const unsubscribe = this.once(event, (...args: Events[T]) => {
|
||||
if (settle()) {
|
||||
resolve(args[0] as Result);
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (settle()) {
|
||||
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
if (settle()) {
|
||||
resolve(undefined as Result);
|
||||
}
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
* and allowing operations to check if they're still valid.
|
||||
*/
|
||||
export class InvocationTracker {
|
||||
/** Counter for the number of invocations that have been started */
|
||||
invocationsStarted = 0;
|
||||
/** Counter for the number of invocations that have been completed */
|
||||
invocationsEnded = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Starts a new invocation and returns an object with utilities to manage the invocation lifecycle.
|
||||
* @returns An object containing methods to manage the invocation:
|
||||
* - isInvalidInvocationError: Checks if an error is an invalid invocation error
|
||||
* - checkStillValid: Throws an error if the invocation is no longer valid
|
||||
* - endInvocation: Marks the invocation as complete
|
||||
*/
|
||||
startInvocation() {
|
||||
this.invocationsStarted++;
|
||||
const invocation = this.invocationsStarted;
|
||||
|
||||
return {
|
||||
isStillValid: () => invocation === this.invocationsStarted,
|
||||
/**
|
||||
* Throws an error if this invocation is no longer valid
|
||||
* @throws {Error} If the invocation is no longer valid
|
||||
*/
|
||||
isStillValid: () => {
|
||||
if (invocation !== this.invocationsStarted) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Marks this invocation as complete
|
||||
*/
|
||||
endInvocation: () => {
|
||||
this.invocationsEnded = Math.max(this.invocationsEnded, invocation);
|
||||
this.invocationsEnded = invocation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any active invocations
|
||||
* @returns True if there are active invocations, false otherwise
|
||||
*/
|
||||
isActive() {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
if (catchCallback) {
|
||||
catchCallback(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
handleError(error, localizedMessage);
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
finallyCallback?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook } from '$lib/stores/slideshow.store';
|
||||
import type { Point } from '$lib/utils/container-utils';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { clamp } from 'lodash-es';
|
||||
import smartcrop from 'smartcrop';
|
||||
|
||||
const KEN_BURNS_MAX_ZOOM_SPEED = 0.08;
|
||||
const KEN_BURNS_MAX_PAN_SPEED = 8;
|
||||
|
||||
export interface KenBurnsKeyframes {
|
||||
startTransform: string;
|
||||
endTransform: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface KenBurnsInput {
|
||||
faces: Faces[];
|
||||
fallbackFaces: Faces[];
|
||||
smartCropCenter: Point | undefined;
|
||||
contentWidth: number;
|
||||
contentHeight: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
slideshowLook: SlideshowLook;
|
||||
isCoverMode: boolean;
|
||||
slideshowDelay: number;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
export async function computeSmartCropCenter(
|
||||
imgRef: HTMLImageElement,
|
||||
faces: Faces[],
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
): Promise<Point | undefined> {
|
||||
if (!TUNABLES.KEN_BURNS.SMARTCROP) {
|
||||
return undefined;
|
||||
}
|
||||
if (!TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const boosts =
|
||||
TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0
|
||||
? faces.map((face) => ({
|
||||
x: (face.boundingBoxX1 / face.imageWidth) * imgRef.naturalWidth,
|
||||
y: (face.boundingBoxY1 / face.imageHeight) * imgRef.naturalHeight,
|
||||
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * imgRef.naturalWidth,
|
||||
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * imgRef.naturalHeight,
|
||||
weight: 1,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const result = await smartcrop.crop(imgRef, {
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
...(boosts && { boost: boosts }),
|
||||
});
|
||||
|
||||
const { x, y, width, height } = result.topCrop;
|
||||
return {
|
||||
x: (x + width / 2) / imgRef.naturalWidth,
|
||||
y: (y + height / 2) / imgRef.naturalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
const selectKenBurnsFace = (faces: Faces[]): Faces | null => {
|
||||
let best: Faces | null = null;
|
||||
let bestArea = 0;
|
||||
for (const face of faces) {
|
||||
const area = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
|
||||
if (area > bestArea) {
|
||||
best = face;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
export function computeKenBurnsKeyframes({
|
||||
faces,
|
||||
fallbackFaces,
|
||||
smartCropCenter,
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
slideshowLook,
|
||||
isCoverMode,
|
||||
slideshowDelay,
|
||||
assetId,
|
||||
}: KenBurnsInput): KenBurnsKeyframes {
|
||||
const blurredBackground = slideshowLook === SlideshowLook.BlurredBackground;
|
||||
|
||||
const minZoom =
|
||||
!blurredBackground && contentWidth > 0 && contentHeight > 0
|
||||
? Math.min(Math.max(containerWidth / contentWidth, containerHeight / contentHeight), 2)
|
||||
: 1;
|
||||
|
||||
const slideDurationMs = slideshowDelay * 1000;
|
||||
const maxZoomChange = KEN_BURNS_MAX_ZOOM_SPEED * (slideDurationMs / 1000);
|
||||
|
||||
const face = selectKenBurnsFace(faces) ?? selectKenBurnsFace(fallbackFaces);
|
||||
|
||||
let targetScale: number;
|
||||
let endX = 0;
|
||||
let endY = 0;
|
||||
|
||||
if (face && contentWidth > 0) {
|
||||
const faceHeightFraction = (face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight;
|
||||
const faceTargetZoom = (0.4 * containerHeight) / (faceHeightFraction * contentHeight);
|
||||
targetScale = clamp(faceTargetZoom, Math.max(minZoom, 1.2), 2);
|
||||
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
|
||||
|
||||
const targetNormalizedX =
|
||||
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
|
||||
? smartCropCenter.x
|
||||
: (face.boundingBoxX1 + face.boundingBoxX2) / 2 / face.imageWidth;
|
||||
const targetNormalizedY =
|
||||
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
|
||||
? smartCropCenter.y
|
||||
: (face.boundingBoxY1 + face.boundingBoxY2) / 2 / face.imageHeight;
|
||||
|
||||
endX = (((0.5 - targetNormalizedX) * contentWidth) / containerWidth) * 100;
|
||||
endY = (((0.5 - targetNormalizedY) * contentHeight) / containerHeight) * 100;
|
||||
} else {
|
||||
targetScale = clamp(minZoom, 1.2, 2);
|
||||
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
|
||||
|
||||
if (smartCropCenter && contentWidth > 0) {
|
||||
endX = (((0.5 - smartCropCenter.x) * contentWidth) / containerWidth) * 100;
|
||||
endY = (((0.5 - smartCropCenter.y) * contentHeight) / containerHeight) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
const clampWidth = blurredBackground || isCoverMode ? containerWidth : contentWidth;
|
||||
const clampHeight = blurredBackground || isCoverMode ? containerHeight : contentHeight;
|
||||
const maxTranslateX = Math.max(0, (clampWidth / (2 * containerWidth) - 1 / (2 * targetScale)) * 100);
|
||||
const maxTranslateY = Math.max(0, (clampHeight / (2 * containerHeight) - 1 / (2 * targetScale)) * 100);
|
||||
endX = clamp(endX, -maxTranslateX, maxTranslateX);
|
||||
endY = clamp(endY, -maxTranslateY, maxTranslateY);
|
||||
|
||||
const panDist = Math.hypot(endX, endY);
|
||||
const maxPan = KEN_BURNS_MAX_PAN_SPEED * (slideDurationMs / 1000);
|
||||
if (panDist > maxPan && panDist > 0) {
|
||||
const ratio = maxPan / panDist;
|
||||
endX *= ratio;
|
||||
endY *= ratio;
|
||||
}
|
||||
|
||||
const zoomIn = Number.parseInt(assetId.at(-1) ?? '0', 16) < 8;
|
||||
|
||||
const startTransform = zoomIn
|
||||
? `scale(${minZoom}) translate(0%, 0%)`
|
||||
: `scale(${targetScale}) translate(${endX}%, ${endY}%)`;
|
||||
const endTransform = zoomIn
|
||||
? `scale(${targetScale}) translate(${endX}%, ${endY}%)`
|
||||
: `scale(${minZoom}) translate(0%, 0%)`;
|
||||
|
||||
return { startTransform, endTransform, duration: slideDurationMs };
|
||||
}
|
||||
|
||||
export class KenBurnsAnimation {
|
||||
#animation: Animation | undefined;
|
||||
#element: HTMLElement | undefined;
|
||||
#cancelToken: { value: boolean } | undefined;
|
||||
|
||||
async startWithSmartCrop(
|
||||
element: HTMLElement,
|
||||
input: Omit<KenBurnsInput, 'smartCropCenter'> & { imgRef: HTMLImageElement },
|
||||
) {
|
||||
this.cancel();
|
||||
const token = { value: false };
|
||||
this.#cancelToken = token;
|
||||
|
||||
const { imgRef, faces, fallbackFaces, containerWidth, containerHeight, ...rest } = input;
|
||||
const allFaces = faces.length > 0 ? faces : fallbackFaces;
|
||||
const smartCropCenter = await computeSmartCropCenter(imgRef, allFaces, containerWidth, containerHeight);
|
||||
|
||||
if (token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyframes = computeKenBurnsKeyframes({
|
||||
faces,
|
||||
fallbackFaces,
|
||||
smartCropCenter,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
...rest,
|
||||
});
|
||||
this.start(element, keyframes);
|
||||
}
|
||||
|
||||
start(element: HTMLElement, { startTransform, endTransform, duration }: KenBurnsKeyframes) {
|
||||
this.cancel();
|
||||
this.#element = element;
|
||||
|
||||
element.style.transformOrigin = '50% 50%';
|
||||
element.style.transform = startTransform;
|
||||
|
||||
const keyframes: Keyframe[] = [{ transform: startTransform, easing: 'ease-in-out' }, { transform: endTransform }];
|
||||
this.#animation = element.animate(keyframes, { duration, fill: 'forwards' });
|
||||
}
|
||||
|
||||
freeze() {
|
||||
if (!this.#animation || !this.#element) {
|
||||
return;
|
||||
}
|
||||
const frozen = getComputedStyle(this.#element).transform;
|
||||
this.#animation.cancel();
|
||||
this.#animation = undefined;
|
||||
if (frozen && frozen !== 'none') {
|
||||
this.#element.style.transform = frozen;
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.#animation?.pause();
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.#animation?.play();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.#cancelToken) {
|
||||
this.#cancelToken.value = true;
|
||||
this.#cancelToken = undefined;
|
||||
}
|
||||
this.#animation?.cancel();
|
||||
this.#animation = undefined;
|
||||
this.#element?.style.removeProperty('transform-origin');
|
||||
this.#element = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
function startHeroTransition(
|
||||
type: string,
|
||||
id: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: [type],
|
||||
prepareOldSnapshot: () => {
|
||||
setTransitionId(id);
|
||||
},
|
||||
performUpdate: async (signal) => {
|
||||
setTransitionId(null);
|
||||
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||
navigate();
|
||||
await ready;
|
||||
eventManager.emit('ViewerOpenTransition');
|
||||
await tick();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function startViewerTransition(
|
||||
assetId: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
startHeroTransition('viewer', assetId, navigate, setTransitionId);
|
||||
}
|
||||
|
||||
export function startMemoryTransition(
|
||||
memoryId: string,
|
||||
navigate: () => void,
|
||||
setTransitionId: (id: string | null) => void,
|
||||
) {
|
||||
startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
eventManager.emit('ViewTransitionOldSnapshotPending');
|
||||
|
||||
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||
Object.assign(clone.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
zIndex: '1',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'black',
|
||||
});
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,8 +31,8 @@ export const TUNABLES = {
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
||||
},
|
||||
KEN_BURNS: {
|
||||
SMARTCROP: getBoolean(storage.getItem('KEN_BURNS.SMARTCROP'), true),
|
||||
FACE_BOOST: getBoolean(storage.getItem('KEN_BURNS.FACE_BOOST'), true),
|
||||
MINIMAP: {
|
||||
FADE_DURATION: getNumber(storage.getItem('MINIMAP.FADE_DURATION'), 150),
|
||||
HIDE_DELAY: getNumber(storage.getItem('MINIMAP.HIDE_DELAY'), 1500),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:invisible={$showAssetViewer}>
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -33,4 +33,7 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,16 +6,13 @@
|
||||
import { timeToLoadTheMap } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { delay } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -44,14 +41,9 @@
|
||||
handlePromiseError(goto(Route.photos()));
|
||||
}
|
||||
|
||||
function onViewAssets(assetIds: string[]) {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['viewer'],
|
||||
performUpdate: async () => {
|
||||
await setAssetId(assetIds[0]);
|
||||
closeTimelinePanel();
|
||||
},
|
||||
});
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
await setAssetId(assetIds[0]);
|
||||
closeTimelinePanel();
|
||||
}
|
||||
|
||||
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
|
||||
@@ -85,10 +77,7 @@
|
||||
</div>
|
||||
|
||||
{#if isTimelinePanelVisible && selectedClusterBBox}
|
||||
<div
|
||||
transition:fly={{ x: 400, duration: 150, easing: linear }}
|
||||
class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0"
|
||||
>
|
||||
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
|
||||
<MapTimelinePanel
|
||||
bbox={selectedClusterBBox}
|
||||
{selectedClusterIds}
|
||||
@@ -100,7 +89,7 @@
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer && !isTimelinePanelVisible}
|
||||
{#if $showAssetViewer}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
cursor={{ current: $viewingAsset }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
@@ -38,9 +38,8 @@
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { startMemoryTransition } from '$lib/utils/transition-utils';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -100,16 +99,6 @@
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
|
||||
let memoryTransitionId = $state<string | null>(null);
|
||||
|
||||
const handleMemoryCardClick = (item: CarouselImageItem) => {
|
||||
startMemoryTransition(
|
||||
item.id ?? item.href,
|
||||
() => void goto(item.href),
|
||||
(id) => (memoryTransitionId = id),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
|
||||
@@ -123,33 +112,7 @@
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
{#snippet memoryCard(item: CarouselImageItem)}
|
||||
<a
|
||||
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
||||
href={item.href}
|
||||
data-memory-id={item.id}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMemoryCardClick(item);
|
||||
}}
|
||||
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
|
||||
>
|
||||
<img
|
||||
class="h-full w-full rounded-xl object-cover"
|
||||
src={item.src}
|
||||
alt={item.alt ?? item.title}
|
||||
draggable="false"
|
||||
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
></div>
|
||||
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
||||
{item.title}
|
||||
</p>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ImageCarousel {items} child={memoryCard} />
|
||||
<ImageCarousel {items} />
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||
@@ -8,7 +8,6 @@
|
||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -78,8 +77,6 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
appManager.isAssetViewer = isAssetViewerRoute(page);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -105,15 +102,8 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
onNavigate(({ to }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||
});
|
||||
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
|
||||
const { serverRestarting } = websocketStore;
|
||||
|
||||
Reference in New Issue
Block a user