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} + + {#if isFaceEditMode.value && assetViewerManager.imgRef} {/if} diff --git a/web/src/lib/components/asset-viewer/zoom-minimap.svelte b/web/src/lib/components/asset-viewer/zoom-minimap.svelte new file mode 100644 index 0000000000..03554d4079 --- /dev/null +++ b/web/src/lib/components/asset-viewer/zoom-minimap.svelte @@ -0,0 +1,272 @@ + + +{#if isVisible} + +
+ +
+ +
+
+ +
+
+ + {zoomLabel} + +
+
+{/if} diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 0bab3aff80..475ef7d0a9 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -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 { this.#zoomState = state; } + directTransform(state: Partial) { + 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); diff --git a/web/src/lib/utils/tunables.ts b/web/src/lib/utils/tunables.ts index c586e11957..93b70a3875 100644 --- a/web/src/lib/utils/tunables.ts +++ b/web/src/lib/utils/tunables.ts @@ -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), + }, };