Compare commits

...

2 Commits

Author SHA1 Message Date
midzelis
168414d1ab feat: web - view transitions from timeline to viewer, next/prev
feat: web - view transitions from timeline to viewer, next/prev

feat: web - swipe feedback - show image while swiping/dragging left/right

feat: web - swipe feedback - show image while swiping/dragging left/right

tweak animation - no crossfade by default

refactor(web): replace ViewTransitionManager event-driven API with phase-based callbacks

Change-Id: Ia52f300a08a725062acc19574b10593e6a6a6964

fix(web): graceful degradation for ViewTransitionManager, rename AssetViewerFree to AssetViewerReady, extract onClick handler

Change-Id: I4ad85d43e9922742910748a6487cd41f6a6a6964

Change-Id: Ie9c55914b0e87635e0d9e5889ca0ec3d6a6a6964

Change-Id: I0a37b417ee4c247dcc93d442c976eede6a6a6964
2026-03-17 12:23:59 -04:00
midzelis
c852c58940 feat(web): image-relative overlays with zoom support for faces, OCR, and face editor 2026-03-17 03:17:04 +00:00
48 changed files with 3048 additions and 503 deletions

View File

@@ -20,7 +20,7 @@ export {
toColumnarFormat,
} from './timeline/rest-response';
export type { Changes } from './timeline/rest-response';
export type { Changes, FaceData } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';

View File

@@ -7,8 +7,10 @@ import {
AssetVisibility,
UserAvatarColor,
type AlbumResponseDto,
type AssetFaceWithoutPersonResponseDto,
type AssetResponseDto,
type ExifResponseDto,
type PersonWithFacesResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UserResponseDto,
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
export type FaceData = {
people: PersonWithFacesResponseDto[];
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
};
export function toAssetResponseDto(
asset: MockTimelineAsset,
owner?: UserResponseDto,
faceData?: FaceData,
): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
people: faceData?.people ?? [],
unassignedFaces: faceData?.unassignedFaces ?? [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,

View File

@@ -1,5 +1,6 @@
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
import { type FaceData, randomThumbnail } from 'src/ui/generators/timeline';
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
const MINIMAL_MP4_BASE64 =
@@ -125,3 +126,84 @@ export const setupFaceEditorMockApiRoutes = async (
});
});
};
export type MockFaceSpec = {
personId: string;
personName: string;
faceId: string;
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
};
const toPersonResponseDto = (spec: MockFaceSpec) => ({
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: '2025-01-01T00:00:00.000Z',
});
const toBoundingBox = (spec: MockFaceSpec, imageWidth: number, imageHeight: number) => ({
id: spec.faceId,
imageWidth,
imageHeight,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
});
export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): FaceData => {
const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({
...toPersonResponseDto(spec),
faces: [toBoundingBox(spec, imageWidth, imageHeight)],
}));
return { people, unassignedFaces: [] };
};
export const createMockAssetFaces = (
specs: MockFaceSpec[],
imageWidth: number,
imageHeight: number,
): AssetFaceResponseDto[] => {
return specs.map((spec) => ({
...toBoundingBox(spec, imageWidth, imageHeight),
person: toPersonResponseDto(spec),
sourceType: 'machine-learning' as SourceType,
}));
};
export const setupGetFacesMockApiRoute = async (context: BrowserContext, faces: AssetFaceResponseDto[]) => {
await context.route('**/api/faces?*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: faces,
});
});
};
export const setupFaceOverlayMockApiRoutes = async (context: BrowserContext, assetDto: AssetResponseDto) => {
await context.route('**/api/assets/*', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const assetId = url.pathname.split('/').at(-1);
if (assetId !== assetDto.id) {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assetDto,
});
});
};

View File

@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};

View File

@@ -149,7 +149,7 @@ test.describe('face-editor', () => {
await expect(page.getByRole('dialog')).toBeVisible();
});
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
test('Confirming tag calls createFace API with valid coordinates and closes editor', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
@@ -163,8 +163,15 @@ test.describe('face-editor', () => {
await expect(page.locator('#face-editor')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
const request = faceCreateCapture.requests[0];
expect(request.assetId).toBe(asset.id);
expect(request.personId).toBe(personToTag.id);
expect(request.x).toBeGreaterThanOrEqual(0);
expect(request.y).toBeGreaterThanOrEqual(0);
expect(request.width).toBeGreaterThan(0);
expect(request.height).toBeGreaterThan(0);
expect(request.x + request.width).toBeLessThanOrEqual(request.imageWidth);
expect(request.y + request.height).toBeLessThanOrEqual(request.imageHeight);
});
test('Cancel button closes face editor', async ({ page }) => {
@@ -282,4 +289,39 @@ test.describe('face-editor', () => {
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
});
test('Cancel on confirmation dialog keeps face editor open', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const personToTag = mockPeople[0];
await page.locator('#face-selector').getByText(personToTag.name).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page
.getByRole('dialog')
.getByRole('button', { name: /cancel/i })
.click();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page.locator('#face-selector')).toBeVisible();
await expect(page.locator('#face-editor')).toBeVisible();
expect(faceCreateCapture.requests).toHaveLength(0);
});
test('Clicking on face rect center does not reposition it', async ({ page }) => {
const asset = selectRandom(fixture.assets, rng);
await openFaceEditor(page, asset);
const beforeClick = await getFaceBoxRect(page);
const centerX = beforeClick.left + beforeClick.width / 2;
const centerY = beforeClick.top + beforeClick.height / 2;
await page.mouse.click(centerX, centerY);
await page.waitForTimeout(300);
const afterClick = await getFaceBoxRect(page);
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
});
});

View File

