feat: enhance face-editor positioning (#26303)

feat: enhance face-editor positioning - less overlap

test: timeline with actual video
This commit is contained in:
Min Idzelis
2026-03-02 09:44:59 -05:00
committed by GitHub
parent 625b30c50a
commit 7f47cdd645
4 changed files with 531 additions and 113 deletions

View File

@@ -7,6 +7,7 @@
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -23,10 +24,12 @@
let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
let scrollableListEl: HTMLDivElement | undefined = $state();
let page = $state(1);
let candidates = $state<PersonResponseDto[]>([]);
let searchTerm = $state('');
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
let filteredCandidates = $derived(
searchTerm
@@ -113,30 +116,33 @@
positionFaceSelector();
});
const getContainedSize = (
img: HTMLImageElement | HTMLVideoElement,
): { actualWidth: number; actualHeight: number } => {
if (img instanceof HTMLImageElement) {
const ratio = img.naturalWidth / img.naturalHeight;
let actualWidth = img.height * ratio;
let actualHeight = img.height;
if (actualWidth > img.width) {
actualWidth = img.width;
actualHeight = img.width / ratio;
}
return { actualWidth, actualHeight };
} else if (img instanceof HTMLVideoElement) {
const ratio = img.videoWidth / img.videoHeight;
let actualWidth = img.clientHeight * ratio;
let actualHeight = img.clientHeight;
if (actualWidth > img.clientWidth) {
actualWidth = img.clientWidth;
actualHeight = img.clientWidth / ratio;
}
return { actualWidth, actualHeight };
const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement) => {
if (element instanceof HTMLImageElement) {
return {
naturalWidth: element.naturalWidth,
naturalHeight: element.naturalHeight,
displayWidth: element.width,
displayHeight: element.height,
};
}
return {
naturalWidth: element.videoWidth,
naturalHeight: element.videoHeight,
displayWidth: element.clientWidth,
displayHeight: element.clientHeight,
};
};
return { actualWidth: 0, actualHeight: 0 };
const getContainedSize = (element: HTMLImageElement | HTMLVideoElement) => {
const { naturalWidth, naturalHeight, displayWidth, displayHeight } = getNaturalSize(element);
const ratio = naturalWidth / naturalHeight;
let actualWidth = displayHeight * ratio;
let actualHeight = displayHeight;
if (actualWidth > displayWidth) {
actualWidth = displayWidth;
actualHeight = displayWidth / ratio;
}
return { actualWidth, actualHeight };
};
const cancel = () => {
@@ -157,69 +163,80 @@
}
};
const MAX_LIST_HEIGHT = 250;
const positionFaceSelector = () => {
if (!faceRect || !faceSelectorEl) {
if (!faceRect || !faceSelectorEl || !scrollableListEl) {
return;
}
const rect = faceRect.getBoundingRect();
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
width: rawBox.width + padding * 2,
height: rawBox.height + padding * 2,
};
const selectorWidth = faceSelectorEl.offsetWidth;
const selectorHeight = faceSelectorEl.offsetHeight;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight;
const spaceAbove = rect.top;
const spaceBelow = containerHeight - (rect.top + rect.height);
const spaceLeft = rect.left;
const spaceRight = containerWidth - (rect.left + rect.width);
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
let top, left;
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
const selectorBottom = position.top + selectorHeight;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
if (
spaceBelow >= selectorHeight ||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
) {
top = rect.top + rect.height + 15;
left = rect.left;
} else if (
spaceAbove >= selectorHeight ||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
) {
top = rect.top - selectorHeight - 15;
left = rect.left;
} else if (
spaceRight >= selectorWidth ||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
) {
top = rect.top;
left = rect.left + rect.width + 15;
} else {
top = rect.top;
left = rect.left - selectorWidth - 15;
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
return overlapX * overlapY;
};
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const positions = [
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) },
{ top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) },
];
let bestPosition = positions[0];
let leastOverlap = Infinity;
for (const position of positions) {
const overlap = overlapArea(position);
if (overlap < leastOverlap) {
leastOverlap = overlap;
bestPosition = position;
if (overlap === 0) {
break;
}
}
}
if (left + selectorWidth > containerWidth) {
left = containerWidth - selectorWidth - 15;
}
if (left < 0) {
left = 15;
}
if (top + selectorHeight > containerHeight) {
top = containerHeight - selectorHeight - 15;
}
if (top < 0) {
top = 15;
}
faceSelectorEl.style.top = `${top}px`;
faceSelectorEl.style.left = `${left}px`;
faceSelectorEl.style.top = `${bestPosition.top}px`;
faceSelectorEl.style.left = `${bestPosition.left}px`;
scrollableListEl.style.height = `${listHeight}px`;
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
};
$effect(() => {
if (faceRect) {
faceRect.on('moving', positionFaceSelector);
faceRect.on('scaling', positionFaceSelector);
const rect = faceRect;
if (rect) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
};
}
});
@@ -228,49 +245,22 @@
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const faceBox = faceRect.getBoundingRect();
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const { naturalWidth, naturalHeight } = getNaturalSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const offsetX = (containerWidth - actualWidth) / 2;
const offsetY = (containerHeight - actualHeight) / 2;
const x1Coeff = (left - offsetArea.width) / actualWidth;
const y1Coeff = (top - offsetArea.height) / actualHeight;
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
const scaleX = naturalWidth / actualWidth;
const scaleY = naturalHeight / actualHeight;
// transpose to the natural image location
if (htmlElement instanceof HTMLImageElement) {
const x1 = x1Coeff * htmlElement.naturalWidth;
const y1 = y1Coeff * htmlElement.naturalHeight;
const x2 = x2Coeff * htmlElement.naturalWidth;
const y2 = y2Coeff * htmlElement.naturalHeight;
const x = Math.floor((faceBox.left - offsetX) * scaleX);
const y = Math.floor((faceBox.top - offsetY) * scaleY);
const width = Math.floor(faceBox.width * scaleX);
const height = Math.floor(faceBox.height * scaleY);
return {
imageWidth: htmlElement.naturalWidth,
imageHeight: htmlElement.naturalHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
} else if (htmlElement instanceof HTMLVideoElement) {
const x1 = x1Coeff * htmlElement.videoWidth;
const y1 = y1Coeff * htmlElement.videoHeight;
const x2 = x2Coeff * htmlElement.videoWidth;
const y2 = y2Coeff * htmlElement.videoHeight;
return {
imageWidth: htmlElement.videoWidth,
imageHeight: htmlElement.videoHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
}
return { imageWidth: naturalWidth, imageHeight: naturalHeight, x, y, width, height };
};
const tagFace = async (person: PersonResponseDto) => {
@@ -308,13 +298,20 @@
};
</script>
<div class="absolute start-0 top-0">
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
<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"
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"
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
@@ -322,7 +319,7 @@
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
</div>
<div class="h-62.5 overflow-y-auto mt-2">
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
{#if filteredCandidates.length > 0}
<div class="mt-2 rounded-lg">
{#each filteredCandidates as person (person.id)}