mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
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:
@@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user