@@ -0,0 +1,264 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockAssetFaces,
createMockFaceData,
createMockPeople,
type MockFaceSpec,
setupFaceEditorMockApiRoutes,
setupFaceOverlayMockApiRoutes,
setupGetFacesMockApiRoute,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const FACE_SPECS: MockFaceSpec[] = [
{
personId: 'person-alice',
personName: 'Alice Johnson',
faceId: 'face-alice',
boundingBoxX1: 1000,
boundingBoxY1: 500,
boundingBoxX2: 1500,
boundingBoxY2: 1200,
},
{
personId: 'person-bob',
personName: 'Bob Smith',
faceId: 'face-bob',
boundingBoxX1: 2000,
boundingBoxY1: 800,
boundingBoxX2: 2400,
boundingBoxY2: 1600,
},
];
const setupFaceMocks = async (
context: import('@playwright/test').BrowserContext,
fixture: ReturnType<typeof setupAssetViewerFixture>,
) => {
const mockPeople = createMockPeople(4);
const faceData = createMockFaceData(
FACE_SPECS,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces);
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
};
test.describe('face overlay bounding boxes', () => {
const fixture = setupAssetViewerFixture(901);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('face overlay divs render with correct aria labels', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
const bobOverlay = page.getByLabel('Person: Bob Smith');
await expect(aliceOverlay).toBeVisible();
await expect(bobOverlay).toBeVisible();
});
test('face overlay shows border on hover', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await aliceOverlay.hover();
await expect(activeBorder).toHaveCount(1);
});
test('face name tooltip appears on hover', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await aliceOverlay.hover();
const nameTooltip = page.locator('[data-viewer-content]').getByText('Alice Johnson');
await expect(nameTooltip).toBeVisible();
});
test('face overlays hidden in face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(aliceOverlay).toBeHidden();
});
test('face overlay hover works after exiting face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
await expect(aliceOverlay).toBeVisible();
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(aliceOverlay).toBeHidden();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
await expect(aliceOverlay).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await aliceOverlay.hover();
await expect(activeBorder).toHaveCount(1);
});
});
test.describe('zoom and face editor interaction', () => {
const fixture = setupAssetViewerFixture(902);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('zoom is preserved when entering face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const { width, height } = page.viewportSize()!;
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"]');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(transform).not.toBe('none');
expect(transform).not.toBe('');
}).toPass({ timeout: 2000 });
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await expect(page.locator('#face-editor')).toBeVisible();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).not.toBe('none');
});
});
test.describe('face overlay via detail panel interaction', () => {
const fixture = setupAssetViewerFixture(903);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
});
test('hovering person in detail panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await personLink.hover();
await expect(activeBorder).toHaveCount(1);
});
test('touch pointer on person in detail panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
// Simulate a touch-type pointerover (the fix changed from onmouseover to onpointerover,
// which fires for touch pointers unlike mouseover)
await personLink.dispatchEvent('pointerover', { pointerType: 'touch' });
await expect(activeBorder).toHaveCount(1);
});
test('hovering person in detail panel works after exiting face edit mode', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.locator('#face-selector')).toBeHidden();
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
await expect(personLink).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await personLink.hover();
await expect(activeBorder).toHaveCount(1);
});
});
test.describe('face overlay via edit faces side panel', () => {
const fixture = setupAssetViewerFixture(904);
test.beforeEach(async ({ context }) => {
await setupFaceMocks(context, fixture);
const assetFaces = createMockAssetFaces(
FACE_SPECS,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
await setupGetFacesMockApiRoute(context, assetFaces);
});
test('hovering person in edit faces panel shows face overlay border', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await page.getByLabel('Edit people').click();
const faceThumbnail = page.getByTestId('face-thumbnail').first();
await expect(faceThumbnail).toBeVisible();
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
await expect(activeBorder).toHaveCount(0);
await faceThumbnail.hover();
await expect(activeBorder).toHaveCount(1);
});
});

View File

@@ -0,0 +1,300 @@
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const PRIMARY_OCR_BOXES = [
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
];
const SECONDARY_OCR_BOXES = [
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
];
test.describe('OCR bounding boxes', () => {
const fixture = setupAssetViewerFixture(920);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
});
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
await ocrButton.click();
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
});
});
test.describe('OCR with stacked assets', () => {
const fixture = setupAssetViewerFixture(921);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
});
test.beforeEach(async ({ context }) => {
await setupBrokenAssetMockApiRoutes(context, mockStack);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
await expect(ocrBoxes).toHaveCount(2);
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0);
await expect(ocrButton).toBeVisible();
await ocrButton.click();
await expect(ocrBoxes).toHaveCount(1);
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
});
});
test.describe('OCR boxes and zoom', () => {
const fixture = setupAssetViewerFixture(922);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR boxes scale with zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const ocrButton = page.getByLabel('Text recognition');
await expect(ocrButton).toBeVisible();
await ocrButton.click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const initialBox = await ocrBox.boundingBox();
expect(initialBox).toBeTruthy();
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -3);
await expect(async () => {
const zoomedBox = await ocrBox.boundingBox();
expect(zoomedBox).toBeTruthy();
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
}).toPass({ timeout: 2000 });
});
});
test.describe('OCR text interaction', () => {
const fixture = setupAssetViewerFixture(923);
test.beforeEach(async ({ context }) => {
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
]);
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
});
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
});
test('OCR text box receives focus on click', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
await ocrBox.click();
await expect(ocrBox).toBeFocused();
});
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const box = await ocrBox.boundingBox();
expect(box).toBeTruthy();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
await page.mouse.up();
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await page.getByLabel('Text recognition').click();
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
await expect(ocrBox).toBeVisible();
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const initialTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
const viewerContent = page.locator('[data-viewer-content]');
const viewerBox = await viewerContent.boundingBox();
expect(viewerBox).toBeTruthy();
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
await page.evaluate(
({ viewerCenterX, viewerCenterY, outsideY }) => {
const viewer = document.querySelector('[data-viewer-content]');
if (!viewer) {
return;
}
const createTouch = (id: number, x: number, y: number) => {
return new Touch({
identifier: id,
target: viewer,
clientX: x,
clientY: y,
});
};
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
const touchStartEvent = new TouchEvent('touchstart', {
touches: [insideTouch, outsideTouch],
targetTouches: [insideTouch],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
const touchMoveEvent = new TouchEvent('touchmove', {
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
changedTouches: [
createTouch(0, viewerCenterX, viewerCenterY - 30),
createTouch(1, viewerCenterX, outsideY + 30),
],
bubbles: true,
cancelable: true,
});
const touchEndEvent = new TouchEvent('touchend', {
touches: [],
targetTouches: [],
changedTouches: [insideTouch, outsideTouch],
bubbles: true,
cancelable: true,
});
viewer.dispatchEvent(touchStartEvent);
viewer.dispatchEvent(touchMoveEvent);
viewer.dispatchEvent(touchEndEvent);
},
{
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
outsideY: 10, // near the top of the page, outside the viewer
},
);
const afterTransform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
expect(afterTransform).toBe(initialTransform);
});
});

View File

