From 39d8c6d0483c6729dec5b7a73c6ba902f0b48d71 Mon Sep 17 00:00:00 2001 From: midzelis Date: Sat, 7 Mar 2026 21:14:45 +0000 Subject: [PATCH] feat(web): image-relative overlays with zoom support for faces and OCR Split out from #26636 (adaptive image loading). Leverages the ContentMetrics extraction from #26310. Moves face bounding boxes and OCR overlays from viewport-relative to image-relative coordinates. New features: zoom with face editor open, zoom with OCR boxes visible, accurate face hover hit-testing at any zoom level. Bug fixes: face tagging on stacked assets, faces loading when browsing stacks, face editor auto-close on last face deletion, zoomTarget was null, disablePointer option name mismatch. --- .../ui/generators/timeline/rest-response.ts | 10 +- .../ui/mock-network/face-editor-network.ts | 120 +++++++++++ e2e/src/ui/mock-network/ocr-network.ts | 55 +++++ .../asset-viewer/face-overlay.e2e-spec.ts | 196 ++++++++++++++++++ e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts | 152 ++++++++++++++ .../asset-viewer/stack-face-tag.e2e-spec.ts | 100 +++++++++ web/src/lib/actions/zoom-image.ts | 22 +- web/src/lib/components/AdaptiveImage.svelte | 114 +++++----- .../asset-viewer/asset-viewer.svelte | 165 +++++++++------ .../asset-viewer/detail-panel.svelte | 18 +- .../face-editor/face-editor.svelte | 6 +- .../asset-viewer/photo-viewer.svelte | 91 ++++---- .../faces-page/person-side-panel.svelte | 8 +- 13 files changed, 848 insertions(+), 209 deletions(-) create mode 100644 e2e/src/ui/mock-network/ocr-network.ts create mode 100644 e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts create mode 100644 e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts create mode 100644 e2e/src/ui/specs/asset-viewer/stack-face-tag.e2e-spec.ts diff --git a/e2e/src/ui/generators/timeline/rest-response.ts b/e2e/src/ui/generators/timeline/rest-response.ts index 0c4bd06dc3..d677edbcb3 100644 --- a/e2e/src/ui/generators/timeline/rest-response.ts +++ b/e2e/src/ui/generators/timeline/rest-response.ts @@ -284,7 +284,11 @@ const createDefaultOwner = (ownerId: string) => { * Convert a TimelineAssetConfig to a full AssetResponseDto * This matches the response from GET /api/assets/:id */ -export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto { +export function toAssetResponseDto( + asset: MockTimelineAsset, + owner?: UserResponseDto, + overrides?: Partial>, +): AssetResponseDto { const now = new Date().toISOString(); // Default owner if not provided @@ -338,8 +342,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons exifInfo, livePhotoVideoId: asset.livePhotoVideoId, tags: [], - people: [], - unassignedFaces: [], + people: overrides?.people ?? [], + unassignedFaces: overrides?.unassignedFaces ?? [], stack: asset.stack, isOffline: false, hasMetadata: true, diff --git a/e2e/src/ui/mock-network/face-editor-network.ts b/e2e/src/ui/mock-network/face-editor-network.ts index 778f04baf9..c52c435a41 100644 --- a/e2e/src/ui/mock-network/face-editor-network.ts +++ b/e2e/src/ui/mock-network/face-editor-network.ts @@ -1,3 +1,9 @@ +import { + type AssetFaceResponseDto, + type AssetFaceWithoutPersonResponseDto, + type AssetResponseDto, + type PersonWithFacesResponseDto, +} from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; import { randomThumbnail } from 'src/ui/generators/timeline'; @@ -125,3 +131,117 @@ export const setupFaceEditorMockApiRoutes = async ( }); }); }; + +export type MockFaceSpec = { + personId: string; + personName: string; + faceId: string; + boundingBoxX1: number; + boundingBoxY1: number; + boundingBoxX2: number; + boundingBoxY2: number; +}; + +export const createMockFaceData = ( + faceSpecs: MockFaceSpec[], + imageWidth: number, + imageHeight: number, +): { people: PersonWithFacesResponseDto[]; unassignedFaces: AssetFaceWithoutPersonResponseDto[] } => { + const people: PersonWithFacesResponseDto[] = faceSpecs.map((spec) => ({ + id: spec.personId, + name: spec.personName, + birthDate: null, + isHidden: false, + thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`, + updatedAt: new Date().toISOString(), + faces: [ + { + id: spec.faceId, + imageWidth, + imageHeight, + boundingBoxX1: spec.boundingBoxX1, + boundingBoxY1: spec.boundingBoxY1, + boundingBoxX2: spec.boundingBoxX2, + boundingBoxY2: spec.boundingBoxY2, + }, + ], + })); + + return { people, unassignedFaces: [] }; +}; + +export const setupFaceOverlayMockApiRoutes = async ( + context: BrowserContext, + assetDto: AssetResponseDto, + faceSpecs: MockFaceSpec[], +) => { + const faceResponseMap = new Map(); + for (const spec of faceSpecs) { + faceResponseMap.set(spec.faceId, { + id: spec.faceId, + imageWidth: assetDto.width ?? 3000, + imageHeight: assetDto.height ?? 4000, + boundingBoxX1: spec.boundingBoxX1, + boundingBoxY1: spec.boundingBoxY1, + boundingBoxX2: spec.boundingBoxX2, + boundingBoxY2: spec.boundingBoxY2, + person: { + id: spec.personId, + name: spec.personName, + birthDate: null, + isHidden: false, + thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`, + updatedAt: new Date().toISOString(), + }, + }); + } + + await context.route(`**/api/assets/${assetDto.id}`, async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: assetDto, + }); + }); + + await context.route(`**/api/faces?id=${assetDto.id}`, async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: [...faceResponseMap.values()], + }); + }); + + await context.route('**/api/faces/*', async (route, request) => { + if (request.method() !== 'DELETE') { + return route.fallback(); + } + const url = new URL(request.url()); + const faceId = url.pathname.split('/').at(-1); + if (faceId) { + faceResponseMap.delete(faceId); + } + return route.fulfill({ + status: 200, + contentType: 'text/plain', + body: 'OK', + }); + }); + + await context.route('**/api/people/*/thumbnail', async (route) => { + if (!route.request().serviceWorker()) { + return route.continue(); + } + return route.fulfill({ + status: 200, + headers: { 'content-type': 'image/jpeg' }, + body: await randomThumbnail('person-thumb', 1), + }); + }); +}; diff --git a/e2e/src/ui/mock-network/ocr-network.ts b/e2e/src/ui/mock-network/ocr-network.ts new file mode 100644 index 0000000000..3b1a2fe62e --- /dev/null +++ b/e2e/src/ui/mock-network/ocr-network.ts @@ -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, +) => { + 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, + }); + }); +}; diff --git a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts new file mode 100644 index 0000000000..9ff230e9f2 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from '@playwright/test'; +import { toAssetResponseDto } from 'src/ui/generators/timeline'; +import { + createMockFaceData, + createMockPeople, + type MockFaceSpec, + setupFaceEditorMockApiRoutes, + setupFaceOverlayMockApiRoutes, +} 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, + }, +]; + +test.describe('face overlay bounding boxes', () => { + const fixture = setupAssetViewerFixture(901); + const mockPeople = createMockPeople(4); + + test.beforeEach(async ({ context }) => { + const faceData = createMockFaceData( + FACE_SPECS, + fixture.primaryAssetDto.width ?? 3000, + fixture.primaryAssetDto.height ?? 4000, + ); + const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData); + await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, FACE_SPECS); + await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] }); + }); + + 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(); + + await expect(aliceOverlay).not.toHaveClass(/border-solid/); + + await aliceOverlay.hover(); + await expect(aliceOverlay).toHaveClass(/border-solid/); + await expect(aliceOverlay).toHaveClass(/border-white/); + await expect(aliceOverlay).toHaveClass(/border-3/); + }); + + 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 = aliceOverlay.locator('div', { hasText: '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.describe('zoom and face editor interaction', () => { + const fixture = setupAssetViewerFixture(902); + const mockPeople = createMockPeople(4); + + test.beforeEach(async ({ context }) => { + const faceData = createMockFaceData( + FACE_SPECS, + fixture.primaryAssetDto.width ?? 3000, + fixture.primaryAssetDto.height ?? 4000, + ); + const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData); + await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, FACE_SPECS); + await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] }); + }); + + 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); + await page.waitForTimeout(300); + + const zoomedTransform = await page.locator('[data-viewer-content] img[draggable="false"]').evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + const isZoomed = zoomedTransform !== 'none' && zoomedTransform !== ''; + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await expect(page.locator('#face-editor')).toBeVisible(); + + if (isZoomed) { + const afterTransform = await page.locator('[data-viewer-content] img[draggable="false"]').evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + expect(afterTransform).not.toBe('none'); + } + }); +}); + +test.describe('face removal auto-close', () => { + const fixture = setupAssetViewerFixture(903); + const singleFaceSpec: MockFaceSpec[] = [ + { + personId: 'person-solo', + personName: 'Solo Person', + faceId: 'face-solo', + boundingBoxX1: 1000, + boundingBoxY1: 500, + boundingBoxX2: 1500, + boundingBoxY2: 1200, + }, + ]; + + test.beforeEach(async ({ context }) => { + const faceData = createMockFaceData( + singleFaceSpec, + fixture.primaryAssetDto.width ?? 3000, + fixture.primaryAssetDto.height ?? 4000, + ); + const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData); + await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, singleFaceSpec); + }); + + test('person side panel closes when last face is removed', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + + const editPeopleButton = page.locator('#detail-panel').getByLabel('Edit people'); + await expect(editPeopleButton).toBeVisible(); + await editPeopleButton.click(); + + const personName = page.locator('text=Solo Person'); + await expect(personName.first()).toBeVisible({ timeout: 5000 }); + + const deleteButton = page.getByLabel('Delete face'); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + const confirmButton = page.getByRole('button', { name: /confirm/i }); + await expect(confirmButton).toBeVisible(); + await confirmButton.click(); + + await expect(page.locator('text=Edit faces')).toBeHidden({ timeout: 5000 }); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts new file mode 100644 index 0000000000..2718d38800 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts @@ -0,0 +1,152 @@ +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([ + [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] .border-blue-500'); + 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] .border-blue-500').first()).toBeVisible(); + + await ocrButton.click(); + await expect(page.locator('[data-viewer-content] .border-blue-500')).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([ + [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] .border-blue-500'); + 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([ + [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] .border-blue-500').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 page.waitForTimeout(500); + + const zoomedBox = await ocrBox.boundingBox(); + expect(zoomedBox).toBeTruthy(); + + expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width); + expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height); + }); +}); diff --git a/e2e/src/ui/specs/asset-viewer/stack-face-tag.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/stack-face-tag.e2e-spec.ts new file mode 100644 index 0000000000..1e27d1b12f --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/stack-face-tag.e2e-spec.ts @@ -0,0 +1,100 @@ +import { type 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 { + createMockPeople, + FaceCreateCapture, + MockPerson, + setupFaceEditorMockApiRoutes, +} from 'src/ui/mock-network/face-editor-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils'; + +test.describe.configure({ mode: 'parallel' }); +test.describe('stack face-tag selection preservation', () => { + const fixture = setupAssetViewerFixture(910); + let mockStack: MockStack; + let primaryAssetDto: AssetResponseDto; + let secondAssetDto: AssetResponseDto; + let mockPeople: MockPerson[]; + let faceCreateCapture: FaceCreateCapture; + + test.beforeAll(async () => { + primaryAssetDto = toAssetResponseDto(fixture.primaryAsset); + secondAssetDto = createMockStackAsset(fixture.adminUserId); + secondAssetDto.originalFileName = 'second-stacked-asset.jpg'; + mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set()); + mockPeople = createMockPeople(3); + }); + + test.beforeEach(async ({ context }) => { + faceCreateCapture = { requests: [] }; + await setupBrokenAssetMockApiRoutes(context, mockStack); + await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture); + }); + + test('selected stacked asset is preserved after tagging a face', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const stackSlideshow = page.locator('#stack-slideshow'); + await expect(stackSlideshow).toBeVisible(); + + const stackThumbnails = stackSlideshow.locator('[data-asset]'); + await expect(stackThumbnails).toHaveCount(2); + + await stackThumbnails.nth(1).click(); + + await ensureDetailPanelVisible(page); + await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg'); + + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await page.locator('#face-selector').getByText(mockPeople[0].name).click(); + + const confirmButton = page.getByRole('button', { name: /confirm/i }); + await expect(confirmButton).toBeVisible(); + await confirmButton.click(); + + await expect(page.locator('#face-selector')).toBeHidden(); + + expect(faceCreateCapture.requests).toHaveLength(1); + expect(faceCreateCapture.requests[0].assetId).toBe(secondAssetDto.id); + + await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg'); + + const selectedThumbnail = stackSlideshow.locator(`[data-asset="${secondAssetDto.id}"]`); + await expect(selectedThumbnail).toBeVisible(); + }); + + test('primary asset stays selected after tagging a face without switching', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName); + + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await page.locator('#face-selector').getByText(mockPeople[0].name).click(); + + const confirmButton = page.getByRole('button', { name: /confirm/i }); + await expect(confirmButton).toBeVisible(); + await confirmButton.click(); + + await expect(page.locator('#face-selector')).toBeHidden(); + + expect(faceCreateCapture.requests).toHaveLength(1); + expect(faceCreateCapture.requests[0].assetId).toBe(primaryAssetDto.id); + + await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName); + }); +}); diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 2b0dddfe8e..aa617ad480 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -1,11 +1,14 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { createZoomImageWheel } from '@zoom-image/core'; -export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { +export const zoomImageAction = ( + node: HTMLElement, + options?: { disablePointer?: boolean; zoomTarget?: HTMLElement }, +) => { const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState, - zoomTarget: null, + zoomTarget: options?.zoomTarget, }); const unsubscribes = [ @@ -13,26 +16,27 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), ]; - const stopIfDisabled = (event: Event) => { - if (options?.disabled) { + const stopPointerIfDisabled = (event: Event) => { + if (options?.disablePointer) { event.stopImmediatePropagation(); } }; - node.addEventListener('wheel', stopIfDisabled, { capture: true }); - node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.addEventListener('pointerdown', stopPointerIfDisabled, { capture: true }); node.style.overflow = 'visible'; return { - update(newOptions?: { disabled?: boolean }) { + update(newOptions?: { disablePointer?: boolean; zoomTarget?: HTMLElement }) { options = newOptions; + if (newOptions?.zoomTarget !== undefined) { + zoomInstance.setState({ zoomTarget: newOptions.zoomTarget }); + } }, destroy() { for (const unsubscribe of unsubscribes) { unsubscribe(); } - node.removeEventListener('wheel', stopIfDisabled, { capture: true }); - node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.removeEventListener('pointerdown', stopPointerIfDisabled, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 92e3fad2d3..5558e84b11 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -149,80 +149,66 @@ (quality.preview === 'success' ? previewElement : undefined) ?? (quality.thumbnail === 'success' ? thumbnailElement : undefined); }); - - const zoomTransform = $derived.by(() => { - const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; - if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) { - return undefined; - } - return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`; - });
{@render backdrop?.()} -
-
- {#if show.alphaBackground} - - {/if} +
+ {#if show.alphaBackground} + + {/if} - {#if show.thumbhash} - {#if asset.thumbhash} - - - {:else if show.spinner} - - {/if} + {#if show.thumbhash} + {#if asset.thumbhash} + + + {:else if show.spinner} + {/if} + {/if} - {#if show.thumbnail} - - {/if} + {#if show.thumbnail} + + {/if} - {#if show.brokenAsset} - - {/if} + {#if show.brokenAsset} + + {/if} - {#if show.preview} - - {/if} + {#if show.preview} + + {/if} - {#if show.original} - - {/if} -
+ {#if show.original} + + {/if}
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 46353170e8..5115ab18f1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -9,6 +9,7 @@ import { AssetAction, ProjectionType } from '$lib/constants'; import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; @@ -99,10 +100,11 @@ const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; + let stack: StackResponseDto | undefined = $state(); + let selectedStackAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state(); - let stack: StackResponseDto | null = $state(null); - const asset = $derived(previewStackedAsset ?? cursor.current); + const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current); const nextAsset = $derived(cursor.nextAsset); const previousAsset = $derived(cursor.previousAsset); let sharedLink = getSharedLink(); @@ -115,17 +117,25 @@ playOriginalVideo = value; }; + const selectStackedAsset = async (id: string) => { + selectedStackAsset = await assetCacheManager.getAsset({ id }); + }; + const refreshStack = async () => { if (authManager.isSharedLink || !withStacked) { return; } - if (asset.stack) { - stack = await getStack({ id: asset.stack.id }); + if (!cursor.current.stack) { + stack = undefined; + selectedStackAsset = undefined; + return; } - if (!stack?.assets.some(({ id }) => id === asset.id)) { - stack = null; + stack = await getStack({ id: cursor.current.stack.id }); + const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId); + if (primaryAsset) { + await selectStackedAsset(primaryAsset.id); } }; @@ -177,11 +187,21 @@ onClose?.(asset); }; + const refreshPreservingSelection = async () => { + const id = asset.id; + assetCacheManager.invalidateAsset(id); + if (selectedStackAsset) { + await selectStackedAsset(id); + } else { + const asset = await assetCacheManager.getAsset({ id }); + assetViewingStore.setAsset(asset); + } + onAssetChange?.(asset); + }; + const closeEditor = async () => { if (editManager.hasAppliedEdits) { - const refreshedAsset = await getAssetInfo({ id: asset.id }); - onAssetChange?.(refreshedAsset); - assetViewingStore.setAsset(refreshedAsset); + await refreshPreservingSelection(); } assetViewerManager.closeEditor(); }; @@ -281,10 +301,6 @@ } }; - const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { - previewStackedAsset = isMouseOver ? stackedAsset : undefined; - }; - const handlePreAction = (action: Action) => { preAction?.(action); }; @@ -297,7 +313,7 @@ break; } case AssetAction.REMOVE_ASSET_FROM_STACK: { - stack = action.stack; + stack = action.stack ?? undefined; if (stack) { cursor.current = stack.assets[0]; } @@ -351,23 +367,32 @@ } }; + const refreshOcr = async () => { + ocrManager.clear(); + if (sharedLink) { + return; + } + + await ocrManager.getAssetOcr(asset.id); + }; + const refresh = async () => { await refreshStack(); - ocrManager.clear(); - if (!sharedLink) { - if (previewStackedAsset) { - await ocrManager.getAssetOcr(previewStackedAsset.id); - } - await ocrManager.getAssetOcr(asset.id); - } + await refreshOcr(); }; $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - asset; + cursor.current; untrack(() => handlePromiseError(refresh())); }); + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + previewStackedAsset; + untrack(() => handlePromiseError(refreshOcr())); + }); + let lastCursor = $state(); $effect(() => { @@ -444,6 +469,9 @@ navigateAsset('previous'); } }; + + let containerWidth = $state(0); + let containerHeight = $state(0); @@ -455,6 +483,8 @@ class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black" use:focusTrap bind:this={assetViewerHtmlElement} + bind:clientWidth={containerWidth} + bind:clientHeight={containerHeight} > {#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} @@ -527,7 +557,12 @@ {:else if viewerKind === 'CropArea'} {:else if viewerKind === 'PhotoViewer'} - + {:else if viewerKind === 'VideoViewer'}
{/if} + + {#if stack && withStacked && !assetViewerManager.isShowEditor} + {@const stackedAssets = stack.assets} +
+ +
+ {/if} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset} @@ -577,7 +658,7 @@ > {#if showDetailPanel}
- +
{:else if assetViewerManager.isShowEditor}
@@ -587,42 +668,6 @@
{/if} - {#if stack && withStacked && !assetViewerManager.isShowEditor} - {@const stackedAssets = stack.assets} -
-
- {#each stackedAssets as stackedAsset (stackedAsset.id)} -
- { - cursor.current = stackedAsset; - previewStackedAsset = undefined; - }} - onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} - readonly - thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} - showStackedIcon={false} - disableLinkMouseOver - /> - - {#if stackedAsset.id === asset.id} -
-
-
- {/if} -
- {/each} -
-
- {/if} - {#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
Promise; } - let { asset, currentAlbum = null }: Props = $props(); + let { asset, currentAlbum = null, onRefreshPeople }: Props = $props(); let showAssetPath = $state(false); let showEditFaces = $state(false); @@ -120,11 +115,6 @@ return undefined; }; - const handleRefreshPeople = async () => { - asset = await getAssetInfo({ id: asset.id }); - showEditFaces = false; - }; - const getAssetFolderHref = (asset: AssetResponseDto) => { // Remove the last part of the path to get the parent path return Route.folders({ path: getParentPath(asset.originalPath) }); @@ -575,6 +565,6 @@ assetId={asset.id} assetType={asset.type} onClose={() => (showEditFaces = false)} - onRefresh={handleRefreshPeople} + onRefresh={() => void onRefreshPeople?.()} /> {/if} diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index e84bc9fa0c..782306edbb 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -1,6 +1,5 @@ @@ -211,9 +178,7 @@ bind:clientHeight={containerHeight} role="presentation" ondblclick={onZoom} - onmousemove={handleImageMouseMove} - onmouseleave={handleImageMouseLeave} - use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }} + use:zoomImageAction={{ disablePointer: isFaceEditMode.value, zoomTarget: adaptiveImage }} {...useSwipe((event) => onSwipe?.(event))} > {#snippet backdrop()} {#if blurredSlideshow} @@ -241,21 +207,34 @@ {/if} {/snippet} {#snippet overlays()} - {#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)} -
- {#if faceToNameMap.get($boundingBoxesArray[index])} + {#if !isFaceEditMode.value && !ocrManager.showOverlay} + {#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)} + {@const face = faces[index]} + {@const name = faceToNameMap.get(face)} + {@const isActive = $boundingBoxesArray.includes(face)} +
($boundingBoxesArray = [face])} + onmouseleave={() => ($boundingBoxesArray = [])} > - {faceToNameMap.get($boundingBoxesArray[index])} + {#if isActive && name} + + {/if}
- {/if} - {/each} + {/each} + {/if} {#each ocrBoxes as ocrBox (ocrBox.id)} @@ -264,6 +243,12 @@
{#if isFaceEditMode.value && assetViewerManager.imgRef} - + {/if}
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 6bcede36c3..15545da0cc 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -1,8 +1,8 @@