mirror of
https://github.com/immich-app/immich.git
synced 2026-03-12 21:42:54 -07:00
feat(web): image-relative overlays with zoom support for faces, OCR, and face editor
- Move zoom transform into AdaptiveImage so overlays (face boxes, OCR) scale naturally with zoom - Face editor selection rectangle scales with zoom via Fabric.js viewport transform - Forward wheel events from sibling overlays to zoom handler via capturing listener - Replace container-level mousemove hit-testing with per-face mouseenter/mouseleave - Add zoomTarget option to zoom-image action, rename disabled to disablePointer - Move face editor from photo-viewer to asset-viewer for shared container dimensions - Scale face rect proportionally on container resize - Use object-space coordinates for face tagging to stay zoom-independent - Refactor OCR refresh for stacked assets - Move stacked assets UI into viewer content div
This commit is contained in:
@@ -7,8 +7,10 @@ import {
|
||||
AssetVisibility,
|
||||
UserAvatarColor,
|
||||
type AlbumResponseDto,
|
||||
type AssetFaceWithoutPersonResponseDto,
|
||||
type AssetResponseDto,
|
||||
type ExifResponseDto,
|
||||
type PersonWithFacesResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
type TimeBucketsResponseDto,
|
||||
type UserResponseDto,
|
||||
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
export type FaceData = {
|
||||
people: PersonWithFacesResponseDto[];
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
|
||||
};
|
||||
|
||||
export function toAssetResponseDto(
|
||||
asset: MockTimelineAsset,
|
||||
owner?: UserResponseDto,
|
||||
faceData?: FaceData,
|
||||
): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
people: faceData?.people ?? [],
|
||||
unassignedFaces: faceData?.unassignedFaces ?? [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AssetFaceWithoutPersonResponseDto, AssetResponseDto, PersonWithFacesResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
@@ -125,3 +126,60 @@ export const setupFaceEditorMockApiRoutes = async (
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export type MockFaceSpec = {
|
||||
personId: string;
|
||||
personName: string;
|
||||
faceId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY2: number;
|
||||
};
|
||||
|
||||
export type MockFaceData = {
|
||||
people: PersonWithFacesResponseDto[];
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
|
||||
};
|
||||
|
||||
export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): MockFaceData => {
|
||||
const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({
|
||||
id: spec.personId,
|
||||
name: spec.personName,
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
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) => {
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const assetId = url.pathname.split('/').at(-1);
|
||||
if (assetId !== assetDto.id) {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assetDto,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
55
e2e/src/ui/mock-network/ocr-network.ts
Normal file
55
e2e/src/ui/mock-network/ocr-network.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
};
|
||||
147
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
147
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
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);
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
152
e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts
Normal file
152
e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: null,
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
@@ -13,15 +13,12 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
const onInteractionStart = (event: Event) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
assetViewerManager.cancelZoomAnimation();
|
||||
};
|
||||
const cancelAnimation = () => assetViewerManager.cancelZoomAnimation();
|
||||
|
||||
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
node.addEventListener('pointerdown', cancelAnimation, { capture: true, signal });
|
||||
|
||||
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
|
||||
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
|
||||
@@ -35,25 +32,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
node.addEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
node.addEventListener('pointerdown', trackPointerType, { capture: true, signal });
|
||||
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true, signal });
|
||||
|
||||
// Allow zoomed content to render outside the container bounds
|
||||
node.style.overflow = 'visible';
|
||||
// Prevent browser handling of touch gestures so zoom-image can manage them
|
||||
node.style.touchAction = 'none';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
update(newOptions?: { zoomTarget?: HTMLElement }) {
|
||||
options = newOptions;
|
||||
if (newOptions?.zoomTarget !== undefined) {
|
||||
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
controller.abort();
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,81 +149,66 @@
|
||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||
});
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
>
|
||||
<div class="absolute" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
<div class="absolute inset-0 pointer-events-none" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
onDestroy(() => {
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
isFaceEditMode.value = false;
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
});
|
||||
@@ -358,15 +359,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOcr = async () => {
|
||||
ocrManager.clear();
|
||||
if (sharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
await refreshOcr();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
@@ -375,6 +379,12 @@
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
previewStackedAsset;
|
||||
untrack(() => handlePromiseError(refreshOcr()));
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
@@ -568,6 +578,47 @@
|
||||
<OcrButton />
|
||||
</div>
|
||||
{/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={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
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>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
@@ -595,42 +646,6 @@
|
||||
</div>
|
||||
{/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}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleFaceRectOnResize } from '$lib/utils/people-utils';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
@@ -31,6 +33,10 @@
|
||||
|
||||
let searchTerm = $state('');
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
let initialized = false;
|
||||
let previousContentWidth = 0;
|
||||
let previousOffsetX = 0;
|
||||
let previousOffsetY = 0;
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@@ -77,9 +83,37 @@
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
setupCanvas();
|
||||
await getPeople();
|
||||
void getPeople();
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.selection = false;
|
||||
|
||||
const upperCanvas = canvas.upperCanvasEl;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const stopIfOnTarget = (event: Event) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const { target } = canvas.findTarget(event as PointerEvent);
|
||||
if (target) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
for (const type of ['pointerdown', 'pointermove', 'pointerup'] as const) {
|
||||
upperCanvas.addEventListener(type, stopIfOnTarget, { signal });
|
||||
}
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
@@ -107,34 +141,50 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||
|
||||
if (!canvas || contentWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||
|
||||
if (!faceRect) {
|
||||
if (faceRect) {
|
||||
faceRect.set({ top: offsetY + 200, left: offsetX + 200 });
|
||||
faceRect.setCoords();
|
||||
}
|
||||
|
||||
previousContentWidth = contentWidth;
|
||||
previousOffsetX = offsetX;
|
||||
previousOffsetY = offsetY;
|
||||
positionFaceSelector();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||
|
||||
if (faceRect && previousContentWidth > 0) {
|
||||
const scaled = scaleFaceRectOnResize(
|
||||
{ left: faceRect.left, top: faceRect.top, scaleX: faceRect.scaleX, scaleY: faceRect.scaleY },
|
||||
{ previousOffsetX, previousOffsetY, previousContentWidth },
|
||||
offsetX,
|
||||
offsetY,
|
||||
contentWidth,
|
||||
);
|
||||
faceRect.set(scaled);
|
||||
faceRect.setCoords();
|
||||
}
|
||||
|
||||
previousContentWidth = contentWidth;
|
||||
previousOffsetX = offsetX;
|
||||
previousOffsetY = offsetY;
|
||||
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
|
||||
const faceBox = faceRect.getBoundingRect();
|
||||
return !(
|
||||
0 > faceBox.left + faceBox.width ||
|
||||
0 > faceBox.top + faceBox.height ||
|
||||
canvas.width < faceBox.left ||
|
||||
canvas.height < faceBox.top
|
||||
);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
};
|
||||
@@ -163,11 +213,12 @@
|
||||
const gap = 15;
|
||||
const padding = faceRect.padding ?? 0;
|
||||
const rawBox = faceRect.getBoundingRect();
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const faceBox = {
|
||||
left: rawBox.left - padding,
|
||||
top: rawBox.top - padding,
|
||||
width: rawBox.width + padding * 2,
|
||||
height: rawBox.height + padding * 2,
|
||||
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||
top: (rawBox.top - padding) * currentZoom + currentPositionY,
|
||||
width: (rawBox.width + padding * 2) * currentZoom,
|
||||
height: (rawBox.height + padding * 2) * currentZoom,
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
@@ -218,6 +269,17 @@
|
||||
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const rect = faceRect;
|
||||
if (rect) {
|
||||
@@ -235,7 +297,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const left = faceRect.left;
|
||||
const top = faceRect.top;
|
||||
const width = faceRect.getScaledWidth();
|
||||
const height = faceRect.getScaledHeight();
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
@@ -299,10 +364,15 @@
|
||||
>
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onpointermove={(e) => e.stopPropagation()}
|
||||
onpointerup={(e) => e.stopPropagation()}
|
||||
onwheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||
|
||||
|
||||
@@ -104,12 +104,6 @@
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO move to action + command palette
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (globalThis.getSelection()?.type === 'Range') {
|
||||
@@ -150,6 +144,8 @@
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
@@ -162,36 +158,6 @@
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
@@ -212,9 +178,7 @@
|
||||
bind:clientHeight={containerHeight}
|
||||
role="presentation"
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
|
||||
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
<AdaptiveImage
|
||||
@@ -232,6 +196,7 @@
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:ref={adaptiveImage}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
@@ -242,21 +207,34 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
||||
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
{@const face = faces[index]}
|
||||
{@const name = faceToNameMap.get(face)}
|
||||
{@const isActive = $boundingBoxesArray.includes(face)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||
boundingbox.width}px; transform: translateX(-100%);"
|
||||
class={[
|
||||
'absolute pointer-events-auto outline-none rounded-lg',
|
||||
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>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getBoundingBox, scaleFaceRectOnResize, type FaceRectState, type ResizeContext } from '$lib/utils/people-utils';
|
||||
|
||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||
id: 'face-1',
|
||||
@@ -97,3 +97,96 @@ describe('getBoundingBox', () => {
|
||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaleFaceRectOnResize', () => {
|
||||
const makeRect = (overrides: Partial<FaceRectState> = {}): FaceRectState => ({
|
||||
left: 300,
|
||||
top: 400,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePrevious = (overrides: Partial<ResizeContext> = {}): ResizeContext => ({
|
||||
previousOffsetX: 100,
|
||||
previousOffsetY: 50,
|
||||
previousContentWidth: 800,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should preserve relative position when container doubles in size', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
|
||||
|
||||
// imageRelLeft = (300 - 100) * 2 = 400, new left = 200 + 400 = 600
|
||||
// imageRelTop = (250 - 50) * 2 = 400, new top = 100 + 400 = 500
|
||||
expect(result.left).toBe(600);
|
||||
expect(result.top).toBe(500);
|
||||
expect(result.scaleX).toBe(2);
|
||||
expect(result.scaleY).toBe(2);
|
||||
});
|
||||
|
||||
it('should preserve relative position when container halves in size', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 50, 25, 400);
|
||||
|
||||
// imageRelLeft = (300 - 100) * 0.5 = 100, new left = 50 + 100 = 150
|
||||
// imageRelTop = (250 - 50) * 0.5 = 100, new top = 25 + 100 = 125
|
||||
expect(result.left).toBe(150);
|
||||
expect(result.top).toBe(125);
|
||||
expect(result.scaleX).toBe(0.5);
|
||||
expect(result.scaleY).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should handle no change in dimensions', () => {
|
||||
const rect = makeRect({ left: 300, top: 250, scaleX: 1.5, scaleY: 1.5 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 100, 50, 800);
|
||||
|
||||
expect(result.left).toBe(300);
|
||||
expect(result.top).toBe(250);
|
||||
expect(result.scaleX).toBe(1.5);
|
||||
expect(result.scaleY).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should handle offset changes without content width change', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 150, 75, 800);
|
||||
|
||||
// scale = 1, imageRelLeft = 200, imageRelTop = 200
|
||||
// new left = 150 + 200 = 350, new top = 75 + 200 = 275
|
||||
expect(result.left).toBe(350);
|
||||
expect(result.top).toBe(275);
|
||||
expect(result.scaleX).toBe(1);
|
||||
expect(result.scaleY).toBe(1);
|
||||
});
|
||||
|
||||
it('should compound existing scale factors', () => {
|
||||
const rect = makeRect({ left: 300, top: 250, scaleX: 2, scaleY: 3 });
|
||||
const previous = makePrevious({ previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, previous.previousOffsetX, previous.previousOffsetY, 1600);
|
||||
|
||||
expect(result.scaleX).toBe(4);
|
||||
expect(result.scaleY).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle rect at image origin (top-left of content area)', () => {
|
||||
const rect = makeRect({ left: 100, top: 50 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
|
||||
|
||||
// imageRelLeft = (100 - 100) * 2 = 0, new left = 200
|
||||
// imageRelTop = (50 - 50) * 2 = 0, new top = 100
|
||||
expect(result.left).toBe(200);
|
||||
expect(result.top).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,38 @@ export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): Boundin
|
||||
return boxes;
|
||||
};
|
||||
|
||||
export type FaceRectState = {
|
||||
left: number;
|
||||
top: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
|
||||
export type ResizeContext = {
|
||||
previousOffsetX: number;
|
||||
previousOffsetY: number;
|
||||
previousContentWidth: number;
|
||||
};
|
||||
|
||||
export const scaleFaceRectOnResize = (
|
||||
faceRect: FaceRectState,
|
||||
previous: ResizeContext,
|
||||
newOffsetX: number,
|
||||
newOffsetY: number,
|
||||
newContentWidth: number,
|
||||
): FaceRectState => {
|
||||
const scale = newContentWidth / previous.previousContentWidth;
|
||||
const imageRelativeLeft = (faceRect.left - previous.previousOffsetX) * scale;
|
||||
const imageRelativeTop = (faceRect.top - previous.previousOffsetY) * scale;
|
||||
|
||||
return {
|
||||
left: newOffsetX + imageRelativeLeft,
|
||||
top: newOffsetY + imageRelativeTop,
|
||||
scaleX: faceRect.scaleX * scale,
|
||||
scaleY: faceRect.scaleY * scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetId: string,
|
||||
|
||||
Reference in New Issue
Block a user