diff --git a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
index c69503cf11..8ac5a89fa4 100644
--- a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
+++ b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
@@ -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;
diff --git a/e2e/src/ui/specs/asset-viewer/zoom-minimap.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/zoom-minimap.e2e-spec.ts
new file mode 100644
index 0000000000..5ce1e74f5c
--- /dev/null
+++ b/e2e/src/ui/specs/asset-viewer/zoom-minimap.e2e-spec.ts
@@ -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);
+ });
+});
diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts
index 1616f56cbc..fb0608f18e 100644
--- a/web/src/lib/actions/zoom-image.ts
+++ b/web/src/lib/actions/zoom-image.ts
@@ -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();
},
};
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte
index fb49633288..6d58a0d9df 100644
--- a/web/src/lib/components/asset-viewer/photo-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte
@@ -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}
+