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.
This commit is contained in:
midzelis
2026-03-07 21:14:45 +00:00
parent 6fdf99ca16
commit 39d8c6d048
13 changed files with 848 additions and 209 deletions
@@ -284,7 +284,11 @@ 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 function toAssetResponseDto(
asset: MockTimelineAsset,
owner?: UserResponseDto,
overrides?: Partial<Pick<AssetResponseDto, 'people' | 'unassignedFaces'>>,
): AssetResponseDto {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Default owner if not provided // Default owner if not provided
@@ -338,8 +342,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
exifInfo, exifInfo,
livePhotoVideoId: asset.livePhotoVideoId, livePhotoVideoId: asset.livePhotoVideoId,
tags: [], tags: [],
people: [], people: overrides?.people ?? [],
unassignedFaces: [], unassignedFaces: overrides?.unassignedFaces ?? [],
stack: asset.stack, stack: asset.stack,
isOffline: false, isOffline: false,
hasMetadata: true, hasMetadata: true,
@@ -1,3 +1,9 @@
import {
type AssetFaceResponseDto,
type AssetFaceWithoutPersonResponseDto,
type AssetResponseDto,
type PersonWithFacesResponseDto,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline'; 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<string, AssetFaceResponseDto>();
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),
});
});
};
+55
View File
@@ -0,0 +1,55 @@
import { faker } from '@faker-js/faker';
import type { AssetOcrResponseDto } from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
export type MockOcrBox = {
text: string;
x1: number;
y1: number;
x2: number;
y2: number;
x3: number;
y3: number;
x4: number;
y4: number;
};
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
return boxes.map((box) => ({
id: faker.string.uuid(),
assetId,
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
x3: box.x3,
y3: box.y3,
x4: box.x4,
y4: box.y4,
boxScore: 0.95,
textScore: 0.9,
text: box.text,
}));
};
export const setupOcrMockApiRoutes = async (
context: BrowserContext,
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
) => {
await context.route('**/assets/*/ocr', async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
const url = new URL(request.url());
const segments = url.pathname.split('/');
const assetIdIndex = segments.indexOf('assets') + 1;
const assetId = segments[assetIdIndex];
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
json: ocrData,
});
});
};
@@ -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 });
});
});
@@ -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<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] .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<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] .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<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] .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);
});
});
@@ -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);
});
});
+13 -9
View File
@@ -1,11 +1,14 @@
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 }) => { export const zoomImageAction = (
node: HTMLElement,
options?: { disablePointer?: boolean; 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,26 +16,27 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
]; ];
const stopIfDisabled = (event: Event) => { const stopPointerIfDisabled = (event: Event) => {
if (options?.disabled) { if (options?.disablePointer) {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
}; };
node.addEventListener('wheel', stopIfDisabled, { capture: true }); node.addEventListener('pointerdown', stopPointerIfDisabled, { capture: true });
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
node.style.overflow = 'visible'; node.style.overflow = 'visible';
return { return {
update(newOptions?: { disabled?: boolean }) { update(newOptions?: { disablePointer?: boolean; zoomTarget?: HTMLElement }) {
options = newOptions; options = newOptions;
if (newOptions?.zoomTarget !== undefined) {
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
}
}, },
destroy() { destroy() {
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
unsubscribe(); unsubscribe();
} }
node.removeEventListener('wheel', stopIfDisabled, { capture: true }); node.removeEventListener('pointerdown', stopPointerIfDisabled, { capture: true });
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
zoomInstance.cleanup(); zoomInstance.cleanup();
}, },
}; };
+50 -64
View File
@@ -149,80 +149,66 @@
(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 will-change-transform" bind:this={ref}>
{@render backdrop?.()} {@render backdrop?.()}
<div <div class="absolute inset-0" style:left style:top style:width style:height>
class="absolute inset-0" {#if show.alphaBackground}
style:transform={zoomTransform} <AlphaBackground />
style:transform-origin={zoomTransform ? '0 0' : undefined} {/if}
>
<div class="absolute" style:left style:top style:width style:height>
{#if show.alphaBackground}
<AlphaBackground />
{/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>
@@ -9,6 +9,7 @@
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-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 { 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';
@@ -99,10 +100,11 @@
const stackThumbnailSize = 60; const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let previewStackedAsset: 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 nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset); const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
@@ -115,17 +117,25 @@
playOriginalVideo = value; playOriginalVideo = value;
}; };
const selectStackedAsset = async (id: string) => {
selectedStackAsset = await assetCacheManager.getAsset({ id });
};
const refreshStack = async () => { const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) { if (authManager.isSharedLink || !withStacked) {
return; return;
} }
if (asset.stack) { if (!cursor.current.stack) {
stack = await getStack({ id: asset.stack.id }); stack = undefined;
selectedStackAsset = undefined;
return;
} }
if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = await getStack({ id: cursor.current.stack.id });
stack = null; const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
} }
}; };
@@ -177,11 +187,21 @@
onClose?.(asset); 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 () => { const closeEditor = async () => {
if (editManager.hasAppliedEdits) { if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id }); await refreshPreservingSelection();
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
} }
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
}; };
@@ -281,10 +301,6 @@
} }
}; };
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
preAction?.(action); preAction?.(action);
}; };
@@ -297,7 +313,7 @@
break; break;
} }
case AssetAction.REMOVE_ASSET_FROM_STACK: { case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack; stack = action.stack ?? undefined;
if (stack) { if (stack) {
cursor.current = stack.assets[0]; 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 () => { 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(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset; cursor.current;
untrack(() => handlePromiseError(refresh())); untrack(() => handlePromiseError(refresh()));
}); });
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
previewStackedAsset;
untrack(() => handlePromiseError(refreshOcr()));
});
let lastCursor = $state<AssetCursor>(); let lastCursor = $state<AssetCursor>();
$effect(() => { $effect(() => {
@@ -444,6 +469,9 @@
navigateAsset('previous'); navigateAsset('previous');
} }
}; };
let containerWidth = $state(0);
let containerHeight = $state(0);
</script> </script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} /> <CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
@@ -455,6 +483,8 @@
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black" class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
use:focusTrap use:focusTrap
bind:this={assetViewerHtmlElement} bind:this={assetViewerHtmlElement}
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
> >
<!-- Top navigation bar --> <!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} {#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
@@ -527,7 +557,12 @@
{: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
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{asset} {asset}
@@ -560,6 +595,52 @@
<OcrButton /> <OcrButton />
</div> </div>
{/if} {/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div
id="stack-slideshow"
class="absolute bottom-0 max-w-[calc(100%-5rem)] col-span-4 col-start-1 pointer-events-none"
>
<div
role="presentation"
class="relative inline-flex flex-row flex-nowrap max-w-full overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={() => (previewStackedAsset = undefined)}
>
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={async () => {
await selectStackedAsset(stackedAsset.id);
previewStackedAsset = undefined;
}}
onMouseEvent={async ({ isMouseOver }) => {
if (isMouseOver) {
previewStackedAsset = stackedAsset;
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
}
}}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
<div class="w-full flex place-items-center place-content-center">
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
@@ -577,7 +658,7 @@
> >
{#if showDetailPanel} {#if showDetailPanel}
<div class="w-90 h-full"> <div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} /> <DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
</div> </div>
{:else if assetViewerManager.isShowEditor} {:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full"> <div class="w-100 h-full">
@@ -587,42 +668,6 @@
</div> </div>
{/if} {/if}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
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}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user} {#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
@@ -20,13 +20,7 @@
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils'; import { getParentPath } from '$lib/utils/tree-utils';
import { import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import { import {
mdiCalendar, mdiCalendar,
@@ -52,9 +46,10 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null; currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
} }
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let showAssetPath = $state(false); let showAssetPath = $state(false);
let showEditFaces = $state(false); let showEditFaces = $state(false);
@@ -120,11 +115,6 @@
return undefined; return undefined;
}; };
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
showEditFaces = false;
};
const getAssetFolderHref = (asset: AssetResponseDto) => { const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path // Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) }); return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -575,6 +565,6 @@
assetId={asset.id} assetId={asset.id}
assetType={asset.type} assetType={asset.type}
onClose={() => (showEditFaces = false)} onClose={() => (showEditFaces = false)}
onRefresh={handleRefreshPeople} onRefresh={() => void onRefreshPeople?.()}
/> />
{/if} {/if}
@@ -1,6 +1,5 @@
<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 { 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 { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
@@ -17,9 +16,10 @@
containerWidth: number; containerWidth: number;
containerHeight: number; containerHeight: number;
assetId: string; assetId: string;
onTagFace?: () => Promise<void>;
} }
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
@@ -263,7 +263,7 @@
}, },
}); });
await assetViewingStore.setAssetId(assetId); await onTagFace?.();
} catch (error) { } catch (error) {
handleError(error, 'Error tagging face'); handleError(error, 'Error tagging face');
} finally { } finally {
@@ -32,9 +32,10 @@
onReady?: () => void; onReady?: () => void;
onError?: () => void; onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void; onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
} }
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
@@ -103,12 +104,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') {
@@ -149,6 +144,8 @@
$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>();
@@ -161,36 +158,6 @@
}); });
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
return;
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlayMetrics);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} /> <AssetViewerEvents {onCopy} {onZoom} />
@@ -211,9 +178,7 @@
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
role="presentation" role="presentation"
ondblclick={onZoom} ondblclick={onZoom}
onmousemove={handleImageMouseMove} use:zoomImageAction={{ disablePointer: isFaceEditMode.value, zoomTarget: adaptiveImage }}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
{...useSwipe((event) => onSwipe?.(event))} {...useSwipe((event) => onSwipe?.(event))}
> >
<AdaptiveImage <AdaptiveImage
@@ -231,6 +196,7 @@
onReady?.(); onReady?.();
}} }}
bind:imgRef={assetViewerManager.imgRef} bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}
> >
{#snippet backdrop()} {#snippet backdrop()}
{#if blurredSlideshow} {#if blurredSlideshow}
@@ -241,21 +207,34 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet overlays()} {#snippet overlays()}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)} {#if !isFaceEditMode.value && !ocrManager.showOverlay}
<div {#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
class="absolute border-solid border-white border-3 rounded-lg" {@const face = faces[index]}
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" {@const name = faceToNameMap.get(face)}
></div> {@const isActive = $boundingBoxesArray.includes(face)}
{#if faceToNameMap.get($boundingBoxesArray[index])} <!-- svelte-ignore a11y_no_static_element_interactions -->
<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" class={[
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left + 'absolute pointer-events-auto outline-none rounded-lg',
boundingbox.width}px; transform: translateX(-100%);" isActive && 'border-solid border-white border-3',
]}
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
aria-label="{$t('person')}: {name || $t('unknown')}"
onmouseenter={() => ($boundingBoxesArray = [face])}
onmouseleave={() => ($boundingBoxesArray = [])}
> >
{faceToNameMap.get($boundingBoxesArray[index])} {#if isActive && name}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
>
{name}
</div>
{/if}
</div> </div>
{/if} {/each}
{/each} {/if}
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} /> <OcrBoundingBox {ocrBox} />
@@ -264,6 +243,12 @@
</AdaptiveImage> </AdaptiveImage>
{#if isFaceEditMode.value && assetViewerManager.imgRef} {#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
{/if} {/if}
</div> </div>
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -25,7 +25,6 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
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';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props { interface Props {
assetId: string; assetId: string;
@@ -179,7 +178,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id); peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId); onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) { } catch (error) {
handleError(error, $t('error_delete_face')); handleError(error, $t('error_delete_face'));
} }