@@ -6,6 +6,7 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
return route.fulfill({
status: 200,
contentType: 'application/json',

View File

@@ -164,12 +164,8 @@ export const assetViewerUtils = {
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
)
.or(
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
)
.locator(`img[data-testid="preview"][src*="${asset.id}"]`)
.or(page.locator(`video[poster*="${asset.id}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {

View File

@@ -0,0 +1,273 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
SeededRandom,
selectRandom,
TimelineAssetConfig,
TimelineData,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('asset-viewer', () => {
const rng = new SeededRandom(529);
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos/:id', () => {
test('Navigate to next asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
});
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
});
test('Navigate forward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
}
});
test('Navigate backward 5 times via button', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
for (let i = 1; i <= 5; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
}
});
test('Navigate forward then backward via keyboard', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
// Navigate forward 3 times
for (let i = 1; i <= 3; i++) {
await page.getByTestId('next-asset').waitFor();
await page.keyboard.press('ArrowRight');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Navigate backward 3 times to return to original
for (let i = 2; i >= 0; i--) {
await page.getByTestId('previous-asset').waitFor();
await page.keyboard.press('ArrowLeft');
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
}
// Verify we're back at the original asset
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
});
test('Verify no next button on last asset', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await page.goto(`/photos/${lastAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
// Verify next button doesn't exist
await expect(page.getByLabel('View next asset')).toHaveCount(0);
});
test('Verify no previous button on first asset', async ({ page }) => {
const firstAsset = assets[0];
await page.goto(`/photos/${firstAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
// Verify previous button doesn't exist
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
});
test('Delete photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete photo advances to next (2x)', async ({ page }) => {
const asset = selectRandom(assets, rng);
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete last photo advances to prev', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
});
test('Delete last photo advances to prev (2x)', async ({ page }) => {
const asset = assets.at(-1)!;
await page.goto(`/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
const index = assets.indexOf(asset);
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
});
});
test.describe('/trash/photos/:id', () => {
test('Delete trashed photo advances to next', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
});
test('Delete trashed photo advances to next 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${asset.id}`);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
});
test('Delete trashed photo advances to prev', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
});
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
const asset = selectRandom(assets, rng);
const index = assets.indexOf(asset);
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
changes.assetDeletions.push(...deletedAssets);
await page.goto(`/trash/photos/${assets[index + 9].id}`);
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
await page.getByLabel('Delete').click();
// confirm dialog
await page.getByRole('button').getByText('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
});
});
});

View File

@@ -33,7 +33,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \
pnpm --filter @immich/sdk --filter immich-web build
NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @immich/sdk --filter immich-web build
FROM builder AS cli

View File

@@ -75,6 +75,33 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
/* Base animation duration for standard transitions (page fades, info panel) */
--vt-duration-default: 250ms;
/* Duration for hero transitions (thumbnail to full viewer) */
--vt-duration-hero: 280ms;
/* Duration for next/previous photo navigation */
--vt-duration-viewer-navigation: 270ms;
/* Duration for slideshow mode transitions */
--vt-duration-slideshow: 1s;
/* Easing function for slide animations (ease-out) */
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
/* How far images slide in/out during navigation (% of viewport) */
--vt-viewer-slide-distance: 15%;
/* Starting opacity for fly transitions (slide+fade effect) */
--vt-viewer-opacity-start: 0.1;
/* Maximum blur during fly transitions (currently disabled) */
--vt-viewer-blur-max: 0px;
--vt-viewer-next-in: flyInRight;
--vt-viewer-next-out: flyOutLeft;
--vt-viewer-prev-in: flyInLeft;
--vt-viewer-prev-out: flyOutRight;
--vt-viewer-old-opacity: 1;
}
button:not(:disabled),
@@ -176,3 +203,367 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(*) {
animation: var(--vt-duration-slideshow) linear crossfadeOut forwards;
}
&::view-transition-new(*) {
animation: var(--vt-duration-slideshow) linear crossfadeIn forwards;
}
&::view-transition-image-pair(*) {
isolation: auto;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-image-pair(info) {
isolation: auto;
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
z-index: 4;
}
::view-transition-image-pair(letterbox-left),
::view-transition-image-pair(letterbox-right),
::view-transition-image-pair(letterbox-top),
::view-transition-image-pair(letterbox-bottom) {
isolation: auto;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: none;
width: 100%;
height: 100%;
object-fit: fill;
background-color: var(--color-black);
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-old(hero),
::view-transition-new(hero) {
animation: none;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old),
::view-transition-new(next),
::view-transition-new(next-new),
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-new(previous),
::view-transition-new(previous-new) {
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
}
::view-transition-old(next),
::view-transition-old(next-old),
::view-transition-old(previous),
::view-transition-old(previous-old) {
opacity: var(--vt-viewer-old-opacity);
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation-name: var(--vt-viewer-next-out);
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation-name: var(--vt-viewer-next-in);
}
::view-transition-old(previous),
::view-transition-old(previous-old) {
animation-name: var(--vt-viewer-prev-out);
}
::view-transition-new(previous),
::view-transition-new(previous-new) {
animation-name: var(--vt-viewer-prev-in);
}
::view-transition-old(next-old),
::view-transition-new(next-new),
::view-transition-old(previous-old),
::view-transition-new(previous-new) {
overflow: hidden;
}
::view-transition-old(previous-old) {
z-index: -1;
}
@keyframes flyInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes panelSlideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes panelSlideOutRight {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
@keyframes fadeIn {
from {
opacity: 0;
}
50% {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
50% {
opacity: 0.85;
}
to {
opacity: 0;
}
}
@keyframes crossfadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes crossfadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 100;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
background-color: transparent;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -1,11 +1,17 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
type TouchEventLike = {
touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
targetTouches: ArrayLike<unknown>;
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: null,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
@@ -13,47 +19,124 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const onInteractionStart = (event: Event) => {
if (options?.disabled) {
event.stopImmediatePropagation();
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, 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.
const isOverlayEvent = (event: Event) => !!(event.target as HTMLElement).closest('[data-overlay-interactive]');
const isOverlayAtPoint = (x: number, y: number) =>
!!document.elementFromPoint(x, y)?.closest('[data-overlay-interactive]');
// Pointer event interception: track pointers that start on overlays and intercept the entire gesture.
const overlayPointers = new Set<number>();
const interceptedPointers = new Set<number>();
const interceptOverlayPointerDown = (event: PointerEvent) => {
if (isOverlayEvent(event) || isOverlayAtPoint(event.clientX, event.clientY)) {
overlayPointers.add(event.pointerId);
interceptedPointers.add(event.pointerId);
event.stopPropagation();
} else if (overlayPointers.size > 0) {
// Split gesture (e.g. pinch with one finger on overlay) — intercept entirely.
interceptedPointers.add(event.pointerId);
event.stopPropagation();
}
assetViewerManager.cancelZoomAnimation();
};
const interceptOverlayPointerEvent = (event: PointerEvent) => {
if (interceptedPointers.has(event.pointerId)) {
event.stopPropagation();
}
};
const interceptOverlayPointerEnd = (event: PointerEvent) => {
overlayPointers.delete(event.pointerId);
if (interceptedPointers.delete(event.pointerId)) {
event.stopPropagation();
}
};
node.addEventListener('pointerdown', interceptOverlayPointerDown, { capture: true, signal });
node.addEventListener('pointermove', interceptOverlayPointerEvent, { capture: true, signal });
node.addEventListener('pointerup', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('pointerleave', interceptOverlayPointerEnd, { capture: true, signal });
node.addEventListener('wheel', onInteractionStart, { capture: true });
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
// Once intercepted, stays intercepted until all fingers are lifted.
let touchGestureIntercepted = false;
const interceptOverlayTouchEvent = (event: Event) => {
if (touchGestureIntercepted) {
event.stopPropagation();
return;
}
const { touches, targetTouches } = asTouchEvent(event);
if (touches && targetTouches) {
if (touches.length > targetTouches.length) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
for (const touch of touches) {
if (isOverlayAtPoint(touch.clientX, touch.clientY)) {
touchGestureIntercepted = true;
event.stopPropagation();
return;
}
}
} else if (isOverlayEvent(event)) {
event.stopPropagation();
}
};
const resetTouchGesture = (event: Event) => {
const { touches } = asTouchEvent(event);
if (touches.length === 0) {
touchGestureIntercepted = false;
}
};
node.addEventListener('touchstart', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchmove', interceptOverlayTouchEvent, { capture: true, signal });
node.addEventListener('touchend', resetTouchGesture, { capture: true, signal });
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
// Wheel and dblclick interception on overlay elements.
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
// on double-tap, which conflicts with zoom-image's touch zoom handler).
let lastPointerWasTouch = false;
const trackPointerType = (event: PointerEvent) => {
lastPointerWasTouch = event.pointerType === 'touch';
};
const suppressTouchDblClick = (event: MouseEvent) => {
if (lastPointerWasTouch) {
event.stopImmediatePropagation();
}
};
node.addEventListener('pointerdown', trackPointerType, { capture: true });
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
capture: true,
signal,
});
node.addEventListener(
'wheel',
(event) => {
if (isOverlayEvent(event)) {
event.stopPropagation();
}
},
{ capture: true, signal },
);
node.addEventListener(
'dblclick',
(event) => {
if (lastPointerWasTouch || isOverlayEvent(event)) {
event.stopImmediatePropagation();
}
},
{ capture: true, signal },
);
// Allow zoomed content to render outside the container bounds
node.style.overflow = 'visible';
// Prevent browser handling of touch gestures so zoom-image can manage them
node.style.touchAction = 'none';
return {
update(newOptions?: { disabled?: boolean }) {
update(newOptions?: { zoomTarget?: HTMLElement }) {
options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
},
destroy() {
controller.abort();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
node.removeEventListener('wheel', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
zoomInstance.cleanup();
},
};

View File

@@ -1,6 +1,7 @@
<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';
@@ -21,6 +22,10 @@
width: number;
height: number;
};
showLetterboxes?: boolean;
transitionName?: string | null | undefined;
letterboxTransitionName?: string | undefined;
imageClass?: string;
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
@@ -38,6 +43,10 @@
sharedLink,
objectFit = 'contain',
container,
showLetterboxes = true,
transitionName,
letterboxTransitionName,
imageClass,
onUrlChange,
onImageReady,
onError,
@@ -101,9 +110,13 @@
return { width: 1, height: 1 };
});
const { width, height, left, top } = $derived.by(() => {
const scaledDimensions = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
return scaleFn(imageDimensions, container);
});
const { width, height, left, top } = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
@@ -149,81 +162,79 @@
(quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
});
const zoomTransform = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
return undefined;
}
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
});
</script>
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()}
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
<Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
<div
class="absolute inset-0 pointer-events-none"
style:transform={zoomTransform}
style:transform-origin={zoomTransform ? '0 0' : undefined}
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}
data-debug-container={`${container.width}x${container.height}`}
data-debug-scaled={`${scaledDimensions.width}x${scaledDimensions.height}`}
data-debug-imagedim={`${imageDimensions.width}x${imageDimensions.height}`}
>
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.brokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
</div>

