mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 15:42:32 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc89e2f15 | |||
| e79a98fa82 | |||
| 590a9df7ec | |||
| ed04d87273 |
@@ -20,7 +20,7 @@ export {
|
|||||||
toColumnarFormat,
|
toColumnarFormat,
|
||||||
} from './timeline/rest-response';
|
} 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';
|
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
UserAvatarColor,
|
UserAvatarColor,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
|
type AssetFaceWithoutPersonResponseDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type ExifResponseDto,
|
type ExifResponseDto,
|
||||||
|
type PersonWithFacesResponseDto,
|
||||||
type TimeBucketAssetResponseDto,
|
type TimeBucketAssetResponseDto,
|
||||||
type TimeBucketsResponseDto,
|
type TimeBucketsResponseDto,
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
|
|||||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||||
* This matches the response from GET /api/assets/:id
|
* 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();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Default owner if not provided
|
// Default owner if not provided
|
||||||
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
people: [],
|
people: faceData?.people ?? [],
|
||||||
unassignedFaces: [],
|
unassignedFaces: faceData?.unassignedFaces ?? [],
|
||||||
stack: asset.stack,
|
stack: asset.stack,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
|
||||||
import { BrowserContext } from '@playwright/test';
|
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
|
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||||
const MINIMAL_MP4_BASE64 =
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils';
|
|||||||
import { setupAssetViewerFixture } from './utils';
|
import { setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
const waitForSelectorTransition = async (page: Page) => {
|
const waitForSelectorTransition = async (page: Page) => {
|
||||||
await page.waitForFunction(
|
await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
|
||||||
() => {
|
await page.locator('#face-selector').evaluate(
|
||||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
(el) =>
|
||||||
if (!selector) {
|
new Promise<void>((resolve) => {
|
||||||
return false;
|
requestAnimationFrame(() =>
|
||||||
}
|
requestAnimationFrame(() => {
|
||||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
const animations = el.getAnimations();
|
||||||
},
|
if (animations.length === 0) {
|
||||||
undefined,
|
resolve();
|
||||||
{ timeout: 1000, polling: 50 },
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all(animations.map((a) => a.finished)).then(() => resolve());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ test.describe('face-editor', () => {
|
|||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(300);
|
await waitForSelectorTransition(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Face editor opens with person list', async ({ page }) => {
|
test('Face editor opens with person list', async ({ page }) => {
|
||||||
@@ -149,7 +154,7 @@ test.describe('face-editor', () => {
|
|||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
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);
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
await openFaceEditor(page, asset);
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
@@ -163,8 +168,15 @@ test.describe('face-editor', () => {
|
|||||||
await expect(page.locator('#face-editor')).toBeHidden();
|
await expect(page.locator('#face-editor')).toBeHidden();
|
||||||
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
const request = faceCreateCapture.requests[0];
|
||||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
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 }) => {
|
test('Cancel button closes face editor', async ({ page }) => {
|
||||||
@@ -282,4 +294,39 @@ test.describe('face-editor', () => {
|
|||||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
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 waitForSelectorTransition(page);
|
||||||
|
|
||||||
|
const afterClick = await getFaceBoxRect(page);
|
||||||
|
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
|
||||||
|
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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('modifier+drag pans zoomed image without repositioning face rect', 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);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await page.mouse.wheel(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}).toPass({ timeout: 2000 });
|
||||||
|
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
await page.getByLabel('Tag people').click();
|
||||||
|
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||||
|
|
||||||
|
const dataEl = page.locator('#face-editor-data');
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||||
|
const beforeLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||||
|
const beforeTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||||
|
const transformBefore = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
|
||||||
|
const panModifier = await page.evaluate(() =>
|
||||||
|
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control',
|
||||||
|
);
|
||||||
|
await page.keyboard.down(panModifier);
|
||||||
|
|
||||||
|
// Verify face editor becomes transparent to pointer events
|
||||||
|
await expect(async () => {
|
||||||
|
const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents);
|
||||||
|
expect(pe).toBe('none');
|
||||||
|
}).toPass({ timeout: 2000 });
|
||||||
|
|
||||||
|
await page.mouse.move(width / 2, height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.keyboard.up(panModifier);
|
||||||
|
|
||||||
|
const transformAfter = await imgLocator.evaluate((element) => {
|
||||||
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
});
|
||||||
|
expect(transformAfter).not.toBe(transformBefore);
|
||||||
|
|
||||||
|
// Extract translate values from matrix(a, b, c, d, tx, ty)
|
||||||
|
const parseTranslate = (matrix: string) => {
|
||||||
|
const values =
|
||||||
|
matrix
|
||||||
|
.match(/matrix\((.+)\)/)?.[1]
|
||||||
|
.split(',')
|
||||||
|
.map(Number) ?? [];
|
||||||
|
return { tx: values[4], ty: values[5] };
|
||||||
|
};
|
||||||
|
const panBefore = parseTranslate(transformBefore);
|
||||||
|
const panAfter = parseTranslate(transformAfter);
|
||||||
|
const panDeltaX = panAfter.tx - panBefore.tx;
|
||||||
|
const panDeltaY = panAfter.ty - panBefore.ty;
|
||||||
|
|
||||||
|
// Face rect screen position should have moved by the same amount as the pan
|
||||||
|
// (it follows the image), NOT been repositioned by a click
|
||||||
|
const afterLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||||
|
const afterTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||||
|
const faceDeltaX = afterLeft - beforeLeft;
|
||||||
|
const faceDeltaY = afterTop - beforeTop;
|
||||||
|
expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3);
|
||||||
|
expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
generateTimelineData,
|
generateTimelineData,
|
||||||
TimelineAssetConfig,
|
TimelineAssetConfig,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
|
toAssetResponseDto,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
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();
|
adminUserId = faker.string.uuid();
|
||||||
testContext.adminId = adminUserId;
|
testContext.adminId = adminUserId;
|
||||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
|
|||||||
|
|
||||||
await context.route('**/api/search/metadata', async (route, request) => {
|
await context.route('**/api/search/metadata', async (route, request) => {
|
||||||
if (request.method() === 'POST') {
|
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({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const timelineUtils = {
|
|||||||
return page.locator('#asset-grid');
|
return page.locator('#asset-grid');
|
||||||
},
|
},
|
||||||
async waitForTimelineLoad(page: Page) {
|
async waitForTimelineLoad(page: Page) {
|
||||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
await page.locator('#asset-grid[data-initialized]').waitFor();
|
||||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||||
},
|
},
|
||||||
async getScrollTop(page: Page) {
|
async getScrollTop(page: Page) {
|
||||||
@@ -163,14 +163,17 @@ export const assetViewerUtils = {
|
|||||||
return page.locator('#immich-asset-viewer');
|
return page.locator('#immich-asset-viewer');
|
||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
await page
|
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
|
||||||
.locator(
|
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
|
||||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
await imgLocator.or(videoLocator).waitFor();
|
||||||
)
|
|
||||||
.or(
|
if ((await videoLocator.count()) === 0) {
|
||||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
await expect
|
||||||
)
|
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
|
||||||
.waitFor();
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
const activeElement = () =>
|
const activeElement = () =>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1275,6 +1275,7 @@
|
|||||||
"hide_schema": "Hide schema",
|
"hide_schema": "Hide schema",
|
||||||
"hide_text_recognition": "Hide text recognition",
|
"hide_text_recognition": "Hide text recognition",
|
||||||
"hide_unnamed_people": "Hide unnamed people",
|
"hide_unnamed_people": "Hide unnamed people",
|
||||||
|
"hold_key_to_pan": "Hold {key} to pan",
|
||||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
+396
@@ -75,6 +75,35 @@
|
|||||||
--immich-dark-bg: 10 10 10;
|
--immich-dark-bg: 10 10 10;
|
||||||
--immich-dark-fg: 229 231 235;
|
--immich-dark-fg: 229 231 235;
|
||||||
--immich-dark-gray: 33 33 33;
|
--immich-dark-gray: 33 33 33;
|
||||||
|
|
||||||
|
/* transitions */
|
||||||
|
--immich-split-viewer-nav: enabled;
|
||||||
|
|
||||||
|
/* view transition variables */
|
||||||
|
/* Base animation duration for standard transitions (page fades, info panel) */
|
||||||
|
--vt-duration-default: 250ms;
|
||||||
|
/* Duration for hero transitions (thumbnail to full viewer) */
|
||||||
|
--vt-duration-hero: 280ms;
|
||||||
|
/* Duration for next/previous photo navigation */
|
||||||
|
--vt-duration-viewer-navigation: 270ms;
|
||||||
|
/* Duration for slideshow mode transitions */
|
||||||
|
--vt-duration-slideshow: 1s;
|
||||||
|
/* Easing function for slide animations (ease-out) */
|
||||||
|
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
/* How far images slide in/out during navigation (% of viewport) */
|
||||||
|
--vt-viewer-slide-distance: 15%;
|
||||||
|
/* Starting opacity for fly transitions (slide+fade effect) */
|
||||||
|
--vt-viewer-opacity-start: 0.1;
|
||||||
|
/* Maximum blur during fly transitions (currently disabled) */
|
||||||
|
--vt-viewer-blur-max: 0px;
|
||||||
|
|
||||||
|
--vt-viewer-next-in: flyInRight;
|
||||||
|
--vt-viewer-next-out: flyOutLeft;
|
||||||
|
--vt-viewer-prev-in: flyInLeft;
|
||||||
|
--vt-viewer-prev-out: flyOutRight;
|
||||||
|
--vt-viewer-old-opacity: 1;
|
||||||
|
/* Easing function for memory and hero morph transitions */
|
||||||
|
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
@@ -176,3 +205,370 @@
|
|||||||
@apply bg-subtle rounded-lg;
|
@apply bg-subtle rounded-lg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
::view-transition {
|
||||||
|
background: var(--color-black);
|
||||||
|
animation-duration: var(--vt-duration-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(*),
|
||||||
|
::view-transition-new(*) {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
animation-duration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(*) {
|
||||||
|
animation-name: fadeOut;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(*) {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||||
|
}
|
||||||
|
html:active-view-transition-type(slideshow) {
|
||||||
|
&::view-transition-old(*) {
|
||||||
|
animation: var(--vt-duration-slideshow) linear crossfadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(*) {
|
||||||
|
animation: var(--vt-duration-slideshow) linear crossfadeIn forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(*) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html:active-view-transition-type(viewer-nav) {
|
||||||
|
&::view-transition-old(root) {
|
||||||
|
animation: var(--vt-duration-hero) 0s fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(root) {
|
||||||
|
animation: var(--vt-duration-hero) 0s fadeIn forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
::view-transition-image-pair(info) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
::view-transition-old(info) {
|
||||||
|
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(info) {
|
||||||
|
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(detail-panel) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
::view-transition-old(detail-panel),
|
||||||
|
::view-transition-new(detail-panel) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
::view-transition-group(letterbox-left),
|
||||||
|
::view-transition-group(letterbox-right),
|
||||||
|
::view-transition-group(letterbox-top),
|
||||||
|
::view-transition-group(letterbox-bottom) {
|
||||||
|
animation-duration: var(--vt-duration-viewer-navigation);
|
||||||
|
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-image-pair(letterbox-left),
|
||||||
|
::view-transition-image-pair(letterbox-right),
|
||||||
|
::view-transition-image-pair(letterbox-top),
|
||||||
|
::view-transition-image-pair(letterbox-bottom) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(letterbox-left),
|
||||||
|
::view-transition-old(letterbox-right),
|
||||||
|
::view-transition-old(letterbox-top),
|
||||||
|
::view-transition-old(letterbox-bottom),
|
||||||
|
::view-transition-new(letterbox-left),
|
||||||
|
::view-transition-new(letterbox-right),
|
||||||
|
::view-transition-new(letterbox-top),
|
||||||
|
::view-transition-new(letterbox-bottom) {
|
||||||
|
animation: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
background-color: var(--color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(exclude-leftbutton),
|
||||||
|
::view-transition-group(exclude-rightbutton),
|
||||||
|
::view-transition-group(exclude) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
::view-transition-old(exclude-leftbutton),
|
||||||
|
::view-transition-old(exclude-rightbutton),
|
||||||
|
::view-transition-old(exclude) {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
::view-transition-new(exclude-leftbutton),
|
||||||
|
::view-transition-new(exclude-rightbutton),
|
||||||
|
::view-transition-new(exclude) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(hero) {
|
||||||
|
animation-duration: var(--vt-duration-hero);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
}
|
||||||
|
::view-transition-old(hero) {
|
||||||
|
animation: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { createZoomImageWheel } from '@zoom-image/core';
|
import { createZoomImageWheel } from '@zoom-image/core';
|
||||||
|
|
||||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||||
|
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, {
|
const zoomInstance = createZoomImageWheel(node, {
|
||||||
maxZoom: 10,
|
maxZoom: 10,
|
||||||
initialState: assetViewerManager.zoomState,
|
initialState: assetViewerManager.zoomState,
|
||||||
zoomTarget: null,
|
zoomTarget: options?.zoomTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
@@ -13,47 +20,130 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
|||||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const onInteractionStart = (event: Event) => {
|
const controller = new AbortController();
|
||||||
if (options?.disabled) {
|
const { signal } = controller;
|
||||||
event.stopImmediatePropagation();
|
|
||||||
|
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 });
|
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
|
||||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
// 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
|
// Wheel and dblclick interception on overlay elements.
|
||||||
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
|
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
|
||||||
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
|
// on double-tap, which conflicts with zoom-image's touch zoom handler).
|
||||||
let lastPointerWasTouch = false;
|
let lastPointerWasTouch = false;
|
||||||
const trackPointerType = (event: PointerEvent) => {
|
node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
|
||||||
lastPointerWasTouch = event.pointerType === 'touch';
|
capture: true,
|
||||||
};
|
signal,
|
||||||
const suppressTouchDblClick = (event: MouseEvent) => {
|
});
|
||||||
if (lastPointerWasTouch) {
|
node.addEventListener(
|
||||||
event.stopImmediatePropagation();
|
'wheel',
|
||||||
}
|
(event) => {
|
||||||
};
|
if (isOverlayEvent(event)) {
|
||||||
node.addEventListener('pointerdown', trackPointerType, { capture: true });
|
event.stopPropagation();
|
||||||
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
}
|
||||||
|
},
|
||||||
|
{ 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
|
if (options?.zoomTarget) {
|
||||||
|
options.zoomTarget.style.willChange = 'transform';
|
||||||
|
}
|
||||||
node.style.overflow = 'visible';
|
node.style.overflow = 'visible';
|
||||||
// Prevent browser handling of touch gestures so zoom-image can manage them
|
|
||||||
node.style.touchAction = 'none';
|
node.style.touchAction = 'none';
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { disabled?: boolean }) {
|
update(newOptions?: { zoomTarget?: HTMLElement }) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
|
if (newOptions?.zoomTarget !== undefined) {
|
||||||
|
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
|
controller.abort();
|
||||||
|
if (options?.zoomTarget) {
|
||||||
|
options.zoomTarget.style.willChange = '';
|
||||||
|
}
|
||||||
for (const unsubscribe of unsubscribes) {
|
for (const unsubscribe of unsubscribes) {
|
||||||
unsubscribe();
|
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();
|
zoomInstance.cleanup();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
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 BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { getAssetUrls } from '$lib/utils';
|
import { getAssetUrls } from '$lib/utils';
|
||||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||||
@@ -17,15 +18,18 @@
|
|||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
objectFit?: 'contain' | 'cover';
|
objectFit?: 'contain' | 'cover';
|
||||||
container: {
|
container: Size;
|
||||||
width: number;
|
showLetterboxes?: boolean;
|
||||||
height: number;
|
transitionName?: string | null | undefined;
|
||||||
};
|
letterboxTransitionName?: string | undefined;
|
||||||
|
imageClass?: string;
|
||||||
onUrlChange?: (url: string) => void;
|
onUrlChange?: (url: string) => void;
|
||||||
onImageReady?: () => void;
|
onImageReady?: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
ref?: HTMLDivElement;
|
ref?: HTMLDivElement;
|
||||||
imgRef?: HTMLImageElement;
|
imgRef?: HTMLImageElement;
|
||||||
|
imgNaturalSize?: Size;
|
||||||
|
imgScaledSize?: Size;
|
||||||
backdrop?: Snippet;
|
backdrop?: Snippet;
|
||||||
overlays?: Snippet;
|
overlays?: Snippet;
|
||||||
};
|
};
|
||||||
@@ -34,10 +38,18 @@
|
|||||||
ref = $bindable(),
|
ref = $bindable(),
|
||||||
// eslint-disable-next-line no-useless-assignment
|
// eslint-disable-next-line no-useless-assignment
|
||||||
imgRef = $bindable(),
|
imgRef = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
imgNaturalSize = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
imgScaledSize = $bindable(),
|
||||||
asset,
|
asset,
|
||||||
sharedLink,
|
sharedLink,
|
||||||
objectFit = 'contain',
|
objectFit = 'contain',
|
||||||
container,
|
container,
|
||||||
|
showLetterboxes = true,
|
||||||
|
transitionName,
|
||||||
|
letterboxTransitionName,
|
||||||
|
imageClass,
|
||||||
onUrlChange,
|
onUrlChange,
|
||||||
onImageReady,
|
onImageReady,
|
||||||
onError,
|
onError,
|
||||||
@@ -101,9 +113,21 @@
|
|||||||
return { width: 1, height: 1 };
|
return { width: 1, height: 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { width, height, left, top } = $derived.by(() => {
|
$effect(() => {
|
||||||
|
imgNaturalSize = imageDimensions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scaledDimensions = $derived.by(() => {
|
||||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||||
const { width, height } = scaleFn(imageDimensions, container);
|
return scaleFn(imageDimensions, container);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
imgScaledSize = scaledDimensions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width, height, left, top } = $derived.by(() => {
|
||||||
|
const { width, height } = scaledDimensions;
|
||||||
return {
|
return {
|
||||||
width: width + 'px',
|
width: width + 'px',
|
||||||
height: height + 'px',
|
height: height + 'px',
|
||||||
@@ -149,81 +173,76 @@
|
|||||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||||
(quality.thumbnail === 'success' ? thumbnailElement : 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>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
|
||||||
{@render backdrop?.()}
|
{@render backdrop?.()}
|
||||||
|
|
||||||
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
|
<Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 pointer-events-none"
|
class={['absolute inset-0 pointer-events-none', imageClass]}
|
||||||
style:transform={zoomTransform}
|
style:left
|
||||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
style:top
|
||||||
|
style:width
|
||||||
|
style:height
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
|
data-transition-name={transitionName}
|
||||||
>
|
>
|
||||||
<div class="absolute" style:left style:top style:width style:height>
|
{#if show.alphaBackground}
|
||||||
{#if show.alphaBackground}
|
<AlphaBackground />
|
||||||
<AlphaBackground />
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if show.thumbhash}
|
{#if show.thumbhash}
|
||||||
{#if asset.thumbhash}
|
{#if asset.thumbhash}
|
||||||
<!-- Thumbhash / spinner layer -->
|
<!-- Thumbhash / spinner layer -->
|
||||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||||
{:else if show.spinner}
|
{:else if show.spinner}
|
||||||
<DelayedLoadingSpinner />
|
<DelayedLoadingSpinner />
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if show.thumbnail}
|
{#if show.thumbnail}
|
||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{width}
|
{width}
|
||||||
{height}
|
{height}
|
||||||
quality="thumbnail"
|
quality="thumbnail"
|
||||||
src={status.urls.thumbnail}
|
src={status.urls.thumbnail}
|
||||||
alt=""
|
alt=""
|
||||||
role="presentation"
|
role="presentation"
|
||||||
bind:ref={thumbnailElement}
|
bind:ref={thumbnailElement}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if show.brokenAsset}
|
{#if show.brokenAsset}
|
||||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if show.preview}
|
{#if show.preview}
|
||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{alt}
|
{alt}
|
||||||
{width}
|
{width}
|
||||||
{height}
|
{height}
|
||||||
{overlays}
|
{overlays}
|
||||||
quality="preview"
|
quality="preview"
|
||||||
src={status.urls.preview}
|
src={status.urls.preview}
|
||||||
bind:ref={previewElement}
|
bind:ref={previewElement}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if show.original}
|
{#if show.original}
|
||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{alt}
|
{alt}
|
||||||
{width}
|
{width}
|
||||||
{height}
|
{height}
|
||||||
{overlays}
|
{overlays}
|
||||||
quality="original"
|
quality="original"
|
||||||
src={status.urls.original}
|
src={status.urls.original}
|
||||||
bind:ref={originalElement}
|
bind:ref={originalElement}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||||
@@ -17,15 +18,19 @@
|
|||||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
|
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
||||||
import { getGlobalActions } from '$lib/services/app.service';
|
import { getGlobalActions } from '$lib/services/app.service';
|
||||||
import { getAssetActions } from '$lib/services/asset.service';
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
|
import { isPhotosRoute } from '$lib/utils/navigation';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
@@ -184,7 +189,21 @@
|
|||||||
{#if !asset.isArchived && !asset.isTrashed}
|
{#if !asset.isArchived && !asset.isTrashed}
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiImageSearch}
|
icon={mdiImageSearch}
|
||||||
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
|
onClick={async () => {
|
||||||
|
const assetId = stack?.primaryAssetId ?? asset.id;
|
||||||
|
if (isPhotosRoute(page.route.id) && viewTransitionManager.isSupported()) {
|
||||||
|
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||||
|
eventManager.emit('ViewerCloseTransition', { id: assetId });
|
||||||
|
await transitionReady;
|
||||||
|
await goto(Route.photos({ at: assetId }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToTimeline(assetId, {
|
||||||
|
types: ['timeline'],
|
||||||
|
prepareOldSnapshot: () => eventManager.emit('ViewerOpenTransition'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
text={$t('view_in_timeline')}
|
text={$t('view_in_timeline')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
const useSplitNavTransitions =
|
||||||
|
typeof document !== 'undefined' &&
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
@@ -13,6 +19,7 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-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 { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
@@ -27,6 +34,7 @@
|
|||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
@@ -40,7 +48,7 @@
|
|||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly, slide } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
import ActivityStatus from './activity-status.svelte';
|
import ActivityStatus from './activity-status.svelte';
|
||||||
import ActivityViewer from './activity-viewer.svelte';
|
import ActivityViewer from './activity-viewer.svelte';
|
||||||
@@ -59,7 +67,7 @@
|
|||||||
previousAsset?: AssetResponseDto;
|
previousAsset?: AssetResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
@@ -72,7 +80,7 @@
|
|||||||
onUndoDelete?: OnUndoDelete;
|
onUndoDelete?: OnUndoDelete;
|
||||||
onClose?: (asset: AssetResponseDto) => void;
|
onClose?: (asset: AssetResponseDto) => void;
|
||||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
cursor,
|
cursor,
|
||||||
@@ -89,13 +97,14 @@
|
|||||||
onRandom,
|
onRandom,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const { setAssetId } = assetViewingStore;
|
const { setAssetId, invisible } = assetViewingStore;
|
||||||
const {
|
const {
|
||||||
restartProgress: restartSlideshowProgress,
|
restartProgress: restartSlideshowProgress,
|
||||||
stopProgress: stopSlideshowProgress,
|
stopProgress: stopSlideshowProgress,
|
||||||
slideshowNavigation,
|
slideshowNavigation,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
slideshowTransition,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
@@ -109,6 +118,10 @@
|
|||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let fullscreenElement = $state<Element>();
|
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 playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||||
let slideshowStartAssetId = $state<string>();
|
let slideshowStartAssetId = $state<string>();
|
||||||
|
|
||||||
@@ -142,46 +155,58 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
let transitionName = $state<string | undefined>('hero');
|
||||||
if (asset.id === updatedAsset.id) {
|
let letterboxTransitionName = $state<string | undefined>(undefined);
|
||||||
cursor = { ...cursor, current: updatedAsset };
|
let detailPanelTransitionName = $state<string | undefined>(undefined);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let unsubscribes: (() => void)[] = [];
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncAssetViewerOpenClass(true);
|
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) => {
|
const addInfoTransition = () => {
|
||||||
if (value === SlideshowNavigation.Shuffle) {
|
detailPanelTransitionName = 'info';
|
||||||
slideshowHistory.reset();
|
transitionName = 'hero';
|
||||||
slideshowHistory.queue(toTimelineAsset(asset));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
slideshowStateUnsubscribe();
|
|
||||||
slideshowNavigationUnsubscribe();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
unsubscribes.push(
|
||||||
|
eventManager.on({
|
||||||
|
ViewerOpenTransition: addInfoTransition,
|
||||||
|
ViewerCloseTransition: addInfoTransition,
|
||||||
|
}),
|
||||||
|
slideshowState.subscribe((value) => {
|
||||||
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
|
slideshowHistory.reset();
|
||||||
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
|
handlePromiseError(handlePlaySlideshow());
|
||||||
|
} else if (value === SlideshowState.StopSlideshow) {
|
||||||
|
handlePromiseError(handleStopSlideshow());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
slideshowNavigation.subscribe((value) => {
|
||||||
|
if (value === SlideshowNavigation.Shuffle) {
|
||||||
|
slideshowHistory.reset();
|
||||||
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
|
isFaceEditMode.value = false;
|
||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
preloadManager.destroy();
|
preloadManager.destroy();
|
||||||
|
|
||||||
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
onClose?.(asset);
|
transitionName = 'hero';
|
||||||
|
const id = stack?.primaryAssetId ?? asset.id;
|
||||||
|
onClose?.({ ...asset, id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = async () => {
|
const closeEditor = async () => {
|
||||||
@@ -193,65 +218,132 @@
|
|||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||||
|
if (direction === 'previous' || direction === 'next') {
|
||||||
|
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
|
||||||
|
}
|
||||||
|
return direction ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTransitionNames = () => {
|
||||||
|
detailPanelTransitionName = undefined;
|
||||||
|
transitionName = undefined;
|
||||||
|
letterboxTransitionName = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTransition = async (
|
||||||
|
types: string[],
|
||||||
|
targetTransition: string | null,
|
||||||
|
navigateFn: () => Promise<boolean>,
|
||||||
|
) => {
|
||||||
|
const oldName = getTransitionName('old', targetTransition);
|
||||||
|
const newName = getTransitionName('new', targetTransition);
|
||||||
|
|
||||||
|
let result = false;
|
||||||
|
|
||||||
|
await viewTransitionManager.startTransition({
|
||||||
|
types,
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transitionName = oldName;
|
||||||
|
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
|
||||||
|
detailPanelTransitionName = 'detail-panel';
|
||||||
|
},
|
||||||
|
performUpdate: async (signal) => {
|
||||||
|
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||||
|
result = await navigateFn();
|
||||||
|
await ready;
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
transitionName = newName;
|
||||||
|
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
|
||||||
|
},
|
||||||
|
onFinished: clearTransitionNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
|
||||||
|
preloadManager.cancelBeforeNavigation(order);
|
||||||
|
const skipped = viewTransitionManager.skipTransitions();
|
||||||
|
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
|
||||||
|
|
||||||
|
let navigate: () => Promise<boolean>;
|
||||||
|
let types: string[];
|
||||||
|
let targetTransition: string | null;
|
||||||
|
|
||||||
|
if (slideShowPlaying && slideShowShuffle) {
|
||||||
|
navigate = async () => {
|
||||||
|
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||||
|
if (!next) {
|
||||||
|
const asset = await onRandom?.();
|
||||||
|
if (asset) {
|
||||||
|
slideshowHistory.queue(asset);
|
||||||
|
next = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
types = ['slideshow'];
|
||||||
|
targetTransition = null;
|
||||||
|
} else {
|
||||||
|
navigate = async () => {
|
||||||
|
const target = order === 'previous' ? previousAsset : nextAsset;
|
||||||
|
return navigateToAsset(target);
|
||||||
|
};
|
||||||
|
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
|
||||||
|
targetTransition = slideShowPlaying ? null : order;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||||
|
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||||
|
const useTransition = canTransition && slideshowAllowsTransition && (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 tracker = new InvocationTracker();
|
||||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
let navigating = $state(false);
|
||||||
|
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if (slideShowPlaying) {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
order = slideShowAscending ? 'previous' : 'next';
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadManager.cancelBeforeNavigation(order);
|
|
||||||
|
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void tracker.invoke(async () => {
|
navigating = true;
|
||||||
const isShuffle =
|
void tracker.invoke(
|
||||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
() => completeNavigation(order, skipTransition),
|
||||||
|
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||||
let hasNext: boolean;
|
() => {
|
||||||
|
navigating = false;
|
||||||
if (isShuffle) {
|
eventManager.emit('ViewerAfterNavigate');
|
||||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
},
|
||||||
if (!hasNext) {
|
);
|
||||||
const asset = await onRandom?.();
|
|
||||||
if (asset) {
|
|
||||||
slideshowHistory.queue(asset);
|
|
||||||
hasNext = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hasNext =
|
|
||||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNext) {
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
|
||||||
await setAssetId(slideshowStartAssetId);
|
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleStopSlideshow();
|
|
||||||
}, $t('error_while_navigating'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Slide show mode
|
|
||||||
*/
|
|
||||||
|
|
||||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||||
|
|
||||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||||
@@ -276,10 +368,11 @@
|
|||||||
|
|
||||||
const handleStopSlideshow = async () => {
|
const handleStopSlideshow = async () => {
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.body.style.cursor = '';
|
return;
|
||||||
await document.exitFullscreen();
|
|
||||||
}
|
}
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
await document.exitFullscreen();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -288,8 +381,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
void crossfadeViewerContent(() => {
|
||||||
|
previewStackedAsset = stackedAsset;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStackSlideshowMouseLeave = () => {
|
||||||
|
if (!previewStackedAsset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCrossfadeOverlay();
|
||||||
|
previewStackedAsset = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
@@ -358,15 +467,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshOcr = async () => {
|
||||||
|
ocrManager.clear();
|
||||||
|
if (sharedLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ocrManager.getAssetOcr(asset.id);
|
||||||
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await refreshStack();
|
await refreshStack();
|
||||||
ocrManager.clear();
|
await refreshOcr();
|
||||||
if (!sharedLink) {
|
|
||||||
if (previewStackedAsset) {
|
|
||||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
|
||||||
}
|
|
||||||
await ocrManager.getAssetOcr(asset.id);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -375,21 +487,36 @@
|
|||||||
untrack(() => handlePromiseError(refresh()));
|
untrack(() => handlePromiseError(refresh()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
previewStackedAsset;
|
||||||
|
untrack(() => ocrManager.clear());
|
||||||
|
});
|
||||||
|
|
||||||
let lastCursor = $state<AssetCursor>();
|
let lastCursor = $state<AssetCursor>();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (cursor.current.id === lastCursor?.current.id) {
|
if (cursor.current.id === lastCursor?.current.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastCursor) {
|
if (lastCursor) {
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
ocrManager.showOverlay = false;
|
||||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||||
|
lastCursor = cursor;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!lastCursor) {
|
preloadManager.initializePreloads(cursor, sharedLink);
|
||||||
preloadManager.initializePreloads(cursor, sharedLink);
|
|
||||||
}
|
|
||||||
lastCursor = cursor;
|
lastCursor = cursor;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||||
|
if (asset.id === update.id) {
|
||||||
|
cursor = { ...cursor, current: update };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const viewerKind = $derived.by(() => {
|
const viewerKind = $derived.by(() => {
|
||||||
if (previewStackedAsset) {
|
if (previewStackedAsset) {
|
||||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||||
@@ -460,13 +587,17 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
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}
|
||||||
|
data-navigating={navigating || undefined}
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
>
|
>
|
||||||
<!-- Top navigation bar -->
|
|
||||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
{#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
|
<AssetViewerNavBar
|
||||||
{asset}
|
{asset}
|
||||||
{album}
|
{album}
|
||||||
@@ -477,7 +608,7 @@
|
|||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
{onUndoDelete}
|
{onUndoDelete}
|
||||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||||
onClose={onClose ? () => onClose(asset) : undefined}
|
onClose={onClose ? closeViewer : undefined}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
{setPlayOriginalVideo}
|
{setPlayOriginalVideo}
|
||||||
/>
|
/>
|
||||||
@@ -498,16 +629,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
{#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')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
|
||||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if viewerKind === 'StackVideoViewer'}
|
{#if viewerKind === 'StackVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
|
{transitionName}
|
||||||
asset={previewStackedAsset!}
|
asset={previewStackedAsset!}
|
||||||
|
assetId={previewStackedAsset!.id}
|
||||||
cacheKey={previewStackedAsset!.thumbhash}
|
cacheKey={previewStackedAsset!.thumbhash}
|
||||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||||
loopVideo={true}
|
loopVideo={true}
|
||||||
@@ -520,6 +656,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else if viewerKind === 'LiveVideoViewer'}
|
{:else if viewerKind === 'LiveVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
|
{transitionName}
|
||||||
{asset}
|
{asset}
|
||||||
assetId={asset.livePhotoVideoId!}
|
assetId={asset.livePhotoVideoId!}
|
||||||
cacheKey={asset.thumbhash}
|
cacheKey={asset.thumbhash}
|
||||||
@@ -531,13 +668,20 @@
|
|||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
/>
|
/>
|
||||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||||
<ImagePanoramaViewer {asset} />
|
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
|
||||||
{:else if viewerKind === 'CropArea'}
|
{:else if viewerKind === 'CropArea'}
|
||||||
<CropArea {asset} />
|
<CropArea {asset} />
|
||||||
{:else if viewerKind === 'PhotoViewer'}
|
{:else if viewerKind === 'PhotoViewer'}
|
||||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
<PhotoViewer
|
||||||
|
{transitionName}
|
||||||
|
{letterboxTransitionName}
|
||||||
|
cursor={{ ...cursor, current: asset }}
|
||||||
|
{sharedLink}
|
||||||
|
{onSwipe}
|
||||||
|
/>
|
||||||
{:else if viewerKind === 'VideoViewer'}
|
{:else if viewerKind === 'VideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
|
{transitionName}
|
||||||
{asset}
|
{asset}
|
||||||
cacheKey={asset.thumbhash}
|
cacheKey={asset.thumbhash}
|
||||||
projectionType={asset.exifInfo?.projectionType}
|
projectionType={asset.exifInfo?.projectionType}
|
||||||
@@ -571,15 +715,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
{#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')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:slide={{ axis: 'x', duration: 150 }}
|
||||||
id="detail-panel"
|
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"
|
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"
|
translate="yes"
|
||||||
>
|
>
|
||||||
@@ -595,9 +744,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
||||||
{@const stackedAssets = stack.assets}
|
{@const stackedAssets = stack.assets}
|
||||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
id="stack-slideshow"
|
||||||
|
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
|
||||||
|
onmouseleave={handleStackSlideshowMouseLeave}
|
||||||
|
>
|
||||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
@@ -610,10 +764,12 @@
|
|||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
removeCrossfadeOverlay();
|
||||||
cursor.current = stackedAsset;
|
cursor.current = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
|
isFaceEditMode.value = false;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||||
showStackedIcon={false}
|
showStackedIcon={false}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
import { timeToLoadTheMap } from '$lib/constants';
|
import { timeToLoadTheMap } from '$lib/constants';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-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 { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
@@ -49,15 +50,15 @@
|
|||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
currentAlbum?: AlbumResponseDto | null;
|
currentAlbum?: AlbumResponseDto | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { asset, currentAlbum = null }: Props = $props();
|
let { asset, currentAlbum = null }: Props = $props();
|
||||||
|
|
||||||
let showAssetPath = $state(false);
|
let showAssetPath = $state(false);
|
||||||
let showEditFaces = $state(false);
|
let showEditFaces = $derived(isEditFacesPanelOpen.value);
|
||||||
let isOwner = $derived($user?.id === asset.ownerId);
|
let isOwner = $derived($user?.id === asset.ownerId);
|
||||||
let people = $derived(asset.people || []);
|
let people = $derived(asset.people || []);
|
||||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showEditFaces = false;
|
isEditFacesPanelOpen.value = false;
|
||||||
previousId = asset.id;
|
previousId = asset.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@
|
|||||||
|
|
||||||
const handleRefreshPeople = async () => {
|
const handleRefreshPeople = async () => {
|
||||||
asset = await getAssetInfo({ id: asset.id });
|
asset = await getAssetInfo({ id: asset.id });
|
||||||
showEditFaces = false;
|
eventManager.emit('AssetUpdate', asset);
|
||||||
|
isEditFacesPanelOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||||
@@ -219,7 +221,7 @@
|
|||||||
shape="round"
|
shape="round"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => (showEditFaces = true)}
|
onclick={() => (isEditFacesPanelOpen.value = true)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,13 +230,14 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
{#each people as person, index (person.id)}
|
{#each people as person, index (person.id)}
|
||||||
{#if showingHiddenPeople || !person.isHidden}
|
{#if showingHiddenPeople || !person.isHidden}
|
||||||
|
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
|
||||||
<a
|
<a
|
||||||
class="w-22"
|
class="group w-22 outline-none"
|
||||||
href={Route.viewPerson(person, { previousRoute })}
|
href={Route.viewPerson(person, { previousRoute })}
|
||||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||||
onblur={() => ($boundingBoxesArray = [])}
|
onblur={() => ($boundingBoxesArray = [])}
|
||||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
onpointerover={() => ($boundingBoxesArray = people[index].faces)}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -246,6 +249,8 @@
|
|||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
hidden={person.isHidden}
|
hidden={person.isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||||
@@ -574,7 +579,7 @@
|
|||||||
<PersonSidePanel
|
<PersonSidePanel
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
onClose={() => (showEditFaces = false)}
|
onClose={() => (isEditFacesPanelOpen.value = false)}
|
||||||
onRefresh={handleRefreshPeople}
|
onRefresh={handleRefreshPeople}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@@ -74,6 +75,8 @@
|
|||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full select-none transition-transform motion-reduce:transition-none"
|
class="h-full select-none transition-transform motion-reduce:transition-none"
|
||||||
style:transform={imageTransform}
|
style:transform={imageTransform}
|
||||||
|
onload={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||||
|
onerror={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
@@ -12,17 +14,19 @@
|
|||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
imageSize: Size;
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
containerHeight: number;
|
containerHeight: number;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
let faceRect: Rect | undefined = $state();
|
let faceRect: Rect | undefined = $state();
|
||||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||||
@@ -32,6 +36,9 @@
|
|||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
|
let userMovedRect = false;
|
||||||
|
let previousMetrics: ResizeContext | null = null;
|
||||||
|
let panModifierHeld = $state(false);
|
||||||
|
|
||||||
let filteredCandidates = $derived(
|
let filteredCandidates = $derived(
|
||||||
searchTerm
|
searchTerm
|
||||||
@@ -53,11 +60,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupCanvas = () => {
|
const setupCanvas = () => {
|
||||||
if (!canvasEl || !htmlElement) {
|
if (!canvasEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas = new Canvas(canvasEl);
|
canvas = new Canvas(canvasEl, { width: containerWidth, height: containerHeight });
|
||||||
|
canvas.selection = false;
|
||||||
configureControlStyle();
|
configureControlStyle();
|
||||||
|
|
||||||
// eslint-disable-next-line tscompat/tscompat
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
@@ -75,66 +83,103 @@
|
|||||||
|
|
||||||
canvas.add(faceRect);
|
canvas.add(faceRect);
|
||||||
canvas.setActiveObject(faceRect);
|
canvas.setActiveObject(faceRect);
|
||||||
setDefaultFaceRectanglePosition(faceRect);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
setupCanvas();
|
void getPeople();
|
||||||
await getPeople();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageContentMetrics = $derived.by(() => {
|
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
const container = { width: containerWidth, height: containerHeight };
|
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
|
||||||
return {
|
|
||||||
contentWidth,
|
|
||||||
contentHeight,
|
|
||||||
offsetX: (containerWidth - contentWidth) / 2,
|
|
||||||
offsetY: (containerHeight - contentHeight) / 2,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
|
||||||
const { offsetX, offsetY } = imageContentMetrics;
|
|
||||||
|
|
||||||
faceRect.set({
|
|
||||||
top: offsetY + 200,
|
|
||||||
left: offsetX + 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
faceRect.setCoords();
|
|
||||||
positionFaceSelector();
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.setDimensions({
|
const upperCanvas = canvas.upperCanvasEl;
|
||||||
width: containerWidth,
|
const controller = new AbortController();
|
||||||
height: containerHeight,
|
const { signal } = controller;
|
||||||
});
|
|
||||||
|
|
||||||
if (!faceRect) {
|
const stopIfOnTarget = (event: PointerEvent) => {
|
||||||
|
if (canvas?.findTarget(event).target) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvas.findTarget(event).target) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (faceRect) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const pointer = canvas.getScenePoint(event);
|
||||||
|
faceRect.set({ left: pointer.x, top: pointer.y });
|
||||||
|
faceRect.setCoords();
|
||||||
|
userMovedRect = true;
|
||||||
|
canvas.renderAll();
|
||||||
|
positionFaceSelector();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upperCanvas.addEventListener('pointerdown', handlePointerDown, { signal });
|
||||||
|
upperCanvas.addEventListener('pointermove', stopIfOnTarget, { signal });
|
||||||
|
upperCanvas.addEventListener('pointerup', stopIfOnTarget, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageContentMetrics = $derived.by(() => {
|
||||||
|
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||||
|
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||||
|
}
|
||||||
|
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||||
|
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||||
|
|
||||||
|
faceRect.set({
|
||||||
|
top: offsetY + contentHeight / 2 - 56,
|
||||||
|
left: offsetX + contentWidth / 2 - 56,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||||
|
|
||||||
|
if (contentWidth === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
|
const isFirstRun = previousMetrics === null;
|
||||||
|
|
||||||
|
if (isFirstRun && !canvas) {
|
||||||
|
setupCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canvas || !faceRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFirstRun) {
|
||||||
|
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFirstRun && userMovedRect && previousMetrics) {
|
||||||
|
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
|
||||||
|
} else {
|
||||||
setDefaultFaceRectanglePosition(faceRect);
|
setDefaultFaceRectanglePosition(faceRect);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
|
faceRect.setCoords();
|
||||||
const faceBox = faceRect.getBoundingRect();
|
previousMetrics = { contentWidth, offsetX, offsetY };
|
||||||
return !(
|
canvas.renderAll();
|
||||||
0 > faceBox.left + faceBox.width ||
|
positionFaceSelector();
|
||||||
0 > faceBox.top + faceBox.height ||
|
});
|
||||||
canvas.width < faceBox.left ||
|
|
||||||
canvas.height < faceBox.top
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
isFaceEditMode.value = false;
|
isFaceEditMode.value = false;
|
||||||
@@ -164,11 +209,15 @@
|
|||||||
const gap = 15;
|
const gap = 15;
|
||||||
const padding = faceRect.padding ?? 0;
|
const padding = faceRect.padding ?? 0;
|
||||||
const rawBox = faceRect.getBoundingRect();
|
const rawBox = faceRect.getBoundingRect();
|
||||||
|
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||||
const faceBox = {
|
const faceBox = {
|
||||||
left: rawBox.left - padding,
|
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||||
top: rawBox.top - padding,
|
top: (rawBox.top - padding) * currentZoom + currentPositionY,
|
||||||
width: rawBox.width + padding * 2,
|
width: (rawBox.width + padding * 2) * currentZoom,
|
||||||
height: rawBox.height + padding * 2,
|
height: (rawBox.height + padding * 2) * currentZoom,
|
||||||
};
|
};
|
||||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||||
@@ -178,20 +227,21 @@
|
|||||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||||
|
|
||||||
const overlapArea = (position: { top: number; left: number }) => {
|
const faceRight = faceBox.left + faceBox.width;
|
||||||
const selectorRight = position.left + selectorWidth;
|
const faceBottom = faceBox.top + faceBox.height;
|
||||||
const selectorBottom = position.top + selectorHeight;
|
|
||||||
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 overlapArea = (position: { top: number; left: number }) => {
|
||||||
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
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;
|
return overlapX * overlapY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const faceBottom = faceBox.top + faceBox.height;
|
|
||||||
const faceRight = faceBox.left + faceBox.width;
|
|
||||||
|
|
||||||
const positions = [
|
const positions = [
|
||||||
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||||
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||||
@@ -213,45 +263,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
const containerRect = containerEl?.getBoundingClientRect();
|
||||||
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
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`;
|
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(() => {
|
$effect(() => {
|
||||||
const rect = faceRect;
|
const rect = faceRect;
|
||||||
if (rect) {
|
if (rect) {
|
||||||
rect.on('moving', positionFaceSelector);
|
const onUserMove = () => {
|
||||||
rect.on('scaling', positionFaceSelector);
|
userMovedRect = true;
|
||||||
|
positionFaceSelector();
|
||||||
|
};
|
||||||
|
rect.on('moving', onUserMove);
|
||||||
|
rect.on('scaling', onUserMove);
|
||||||
return () => {
|
return () => {
|
||||||
rect.off('moving', positionFaceSelector);
|
rect.off('moving', onUserMove);
|
||||||
rect.off('scaling', positionFaceSelector);
|
rect.off('scaling', onUserMove);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||||
|
const panModifierKey = isMac ? 'Meta' : 'Control';
|
||||||
|
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
|
||||||
|
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = containerEl;
|
||||||
|
const parent = element.parentElement;
|
||||||
|
|
||||||
|
const activate = () => {
|
||||||
|
panModifierHeld = true;
|
||||||
|
element.style.pointerEvents = 'none';
|
||||||
|
if (parent) {
|
||||||
|
parent.style.cursor = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivate = () => {
|
||||||
|
panModifierHeld = false;
|
||||||
|
element.style.pointerEvents = '';
|
||||||
|
if (parent) {
|
||||||
|
parent.style.cursor = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === panModifierKey) {
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === panModifierKey) {
|
||||||
|
deactivate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
document.addEventListener('keyup', onKeyUp);
|
||||||
|
window.addEventListener('blur', deactivate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.removeEventListener('keyup', onKeyUp);
|
||||||
|
window.removeEventListener('blur', deactivate);
|
||||||
|
deactivate();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = () => {
|
const getFaceCroppedCoordinates = () => {
|
||||||
if (!faceRect || !htmlElement) {
|
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const scaledWidth = faceRect.getScaledWidth();
|
||||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
const scaledHeight = faceRect.getScaledHeight();
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
|
|
||||||
const scaleX = natural.width / contentWidth;
|
const imageRect = mapContentRectToNatural(
|
||||||
const scaleY = natural.height / contentHeight;
|
{
|
||||||
const imageX = (left - offsetX) * scaleX;
|
left: faceRect.left - scaledWidth / 2,
|
||||||
const imageY = (top - offsetY) * scaleY;
|
top: faceRect.top - scaledHeight / 2,
|
||||||
|
width: scaledWidth,
|
||||||
|
height: scaledHeight,
|
||||||
|
},
|
||||||
|
imageContentMetrics,
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageWidth: natural.width,
|
imageWidth: imageSize.width,
|
||||||
imageHeight: natural.height,
|
imageHeight: imageSize.height,
|
||||||
x: Math.floor(imageX),
|
x: Math.floor(imageRect.left),
|
||||||
y: Math.floor(imageY),
|
y: Math.floor(imageRect.top),
|
||||||
width: Math.floor(width * scaleX),
|
width: Math.floor(imageRect.width),
|
||||||
height: Math.floor(height * scaleY),
|
height: Math.floor(imageRect.height),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,10 +426,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
await assetViewingStore.setAssetId(assetId);
|
await assetViewingStore.setAssetId(assetId);
|
||||||
|
isFaceEditMode.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Error tagging face');
|
handleError(error, 'Error tagging face');
|
||||||
} finally {
|
|
||||||
isFaceEditMode.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -294,6 +437,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="face-editor-data"
|
id="face-editor-data"
|
||||||
|
bind:this={containerEl}
|
||||||
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||||
data-face-left={faceBoxPosition.left}
|
data-face-left={faceBoxPosition.left}
|
||||||
data-face-top={faceBoxPosition.top}
|
data-face-top={faceBoxPosition.top}
|
||||||
@@ -305,7 +449,9 @@
|
|||||||
<div
|
<div
|
||||||
id="face-selector"
|
id="face-selector"
|
||||||
bind:this={faceSelectorEl}
|
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>
|
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||||
|
|
||||||
@@ -346,4 +492,15 @@
|
|||||||
|
|
||||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isZoomed && !panModifierHeld}
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
|
||||||
|
>
|
||||||
|
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
|
||||||
|
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
transitionName?: string;
|
||||||
|
letterboxTransitionName?: string;
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { asset }: Props = $props();
|
let { transitionName, letterboxTransitionName, asset }: Props = $props();
|
||||||
|
|
||||||
const assetId = $derived(asset.id);
|
const assetId = $derived(asset.id);
|
||||||
|
|
||||||
@@ -20,11 +21,16 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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')])}
|
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [data, { default: PhotoSphereViewer }]}
|
{:then [data, { default: PhotoSphereViewer }]}
|
||||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
<PhotoSphereViewer
|
||||||
|
{transitionName}
|
||||||
|
{letterboxTransitionName}
|
||||||
|
panorama={data}
|
||||||
|
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
||||||
|
/>
|
||||||
{:catch}
|
{:catch}
|
||||||
{$t('errors.failed_to_load_asset')}
|
{$t('errors.failed_to_load_asset')}
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
import type { OcrBox } from '$lib/utils/ocr-utils';
|
import type { OcrBox } from '$lib/utils/ocr-utils';
|
||||||
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
|
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
|
|
||||||
let { ocrBox }: Props = $props();
|
let { ocrBox }: Props = $props();
|
||||||
|
|
||||||
|
const isTouch = $derived(mediaQueryManager.pointerCoarse);
|
||||||
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
|
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
|
||||||
|
|
||||||
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
|
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
|
||||||
@@ -15,13 +17,23 @@
|
|||||||
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
|
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(() => {
|
const verticalStyle = $derived.by(() => {
|
||||||
switch (ocrBox.verticalMode) {
|
switch (ocrBox.verticalMode) {
|
||||||
case 'cjk': {
|
case 'cjk': {
|
||||||
return ' writing-mode: vertical-rl;';
|
return 'writing-mode: vertical-rl;';
|
||||||
}
|
}
|
||||||
case 'rotated': {
|
case 'rotated': {
|
||||||
return ' writing-mode: vertical-rl; text-orientation: sideways;';
|
return 'writing-mode: vertical-rl; text-orientation: sideways;';
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return '';
|
return '';
|
||||||
@@ -30,17 +42,23 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute left-0 top-0">
|
<div
|
||||||
<div
|
class={[
|
||||||
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 ===
|
'absolute left-0 top-0 flex items-center justify-center',
|
||||||
'none'
|
'border-2 border-blue-500 pointer-events-auto cursor-text',
|
||||||
? 'px-2 py-1 whitespace-nowrap'
|
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
|
||||||
: 'px-1 py-2'}"
|
isTouch
|
||||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
|
? 'text-white bg-black/60 select-all'
|
||||||
tabindex="0"
|
: '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',
|
||||||
role="button"
|
ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
|
||||||
aria-label={ocrBox.text}
|
]}
|
||||||
>
|
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
|
||||||
{ocrBox.text}
|
data-testid="ocr-box"
|
||||||
</div>
|
data-overlay-interactive
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label={ocrBox.text}
|
||||||
|
onselectstart={isTouch ? handleSelectStart : undefined}
|
||||||
|
>
|
||||||
|
{ocrBox.text}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
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 { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.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';
|
'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 = {
|
type Props = {
|
||||||
|
transitionName?: string;
|
||||||
|
letterboxTransitionName?: string;
|
||||||
panorama: string | { source: string };
|
panorama: string | { source: string };
|
||||||
originalPanorama?: string | { source: string };
|
originalPanorama?: string | { source: string };
|
||||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||||
@@ -48,11 +52,21 @@
|
|||||||
navbar?: boolean;
|
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 container: HTMLDivElement | undefined = $state();
|
||||||
let viewer: Viewer;
|
let viewer: Viewer;
|
||||||
|
|
||||||
|
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
|
||||||
|
|
||||||
let animationInProgress: { cancel: () => void } | undefined;
|
let animationInProgress: { cancel: () => void } | undefined;
|
||||||
let previousFaces: Faces[] = [];
|
let previousFaces: Faces[] = [];
|
||||||
|
|
||||||
@@ -128,10 +142,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boxes = getOcrBoundingBoxes(ocrData, {
|
const boxes = getOcrBoundingBoxes(ocrData, {
|
||||||
contentWidth: viewer.state.textureData.panoData.croppedWidth,
|
width: viewer.state.textureData.panoData.croppedWidth,
|
||||||
contentHeight: viewer.state.textureData.panoData.croppedHeight,
|
height: viewer.state.textureData.panoData.croppedHeight,
|
||||||
offsetX: 0,
|
|
||||||
offsetY: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [index, box] of boxes.entries()) {
|
for (const [index, box] of boxes.entries()) {
|
||||||
@@ -212,6 +224,7 @@
|
|||||||
zoomSpeed: 0.5,
|
zoomSpeed: 0.5,
|
||||||
fisheye: false,
|
fisheye: false,
|
||||||
});
|
});
|
||||||
|
viewer.addEventListener('ready', () => eventManager.emit('ViewerOpenTransitionReady'), { once: true });
|
||||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||||
// zoomLevel is 0-100
|
// zoomLevel is 0-100
|
||||||
@@ -256,7 +269,15 @@
|
|||||||
<AssetViewerEvents {onZoom} />
|
<AssetViewerEvents {onZoom} />
|
||||||
|
|
||||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
<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>
|
<style>
|
||||||
/* Reset the default tooltip styling */
|
/* Reset the default tooltip styling */
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
@@ -25,18 +26,30 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
element?: HTMLDivElement;
|
element?: HTMLDivElement;
|
||||||
sharedLink?: SharedLinkResponseDto;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
onReady?: () => void;
|
transitionName?: string;
|
||||||
|
letterboxTransitionName?: string;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
onSwipe?: (event: SwipeCustomEvent) => 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 { slideshowState, slideshowLook } = slideshowStore;
|
||||||
|
const objectFit = $derived(
|
||||||
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
||||||
|
);
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
@@ -67,23 +80,12 @@
|
|||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
|
||||||
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
|
|
||||||
|
|
||||||
return {
|
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
|
||||||
contentWidth: scaled.width,
|
|
||||||
contentHeight: scaled.height,
|
|
||||||
offsetX: 0,
|
|
||||||
offsetY: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
|
||||||
|
|
||||||
const onCopy = async () => {
|
const onCopy = async () => {
|
||||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||||
@@ -105,12 +107,6 @@
|
|||||||
|
|
||||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
|
||||||
onZoom();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO move to action + command palette
|
// TODO move to action + command palette
|
||||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||||
if (globalThis.getSelection()?.type === 'Range') {
|
if (globalThis.getSelection()?.type === 'Range') {
|
||||||
@@ -151,48 +147,26 @@
|
|||||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const map = new Map<Faces, string>();
|
const map = new Map<Faces, string | undefined>();
|
||||||
for (const person of asset.people ?? []) {
|
for (const person of asset.people ?? []) {
|
||||||
for (const face of person.faces ?? []) {
|
for (const face of person.faces ?? []) {
|
||||||
map.set(face, person.name);
|
map.set(face, person.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const face of asset.unassignedFaces ?? []) {
|
||||||
|
map.set(face, undefined);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Array needed for indexed access in the template (faces[index])
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||||
const handleImageMouseMove = (event: MouseEvent) => {
|
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
||||||
$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 = [];
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -213,26 +187,30 @@
|
|||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
ondblclick={onZoom}
|
ondblclick={onZoom}
|
||||||
onmousemove={handleImageMouseMove}
|
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||||
onmouseleave={handleImageMouseLeave}
|
|
||||||
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
|
|
||||||
{...useSwipe((event) => onSwipe?.(event))}
|
{...useSwipe((event) => onSwipe?.(event))}
|
||||||
>
|
>
|
||||||
<AdaptiveImage
|
<AdaptiveImage
|
||||||
{asset}
|
{asset}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{container}
|
{container}
|
||||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
{objectFit}
|
||||||
{onUrlChange}
|
{onUrlChange}
|
||||||
onImageReady={() => {
|
onImageReady={() => {
|
||||||
visibleImageReady = true;
|
visibleImageReady = true;
|
||||||
onReady?.();
|
eventManager.emit('ViewerOpenTransitionReady');
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
onError?.();
|
onError?.();
|
||||||
onReady?.();
|
eventManager.emit('ViewerOpenTransitionReady');
|
||||||
}}
|
}}
|
||||||
bind:imgRef={assetViewerManager.imgRef}
|
bind:imgRef={assetViewerManager.imgRef}
|
||||||
|
bind:imgNaturalSize={imageDimensions}
|
||||||
|
bind:imgScaledSize={scaledDimensions}
|
||||||
|
bind:ref={adaptiveImage}
|
||||||
|
{transitionName}
|
||||||
|
{letterboxTransitionName}
|
||||||
|
showLetterboxes={!blurredSlideshow}
|
||||||
>
|
>
|
||||||
{#snippet backdrop()}
|
{#snippet backdrop()}
|
||||||
{#if blurredSlideshow}
|
{#if blurredSlideshow}
|
||||||
@@ -243,20 +221,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet overlays()}
|
{#snippet overlays()}
|
||||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
{#if !isFaceEditMode.value}
|
||||||
|
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
|
||||||
|
{@const face = faces[index]}
|
||||||
|
{@const name = faceToNameMap.get(face)}
|
||||||
|
{#if name !== undefined || isEditFacesPanelOpen.value}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each activeBoundingBoxes as boundingbox (boundingbox.id)}
|
||||||
|
{@const face = faces.find((f) => f.id === boundingbox.id)}
|
||||||
|
{@const name = face ? faceToNameMap.get(face) : undefined}
|
||||||
<div
|
<div
|
||||||
class="absolute border-solid border-white border-3 rounded-lg"
|
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;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
></div>
|
>
|
||||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
{#if name}
|
||||||
<div
|
<div
|
||||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
aria-hidden="true"
|
||||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
||||||
boundingbox.width}px; transform: translateX(-100%);"
|
style="top: {boundingbox.height + 4}px; right: 0;"
|
||||||
>
|
>
|
||||||
{faceToNameMap.get($boundingBoxesArray[index])}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||||
@@ -266,6 +264,6 @@
|
|||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import { assetViewerFadeDuration } from '$lib/constants';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import {
|
import {
|
||||||
autoPlayVideo,
|
autoPlayVideo,
|
||||||
@@ -11,14 +12,17 @@
|
|||||||
videoViewerVolume,
|
videoViewerVolume,
|
||||||
} from '$lib/stores/preferences.store';
|
} from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
|
transitionName?: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
imageSize: Size;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
@@ -27,10 +31,12 @@
|
|||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
transitionName,
|
||||||
assetId,
|
assetId,
|
||||||
|
imageSize,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
@@ -58,7 +64,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// reactive on `assetFileUrl` changes
|
|
||||||
if (assetFileUrl) {
|
if (assetFileUrl) {
|
||||||
hasFocused = false;
|
hasFocused = false;
|
||||||
videoPlayer?.load();
|
videoPlayer?.load();
|
||||||
@@ -139,6 +144,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<video
|
<video
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
bind:this={videoPlayer}
|
bind:this={videoPlayer}
|
||||||
loop={$loopVideoPreference && loopVideo}
|
loop={$loopVideoPreference && loopVideo}
|
||||||
autoplay={$autoPlayVideo}
|
autoplay={$autoPlayVideo}
|
||||||
@@ -147,6 +153,7 @@
|
|||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
class="h-full object-contain"
|
class="h-full object-contain"
|
||||||
{...useSwipe(onSwipe)}
|
{...useSwipe(onSwipe)}
|
||||||
|
onloadedmetadata={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||||
onended={onVideoEnded}
|
onended={onVideoEnded}
|
||||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||||
@@ -173,7 +180,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isFaceEditMode.value}
|
{#if isFaceEditMode.value}
|
||||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
<FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
transitionName?: string;
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset }: Props = $props();
|
const { asset, transitionName }: Props = $props();
|
||||||
|
|
||||||
const modules = Promise.all([
|
const modules = Promise.all([
|
||||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||||
@@ -19,11 +19,12 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</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}
|
{#await modules}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||||
<PhotoSphereViewer
|
<PhotoSphereViewer
|
||||||
|
{transitionName}
|
||||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||||
plugins={[videoPlugin]}
|
plugins={[videoPlugin]}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
|
transitionName?: string;
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
projectionType: string | null | undefined;
|
projectionType: string | null | undefined;
|
||||||
@@ -16,9 +17,10 @@
|
|||||||
onNextAsset?: () => void;
|
onNextAsset?: () => void;
|
||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
transitionName,
|
||||||
asset,
|
asset,
|
||||||
assetId,
|
assetId,
|
||||||
projectionType,
|
projectionType,
|
||||||
@@ -36,12 +38,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||||
<VideoPanoramaViewer {asset} />
|
<VideoPanoramaViewer {transitionName} {asset} />
|
||||||
{:else}
|
{:else}
|
||||||
<VideoNativeViewer
|
<VideoNativeViewer
|
||||||
|
{transitionName}
|
||||||
{loopVideo}
|
{loopVideo}
|
||||||
{cacheKey}
|
{cacheKey}
|
||||||
assetId={effectiveAssetId}
|
assetId={effectiveAssetId}
|
||||||
|
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
{onPreviousAsset}
|
{onPreviousAsset}
|
||||||
{onNextAsset}
|
{onNextAsset}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
circle?: boolean;
|
circle?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
|
highlighted?: boolean;
|
||||||
hiddenIconClass?: string;
|
hiddenIconClass?: string;
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
brokenAssetClass?: ClassValue;
|
brokenAssetClass?: ClassValue;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
circle = false,
|
circle = false,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
border = false,
|
border = false,
|
||||||
|
highlighted = false,
|
||||||
hiddenIconClass = 'text-white',
|
hiddenIconClass = 'text-white',
|
||||||
onComplete = undefined,
|
onComplete = undefined,
|
||||||
class: imageClass = '',
|
class: imageClass = '',
|
||||||
@@ -83,6 +85,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if highlighted}
|
||||||
|
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||||
<!-- TODO fix `title` type -->
|
<!-- TODO fix `title` type -->
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
assetType: AssetTypeEnum;
|
assetType: AssetTypeEnum;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const thumbnailWidth = '90px';
|
const thumbnailWidth = '90px';
|
||||||
|
const focusHighlightClass =
|
||||||
|
'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary';
|
||||||
|
|
||||||
async function loadPeople() {
|
async function loadPeople() {
|
||||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||||
@@ -226,14 +228,16 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each peopleWithFaces as face, index (face.id)}
|
{#each peopleWithFaces as face, index (face.id)}
|
||||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||||
|
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
|
||||||
<div class="relative h-29 w-24">
|
<div class="relative h-29 w-24">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex={index}
|
tabindex={index}
|
||||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
data-testid="face-thumbnail"
|
||||||
|
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
|
||||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if selectedPersonToCreate[face.id]}
|
{#if selectedPersonToCreate[face.id]}
|
||||||
@@ -245,6 +249,8 @@
|
|||||||
title={$t('new_person')}
|
title={$t('new_person')}
|
||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else if selectedPersonToReassign[face.id]}
|
{:else if selectedPersonToReassign[face.id]}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -259,6 +265,8 @@
|
|||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else if face.person}
|
{:else if face.person}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -270,6 +278,8 @@
|
|||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
hidden={face.person.isHidden}
|
hidden={face.person.isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
||||||
@@ -281,6 +291,8 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:then data}
|
{:then data}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -291,6 +303,8 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
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 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 type { HeaderButtonActionItem } from '$lib/types';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
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';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,12 +45,17 @@
|
|||||||
|
|
||||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||||
|
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
{#if !hideNavbar}
|
{#if !hideNavbar && !isAssetViewer}
|
||||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isAssetViewer}
|
||||||
|
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@@ -58,13 +64,15 @@
|
|||||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||||
>
|
>
|
||||||
{#if sidebar}
|
{#if isAssetViewer}
|
||||||
|
<div></div>
|
||||||
|
{:else if sidebar}
|
||||||
{@render sidebar()}
|
{@render sidebar()}
|
||||||
{:else}
|
{:else}
|
||||||
<UserSidebar />
|
<UserSidebar />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<main class="relative">
|
<main class="relative w-full">
|
||||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: TimelineAsset;
|
||||||
onImageLoad: () => void;
|
onImageLoad: () => void;
|
||||||
|
transitionName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset, onImageLoad }: Props = $props();
|
const { asset, onImageLoad, transitionName }: Props = $props();
|
||||||
|
|
||||||
let assetFileUrl: string = $state('');
|
let assetFileUrl: string = $state('');
|
||||||
let imageLoaded: boolean = $state(false);
|
let imageLoaded: boolean = $state(false);
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(asset)}
|
alt={$getAltText(asset)}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
|
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
||||||
@@ -226,6 +227,20 @@
|
|||||||
handlePromiseError(handleAction('galleryInView', 'pause'));
|
handlePromiseError(handleAction('galleryInView', 'pause'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let memoryTransitionName = $state<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const viewInTimeline = (assetId: string) => {
|
||||||
|
navigateToTimeline(assetId, {
|
||||||
|
types: ['memory-enter'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
memoryTransitionName = 'hero';
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
memoryTransitionName = undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleGalleryScrollsOutOfView = () => {
|
const handleGalleryScrollsOutOfView = () => {
|
||||||
galleryInView = false;
|
galleryInView = false;
|
||||||
// only call play after the first page load. When page first loads the gallery will not be visible
|
// only call play after the first page load. When page first loads the gallery will not be visible
|
||||||
@@ -505,7 +520,11 @@
|
|||||||
videoViewerVolume={$videoViewerVolume}
|
videoViewerVolume={$videoViewerVolume}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
<MemoryPhotoViewer
|
||||||
|
asset={current.asset}
|
||||||
|
onImageLoad={resetAndPlay}
|
||||||
|
transitionName={memoryTransitionName}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
@@ -560,6 +579,10 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
shape="round"
|
shape="round"
|
||||||
|
onclick={(event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
|
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
@@ -27,9 +30,11 @@
|
|||||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { modalManager } from '@immich/ui';
|
import { modalManager } from '@immich/ui';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -65,6 +70,16 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($isViewerOpen) {
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.documentElement.style.overflow = '';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const navigationAssets = $derived(viewerAssets ?? assets);
|
const navigationAssets = $derived(viewerAssets ?? assets);
|
||||||
|
|
||||||
const geometry = $derived(
|
const geometry = $derived(
|
||||||
@@ -107,6 +122,36 @@
|
|||||||
|
|
||||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
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 });
|
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||||
|
|
||||||
let lastIntersectedHeight = 0;
|
let lastIntersectedHeight = 0;
|
||||||
@@ -356,6 +401,63 @@
|
|||||||
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
|
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
|
||||||
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
|
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let toViewerTransitionId = $state<string | null>(null);
|
||||||
|
let toGalleryTransitionId = $state<string | null>(null);
|
||||||
|
const transitionTargetId = $derived(toViewerTransitionId ?? toGalleryTransitionId);
|
||||||
|
|
||||||
|
const handleThumbnailClick = (asset: AssetResponseDto, currentAsset: TimelineAsset) => {
|
||||||
|
if (assetInteraction.selectionActive) {
|
||||||
|
handleSelectAssets(currentAsset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doNavigate = () => void navigateToAsset(asset);
|
||||||
|
|
||||||
|
if (!viewTransitionManager.isSupported()) {
|
||||||
|
doNavigate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startViewerTransition(asset.id, doNavigate, (id) => (toViewerTransitionId = id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionToGalleryCallback = ({ id }: { id: string }) => {
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['timeline'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
void scrollGalleryToAsset(id);
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
eventManager.emit('ViewerCloseTransitionReady');
|
||||||
|
toGalleryTransitionId = id;
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
toGalleryTransitionId = null;
|
||||||
|
focusAsset(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (viewTransitionManager.isSupported()) {
|
||||||
|
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToGalleryCallback }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = async (asset: { id: string }) => {
|
||||||
|
const useTransition = viewTransitionManager.isSupported();
|
||||||
|
if (useTransition) {
|
||||||
|
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||||
|
eventManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||||
|
await transitionReady;
|
||||||
|
}
|
||||||
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
if (!useTransition) {
|
||||||
|
await tick();
|
||||||
|
await scrollToAndFocusAsset(asset.id);
|
||||||
|
}
|
||||||
|
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true }));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document
|
<svelte:document
|
||||||
@@ -375,16 +477,17 @@
|
|||||||
{#each assets as asset, i (asset.id + '-' + i)}
|
{#each assets as asset, i (asset.id + '-' + i)}
|
||||||
{#if isIntersecting(i)}
|
{#if isIntersecting(i)}
|
||||||
{@const currentAsset = toTimelineAsset(asset)}
|
{@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
|
<Thumbnail
|
||||||
readonly={disableAssetSelect}
|
readonly={disableAssetSelect}
|
||||||
onClick={() => {
|
onClick={() => handleThumbnailClick(asset, currentAsset)}
|
||||||
if (assetInteraction.selectionActive) {
|
|
||||||
handleSelectAssets(currentAsset);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigateToAsset(asset);
|
|
||||||
}}
|
|
||||||
onSelect={() => handleSelectAssets(currentAsset)}
|
onSelect={() => handleSelectAssets(currentAsset)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
|
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
@@ -416,10 +519,7 @@
|
|||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onRandom={handleRandom}
|
onRandom={handleRandom}
|
||||||
onAssetChange={updateCurrentAsset}
|
onAssetChange={updateCurrentAsset}
|
||||||
onClose={() => {
|
onClose={(asset) => handleClose(asset)}
|
||||||
assetViewingStore.showAssetViewer(false);
|
|
||||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
@@ -50,7 +50,11 @@
|
|||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<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 relative z-10"
|
||||||
|
style:view-transition-name="exclude"
|
||||||
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 { 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 { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { scale } from 'svelte/transition';
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
animationTargetAssetId?: string | null;
|
||||||
|
suspendTransitions?: boolean;
|
||||||
viewerAssets: ViewerAsset[];
|
viewerAssets: ViewerAsset[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
manager: VirtualScrollManager;
|
|
||||||
thumbnail: Snippet<
|
thumbnail: Snippet<
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -26,14 +25,20 @@
|
|||||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
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 scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
|
|
||||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
|
||||||
return intersectables.filter(({ intersecting }) => intersecting);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
@@ -41,11 +46,14 @@
|
|||||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||||
{@const position = viewerAsset.position!}
|
{@const position = viewerAsset.position!}
|
||||||
{@const asset = viewerAsset.asset!}
|
{@const asset = viewerAsset.asset!}
|
||||||
|
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
|
||||||
|
|
||||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
<div
|
<div
|
||||||
data-asset-id={asset.id}
|
data-asset-id={asset.id}
|
||||||
class="absolute"
|
class="absolute"
|
||||||
|
data-transition-name={transitionName}
|
||||||
|
style:view-transition-name={transitionName}
|
||||||
style:top={position.top + 'px'}
|
style:top={position.top + 'px'}
|
||||||
style:inset-inline-start={position.left + 'px'}
|
style:inset-inline-start={position.left + 'px'}
|
||||||
style:width={position.width + 'px'}
|
style:width={position.width + 'px'}
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
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 { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import type { Snippet } from 'svelte';
|
import { onMount, tick, type Snippet } from 'svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
toAssetViewerTransitionId?: string | null;
|
||||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||||
singleSelect: boolean;
|
singleSelect: boolean;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
monthGroup: MonthGroup;
|
monthGroup: MonthGroup;
|
||||||
manager: VirtualScrollManager;
|
|
||||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
toAssetViewerTransitionId,
|
||||||
thumbnail: thumbnailWithGroup,
|
thumbnail: thumbnailWithGroup,
|
||||||
customThumbnailLayout,
|
customThumbnailLayout,
|
||||||
singleSelect,
|
singleSelect,
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
monthGroup,
|
monthGroup,
|
||||||
manager,
|
|
||||||
onDayGroupSelect,
|
onDayGroupSelect,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -37,10 +40,6 @@
|
|||||||
|
|
||||||
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
|
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 getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
||||||
const { month, year } = dayGroup.monthGroup.yearMonth;
|
const { month, year } = dayGroup.monthGroup.yearMonth;
|
||||||
const date = fromTimelinePlainDate({
|
const date = fromTimelinePlainDate({
|
||||||
@@ -50,6 +49,32 @@
|
|||||||
});
|
});
|
||||||
return getDateLocaleString(date);
|
return getDateLocaleString(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||||
|
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||||
|
|
||||||
|
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||||
|
const asset = monthGroup.findAssetById({ id });
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['timeline'],
|
||||||
|
performUpdate: async () => {
|
||||||
|
eventManager.emit('ViewerCloseTransitionReady');
|
||||||
|
const event = await eventManager.untilNext('TimelineLoaded');
|
||||||
|
toTimelineTransitionAssetId = event.id;
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
toTimelineTransitionAssetId = null;
|
||||||
|
focusAsset(asset.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (viewTransitionManager.isSupported()) {
|
||||||
|
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||||
@@ -93,7 +118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetLayout
|
<AssetLayout
|
||||||
{manager}
|
{animationTargetAssetId}
|
||||||
|
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
|
||||||
viewerAssets={dayGroup.viewerAssets}
|
viewerAssets={dayGroup.viewerAssets}
|
||||||
height={dayGroup.height}
|
height={dayGroup.height}
|
||||||
width={dayGroup.width}
|
width={dayGroup.width}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
invisible: boolean;
|
||||||
/** Offset from the top of the timeline (e.g., for headers) */
|
/** Offset from the top of the timeline (e.g., for headers) */
|
||||||
timelineTopOffset?: number;
|
timelineTopOffset?: number;
|
||||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
invisible = false,
|
||||||
timelineTopOffset = 0,
|
timelineTopOffset = 0,
|
||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
@@ -438,7 +440,7 @@
|
|||||||
next = forward
|
next = forward
|
||||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||||
: (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)}
|
aria-valuemin={toScrollY(0)}
|
||||||
data-id="scrubber"
|
data-id="scrubber"
|
||||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||||
|
class:invisible
|
||||||
style:padding-top={PADDING_TOP + 'px'}
|
style:padding-top={PADDING_TOP + 'px'}
|
||||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||||
style:width
|
style:width
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
|
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
@@ -102,6 +104,7 @@
|
|||||||
// Overall scroll percentage through the entire timeline (0-1)
|
// Overall scroll percentage through the entire timeline (0-1)
|
||||||
let timelineScrollPercent: number = $state(0);
|
let timelineScrollPercent: number = $state(0);
|
||||||
let scrubberWidth = $state(0);
|
let scrubberWidth = $state(0);
|
||||||
|
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||||
|
|
||||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||||
@@ -209,19 +212,22 @@
|
|||||||
timelineManager.viewportWidth = rect.width;
|
timelineManager.viewportWidth = rect.width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const scrollTarget = $gridScrollTarget?.at;
|
const scrollTarget = getScrollTarget();
|
||||||
let scrolled = false;
|
let scrolled = false;
|
||||||
if (scrollTarget) {
|
if (scrollTarget) {
|
||||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||||
}
|
}
|
||||||
if (!scrolled) {
|
if (!scrolled) {
|
||||||
// if the asset is not found, scroll to the top
|
|
||||||
timelineManager.scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
|
if (scrollTarget) {
|
||||||
|
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||||
|
}
|
||||||
} else if (scrollTarget) {
|
} else if (scrollTarget) {
|
||||||
await tick();
|
await tick();
|
||||||
focusAsset(scrollTarget);
|
focusAsset(scrollTarget);
|
||||||
|
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||||
}
|
}
|
||||||
invisible = false;
|
invisible = isAssetViewerRoute(page) ? true : false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: only modified once in afterNavigate()
|
// note: only modified once in afterNavigate()
|
||||||
@@ -239,10 +245,13 @@
|
|||||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
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
|
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||||
// after successful navigation.
|
// after successful navigation.
|
||||||
afterNavigate(({ complete }) => {
|
afterNavigate(({ complete }) => {
|
||||||
void complete.finally(() => {
|
void complete.finally(async () => {
|
||||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||||
|
|
||||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||||
@@ -251,8 +260,13 @@
|
|||||||
if (isDirectNavigation) {
|
if (isDirectNavigation) {
|
||||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
void scrollAfterNavigate();
|
void scrollAfterNavigate();
|
||||||
|
if (!isAssetViewerPage) {
|
||||||
|
const scrollTarget = getScrollTarget();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,7 +274,7 @@
|
|||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||||
invisible = false;
|
invisible = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -524,6 +538,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 = (
|
const assetSelectHandler = (
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
asset: TimelineAsset,
|
asset: TimelineAsset,
|
||||||
@@ -544,19 +585,6 @@
|
|||||||
|
|
||||||
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||||
@@ -587,6 +615,7 @@
|
|||||||
{#if timelineManager.months.length > 0}
|
{#if timelineManager.months.length > 0}
|
||||||
<Scrubber
|
<Scrubber
|
||||||
{timelineManager}
|
{timelineManager}
|
||||||
|
{invisible}
|
||||||
height={timelineManager.viewportHeight}
|
height={timelineManager.viewportHeight}
|
||||||
timelineTopOffset={timelineManager.topSectionHeight}
|
timelineTopOffset={timelineManager.topSectionHeight}
|
||||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||||
@@ -618,6 +647,7 @@
|
|||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
|
data-initialized={timelineManager.isInitialized || undefined}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={timelineManager.viewportWidth}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
@@ -666,11 +696,11 @@
|
|||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Month
|
<Month
|
||||||
|
{toAssetViewerTransitionId}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{customThumbnailLayout}
|
{customThumbnailLayout}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{monthGroup}
|
{monthGroup}
|
||||||
manager={timelineManager}
|
|
||||||
onDayGroupSelect={handleGroupSelect}
|
onDayGroupSelect={handleGroupSelect}
|
||||||
>
|
>
|
||||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||||
@@ -684,13 +714,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{albumUsers}
|
{albumUsers}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
onClick={(asset) => {
|
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
|
||||||
if (typeof onThumbnailClick === 'function') {
|
|
||||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
|
||||||
} else {
|
|
||||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.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 { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
@@ -98,6 +100,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = async (asset: { id: string }) => {
|
const handleClose = async (asset: { id: string }) => {
|
||||||
|
if (viewTransitionManager.isSupported()) {
|
||||||
|
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||||
|
eventManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||||
|
await transitionReady;
|
||||||
|
}
|
||||||
|
|
||||||
invisible = true;
|
invisible = true;
|
||||||
$gridScrollTarget = { at: asset.id };
|
$gridScrollTarget = { at: asset.id };
|
||||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
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, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Start first — it will be blocked on updateCallbackDone
|
||||||
|
const firstPromise = manager.startTransition({
|
||||||
|
performUpdate: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush microtasks so the first transition reaches the startViewTransition call
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
// While first is active, 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, ready: Promise.resolve(), skipTransition };
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
const skipped = manager.skipTransitions();
|
||||||
|
expect(skipped).toBe(true);
|
||||||
|
expect(skipTransition).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
resolveUpdate();
|
||||||
|
resolveFinished();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a new transition after skipping', async () => {
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
|
let resolveUpdate!: () => void;
|
||||||
|
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
manager.skipTransitions();
|
||||||
|
resolveUpdate();
|
||||||
|
resolveFinished();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// Now start a second transition — it should NOT be skipped
|
||||||
|
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const secondFinished = Promise.resolve();
|
||||||
|
const secondUpdateDone = Promise.resolve();
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return {
|
||||||
|
updateCallbackDone: secondUpdateDone,
|
||||||
|
finished: secondFinished,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate: secondUpdate });
|
||||||
|
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should propagate error from performUpdate when API is not supported', async () => {
|
||||||
|
const error = new Error('update failed');
|
||||||
|
const performUpdate = vi.fn().mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
|
||||||
|
const error = new Error('update failed');
|
||||||
|
let resolveFinished!: () => void;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
resolveFinished = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
const updateCallbackDone = updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
|
||||||
|
'update failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate transition finishing after error
|
||||||
|
resolveFinished();
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
// Manager should accept new transitions after cleanup
|
||||||
|
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const secondFinished = Promise.resolve();
|
||||||
|
const secondUpdateDone = Promise.resolve();
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return {
|
||||||
|
updateCallbackDone: secondUpdateDone,
|
||||||
|
finished: secondFinished,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate: secondUpdate });
|
||||||
|
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback path', () => {
|
||||||
|
it('should fall back to function argument when object argument throws', async () => {
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const prepareNewSnapshot = vi.fn();
|
||||||
|
const finished = Promise.resolve();
|
||||||
|
const updateCallbackDone = Promise.resolve();
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1 && typeof arg !== 'function') {
|
||||||
|
throw new TypeError('object form not supported');
|
||||||
|
}
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
|
||||||
|
|
||||||
|
expect(performUpdate).toHaveBeenCalledOnce();
|
||||||
|
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('abort signal', () => {
|
||||||
|
it('should pass an AbortSignal to performUpdate', async () => {
|
||||||
|
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return {
|
||||||
|
updateCallbackDone: Promise.resolve(),
|
||||||
|
finished: Promise.resolve(),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({ performUpdate });
|
||||||
|
|
||||||
|
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should abort the signal when transition.ready rejects', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
let resolveUpdate!: () => void;
|
||||||
|
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||||
|
resolveUpdate = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const readyError = new Error('Transition was aborted because of timeout in DOM update');
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return {
|
||||||
|
updateCallbackDone,
|
||||||
|
finished: Promise.reject(readyError),
|
||||||
|
ready: Promise.reject(readyError),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = manager.startTransition({ performUpdate });
|
||||||
|
|
||||||
|
// Flush microtasks so ready rejection fires and aborts the signal
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
|
expect(capturedSignal?.aborted).toBe(true);
|
||||||
|
|
||||||
|
resolveUpdate();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not abort the signal when transition completes normally', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
|
void updateFn();
|
||||||
|
return {
|
||||||
|
updateCallbackDone: Promise.resolve(),
|
||||||
|
finished: Promise.resolve(),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startTransition({
|
||||||
|
performUpdate: (signal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedSignal?.aborted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
|
await manager.startTransition({
|
||||||
|
performUpdate: (signal) => {
|
||||||
|
capturedSignal = signal;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(capturedSignal?.aborted).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSupported', () => {
|
||||||
|
it('should return false when startViewTransition is not in document', () => {
|
||||||
|
expect(manager.isSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when startViewTransition is in document', () => {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
document.startViewTransition = vi.fn();
|
||||||
|
|
||||||
|
expect(manager.isSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
interface TransitionRequest {
|
||||||
|
types?: string[];
|
||||||
|
prepareOldSnapshot?: () => void;
|
||||||
|
performUpdate: (signal: AbortSignal) => Promise<void>;
|
||||||
|
prepareNewSnapshot?: () => void;
|
||||||
|
onFinished?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ViewTransitionManager {
|
||||||
|
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||||
|
|
||||||
|
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(AbortSignal.timeout(10_000));
|
||||||
|
onFinished?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareOldSnapshot?.();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let transition: ViewTransition;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
transition = document.startViewTransition({
|
||||||
|
update: async () => {
|
||||||
|
await performUpdate(abortController.signal);
|
||||||
|
prepareNewSnapshot?.();
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
types,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
transition = document.startViewTransition(async () => {
|
||||||
|
await performUpdate(abortController.signal);
|
||||||
|
prepareNewSnapshot?.();
|
||||||
|
await tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#activeViewTransition = transition;
|
||||||
|
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
void transition.ready.catch((error: unknown) => {
|
||||||
|
abortController.abort(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let animation run in the background — don't block the caller.
|
||||||
|
// This allows skipTransitions() to abort mid-animation for rapid navigation.
|
||||||
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
|
void transition.finished
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
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();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AppManager {
|
||||||
|
isAssetViewer = $state(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appManager = new AppManager();
|
||||||
@@ -89,6 +89,15 @@ export type Events = {
|
|||||||
ReleaseEvent: [ReleaseEvent];
|
ReleaseEvent: [ReleaseEvent];
|
||||||
|
|
||||||
WebsocketConnect: [];
|
WebsocketConnect: [];
|
||||||
|
|
||||||
|
TimelineLoaded: [{ id: string | null }];
|
||||||
|
TimelineScrolledToAsset: [{ id: string }];
|
||||||
|
|
||||||
|
ViewerAfterNavigate: [];
|
||||||
|
ViewerCloseTransition: [{ id: string }];
|
||||||
|
ViewerCloseTransitionReady: [];
|
||||||
|
ViewerOpenTransition: [];
|
||||||
|
ViewerOpenTransitionReady: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const eventManager = new BaseEventManager<Events>();
|
export const eventManager = new BaseEventManager<Events>();
|
||||||
|
|||||||
@@ -2,3 +2,11 @@ import type { TimelineAsset } from './types';
|
|||||||
|
|
||||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
|
|||||||
|
|
||||||
function createAssetViewingStore() {
|
function createAssetViewingStore() {
|
||||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||||
|
const invisible = writable<boolean>(false);
|
||||||
const viewState = writable<boolean>(false);
|
const viewState = writable<boolean>(false);
|
||||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
|
|||||||
setAsset,
|
setAsset,
|
||||||
setAssetId,
|
setAssetId,
|
||||||
showAssetViewer,
|
showAssetViewer,
|
||||||
|
invisible,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const isFaceEditMode = $state({ value: false });
|
export const isFaceEditMode = $state({ value: false });
|
||||||
|
export const isEditFacesPanelOpen = $state({ value: false });
|
||||||
|
|||||||
@@ -43,6 +43,50 @@ export class BaseEventManager<Events extends EventsBase> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
|
||||||
|
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
|
||||||
|
unsubscribe();
|
||||||
|
return callback(...args);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
untilNext<T extends keyof Events>(
|
||||||
|
event: T,
|
||||||
|
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<Events[T] extends [] ? void : Events[T][0]> {
|
||||||
|
type Result = Events[T] extends [] ? void : Events[T][0];
|
||||||
|
return new Promise<Result>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const settle = () => {
|
||||||
|
if (settled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
unsubscribe();
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const unsubscribe = this.once(event, (...args: Events[T]) => {
|
||||||
|
if (settle()) {
|
||||||
|
resolve(args[0] as Result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settle()) {
|
||||||
|
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
const onAbort = () => {
|
||||||
|
if (settle()) {
|
||||||
|
resolve(undefined as Result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||||
const listeners = this.getListeners(event);
|
const listeners = this.getListeners(event);
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { getContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import {
|
||||||
|
computeContentMetrics,
|
||||||
|
getContentMetrics,
|
||||||
|
getNaturalSize,
|
||||||
|
mapContentRectToNatural,
|
||||||
|
mapContentToNatural,
|
||||||
|
mapNormalizedRectToContent,
|
||||||
|
mapNormalizedToContent,
|
||||||
|
scaleToCover,
|
||||||
|
scaleToFit,
|
||||||
|
} from '$lib/utils/container-utils';
|
||||||
|
|
||||||
const mockImage = (props: {
|
const mockImage = (props: {
|
||||||
naturalWidth: number;
|
naturalWidth: number;
|
||||||
@@ -92,3 +102,178 @@ describe('getNaturalSize', () => {
|
|||||||
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
|
expect(getNaturalSize(video)).toEqual({ width: 1920, height: 1080 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scaleToCover', () => {
|
||||||
|
it('should scale up to cover container when image is smaller', () => {
|
||||||
|
expect(scaleToCover({ width: 400, height: 300 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use height scale when image is wider than container', () => {
|
||||||
|
expect(scaleToCover({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
width: 1200,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use width scale when image is taller than container', () => {
|
||||||
|
expect(scaleToCover({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
width: 800,
|
||||||
|
height: 1600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeContentMetrics', () => {
|
||||||
|
it('should compute metrics with scaleToFit by default', () => {
|
||||||
|
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 400,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept scaleToCover as scale function', () => {
|
||||||
|
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 }, scaleToCover)).toEqual({
|
||||||
|
contentWidth: 1200,
|
||||||
|
contentHeight: 600,
|
||||||
|
offsetX: -200,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute zero offsets when aspect ratios match', () => {
|
||||||
|
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 450,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coordinate space glossary:
|
||||||
|
//
|
||||||
|
// "Normalized" coordinates: values in the 0–1 range, where (0,0) is the top-left
|
||||||
|
// of the image and (1,1) is the bottom-right. Resolution-independent.
|
||||||
|
//
|
||||||
|
// "Content" coordinates: pixel positions within the container, after the image
|
||||||
|
// has been scaled (scaleToFit/scaleToCover) and offset (centered). This is what
|
||||||
|
// CSS and DOM layout use for positioning overlays like face boxes and OCR text.
|
||||||
|
//
|
||||||
|
// "Natural" coordinates: pixel positions in the original image file at its full
|
||||||
|
// resolution (e.g. 4000×3000). Used when cropping or drawing on the source image.
|
||||||
|
//
|
||||||
|
// "Metadata pixel space": the coordinate system used by face detection / OCR
|
||||||
|
// models, where positions are in pixels relative to the image dimensions stored
|
||||||
|
// in metadata (face.imageWidth/imageHeight). These may differ from the natural
|
||||||
|
// dimensions if the image was resized. To convert to normalized, divide by
|
||||||
|
// the metadata dimensions (e.g. face.boundingBoxX1 / face.imageWidth).
|
||||||
|
|
||||||
|
describe('mapNormalizedToContent', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
|
||||||
|
it('should map top-left corner', () => {
|
||||||
|
expect(mapNormalizedToContent({ x: 0, y: 0 }, metrics)).toEqual({ x: 0, y: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map bottom-right corner', () => {
|
||||||
|
expect(mapNormalizedToContent({ x: 1, y: 1 }, metrics)).toEqual({ x: 800, y: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map center point', () => {
|
||||||
|
expect(mapNormalizedToContent({ x: 0.5, y: 0.5 }, metrics)).toEqual({ x: 400, y: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply offsets correctly for letterboxed content', () => {
|
||||||
|
const letterboxed = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
|
||||||
|
expect(mapNormalizedToContent({ x: 0, y: 0 }, letterboxed)).toEqual({ x: 250, y: 0 });
|
||||||
|
expect(mapNormalizedToContent({ x: 1, y: 1 }, letterboxed)).toEqual({ x: 550, y: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept Size (zero offsets)', () => {
|
||||||
|
const size = { width: 800, height: 400 };
|
||||||
|
expect(mapNormalizedToContent({ x: 0, y: 0 }, size)).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(mapNormalizedToContent({ x: 1, y: 1 }, size)).toEqual({ x: 800, y: 400 });
|
||||||
|
expect(mapNormalizedToContent({ x: 0.5, y: 0.5 }, size)).toEqual({ x: 400, y: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapContentToNatural', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
const natural = { width: 4000, height: 2000 };
|
||||||
|
|
||||||
|
it('should map content origin to natural origin', () => {
|
||||||
|
expect(mapContentToNatural({ x: 0, y: 100 }, metrics, natural)).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map content bottom-right to natural bottom-right', () => {
|
||||||
|
expect(mapContentToNatural({ x: 800, y: 500 }, metrics, natural)).toEqual({ x: 4000, y: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map content center to natural center', () => {
|
||||||
|
expect(mapContentToNatural({ x: 400, y: 300 }, metrics, natural)).toEqual({ x: 2000, y: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be the inverse of mapNormalizedToContent', () => {
|
||||||
|
const normalized = { x: 0.3, y: 0.7 };
|
||||||
|
const contentPoint = mapNormalizedToContent(normalized, metrics);
|
||||||
|
const naturalPoint = mapContentToNatural(contentPoint, metrics, natural);
|
||||||
|
expect(naturalPoint.x).toBeCloseTo(normalized.x * natural.width);
|
||||||
|
expect(naturalPoint.y).toBeCloseTo(normalized.y * natural.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapNormalizedRectToContent', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
|
||||||
|
it('should map a normalized rect to content pixel coordinates', () => {
|
||||||
|
const rect = mapNormalizedRectToContent({ x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 }, metrics);
|
||||||
|
expect(rect).toEqual({ left: 200, top: 200, width: 400, height: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map full image rect', () => {
|
||||||
|
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, metrics);
|
||||||
|
expect(rect).toEqual({ left: 0, top: 100, width: 800, height: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle letterboxed content with horizontal offsets', () => {
|
||||||
|
const letterboxed = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
|
||||||
|
const rect = mapNormalizedRectToContent({ x: 0, y: 0 }, { x: 1, y: 1 }, letterboxed);
|
||||||
|
expect(rect).toEqual({ left: 250, top: 0, width: 300, height: 600 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept Size (zero offsets)', () => {
|
||||||
|
const size = { width: 800, height: 400 };
|
||||||
|
const rect = mapNormalizedRectToContent({ x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 }, size);
|
||||||
|
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapContentRectToNatural', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
const natural = { width: 4000, height: 2000 };
|
||||||
|
|
||||||
|
it('should map a content rect to natural image coordinates', () => {
|
||||||
|
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, natural);
|
||||||
|
expect(rect).toEqual({ left: 1000, top: 500, width: 2000, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map full content rect to full natural dimensions', () => {
|
||||||
|
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, natural);
|
||||||
|
expect(rect).toEqual({ left: 0, top: 0, width: 4000, height: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be the inverse of mapNormalizedRectToContent', () => {
|
||||||
|
const normalized = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.9 } };
|
||||||
|
const contentRect = mapNormalizedRectToContent(normalized.topLeft, normalized.bottomRight, metrics);
|
||||||
|
const naturalRect = mapContentRectToNatural(contentRect, metrics, natural);
|
||||||
|
expect(naturalRect.left).toBeCloseTo(normalized.topLeft.x * natural.width);
|
||||||
|
expect(naturalRect.top).toBeCloseTo(normalized.topLeft.y * natural.height);
|
||||||
|
expect(naturalRect.width).toBeCloseTo((normalized.bottomRight.x - normalized.topLeft.x) * natural.width);
|
||||||
|
expect(naturalRect.height).toBeCloseTo((normalized.bottomRight.y - normalized.topLeft.y) * natural.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
export interface ContentMetrics {
|
// Coordinate spaces used throughout the viewer:
|
||||||
|
//
|
||||||
|
// "Normalized": 0–1 range, (0,0) = top-left, (1,1) = bottom-right. Resolution-independent.
|
||||||
|
// Example: OCR coordinates, or face coords after dividing by metadata dimensions.
|
||||||
|
//
|
||||||
|
// "Content": pixel position within the container after scaling (scaleToFit/scaleToCover)
|
||||||
|
// and centering. Used for DOM overlay positioning (face boxes, OCR text).
|
||||||
|
//
|
||||||
|
// "Natural": pixel position in the original full-resolution image file (e.g. 4000×3000).
|
||||||
|
// Used when cropping or drawing on the source image.
|
||||||
|
//
|
||||||
|
// "Metadata pixel space": coordinates from face detection / OCR models, in pixels relative
|
||||||
|
// to face.imageWidth/imageHeight. Divide by those dimensions to get normalized coords.
|
||||||
|
|
||||||
|
export type Point = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Size = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentMetrics = {
|
||||||
contentWidth: number;
|
contentWidth: number;
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
offsetX: number;
|
offsetX: number;
|
||||||
offsetY: number;
|
offsetY: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const scaleToCover = (
|
export const scaleToCover = (dimensions: Size, container: Size): Size => {
|
||||||
dimensions: { width: number; height: number },
|
|
||||||
container: { width: number; height: number },
|
|
||||||
): { width: number; height: number } => {
|
|
||||||
const scaleX = container.width / dimensions.width;
|
const scaleX = container.width / dimensions.width;
|
||||||
const scaleY = container.height / dimensions.height;
|
const scaleY = container.height / dimensions.height;
|
||||||
const scale = Math.max(scaleX, scaleY);
|
const scale = Math.max(scaleX, scaleY);
|
||||||
@@ -18,10 +39,7 @@ export const scaleToCover = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scaleToFit = (
|
export const scaleToFit = (dimensions: Size, container: Size): Size => {
|
||||||
dimensions: { width: number; height: number },
|
|
||||||
container: { width: number; height: number },
|
|
||||||
): { width: number; height: number } => {
|
|
||||||
const scaleX = container.width / dimensions.width;
|
const scaleX = container.width / dimensions.width;
|
||||||
const scaleY = container.height / dimensions.height;
|
const scaleY = container.height / dimensions.height;
|
||||||
const scale = Math.min(scaleX, scaleY);
|
const scale = Math.min(scaleX, scaleY);
|
||||||
@@ -31,28 +49,93 @@ export const scaleToFit = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||||||
if (element instanceof HTMLVideoElement) {
|
if (element instanceof HTMLVideoElement) {
|
||||||
return { width: element.clientWidth, height: element.clientHeight };
|
return { width: element.clientWidth, height: element.clientHeight };
|
||||||
}
|
}
|
||||||
return { width: element.width, height: element.height };
|
return { width: element.width, height: element.height };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
|
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||||||
if (element instanceof HTMLVideoElement) {
|
if (element instanceof HTMLVideoElement) {
|
||||||
return { width: element.videoWidth, height: element.videoHeight };
|
return { width: element.videoWidth, height: element.videoHeight };
|
||||||
}
|
}
|
||||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
export function computeContentMetrics(
|
||||||
const natural = getNaturalSize(element);
|
imageSize: Size,
|
||||||
const client = getElementSize(element);
|
containerSize: Size,
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
scaleFn: (dimensions: Size, container: Size) => Size = scaleToFit,
|
||||||
|
) {
|
||||||
|
const { width: contentWidth, height: contentHeight } = scaleFn(imageSize, containerSize);
|
||||||
return {
|
return {
|
||||||
contentWidth,
|
contentWidth,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
offsetX: (client.width - contentWidth) / 2,
|
offsetX: (containerSize.width - contentWidth) / 2,
|
||||||
offsetY: (client.height - contentHeight) / 2,
|
offsetY: (containerSize.height - contentHeight) / 2,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||||
|
const natural = getNaturalSize(element);
|
||||||
|
const client = getElementSize(element);
|
||||||
|
return computeContentMetrics(natural, client);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||||
|
if ('contentWidth' in sizeOrMetrics) {
|
||||||
|
return {
|
||||||
|
x: point.x * sizeOrMetrics.contentWidth + sizeOrMetrics.offsetX,
|
||||||
|
y: point.y * sizeOrMetrics.contentHeight + sizeOrMetrics.offsetY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: point.x * sizeOrMetrics.width,
|
||||||
|
y: point.y * sizeOrMetrics.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||||
|
return {
|
||||||
|
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||||||
|
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Rect = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapNormalizedRectToContent(
|
||||||
|
topLeft: Point,
|
||||||
|
bottomRight: Point,
|
||||||
|
sizeOrMetrics: Size | ContentMetrics,
|
||||||
|
): Rect {
|
||||||
|
const tl = mapNormalizedToContent(topLeft, sizeOrMetrics);
|
||||||
|
const br = mapNormalizedToContent(bottomRight, sizeOrMetrics);
|
||||||
|
return {
|
||||||
|
top: tl.y,
|
||||||
|
left: tl.x,
|
||||||
|
width: br.x - tl.x,
|
||||||
|
height: br.y - tl.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
|
||||||
|
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
|
||||||
|
const bottomRight = mapContentToNatural(
|
||||||
|
{ x: rect.left + rect.width, y: rect.top + rect.height },
|
||||||
|
metrics,
|
||||||
|
naturalSize,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
top: topLeft.y,
|
||||||
|
left: topLeft.x,
|
||||||
|
width: bottomRight.x - topLeft.x,
|
||||||
|
height: bottomRight.y - topLeft.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class InvocationTracker {
|
||||||
/** Counter for the number of invocations that have been started */
|
|
||||||
invocationsStarted = 0;
|
invocationsStarted = 0;
|
||||||
/** Counter for the number of invocations that have been completed */
|
|
||||||
invocationsEnded = 0;
|
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() {
|
startInvocation() {
|
||||||
this.invocationsStarted++;
|
this.invocationsStarted++;
|
||||||
const invocation = this.invocationsStarted;
|
const invocation = this.invocationsStarted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
isStillValid: () => invocation === this.invocationsStarted,
|
||||||
* Throws an error if this invocation is no longer valid
|
|
||||||
* @throws {Error} If the invocation is no longer valid
|
|
||||||
*/
|
|
||||||
isStillValid: () => {
|
|
||||||
if (invocation !== this.invocationsStarted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks this invocation as complete
|
|
||||||
*/
|
|
||||||
endInvocation: () => {
|
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() {
|
isActive() {
|
||||||
return this.invocationsStarted !== this.invocationsEnded;
|
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();
|
const invocation = this.startInvocation();
|
||||||
try {
|
try {
|
||||||
return await invocable();
|
return await invocable();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
handleError(error, localizedMessage);
|
if (catchCallback) {
|
||||||
|
catchCallback(error);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
invocation.endInvocation();
|
invocation.endInvocation();
|
||||||
|
finallyCallback?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
|
|
||||||
describe('getOcrBoundingBoxes', () => {
|
describe('getOcrBoundingBoxes', () => {
|
||||||
@@ -21,9 +21,9 @@ describe('getOcrBoundingBoxes', () => {
|
|||||||
text: 'hello',
|
text: 'hello',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const metrics: ContentMetrics = { contentWidth: 1000, contentHeight: 500, offsetX: 0, offsetY: 0 };
|
const imageSize: Size = { width: 1000, height: 500 };
|
||||||
|
|
||||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
|
||||||
|
|
||||||
expect(boxes).toHaveLength(1);
|
expect(boxes).toHaveLength(1);
|
||||||
expect(boxes[0].id).toBe('box1');
|
expect(boxes[0].id).toBe('box1');
|
||||||
@@ -37,7 +37,7 @@ describe('getOcrBoundingBoxes', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply offsets for letterboxed images', () => {
|
it('should map full-image box to full display area', () => {
|
||||||
const ocrData: OcrBoundingBox[] = [
|
const ocrData: OcrBoundingBox[] = [
|
||||||
{
|
{
|
||||||
id: 'box1',
|
id: 'box1',
|
||||||
@@ -55,21 +55,20 @@ describe('getOcrBoundingBoxes', () => {
|
|||||||
text: 'test',
|
text: 'test',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 400, offsetX: 100, offsetY: 50 };
|
const imageSize: Size = { width: 600, height: 400 };
|
||||||
|
|
||||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
|
||||||
|
|
||||||
expect(boxes[0].points).toEqual([
|
expect(boxes[0].points).toEqual([
|
||||||
{ x: 100, y: 50 },
|
{ x: 0, y: 0 },
|
||||||
{ x: 700, y: 50 },
|
{ x: 600, y: 0 },
|
||||||
{ x: 700, y: 450 },
|
{ x: 600, y: 400 },
|
||||||
{ x: 100, y: 450 },
|
{ x: 0, y: 400 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array for empty input', () => {
|
it('should return empty array for empty input', () => {
|
||||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
expect(getOcrBoundingBoxes([], { width: 800, height: 600 })).toEqual([]);
|
||||||
expect(getOcrBoundingBoxes([], metrics)).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple boxes', () => {
|
it('should handle multiple boxes', () => {
|
||||||
@@ -105,9 +104,9 @@ describe('getOcrBoundingBoxes', () => {
|
|||||||
text: 'second',
|
text: 'second',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const metrics: ContentMetrics = { contentWidth: 200, contentHeight: 200, offsetX: 0, offsetY: 0 };
|
const imageSize: Size = { width: 200, height: 200 };
|
||||||
|
|
||||||
const boxes = getOcrBoundingBoxes(ocrData, metrics);
|
const boxes = getOcrBoundingBoxes(ocrData, imageSize);
|
||||||
|
|
||||||
expect(boxes).toHaveLength(2);
|
expect(boxes).toHaveLength(2);
|
||||||
expect(boxes[0].text).toBe('first');
|
expect(boxes[0].text).toBe('first');
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
import { mapNormalizedToContent, type Point, type Size } from '$lib/utils/container-utils';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
|
export type { Point } from '$lib/utils/container-utils';
|
||||||
export type Point = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
|
||||||
export type VerticalMode = 'none' | 'cjk' | 'rotated';
|
export type VerticalMode = 'none' | 'cjk' | 'rotated';
|
||||||
|
|
||||||
export interface OcrBox {
|
export type OcrBox = {
|
||||||
id: string;
|
id: string;
|
||||||
points: Point[];
|
points: Point[];
|
||||||
text: string;
|
text: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
verticalMode: VerticalMode;
|
verticalMode: VerticalMode;
|
||||||
}
|
};
|
||||||
|
|
||||||
const CJK_PATTERN =
|
const CJK_PATTERN =
|
||||||
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
|
/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
|
||||||
@@ -38,7 +34,7 @@ const getVerticalMode = (width: number, height: number, text: string): VerticalM
|
|||||||
* @param points - Array of 4 corner points of the bounding box
|
* @param points - Array of 4 corner points of the bounding box
|
||||||
* @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div.
|
* @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div.
|
||||||
*/
|
*/
|
||||||
export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => {
|
export const calculateBoundingBoxMatrix = (points: Point[]): Size & { matrix: number[] } => {
|
||||||
const [topLeft, topRight, bottomRight, bottomLeft] = points;
|
const [topLeft, topRight, bottomRight, bottomLeft] = points;
|
||||||
|
|
||||||
const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight));
|
const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight));
|
||||||
@@ -163,7 +159,7 @@ export const calculateFittedFontSize = (
|
|||||||
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
|
return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => {
|
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], imageSize: Size): OcrBox[] => {
|
||||||
const boxes: OcrBox[] = [];
|
const boxes: OcrBox[] = [];
|
||||||
for (const ocr of ocrData) {
|
for (const ocr of ocrData) {
|
||||||
const points = [
|
const points = [
|
||||||
@@ -171,10 +167,7 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
|
|||||||
{ x: ocr.x2, y: ocr.y2 },
|
{ x: ocr.x2, y: ocr.y2 },
|
||||||
{ x: ocr.x3, y: ocr.y3 },
|
{ x: ocr.x3, y: ocr.y3 },
|
||||||
{ x: ocr.x4, y: ocr.y4 },
|
{ x: ocr.x4, y: ocr.y4 },
|
||||||
].map((point) => ({
|
].map((point) => mapNormalizedToContent(point, imageSize));
|
||||||
x: point.x * metrics.contentWidth + metrics.offsetX,
|
|
||||||
y: point.y * metrics.contentHeight + metrics.offsetY,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2]));
|
const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2]));
|
||||||
const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2]));
|
const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2]));
|
||||||
@@ -188,7 +181,7 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowThreshold = metrics.contentHeight * 0.02;
|
const rowThreshold = imageSize.height * 0.02;
|
||||||
boxes.sort((a, b) => {
|
boxes.sort((a, b) => {
|
||||||
const yDifference = a.points[0].y - b.points[0].y;
|
const yDifference = a.points[0].y - b.points[0].y;
|
||||||
if (Math.abs(yDifference) < rowThreshold) {
|
if (Math.abs(yDifference) < rowThreshold) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
import type { Size } 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 => ({
|
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||||
id: 'face-1',
|
id: 'face-1',
|
||||||
@@ -16,21 +16,21 @@ const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
|||||||
describe('getBoundingBox', () => {
|
describe('getBoundingBox', () => {
|
||||||
it('should scale face coordinates to display dimensions', () => {
|
it('should scale face coordinates to display dimensions', () => {
|
||||||
const face = makeFace();
|
const face = makeFace();
|
||||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
const imageSize: Size = { width: 800, height: 600 };
|
||||||
|
|
||||||
const boxes = getBoundingBox([face], metrics);
|
const boxes = getBoundingBox([face], imageSize);
|
||||||
|
|
||||||
expect(boxes).toHaveLength(1);
|
expect(boxes).toHaveLength(1);
|
||||||
expect(boxes[0]).toEqual({
|
expect(boxes[0]).toEqual({
|
||||||
id: 'face-1',
|
id: 'face-1',
|
||||||
top: Math.round(600 * (750 / 3000)),
|
top: 600 * (750 / 3000),
|
||||||
left: Math.round(800 * (1000 / 4000)),
|
left: 800 * (1000 / 4000),
|
||||||
width: Math.round(800 * (2000 / 4000) - 800 * (1000 / 4000)),
|
width: 800 * (2000 / 4000) - 800 * (1000 / 4000),
|
||||||
height: Math.round(600 * (1500 / 3000) - 600 * (750 / 3000)),
|
height: 600 * (1500 / 3000) - 600 * (750 / 3000),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply offsets for letterboxed display', () => {
|
it('should map full-image face to full display area', () => {
|
||||||
const face = makeFace({
|
const face = makeFace({
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
imageHeight: 1000,
|
imageHeight: 1000,
|
||||||
@@ -39,49 +39,21 @@ describe('getBoundingBox', () => {
|
|||||||
boundingBoxX2: 1000,
|
boundingBoxX2: 1000,
|
||||||
boundingBoxY2: 1000,
|
boundingBoxY2: 1000,
|
||||||
});
|
});
|
||||||
const metrics: ContentMetrics = { contentWidth: 600, contentHeight: 600, offsetX: 100, offsetY: 0 };
|
const imageSize: Size = { width: 600, height: 600 };
|
||||||
|
|
||||||
const boxes = getBoundingBox([face], metrics);
|
const boxes = getBoundingBox([face], imageSize);
|
||||||
|
|
||||||
expect(boxes[0]).toEqual({
|
expect(boxes[0]).toEqual({
|
||||||
id: 'face-1',
|
id: 'face-1',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 100,
|
left: 0,
|
||||||
width: 600,
|
width: 600,
|
||||||
height: 600,
|
height: 600,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle zoom by pre-scaled metrics', () => {
|
|
||||||
const face = makeFace({
|
|
||||||
imageWidth: 1000,
|
|
||||||
imageHeight: 1000,
|
|
||||||
boundingBoxX1: 0,
|
|
||||||
boundingBoxY1: 0,
|
|
||||||
boundingBoxX2: 500,
|
|
||||||
boundingBoxY2: 500,
|
|
||||||
});
|
|
||||||
const metrics: ContentMetrics = {
|
|
||||||
contentWidth: 1600,
|
|
||||||
contentHeight: 1200,
|
|
||||||
offsetX: -200,
|
|
||||||
offsetY: -100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const boxes = getBoundingBox([face], metrics);
|
|
||||||
|
|
||||||
expect(boxes[0]).toEqual({
|
|
||||||
id: 'face-1',
|
|
||||||
top: -100,
|
|
||||||
left: -200,
|
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for empty faces', () => {
|
it('should return empty array for empty faces', () => {
|
||||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
expect(getBoundingBox([], { width: 800, height: 600 })).toEqual([]);
|
||||||
expect(getBoundingBox([], metrics)).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple faces', () => {
|
it('should handle multiple faces', () => {
|
||||||
@@ -89,11 +61,103 @@ describe('getBoundingBox', () => {
|
|||||||
makeFace({ id: 'face-1', boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }),
|
makeFace({ id: 'face-1', boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 1000, boundingBoxY2: 1000 }),
|
||||||
makeFace({ id: 'face-2', boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }),
|
makeFace({ id: 'face-2', boundingBoxX1: 2000, boundingBoxY1: 1500, boundingBoxX2: 3000, boundingBoxY2: 2500 }),
|
||||||
];
|
];
|
||||||
const metrics: ContentMetrics = { contentWidth: 800, contentHeight: 600, offsetX: 0, offsetY: 0 };
|
|
||||||
|
|
||||||
const boxes = getBoundingBox(faces, metrics);
|
const boxes = getBoundingBox(faces, { width: 800, height: 600 });
|
||||||
|
|
||||||
expect(boxes).toHaveLength(2);
|
expect(boxes).toHaveLength(2);
|
||||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
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, { offsetX: 200, offsetY: 100, contentWidth: 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, { offsetX: 50, offsetY: 25, contentWidth: 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, { offsetX: 100, offsetY: 50, contentWidth: 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, { offsetX: 150, offsetY: 75, contentWidth: 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, contentWidth: 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, { offsetX: 200, offsetY: 100, contentWidth: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,42 +1,52 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
import { mapNormalizedRectToContent, type ContentMetrics, type Rect, type Size } from '$lib/utils/container-utils';
|
||||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
export interface BoundingBox {
|
export type BoundingBox = Rect & { id: string };
|
||||||
id: string;
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => {
|
export const getBoundingBox = (faces: Faces[], imageSize: Size): BoundingBox[] => {
|
||||||
const boxes: BoundingBox[] = [];
|
const boxes: BoundingBox[] = [];
|
||||||
|
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
const scaleX = metrics.contentWidth / face.imageWidth;
|
const rect = mapNormalizedRectToContent(
|
||||||
const scaleY = metrics.contentHeight / face.imageHeight;
|
{ x: face.boundingBoxX1 / face.imageWidth, y: face.boundingBoxY1 / face.imageHeight },
|
||||||
|
{ x: face.boundingBoxX2 / face.imageWidth, y: face.boundingBoxY2 / face.imageHeight },
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
const coordinates = {
|
boxes.push({ id: face.id, ...rect });
|
||||||
x1: scaleX * face.boundingBoxX1 + metrics.offsetX,
|
|
||||||
x2: scaleX * face.boundingBoxX2 + metrics.offsetX,
|
|
||||||
y1: scaleY * face.boundingBoxY1 + metrics.offsetY,
|
|
||||||
y2: scaleY * face.boundingBoxY2 + metrics.offsetY,
|
|
||||||
};
|
|
||||||
|
|
||||||
boxes.push({
|
|
||||||
id: face.id,
|
|
||||||
top: Math.round(coordinates.y1),
|
|
||||||
left: Math.round(coordinates.x1),
|
|
||||||
width: Math.round(coordinates.x2 - coordinates.x1),
|
|
||||||
height: Math.round(coordinates.y2 - coordinates.y1),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return boxes;
|
return boxes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FaceRectState = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResizeContext = Pick<ContentMetrics, 'contentWidth' | 'offsetX' | 'offsetY'>;
|
||||||
|
|
||||||
|
export const scaleFaceRectOnResize = (
|
||||||
|
faceRect: FaceRectState,
|
||||||
|
previous: ResizeContext,
|
||||||
|
current: ResizeContext,
|
||||||
|
): FaceRectState => {
|
||||||
|
const scale = current.contentWidth / previous.contentWidth;
|
||||||
|
const imageRelativeLeft = (faceRect.left - previous.offsetX) * scale;
|
||||||
|
const imageRelativeTop = (faceRect.top - previous.offsetY) * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: current.offsetX + imageRelativeLeft,
|
||||||
|
top: current.offsetY + imageRelativeTop,
|
||||||
|
scaleX: faceRect.scaleX * scale,
|
||||||
|
scaleY: faceRect.scaleY * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const zoomImageToBase64 = async (
|
export const zoomImageToBase64 = async (
|
||||||
face: AssetFaceResponseDto,
|
face: AssetFaceResponseDto,
|
||||||
assetId: string,
|
assetId: string,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
export function startViewerTransition(
|
||||||
|
assetId: string,
|
||||||
|
navigate: () => void,
|
||||||
|
setTransitionId: (id: string | null) => void,
|
||||||
|
) {
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['viewer'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
setTransitionId(assetId);
|
||||||
|
},
|
||||||
|
performUpdate: async (signal) => {
|
||||||
|
setTransitionId(null);
|
||||||
|
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||||
|
navigate();
|
||||||
|
await ready;
|
||||||
|
eventManager.emit('ViewerOpenTransition');
|
||||||
|
await tick();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeOverlay: HTMLElement | undefined;
|
||||||
|
|
||||||
|
export function removeCrossfadeOverlay() {
|
||||||
|
if (activeOverlay) {
|
||||||
|
activeOverlay.remove();
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateToTimeline(
|
||||||
|
assetId: string,
|
||||||
|
options: { types: string[]; prepareOldSnapshot?: () => void; onFinished?: () => void },
|
||||||
|
) {
|
||||||
|
let heroOverlay: HTMLElement | null = null;
|
||||||
|
let hiddenElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: options.types,
|
||||||
|
prepareOldSnapshot: options.prepareOldSnapshot,
|
||||||
|
performUpdate: async () => {
|
||||||
|
const scrolled = eventManager.untilNext('TimelineScrolledToAsset');
|
||||||
|
await goto(Route.photos({ at: assetId }));
|
||||||
|
await scrolled;
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const element = document.querySelector<HTMLElement>(`[data-asset-id="${CSS.escape(assetId)}"]`);
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const img = element.querySelector('img');
|
||||||
|
|
||||||
|
hiddenElement = element;
|
||||||
|
element.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
heroOverlay = document.createElement('div');
|
||||||
|
heroOverlay.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: ${rect.top}px;
|
||||||
|
left: ${rect.left}px;
|
||||||
|
width: ${rect.width}px;
|
||||||
|
height: ${rect.height}px;
|
||||||
|
view-transition-name: hero;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
if (img?.src) {
|
||||||
|
heroOverlay.style.backgroundImage = `url("${CSS.escape(img.src)}")`;
|
||||||
|
heroOverlay.style.backgroundSize = 'cover';
|
||||||
|
heroOverlay.style.backgroundPosition = 'center';
|
||||||
|
}
|
||||||
|
document.body.append(heroOverlay);
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
heroOverlay?.remove();
|
||||||
|
heroOverlay = null;
|
||||||
|
if (hiddenElement) {
|
||||||
|
hiddenElement.style.visibility = '';
|
||||||
|
hiddenElement = null;
|
||||||
|
}
|
||||||
|
options.onFinished?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
||||||
|
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
||||||
|
if (!viewerContent) {
|
||||||
|
await updateFn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCrossfadeOverlay();
|
||||||
|
|
||||||
|
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||||
|
Object.assign(clone.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '0',
|
||||||
|
zIndex: '1',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
delete clone.dataset.viewerContent;
|
||||||
|
if (!viewerContent.parentElement) {
|
||||||
|
await updateFn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewerContent.parentElement.append(clone);
|
||||||
|
activeOverlay = clone;
|
||||||
|
|
||||||
|
const ready = eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
await updateFn();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ready;
|
||||||
|
} catch {
|
||||||
|
clone.remove();
|
||||||
|
if (activeOverlay === clone) {
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeOut = clone.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||||
|
duration,
|
||||||
|
easing: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
fill: 'forwards',
|
||||||
|
});
|
||||||
|
|
||||||
|
void fadeOut.finished.then(() => {
|
||||||
|
clone.remove();
|
||||||
|
if (activeOverlay === clone) {
|
||||||
|
activeOverlay = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class:display-none={$showAssetViewer}>
|
<div class:invisible={$showAssetViewer}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<UploadCover />
|
<UploadCover />
|
||||||
@@ -33,7 +33,4 @@
|
|||||||
:root {
|
:root {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
.display-none {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,13 +6,16 @@
|
|||||||
import { timeToLoadTheMap } from '$lib/constants';
|
import { timeToLoadTheMap } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { delay } from '$lib/utils/asset-utils';
|
import { delay } from '$lib/utils/asset-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
|
import { linear } from 'svelte/easing';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -41,9 +44,14 @@
|
|||||||
handlePromiseError(goto(Route.photos()));
|
handlePromiseError(goto(Route.photos()));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onViewAssets(assetIds: string[]) {
|
function onViewAssets(assetIds: string[]) {
|
||||||
await setAssetId(assetIds[0]);
|
void viewTransitionManager.startTransition({
|
||||||
closeTimelinePanel();
|
types: ['viewer'],
|
||||||
|
performUpdate: async () => {
|
||||||
|
await setAssetId(assetIds[0]);
|
||||||
|
closeTimelinePanel();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
|
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
|
||||||
@@ -77,7 +85,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isTimelinePanelVisible && selectedClusterBBox}
|
{#if isTimelinePanelVisible && selectedClusterBBox}
|
||||||
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
|
<div
|
||||||
|
transition:fly={{ x: 400, duration: 150, easing: linear }}
|
||||||
|
class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0"
|
||||||
|
>
|
||||||
<MapTimelinePanel
|
<MapTimelinePanel
|
||||||
bbox={selectedClusterBBox}
|
bbox={selectedClusterBBox}
|
||||||
{selectedClusterIds}
|
{selectedClusterIds}
|
||||||
@@ -89,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</UserPageLayout>
|
</UserPageLayout>
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
{#if $showAssetViewer}
|
{#if $showAssetViewer && !isTimelinePanelVisible}
|
||||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||||
<AssetViewer
|
<AssetViewer
|
||||||
cursor={{ current: $viewingAsset }}
|
cursor={{ current: $viewingAsset }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
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 NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||||
import VersionAnnouncement from '$lib/components/VersionAnnouncement.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 { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||||
@@ -77,6 +78,8 @@
|
|||||||
|
|
||||||
let showNavigationLoadingBar = $state(false);
|
let showNavigationLoadingBar = $state(false);
|
||||||
|
|
||||||
|
appManager.isAssetViewer = isAssetViewerRoute(page);
|
||||||
|
|
||||||
const getMyImmichLink = () => {
|
const getMyImmichLink = () => {
|
||||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||||
};
|
};
|
||||||
@@ -102,8 +105,15 @@
|
|||||||
showNavigationLoadingBar = true;
|
showNavigationLoadingBar = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate(() => {
|
onNavigate(({ to }) => {
|
||||||
showNavigationLoadingBar = false;
|
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate(({ to, complete }) => {
|
||||||
|
appManager.isAssetViewer = isAssetViewerRoute(to);
|
||||||
|
void complete.finally(() => {
|
||||||
|
showNavigationLoadingBar = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { serverRestarting } = websocketStore;
|
const { serverRestarting } = websocketStore;
|
||||||
|
|||||||
Reference in New Issue
Block a user