mirror of
https://github.com/immich-app/immich.git
synced 2026-03-26 11:50:53 +03:00
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964 refactor(web): use asset metadata for face editor image dimensions instead of DOM The face editor previously read naturalWidth/naturalHeight from the DOM element via a $effect + load event listener. This was fragile on slow hardware (ARM CI) because imgRef changes as AdaptiveImage progresses through quality levels, and the DOM element's natural dimensions could be 0 during transitions. Now the face editor receives imageSize as a prop from the parent, derived from the asset's metadata dimensions which are always available immediately. Change-Id: Id4c3a59110feff4c50f429bbd744eac46a6a6964 Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964
254 lines
8.5 KiB
Svelte
254 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
import { shortcuts } from '$lib/actions/shortcut';
|
|
import { thumbhash } from '$lib/actions/thumbhash';
|
|
import { zoomImageAction } from '$lib/actions/zoom-image';
|
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
|
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
|
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
|
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
|
import { handlePromiseError } from '$lib/utils';
|
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
|
import type { Size } from '$lib/utils/container-utils';
|
|
import { handleError } from '$lib/utils/handle-error';
|
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
|
import { toastManager } from '@immich/ui';
|
|
import { onDestroy, untrack } from 'svelte';
|
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
|
import { t } from 'svelte-i18n';
|
|
import type { AssetCursor } from './asset-viewer.svelte';
|
|
|
|
type Props = {
|
|
cursor: AssetCursor;
|
|
element?: HTMLDivElement;
|
|
sharedLink?: SharedLinkResponseDto;
|
|
onReady?: () => void;
|
|
onError?: () => void;
|
|
onSwipe?: (event: SwipeCustomEvent) => void;
|
|
};
|
|
|
|
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
|
|
|
const { slideshowState, slideshowLook } = slideshowStore;
|
|
const asset = $derived(cursor.current);
|
|
|
|
let visibleImageReady: boolean = $state(false);
|
|
|
|
let previousAssetId: string | undefined;
|
|
$effect.pre(() => {
|
|
const id = asset.id;
|
|
if (id === previousAssetId) {
|
|
return;
|
|
}
|
|
previousAssetId = id;
|
|
untrack(() => {
|
|
assetViewerManager.resetZoomState();
|
|
visibleImageReady = false;
|
|
$boundingBoxesArray = [];
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
$boundingBoxesArray = [];
|
|
});
|
|
|
|
let containerWidth = $state(0);
|
|
let containerHeight = $state(0);
|
|
|
|
const container = $derived({
|
|
width: containerWidth,
|
|
height: containerHeight,
|
|
});
|
|
|
|
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
|
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
|
|
|
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
|
|
|
|
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
|
|
|
|
const onCopy = async () => {
|
|
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await copyImageToClipboard(assetViewerManager.imgRef);
|
|
toastManager.info($t('copied_image_to_clipboard'));
|
|
} catch (error) {
|
|
handleError(error, $t('copy_error'));
|
|
}
|
|
};
|
|
|
|
const onZoom = () => {
|
|
const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
|
assetViewerManager.animatedZoom(targetZoom);
|
|
};
|
|
|
|
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
|
|
|
// TODO move to action + command palette
|
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
|
if (globalThis.getSelection()?.type === 'Range') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
|
|
handlePromiseError(onCopy());
|
|
};
|
|
|
|
let currentPreviewUrl = $state<string>();
|
|
|
|
const onUrlChange = (url: string) => {
|
|
currentPreviewUrl = url;
|
|
};
|
|
|
|
$effect(() => {
|
|
if (currentPreviewUrl) {
|
|
void cast(currentPreviewUrl);
|
|
}
|
|
});
|
|
|
|
const cast = async (url: string) => {
|
|
if (!url || !castManager.isCasting) {
|
|
return;
|
|
}
|
|
const fullUrl = new URL(url, globalThis.location.href);
|
|
|
|
try {
|
|
await castManager.loadMedia(fullUrl.href);
|
|
} catch (error) {
|
|
handleError(error, 'Unable to cast');
|
|
return;
|
|
}
|
|
};
|
|
|
|
const blurredSlideshow = $derived(
|
|
$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 | undefined>();
|
|
for (const person of asset.people ?? []) {
|
|
for (const face of person.faces ?? []) {
|
|
map.set(face, person.name);
|
|
}
|
|
}
|
|
for (const face of asset.unassignedFaces ?? []) {
|
|
map.set(face, undefined);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
// Array needed for indexed access in the template (faces[index])
|
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
|
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
|
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
|
</script>
|
|
|
|
<AssetViewerEvents {onCopy} {onZoom} />
|
|
|
|
<svelte:document
|
|
use:shortcuts={[
|
|
{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true },
|
|
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
|
|
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
|
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
|
]}
|
|
/>
|
|
|
|
<div
|
|
bind:this={element}
|
|
class="relative h-full w-full select-none"
|
|
bind:clientWidth={containerWidth}
|
|
bind:clientHeight={containerHeight}
|
|
role="presentation"
|
|
ondblclick={onZoom}
|
|
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
|
{...useSwipe((event) => onSwipe?.(event))}
|
|
>
|
|
<AdaptiveImage
|
|
{asset}
|
|
{sharedLink}
|
|
{container}
|
|
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
|
{onUrlChange}
|
|
onImageReady={() => {
|
|
visibleImageReady = true;
|
|
onReady?.();
|
|
}}
|
|
onError={() => {
|
|
onError?.();
|
|
onReady?.();
|
|
}}
|
|
bind:imgRef={assetViewerManager.imgRef}
|
|
bind:imgNaturalSize={imageDimensions}
|
|
bind:imgScaledSize={scaledDimensions}
|
|
bind:ref={adaptiveImage}
|
|
>
|
|
{#snippet backdrop()}
|
|
{#if blurredSlideshow}
|
|
<canvas
|
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
|
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
|
></canvas>
|
|
{/if}
|
|
{/snippet}
|
|
{#snippet overlays()}
|
|
{#if !isFaceEditMode.value}
|
|
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
|
|
{@const face = faces[index]}
|
|
{@const name = faceToNameMap.get(face)}
|
|
{#if name !== undefined || isEditFacesPanelOpen.value}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="absolute pointer-events-auto outline-none rounded-lg"
|
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
|
aria-label="{$t('person')}: {name ?? $t('unknown')}"
|
|
onpointerenter={() => ($boundingBoxesArray = [face])}
|
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
|
></div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
|
|
{#each activeBoundingBoxes as boundingbox (boundingbox.id)}
|
|
{@const face = faces.find((f) => f.id === boundingbox.id)}
|
|
{@const name = face ? faceToNameMap.get(face) : undefined}
|
|
<div
|
|
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
|
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
|
>
|
|
{#if name}
|
|
<div
|
|
aria-hidden="true"
|
|
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
|
style="top: {boundingbox.height + 4}px; right: 0;"
|
|
>
|
|
{name}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
|
<OcrBoundingBox {ocrBox} />
|
|
{/each}
|
|
{/snippet}
|
|
</AdaptiveImage>
|
|
|
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
|
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
|
{/if}
|
|
</div>
|