View File

@@ -1,3 +1,9 @@
<script module lang="ts">
const useSplitNavTransitions =
typeof document !== 'undefined' &&
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
</script>
<script lang="ts">
import { browser } from '$app/environment';
import { focusTrap } from '$lib/actions/focus-trap';
@@ -13,6 +19,7 @@
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';
@@ -40,7 +47,7 @@
import { onDestroy, onMount, untrack } from 'svelte';
import type { SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -89,7 +96,7 @@
onRandom,
}: Props = $props();
const { setAssetId } = assetViewingStore;
const { setAssetId, invisible } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@@ -109,6 +116,10 @@
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>();
@@ -142,45 +153,56 @@
}
};
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
if (asset.id === updatedAsset.id) {
cursor = { ...cursor, current: updatedAsset };
}
};
let transitionName = $state<string | undefined>('hero');
let letterboxTransitionName = $state<string | undefined>(undefined);
let detailPanelTransitionName = $state<string | undefined>(undefined);
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 slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset();
slideshowHistory.queue(toTimelineAsset(asset));
}
});
return () => {
slideshowStateUnsubscribe();
slideshowNavigationUnsubscribe();
const addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
};
unsubscribes.push(
eventManager.on({
TransitionToAssetViewer: addInfoTransition,
TransitionToTimeline: 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(() => {
activityManager.reset();
assetViewerManager.closeEditor();
isFaceEditMode.value = false;
syncAssetViewerOpenClass(false);
preloadManager.destroy();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
});
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
@@ -193,65 +215,125 @@
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 () => {
result = await navigateFn();
await eventManager.untilNext('AssetViewerReady');
},
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 useTransition = canTransition && (slideShowShuffle || !!targetAsset);
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : 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();
const navigateAsset = (order?: 'previous' | 'next') => {
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
preloadManager.cancelBeforeNavigation(order);
if (tracker.isActive()) {
return;
}
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'));
void tracker.invoke(
() => completeNavigation(order, skipTransition),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('AssetViewerAfterNavigate'),
);
};
/**
* Slide show mode
*/
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
@@ -276,10 +358,11 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -358,15 +441,18 @@
}
};
const refreshOcr = async () => {
ocrManager.clear();
if (sharedLink) {
return;
}
await ocrManager.getAssetOcr(asset.id);
};
const refresh = async () => {
await refreshStack();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
await refreshOcr();
};
$effect(() => {
@@ -375,21 +461,36 @@
untrack(() => handlePromiseError(refresh()));
});
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
previewStackedAsset;
untrack(() => ocrManager.clear());
});
let lastCursor = $state<AssetCursor>();
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
previewStackedAsset = undefined;
ocrManager.showOverlay = false;
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
lastCursor = cursor;
return;
}
if (!lastCursor) {
preloadManager.initializePreloads(cursor, sharedLink);
}
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';
@@ -460,13 +561,16 @@
<section
id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
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}
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">
<div
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
style:view-transition-name="exclude"
>
<AssetViewerNavBar
{asset}
{album}
@@ -498,16 +602,21 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<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"
>
<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}
@@ -520,6 +629,7 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{asset}
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
@@ -531,13 +641,20 @@
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
<PhotoViewer
{transitionName}
{letterboxTransitionName}
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{asset}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
@@ -571,15 +688,20 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<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"
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if showDetailPanel || assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', 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"
>
@@ -595,7 +717,7 @@
</div>
{/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
{@const stackedAssets = stack.assets}
<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">

View File

@@ -7,6 +7,7 @@
import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route';
@@ -122,6 +123,7 @@
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
eventManager.emit('AssetUpdate', asset);
showEditFaces = false;
};
@@ -233,8 +235,8 @@
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
onpointerover={() => ($boundingBoxesArray = people[index].faces)}
onpointerleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail

View File

@@ -7,6 +7,7 @@
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
interface Props {
asset: AssetResponseDto;
@@ -74,6 +75,8 @@
alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
onload={() => signalAssetViewerReady()}
onerror={() => signalAssetViewerReady()}
/>
<div
class={[

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { scaleFaceRectOnResize } from '$lib/utils/people-utils';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
@@ -13,7 +15,7 @@
import { t } from 'svelte-i18n';
interface Props {
htmlElement: HTMLImageElement | HTMLVideoElement;
htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null;
containerWidth: number;
containerHeight: number;
assetId: string;
@@ -22,6 +24,7 @@
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
@@ -31,6 +34,8 @@
let searchTerm = $state('');
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
let userMovedRect = false;
let previousMetrics = { contentWidth: 0, offsetX: 0, offsetY: 0 };
let filteredCandidates = $derived(
searchTerm
@@ -56,7 +61,8 @@
return;
}
canvas = new Canvas(canvasEl);
canvas = new Canvas(canvasEl, { width: containerWidth, height: containerHeight });
canvas.selection = false;
configureControlStyle();
// eslint-disable-next-line tscompat/tscompat
@@ -74,15 +80,55 @@
canvas.add(faceRect);
canvas.setActiveObject(faceRect);
setDefaultFaceRectanglePosition(faceRect);
};
onMount(async () => {
setupCanvas();
await getPeople();
onMount(() => {
void getPeople();
});
$effect(() => {
if (!canvas) {
return;
}
const upperCanvas = canvas.upperCanvasEl;
const controller = new AbortController();
const { signal } = controller;
const handlePointerOnCanvas = (event: PointerEvent) => {
if (!canvas) {
return;
}
const { target } = canvas.findTarget(event);
if (target) {
event.stopPropagation();
return;
}
if (event.type === 'pointerdown' && faceRect) {
event.stopPropagation();
const pointer = canvas.getScenePoint(event);
faceRect.set({ left: pointer.x, top: pointer.y });
faceRect.setCoords();
userMovedRect = true;
canvas.renderAll();
positionFaceSelector();
}
};
for (const type of ['pointerdown', 'pointermove', 'pointerup'] as const) {
upperCanvas.addEventListener(type, handlePointerOnCanvas, { signal });
}
return () => {
controller.abort();
};
});
const imageContentMetrics = $derived.by(() => {
if (!htmlElement) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
@@ -95,45 +141,46 @@
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
top: offsetY + contentHeight / 2 - 56,
left: offsetX + contentWidth / 2 - 56,
});
faceRect.setCoords();
positionFaceSelector();
};
$effect(() => {
if (!canvas) {
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
if (contentWidth === 0) {
return;
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
});
const isFirstRun = previousMetrics.contentWidth === 0;
if (!faceRect) {
if (isFirstRun && !canvas) {
setupCanvas();
}
if (!canvas || !faceRect) {
return;
}
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
if (!isFirstRun) {
canvas.setDimensions({ width: containerWidth, height: containerHeight });
}
if (!isFirstRun && userMovedRect) {
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, offsetX, offsetY, contentWidth));
} else {
setDefaultFaceRectanglePosition(faceRect);
}
});
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
const faceBox = faceRect.getBoundingRect();
return !(
0 > faceBox.left + faceBox.width ||
0 > faceBox.top + faceBox.height ||
canvas.width < faceBox.left ||
canvas.height < faceBox.top
);
};
faceRect.setCoords();
previousMetrics = { contentWidth, offsetX, offsetY };
canvas.renderAll();
positionFaceSelector();
});
const cancel = () => {
isFaceEditMode.value = false;
@@ -163,11 +210,12 @@
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
width: rawBox.width + padding * 2,
height: rawBox.height + padding * 2,
left: (rawBox.left - padding) * currentZoom + currentPositionX,
top: (rawBox.top - padding) * currentZoom + currentPositionY,
width: (rawBox.width + padding * 2) * currentZoom,
height: (rawBox.height + padding * 2) * currentZoom,
};
const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
@@ -177,20 +225,21 @@
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
const selectorBottom = position.top + selectorHeight;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
const overlapArea = (position: { top: number; left: number }) => {
const overlapX = Math.max(
0,
Math.min(position.left + selectorWidth, faceRight) - Math.max(position.left, faceBox.left),
);
const overlapY = Math.max(
0,
Math.min(position.top + selectorHeight, faceBottom) - Math.max(position.top, faceBox.top),
);
return overlapX * overlapY;
};
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const positions = [
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
@@ -212,30 +261,71 @@
}
}
faceSelectorEl.style.top = `${bestPosition.top}px`;
faceSelectorEl.style.left = `${bestPosition.left}px`;
const containerRect = containerEl?.getBoundingClientRect();
const offsetTop = containerRect?.top ?? 0;
const offsetLeft = containerRect?.left ?? 0;
faceSelectorEl.style.top = `${bestPosition.top + offsetTop}px`;
faceSelectorEl.style.left = `${bestPosition.left + offsetLeft}px`;
scrollableListEl.style.height = `${listHeight}px`;
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
faceBoxPosition = faceBox;
};
$effect(() => {
if (!canvas) {
return;
}
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
canvas.renderAll();
positionFaceSelector();
});
$effect(() => {
const rect = faceRect;
if (rect) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
const onUserMove = () => {
userMovedRect = true;
positionFaceSelector();
};
rect.on('moving', onUserMove);
rect.on('scaling', onUserMove);
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
rect.off('moving', onUserMove);
rect.off('scaling', onUserMove);
};
}
});
const trapEvents = (node: HTMLElement) => {
const stop = (e: Event) => e.stopPropagation();
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
for (const type of eventTypes) {
node.addEventListener(type, stop);
}
// Move to body so the selector isn't affected by the zoom transform on the container
document.body.append(node);
return {
destroy() {
for (const type of eventTypes) {
node.removeEventListener(type, stop);
}
node.remove();
},
};
};
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const scaledWidth = faceRect.getScaledWidth();
const scaledHeight = faceRect.getScaledHeight();
const left = faceRect.left - scaledWidth / 2;
const top = faceRect.top - scaledHeight / 2;
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
@@ -249,8 +339,8 @@
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
width: Math.floor(scaledWidth * scaleX),
height: Math.floor(scaledHeight * scaleY),
};
};
@@ -281,16 +371,16 @@
});
await assetViewingStore.setAssetId(assetId);
isFaceEditMode.value = false;
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
isFaceEditMode.value = false;
}
};
</script>
<div
id="face-editor-data"
bind:this={containerEl}
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
@@ -302,7 +392,9 @@
<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
use:trapEvents
onwheel={(e) => e.stopPropagation()}
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>

View File

