feat(web): OCR overlay interactivity during zoom (#27039)

Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
This commit is contained in:
Min Idzelis
2026-03-30 20:19:53 -04:00
committed by GitHub
parent a2e3635ac9
commit 18201a26d9
14 changed files with 770 additions and 246 deletions
+52 -70
View File
@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
@@ -17,10 +17,7 @@
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
objectFit?: 'contain' | 'cover';
container: {
width: number;
height: number;
};
container: Size;
onUrlChange?: (url: string) => void;
onImageReady?: () => void;
onError?: () => void;
@@ -149,81 +146,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>
@@ -12,12 +12,12 @@
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
assetId: string;
}
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
@@ -304,6 +304,7 @@
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
@@ -1,4 +1,5 @@
<script lang="ts">
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
@@ -8,6 +9,7 @@
let { ocrBox }: Props = $props();
const isTouch = $derived(mediaQueryManager.pointerCoarse);
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
@@ -15,13 +17,23 @@
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
);
const handleSelectStart = (event: Event) => {
const target = event.currentTarget as HTMLElement;
requestAnimationFrame(() => {
const selection = globalThis.getSelection();
if (selection) {
selection.selectAllChildren(target);
}
});
};
const verticalStyle = $derived.by(() => {
switch (ocrBox.verticalMode) {
case 'cjk': {
return ' writing-mode: vertical-rl;';
return 'writing-mode: vertical-rl;';
}
case 'rotated': {
return ' writing-mode: vertical-rl; text-orientation: sideways;';
return 'writing-mode: vertical-rl; text-orientation: sideways;';
}
default: {
return '';
@@ -30,17 +42,23 @@
});
</script>
<div class="absolute left-0 top-0">
<div
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
'none'
? 'px-2 py-1 whitespace-nowrap'
: 'px-1 py-2'}"
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
tabindex="0"
role="button"
aria-label={ocrBox.text}
>
{ocrBox.text}
</div>
<div
class={[
'absolute left-0 top-0 flex items-center justify-center',
'border-2 border-blue-500 pointer-events-auto cursor-text',
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
isTouch
? 'text-white bg-black/60 select-all'
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
]}
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
data-testid="ocr-box"
data-overlay-interactive
tabindex="0"
role="button"
aria-label={ocrBox.text}
onselectstart={isTouch ? handleSelectStart : undefined}
>
{ocrBox.text}
</div>
@@ -128,10 +128,8 @@
}
const boxes = getOcrBoundingBoxes(ocrData, {
contentWidth: viewer.state.textureData.panoData.croppedWidth,
contentHeight: viewer.state.textureData.panoData.croppedHeight,
offsetX: 0,
offsetY: 0,
width: viewer.state.textureData.panoData.croppedWidth,
height: viewer.state.textureData.panoData.croppedHeight,
});
for (const [index, box] of boxes.entries()) {
@@ -13,7 +13,7 @@
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox, type BoundingBox } from '$lib/utils/people-utils';
@@ -24,14 +24,14 @@
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
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();
@@ -66,23 +66,15 @@
height: containerHeight,
});
const overlayMetrics = $derived.by((): ContentMetrics => {
const overlaySize = $derived.by((): Size => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
return { width: 0, height: 0 };
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
return {
contentWidth: scaled.width,
contentHeight: scaled.height,
offsetX: 0,
offsetY: 0,
};
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlayMetrics));
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
@@ -94,7 +86,7 @@
}
});
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
@@ -162,6 +154,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>();
@@ -192,7 +186,7 @@
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);
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
@@ -226,7 +220,7 @@
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ disabled: assetViewerManager.isFaceEditMode || ocrManager.showOverlay }}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
<AdaptiveImage
@@ -244,6 +238,7 @@
onReady?.();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:ref={adaptiveImage}
>
{#snippet backdrop()}
{#if blurredSlideshow}