feat(web): face editor preserves position and proportions on resize

Face editor selection rectangle scales and repositions with zoom, window
resize, and detail panel open/close. Move clientWidth/clientHeight
bindings to the data-viewer-content div so the face editor respects
viewer bounds and does not draw under the detail panel.
This commit is contained in:
midzelis 2026-03-07 19:06:14 +00:00
parent 39d8c6d048
commit 471f4ddeef
4 changed files with 169 additions and 16 deletions

View File

@ -483,8 +483,6 @@
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
use:focusTrap
bind:this={assetViewerHtmlElement}
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
@ -526,7 +524,12 @@
{/if}
<!-- Asset Viewer -->
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
<div
data-viewer-content
class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}

View File

@ -4,6 +4,7 @@
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 +32,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
@ -94,27 +99,47 @@
});
$effect(() => {
const { offsetX, offsetY } = imageContentMetrics;
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
if (!canvas) {
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;
}
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
});
canvas.setDimensions({ width: containerWidth, height: containerHeight });
faceRect.setCoords();
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();
});

View File

@ -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);
});
});

View File

@ -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,