@@ -4,13 +4,14 @@
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 { asset }: Props = $props();
let { transitionName, letterboxTransitionName, asset }: Props = $props();
const assetId = $derived(asset.id);
@@ -20,11 +21,16 @@
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div class="flex h-dvh w-dvw 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 panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
<PhotoSphereViewer
{transitionName}
{letterboxTransitionName}
panorama={data}
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}

View File

@@ -0,0 +1,68 @@
<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}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
@@ -8,6 +9,7 @@
let { ocrBox }: Props = $props();
const isTouch = $derived(mediaQueryManager.pointerCoarse);
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
@@ -15,13 +17,23 @@
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
);
const handleSelectStart = (event: Event) => {
const target = event.currentTarget as HTMLElement;
requestAnimationFrame(() => {
const selection = globalThis.getSelection();
if (selection) {
selection.selectAllChildren(target);
}
});
};
const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) {
case 'cjk': {
return ' writing-mode: vertical-rl;';
return 'writing-mode: vertical-rl;';
}
case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;';
return 'writing-mode: vertical-rl; text-orientation: sideways;';
}
default: {
return '';
@@ -30,17 +42,20 @@
});
</script>
<div class="absolute left-0 top-0">
<div
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
'none'
? 'px-2 py-1 whitespace-nowrap'
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
tabindex="0"
role="button"
aria-label={ocrBox.text}
>
{ocrBox.text}
</div>
<div
class="absolute left-0 top-0 flex items-center justify-center border-2 border-blue-500 pointer-events-auto cursor-text focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none {isTouch
? 'text-white bg-black/60 select-all'
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3'} {ocrBox.verticalMode ===
'none'
? 'px-2 py-1 whitespace-nowrap'
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
data-testid="ocr-box"
data-overlay-interactive
tabindex="0"
role="button"
aria-label={ocrBox.text}
onselectstart={isTouch ? handleSelectStart : undefined}
>
{ocrBox.text}
</div>

View File

@@ -1,7 +1,9 @@
<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 { signalAssetViewerReady } 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';
@@ -41,6 +43,8 @@
'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];
@@ -48,11 +52,21 @@
navbar?: boolean;
};
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let {
transitionName,
letterboxTransitionName,
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[] = [];
@@ -212,6 +226,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', () => signalAssetViewerReady(), { once: true });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel is 0-100
@@ -256,7 +271,15 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<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} />
<style>
/* Reset the default tooltip styling */

View File

@@ -8,6 +8,7 @@
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 { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
@@ -29,12 +30,21 @@
cursor: AssetCursor;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
transitionName?: string;
letterboxTransitionName?: string;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
}
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
let {
cursor,
element = $bindable(),
sharedLink,
transitionName,
letterboxTransitionName,
onError,
onSwipe,
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -105,12 +115,6 @@
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
onZoom();
}
});
// TODO move to action + command palette
const onCopyShortcut = (event: KeyboardEvent) => {
if (globalThis.getSelection()?.type === 'Range') {
@@ -151,6 +155,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>();
@@ -159,40 +165,17 @@
map.set(face, person.name);
}
}
if (isFaceEditMode.value) {
for (const face of asset.unassignedFaces ?? []) {
map.set(face, '');
}
}
return map;
});
const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
return;
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlayMetrics);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
const boundingBoxes = $derived(getBoundingBox(faces, overlayMetrics));
const activeBoundingBoxes = $derived(getBoundingBox($boundingBoxesArray, overlayMetrics));
</script>
<AssetViewerEvents {onCopy} {onZoom} />
@@ -213,9 +196,7 @@
bind:clientHeight={containerHeight}
role="presentation"
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
<AdaptiveImage
@@ -226,13 +207,17 @@
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;
onReady?.();
signalAssetViewerReady();
}}
onError={() => {
onError?.();
onReady?.();
signalAssetViewerReady();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}
{transitionName}
{letterboxTransitionName}
showLetterboxes={!blurredSlideshow}
>
{#snippet backdrop()}
{#if blurredSlideshow}
@@ -243,20 +228,38 @@
{/if}
{/snippet}
{#snippet overlays()}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get($boundingBoxesArray[index])}
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
{@const face = faces[index]}
{@const name = faceToNameMap.get(face)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{faceToNameMap.get($boundingBoxesArray[index])}
</div>
{/if}
class="absolute pointer-events-auto outline-none rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {name || $t('unknown')}"
onpointerenter={() => ($boundingBoxesArray = [face])}
onpointerleave={() => ($boundingBoxesArray = [])}
></div>
{/each}
{/if}
{#each activeBoundingBoxes as boundingbox (boundingbox.id)}
{@const matchedFace = faces.find((f) => f.id === boundingbox.id)}
{@const name = matchedFace ? faceToNameMap.get(matchedFace) : undefined}
<div
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
>
{#if name}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
>
{name}
</div>
{/if}
</div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}

View File

@@ -3,6 +3,7 @@
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 { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import {
autoPlayVideo,
@@ -18,6 +19,7 @@
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
@@ -30,6 +32,7 @@
}
let {
transitionName,
assetId,
loopVideo,
cacheKey,
@@ -58,7 +61,6 @@
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
hasFocused = false;
videoPlayer?.load();
@@ -139,6 +141,7 @@
</div>
{:else}
<video
style:view-transition-name={transitionName}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
@@ -147,6 +150,7 @@
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
onloadedmetadata={() => signalAssetViewerReady()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}

View File

@@ -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 }: Props = $props();
const { asset, transitionName }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -19,11 +19,12 @@
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div 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]}

View File

@@ -5,6 +5,7 @@
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
transitionName?: string;
asset: AssetResponseDto;
assetId?: string;
projectionType: string | null | undefined;
@@ -19,6 +20,7 @@
}
let {
transitionName,
asset,
assetId,
projectionType,
@@ -36,9 +38,10 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {asset} />
<VideoPanoramaViewer {transitionName} {asset} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
assetId={effectiveAssetId}

View File

@@ -230,10 +230,11 @@
<div
role="button"
tabindex={index}
data-testid="face-thumbnail"
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])}
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onpointerleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}

View File

@@ -6,10 +6,11 @@
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 {
@@ -44,12 +45,17 @@
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}
{#if !hideNavbar && !isAssetViewer}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -58,13 +64,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if sidebar}
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative">
<main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -3,8 +3,11 @@
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 { startViewerTransition, viewTransitionManager } from '$lib/managers/ViewTransitionManager.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 AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
@@ -30,6 +33,7 @@
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 = {
@@ -107,6 +111,36 @@
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;
@@ -356,6 +390,64 @@
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 () => {
await new Promise((resolve) => setTimeout(resolve, 100));
eventManager.emit('TransitionToTimelineReady');
toGalleryTransitionId = id;
await tick();
},
onFinished: () => {
toGalleryTransitionId = null;
focusAsset(id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ TransitionToTimeline: transitionToGalleryCallback }));
}
const handleClose = async (asset: { id: string }) => {
const useTransition = viewTransitionManager.isSupported();
if (useTransition) {
const transitionReady = eventManager.untilNext('TransitionToTimelineReady');
eventManager.emit('TransitionToTimeline', { 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
@@ -375,16 +467,17 @@
{#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)}
{@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
{@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}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onClick={() => handleThumbnailClick(asset, currentAsset)}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon}
@@ -416,10 +509,7 @@
onAction={handleAction}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
onClose={(asset) => handleClose(asset)}
/>
{/await}
</Portal>

View File

@@ -50,7 +50,11 @@
<svelte:window bind:innerWidth />
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
<nav
id="dashboard-navbar"
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm"
style:view-transition-name="exclude"
>
<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

View File

@@ -1,20 +1,19 @@
<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<
[
{
@@ -26,14 +25,20 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
let { isUploading } = uploadAssetsStore;
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const {
animationTargetAssetId,
suspendTransitions = false,
viewerAssets,
width,
height,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(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 -->
@@ -41,11 +46,14 @@
{#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'}

View File

@@ -1,34 +1,37 @@
<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 } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.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 type { Snippet } from 'svelte';
import { onMount, tick, 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();
@@ -37,10 +40,6 @@
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({
@@ -50,6 +49,32 @@
});
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('TransitionToTimelineReady');
const event = await eventManager.untilNext('TimelineLoaded');
toTimelineTransitionAssetId = event.id;
await tick();
},
onFinished: () => {
toTimelineTransitionAssetId = null;
focusAsset(asset.id);
},
});
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on({ TransitionToTimeline: transitionToTimelineCallback }));
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -93,7 +118,8 @@
</div>
<AssetLayout
{manager}
{animationTargetAssetId}
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}

View File

@@ -11,6 +11,7 @@
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) */
@@ -39,6 +40,7 @@
}
let {
invisible = false,
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
@@ -438,7 +440,7 @@
next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement);
next.focus();
next?.focus();
}
}
}
@@ -509,6 +511,7 @@
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

View File

@@ -11,6 +11,8 @@
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/managers/ViewTransitionManager.svelte';
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';
@@ -26,7 +28,6 @@
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
isSelectionMode?: boolean;
singleSelect?: boolean;
@@ -102,6 +103,7 @@
// 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);
@@ -209,7 +211,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -221,7 +223,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -239,10 +241,13 @@
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(() => {
void complete.finally(async () => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -251,8 +256,13 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -260,7 +270,7 @@
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -524,6 +534,33 @@
}
});
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,
@@ -544,19 +581,6 @@
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} />
@@ -587,6 +611,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -666,11 +691,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -684,13 +709,7 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);

