feat(web): add zoom minimap preview overlay

This commit is contained in:
midzelis
2026-03-13 13:34:32 +00:00
parent f7558249bd
commit a5c7fb7762
7 changed files with 471 additions and 10 deletions

View File

@@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => {
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
const imgLocator = page.getByTestId('preview');
await expect(async () => {
const transform = await imgLocator.evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;

View File

@@ -0,0 +1,127 @@
import { expect, type Page, test } from '@playwright/test';
import { assetViewerUtils } from '../timeline/utils';
import { setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
const zoomIn = async (page: Page) => {
const { width, height } = page.viewportSize()!;
await page.mouse.move(width / 2, height / 2);
await page.mouse.wheel(0, -1);
await page.waitForTimeout(300);
};
const getImageTransform = (page: Page) => {
return page.getByTestId('preview').evaluate((element) => {
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
});
};
test.describe('zoom minimap', () => {
const fixture = setupAssetViewerFixture(950);
test('minimap is not visible at 1x zoom', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
test('minimap appears when zoomed in', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
});
test('minimap contains thumbnail image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
await expect(canvas).toBeVisible();
const img = canvas.locator('img');
await expect(img).toBeVisible();
await expect(img).toHaveAttribute('src', /thumbnail/);
});
test('viewport rect is visible when zoomed', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const viewport = page.getByTestId('zoom-minimap-viewport');
await expect(viewport).toBeVisible();
const box = await viewport.boundingBox();
expect(box).toBeTruthy();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
test('clicking minimap pans the image', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const transformBefore = await getImageTransform(page);
const canvas = page.getByTestId('zoom-minimap-canvas');
const canvasBox = await canvas.boundingBox();
expect(canvasBox).toBeTruthy();
// Click near the top-left corner of the minimap
await page.mouse.click(canvasBox!.x + 20, canvasBox!.y + 20);
await page.waitForTimeout(100);
const transformAfter = await getImageTransform(page);
expect(transformAfter).not.toBe(transformBefore);
});
test('zoom slider adjusts zoom level', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
const slider = page.getByTestId('zoom-minimap-slider');
await expect(slider).toBeVisible();
const sliderBox = await slider.boundingBox();
expect(sliderBox).toBeTruthy();
const fillBefore = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
// Click near the right end of the slider to increase zoom
await page.mouse.click(sliderBox!.x + sliderBox!.width * 0.8, sliderBox!.y + sliderBox!.height / 2);
await page.waitForTimeout(100);
const fillAfter = await page.getByTestId('zoom-minimap-slider-fill').evaluate((element) => {
return element.style.width;
});
expect(fillAfter).not.toBe(fillBefore);
});
test('minimap auto-hides after inactivity', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await zoomIn(page);
await expect(page.getByTestId('zoom-minimap')).toBeVisible();
// Wait for the hide delay (1500ms) plus fade duration
await page.waitForTimeout(2000);
await expect(page.getByTestId('zoom-minimap')).toHaveCount(0);
});
});

View File

@@ -1,4 +1,5 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { createZoomImageWheel } from '@zoom-image/core';
type TouchEventLike = {
@@ -7,22 +8,66 @@ type TouchEventLike = {
};
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
export const MAX_ZOOM = 10;
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
let zoomInstance = createZoomImageWheel(node, {
maxZoom: MAX_ZOOM,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget,
});
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
let needsResync = false;
const createInstance = () => {
zoomInstance.cleanup();
zoomInstance = createZoomImageWheel(node, {
maxZoom: MAX_ZOOM,
initialState: { ...assetViewerManager.zoomState, enable: true },
zoomTarget: options?.zoomTarget,
});
node.style.overflow = 'visible';
unsubscribeStore?.();
unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
needsResync = false;
};
const applyDirectTransform = (state: ZoomImageWheelState) => {
const target = options?.zoomTarget ?? node.querySelector('img');
if (target) {
(target as HTMLElement).style.transformOrigin = '0 0';
(target as HTMLElement).style.transform =
`translate(${state.currentPositionX}px, ${state.currentPositionY}px) scale(${state.currentZoom})`;
needsResync = true;
}
};
const resyncIfNeeded = () => {
if (needsResync) {
createInstance();
}
};
let unsubscribeStore = zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state));
const unsubscribeManager = assetViewerManager.on({
ZoomChange: (state) => zoomInstance.setState(state),
DirectTransform: (state) => applyDirectTransform(state),
ZoomEnabled: (enabled) => {
if (enabled && needsResync) {
createInstance();
} else {
zoomInstance.setState({ enable: enabled });
}
},
});
const controller = new AbortController();
const { signal } = controller;
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
node.addEventListener('pointerdown', resyncIfNeeded, { signal });
node.addEventListener('wheel', resyncIfNeeded, { signal });
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
@@ -134,9 +179,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
},
destroy() {
controller.abort();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
unsubscribeManager();
unsubscribeStore?.();
zoomInstance.cleanup();
},
};

