mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(web): OCR overlay interactivity during zoom (#27039)
Change-Id: Id62e1a0264df2de0f3177a59b24bc5176a6a6964
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user