View File

@@ -4,6 +4,8 @@
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';
@@ -98,6 +100,12 @@
};
const handleClose = async (asset: { id: string }) => {
if (viewTransitionManager.isSupported()) {
const transitionReady = eventManager.untilNext('TransitionToTimelineReady');
eventManager.emit('TransitionToTimeline', { id: asset.id });
await transitionReady;
}
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });

View File

@@ -0,0 +1,252 @@
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 second transition', 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, 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, try a second — should be skipped
await manager.startTransition({ performUpdate: secondPerformUpdate });
expect(secondPerformUpdate).not.toHaveBeenCalled();
// Clean up
resolveUpdate();
resolveFinished();
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, 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, 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, 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, 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, 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, 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('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);
});
});
});

View File

@@ -0,0 +1,108 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tick } from 'svelte';
interface TransitionRequest {
types?: string[];
prepareOldSnapshot?: () => void;
performUpdate: () => Promise<void>;
prepareNewSnapshot?: () => void;
onFinished?: () => void;
}
export class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
get activeViewTransition() {
return this.#activeViewTransition;
}
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null;
return skipped;
}
async startTransition({
types,
prepareOldSnapshot,
performUpdate,
prepareNewSnapshot,
onFinished,
}: TransitionRequest) {
if (this.#activeViewTransition) {
return;
}
if (!this.isSupported()) {
await performUpdate();
onFinished?.();
return;
}
prepareOldSnapshot?.();
await tick();
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({
update: async () => {
await performUpdate();
prepareNewSnapshot?.();
await tick();
},
types,
});
} catch {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(async () => {
await performUpdate();
prepareNewSnapshot?.();
await tick();
});
}
this.#activeViewTransition = transition;
// 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.finally(() => {
this.#activeViewTransition = null;
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();
export function startViewerTransition(
assetId: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
void viewTransitionManager.startTransition({
types: ['viewer'],
prepareOldSnapshot: () => {
setTransitionId(assetId);
},
performUpdate: async () => {
setTransitionId(null);
const ready = eventManager.untilNext('AssetViewerReady');
navigate();
await ready;
eventManager.emit('TransitionToAssetViewer');
await tick();
},
});
}

View File

@@ -0,0 +1,10 @@
let isAssetViewer = $state(false);
export const appManager = {
get isAssetViewer() {
return isAssetViewer;
},
set isAssetViewer(value: boolean) {
isAssetViewer = value;
},
};

View File

@@ -19,76 +19,84 @@ import type {
} from '@immich/sdk';
export type Events = {
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumCreate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
AlbumUpdate: [AlbumResponseDto];
AlbumUserDelete: [{ albumId: string; userId: string }];
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
ApiKeyCreate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
ApiKeyUpdate: [ApiKeyResponseDto];
AppInit: [];
AssetEditsApplied: [string];
AssetUpdate: [AssetResponseDto];
AssetViewerAfterNavigate: [];
AssetViewerReady: [];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetsTag: [string[]];
AuthLogin: [LoginResponseDto];
AuthLogout: [];
AuthUserLoaded: [UserAdminResponseDto];
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting];
ApiKeyCreate: [ApiKeyResponseDto];
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumCreate: [AlbumResponseDto];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
PersonThumbnailReady: [{ id: string }];
PersonAssetDelete: [{ id: string; assetId: string }];
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
BackupDeleted: [{ filename: string }];
BackupUpload: [{ progress: number; isComplete: boolean }];
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
LibraryCreate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
LibraryUpdate: [LibraryResponseDto];
PersonAssetDelete: [{ id: string; assetId: string }];
PersonThumbnailReady: [{ id: string }];
PersonUpdate: [PersonResponseDto];
QueueUpdate: [QueueResponseDto];
ReleaseEvent: [ReleaseEvent];
SessionLocked: [];
SharedLinkCreate: [SharedLinkResponseDto];
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
SharedLinkUpdate: [SharedLinkResponseDto];
SystemConfigUpdate: [SystemConfigDto];
TagCreate: [TagResponseDto];
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
TagUpdate: [TagResponseDto];
UserPinCodeReset: [];
ThemeChange: [ThemeSetting];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
TransitionToTimeline: [{ id: string }];
TransitionToTimelineReady: [];
UserAdminCreate: [UserAdminResponseDto];
UserAdminUpdate: [UserAdminResponseDto];
UserAdminRestore: [UserAdminResponseDto];
// soft deleted
UserAdminDelete: [UserAdminResponseDto];
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
SessionLocked: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
WorkflowCreate: [WorkflowResponseDto];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEvent];
UserAdminRestore: [UserAdminResponseDto];
UserAdminUpdate: [UserAdminResponseDto];
UserPinCodeReset: [];
WebsocketConnect: [];
WorkflowCreate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
WorkflowUpdate: [WorkflowResponseDto];
};
export const eventManager = new BaseEventManager<Events>();
export const signalAssetViewerReady = () => eventManager.emit('AssetViewerReady');