View File

@@ -5,6 +5,7 @@
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 ZoomMinimap from '$lib/components/asset-viewer/zoom-minimap.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';
@@ -255,6 +256,8 @@
{/snippet}
</AdaptiveImage>
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}

View File

@@ -0,0 +1,272 @@
<script lang="ts">
import { MAX_ZOOM } from '$lib/actions/zoom-image';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { TUNABLES } from '$lib/utils/tunables';
import { clamp } from 'lodash-es';
import { fade } from 'svelte/transition';
interface Props {
containerWidth: number;
containerHeight: number;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
}
let { containerWidth, containerHeight, asset, sharedLink }: Props = $props();
const MINIMAP_MAX = 192;
const MINIMAP_MIN = 100;
const minimapSize = $derived(clamp(Math.min(containerWidth, containerHeight) * 0.25, MINIMAP_MIN, MINIMAP_MAX));
const thumbnailUrl = $derived(getAssetUrls(asset, sharedLink).thumbnail);
const imageDimensions = $derived({
width: asset.width && asset.width > 0 ? asset.width : 1,
height: asset.height && asset.height > 0 ? asset.height : 1,
});
const container = $derived({ width: containerWidth, height: containerHeight });
// Scale the full container into the minimap square
const containerInMinimap = $derived(scaleToFit(container, { width: minimapSize, height: minimapSize }));
const minimapContainerScale = $derived(containerInMinimap.width / containerWidth);
const containerOffsetX = $derived((minimapSize - containerInMinimap.width) / 2);
const containerOffsetY = $derived((minimapSize - containerInMinimap.height) / 2);
// Position the image within the minimap's container representation
const imageInMinimap: ContentMetrics = $derived.by(() => {
const fitted = scaleToFit(imageDimensions, containerInMinimap);
return {
contentWidth: fitted.width,
contentHeight: fitted.height,
offsetX: containerOffsetX + (containerInMinimap.width - fitted.width) / 2,
offsetY: containerOffsetY + (containerInMinimap.height - fitted.height) / 2,
};
});
const { FADE_DURATION, HIDE_DELAY } = TUNABLES.MINIMAP;
let isDragging = $state(false);
let isDraggingZoom = $state(false);
let isRecentActivity = $state(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const resetHideTimer = () => {
isRecentActivity = true;
if (hideTimer !== null) {
clearTimeout(hideTimer);
}
hideTimer = setTimeout(() => {
isRecentActivity = false;
hideTimer = null;
}, HIDE_DELAY);
};
const isZoomed = $derived(assetViewerManager.zoom > 1);
const isVisible = $derived((isZoomed && isRecentActivity) || isDragging || isDraggingZoom);
$effect(() => {
// Track zoom state changes to reset the hide timer
const _state = assetViewerManager.zoomState;
void _state;
if (isZoomed) {
resetHideTimer();
}
return () => {
if (hideTimer !== null) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
});
const zoomPercent = $derived(((assetViewerManager.zoom - 1) / (MAX_ZOOM - 1)) * 100);
const zoomLabel = $derived(assetViewerManager.zoom.toFixed(1) + 'x');
const clampPanPosition = (positionX: number, positionY: number, zoom: number) => ({
positionX: clamp(positionX, -(containerWidth * (zoom - 1)), 0),
positionY: clamp(positionY, -(containerHeight * (zoom - 1)), 0),
});
const minimapToContainerPosition = (minimapX: number, minimapY: number) => {
const containerX = (minimapX - containerOffsetX) / minimapContainerScale;
const containerY = (minimapY - containerOffsetY) / minimapContainerScale;
const { currentZoom } = assetViewerManager.zoomState;
const rawPositionX = containerWidth / 2 - containerX * currentZoom;
const rawPositionY = containerHeight / 2 - containerY * currentZoom;
return clampPanPosition(rawPositionX, rawPositionY, currentZoom);
};
const panToMinimapPosition = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const minimapX = event.clientX - rect.left;
const minimapY = event.clientY - rect.top;
const { positionX, positionY } = minimapToContainerPosition(minimapX, minimapY);
assetViewerManager.directTransform({ currentPositionX: positionX, currentPositionY: positionY });
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDragging = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
panToMinimapPosition(event);
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (!isDragging) {
return;
}
panToMinimapPosition(event);
};
const onPointerUp = () => {
isDragging = false;
assetViewerManager.setZoomEnabled(true);
};
const zoomAroundCenter = (newZoom: number) => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const centerX = containerWidth / 2;
const centerY = containerHeight / 2;
const zoomTargetX = (centerX - currentPositionX) / currentZoom;
const zoomTargetY = (centerY - currentPositionY) / currentZoom;
const newPositionX = -zoomTargetX * newZoom + centerX;
const newPositionY = -zoomTargetY * newZoom + centerY;
assetViewerManager.directTransform({
currentZoom: newZoom,
currentPositionX: clamp(newPositionX, -(containerWidth * (newZoom - 1)), 0),
currentPositionY: clamp(newPositionY, -(containerHeight * (newZoom - 1)), 0),
});
};
const setZoomFromSlider = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const percent = clamp((event.clientX - rect.left) / rect.width, 0, 1);
zoomAroundCenter(1 + percent * (MAX_ZOOM - 1));
};
const WHEEL_ZOOM_RATIO = 0.1;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const { currentZoom } = assetViewerManager.zoomState;
const delta = -clamp(event.deltaY, -0.5, 0.5);
const newZoom = clamp(currentZoom + delta * WHEEL_ZOOM_RATIO * currentZoom, 1, MAX_ZOOM);
zoomAroundCenter(newZoom);
};
const onZoomSliderPointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
isDraggingZoom = true;
assetViewerManager.setZoomEnabled(false);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
setZoomFromSlider(event);
event.preventDefault();
event.stopPropagation();
};
const onZoomSliderPointerMove = (event: PointerEvent) => {
if (!isDraggingZoom) {
return;
}
setZoomFromSlider(event);
};
const onZoomSliderPointerUp = () => {
isDraggingZoom = false;
assetViewerManager.setZoomEnabled(true);
};
const viewportRect = $derived.by(() => {
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
// Visible area in container coordinates
const visibleLeft = -currentPositionX / currentZoom;
const visibleTop = -currentPositionY / currentZoom;
const visibleWidth = containerWidth / currentZoom;
const visibleHeight = containerHeight / currentZoom;
// Map to minimap coordinates
return {
left: visibleLeft * minimapContainerScale + containerOffsetX,
top: visibleTop * minimapContainerScale + containerOffsetY,
width: visibleWidth * minimapContainerScale,
height: visibleHeight * minimapContainerScale,
};
});
</script>
{#if isVisible}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-[68px] right-14 md:right-4 z-10 rounded-lg border border-white/30 bg-black/60 p-1 backdrop-blur-sm"
data-testid="zoom-minimap"
transition:fade={{ duration: FADE_DURATION }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative overflow-hidden rounded bg-black"
class:cursor-grabbing={isDragging}
class:cursor-pointer={!isDragging}
data-testid="zoom-minimap-canvas"
style="width: {minimapSize}px; height: {minimapSize}px;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onwheel={onWheel}
>
<img
src={thumbnailUrl}
alt=""
class="absolute pointer-events-none"
draggable="false"
style="left: {imageInMinimap.offsetX}px; top: {imageInMinimap.offsetY}px; width: {imageInMinimap.contentWidth}px; height: {imageInMinimap.contentHeight}px;"
/>
<div
class={[
'absolute border-2 border-white bg-white/20 pointer-events-none rounded-sm',
isDragging && 'border-white/80',
]}
data-testid="zoom-minimap-viewport"
style="left: {viewportRect.left}px; top: {viewportRect.top}px; width: {viewportRect.width}px; height: {viewportRect.height}px;"
></div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative mt-1 h-3 rounded-full bg-white/20 cursor-pointer"
class:cursor-grabbing={isDraggingZoom}
data-testid="zoom-minimap-slider"
style="width: {minimapSize}px;"
onpointerdown={onZoomSliderPointerDown}
onpointermove={onZoomSliderPointerMove}
onpointerup={onZoomSliderPointerUp}
onpointercancel={onZoomSliderPointerUp}
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-white/80 pointer-events-none"
data-testid="zoom-minimap-slider-fill"
style="width: {zoomPercent}%;"
></div>
<span
class="absolute inset-0 flex items-center justify-center text-[9px] font-semibold pointer-events-none select-none leading-none"
style="color: #000; text-shadow: 0 0 3px rgba(255,255,255,0.8);"
>
{zoomLabel}
</span>
</div>
</div>
{/if}

View File

@@ -18,6 +18,8 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
DirectTransform: [ZoomImageWheelState];
ZoomEnabled: [boolean];
Copy: [];
};
@@ -87,6 +89,15 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
directTransform(state: Partial<ZoomImageWheelState>) {
this.#zoomState = { ...this.#zoomState, ...state };
this.emit('DirectTransform', this.#zoomState);
}
setZoomEnabled(enabled: boolean) {
this.emit('ZoomEnabled', enabled);
}
cancelZoomAnimation() {
if (this.#animationFrameId !== null) {
cancelAnimationFrame(this.#animationFrameId);

View File

@@ -31,4 +31,8 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
MINIMAP: {
FADE_DURATION: getNumber(storage.getItem('MINIMAP.FADE_DURATION'), 150),
HIDE_DELAY: getNumber(storage.getItem('MINIMAP.HIDE_DELAY'), 1500),
},
};