View File

@@ -2,3 +2,11 @@ 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;
}
}
}

View File

@@ -5,6 +5,7 @@ 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>();
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
setAsset,
setAssetId,
showAssetViewer,
invisible,
};
}

View File

@@ -43,6 +43,36 @@ 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: number = 10_000,
): 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 unsubscribe = this.once(event, (...args: Events[T]) => {
settled = true;
clearTimeout(timer);
resolve(args[0] as Result);
});
const timer = setTimeout(() => {
if (!settled) {
settled = true;
unsubscribe();
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
});
}
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
const listeners = this.getListeners(event);
for (const listener of listeners) {

View File

@@ -21,7 +21,7 @@ export const moveFocus = (
if (focusableElements.length === 0) {
return;
}
debugger;
const currentElement = document.activeElement as HTMLElement | null;
const currentIndex = currentElement ? focusableElements.indexOf(currentElement) : -1;

View File

@@ -1,66 +1,36 @@
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 {
/**
* 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
*/
isStillValid: () => invocation === this.invocationsStarted,
endInvocation: () => {
this.invocationsEnded = invocation;
this.invocationsEnded = Math.max(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>, localizedMessage: string) {
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
handleError(error, localizedMessage);
if (catchCallback) {
catchCallback(error);
} else {
console.error(error);
}
} finally {
invocation.endInvocation();
finallyCallback?.();
}
}
}

View File

@@ -1,6 +1,6 @@
import type { Faces } from '$lib/stores/people.store';
import type { ContentMetrics } from '$lib/utils/container-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getBoundingBox, scaleFaceRectOnResize, type FaceRectState, type ResizeContext } from '$lib/utils/people-utils';
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
id: 'face-1',
@@ -97,3 +97,96 @@ describe('getBoundingBox', () => {
expect(boxes[0].left).toBeLessThan(boxes[1].left);
});
});
describe('scaleFaceRectOnResize', () => {
const makeRect = (overrides: Partial<FaceRectState> = {}): FaceRectState => ({
left: 300,
top: 400,
scaleX: 1,
scaleY: 1,
...overrides,
});
const makePrevious = (overrides: Partial<ResizeContext> = {}): ResizeContext => ({
offsetX: 100,
offsetY: 50,
contentWidth: 800,
...overrides,
});
it('should preserve relative position when container doubles in size', () => {
const rect = makeRect({ left: 300, top: 250 });
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
// imageRelLeft = (300 - 100) * 2 = 400, new left = 200 + 400 = 600
// imageRelTop = (250 - 50) * 2 = 400, new top = 100 + 400 = 500
expect(result.left).toBe(600);
expect(result.top).toBe(500);
expect(result.scaleX).toBe(2);
expect(result.scaleY).toBe(2);
});
it('should preserve relative position when container halves in size', () => {
const rect = makeRect({ left: 300, top: 250 });
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, 50, 25, 400);
// imageRelLeft = (300 - 100) * 0.5 = 100, new left = 50 + 100 = 150
// imageRelTop = (250 - 50) * 0.5 = 100, new top = 25 + 100 = 125
expect(result.left).toBe(150);
expect(result.top).toBe(125);
expect(result.scaleX).toBe(0.5);
expect(result.scaleY).toBe(0.5);
});
it('should handle no change in dimensions', () => {
const rect = makeRect({ left: 300, top: 250, scaleX: 1.5, scaleY: 1.5 });
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, 100, 50, 800);
expect(result.left).toBe(300);
expect(result.top).toBe(250);
expect(result.scaleX).toBe(1.5);
expect(result.scaleY).toBe(1.5);
});
it('should handle offset changes without content width change', () => {
const rect = makeRect({ left: 300, top: 250 });
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, 150, 75, 800);
// scale = 1, imageRelLeft = 200, imageRelTop = 200
// new left = 150 + 200 = 350, new top = 75 + 200 = 275
expect(result.left).toBe(350);
expect(result.top).toBe(275);
expect(result.scaleX).toBe(1);
expect(result.scaleY).toBe(1);
});
it('should compound existing scale factors', () => {
const rect = makeRect({ left: 300, top: 250, scaleX: 2, scaleY: 3 });
const previous = makePrevious({ contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, previous.offsetX, previous.offsetY, 1600);
expect(result.scaleX).toBe(4);
expect(result.scaleY).toBe(6);
});
it('should handle rect at image origin (top-left of content area)', () => {
const rect = makeRect({ left: 100, top: 50 });
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
// imageRelLeft = (100 - 100) * 2 = 0, new left = 200
// imageRelTop = (50 - 50) * 2 = 0, new top = 100
expect(result.left).toBe(200);
expect(result.top).toBe(100);
});
});

View File

@@ -37,6 +37,38 @@ export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): Boundin
return boxes;
};
export type FaceRectState = {
left: number;
top: number;
scaleX: number;
scaleY: number;
};
export type ResizeContext = {
contentWidth: number;
offsetX: number;
offsetY: number;
};
export const scaleFaceRectOnResize = (
faceRect: FaceRectState,
previous: ResizeContext,
newOffsetX: number,
newOffsetY: number,
newContentWidth: number,
): FaceRectState => {
const scale = newContentWidth / previous.contentWidth;
const imageRelativeLeft = (faceRect.left - previous.offsetX) * scale;
const imageRelativeTop = (faceRect.top - previous.offsetY) * scale;
return {
left: newOffsetX + imageRelativeLeft,
top: newOffsetY + imageRelativeTop,
scaleX: faceRect.scaleX * scale,
scaleY: faceRect.scaleY * scale,
};
};
export const zoomImageToBase64 = async (
face: AssetFaceResponseDto,
assetId: string,

View File

@@ -24,7 +24,7 @@
});
</script>
<div class:display-none={$showAssetViewer}>
<div class:invisible={$showAssetViewer}>
{@render children?.()}
</div>
<UploadCover />
@@ -33,7 +33,4 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto, onNavigate } 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,6 +8,7 @@
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';
@@ -77,6 +78,8 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -102,8 +105,15 @@
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to);
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to);
void complete.finally(() => {
showNavigationLoadingBar = false;
});
});
const { serverRestarting } = websocketStore;