mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 14:59:26 +03:00
feat(web): add zoom minimap preview overlay
This commit is contained in:
@@ -148,7 +148,7 @@ test.describe('zoom and face editor interaction', () => {
|
|||||||
await page.mouse.move(width / 2, height / 2);
|
await page.mouse.move(width / 2, height / 2);
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
|
||||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
const imgLocator = page.getByTestId('preview');
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const transform = await imgLocator.evaluate((element) => {
|
const transform = await imgLocator.evaluate((element) => {
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||||
|
|||||||
127
e2e/src/ui/specs/asset-viewer/zoom-minimap.e2e-spec.ts
Normal file
127
e2e/src/ui/specs/asset-viewer/zoom-minimap.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||||
import { createZoomImageWheel } from '@zoom-image/core';
|
import { createZoomImageWheel } from '@zoom-image/core';
|
||||||
|
|
||||||
type TouchEventLike = {
|
type TouchEventLike = {
|
||||||
@@ -7,22 +8,66 @@ type TouchEventLike = {
|
|||||||
};
|
};
|
||||||
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
|
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
|
||||||
|
|
||||||
|
export const MAX_ZOOM = 10;
|
||||||
|
|
||||||
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
||||||
const zoomInstance = createZoomImageWheel(node, {
|
let zoomInstance = createZoomImageWheel(node, {
|
||||||
maxZoom: 10,
|
maxZoom: MAX_ZOOM,
|
||||||
initialState: assetViewerManager.zoomState,
|
initialState: assetViewerManager.zoomState,
|
||||||
zoomTarget: options?.zoomTarget,
|
zoomTarget: options?.zoomTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribes = [
|
let needsResync = false;
|
||||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
|
||||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
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 controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
|
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
|
// 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.
|
// 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() {
|
destroy() {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
for (const unsubscribe of unsubscribes) {
|
unsubscribeManager();
|
||||||
unsubscribe();
|
unsubscribeStore?.();
|
||||||
}
|
|
||||||
zoomInstance.cleanup();
|
zoomInstance.cleanup();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.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 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 AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
@@ -255,6 +256,8 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
|
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
272
web/src/lib/components/asset-viewer/zoom-minimap.svelte
Normal file
272
web/src/lib/components/asset-viewer/zoom-minimap.svelte
Normal 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}
|
||||||
@@ -18,6 +18,8 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
|
|||||||
export type Events = {
|
export type Events = {
|
||||||
Zoom: [];
|
Zoom: [];
|
||||||
ZoomChange: [ZoomImageWheelState];
|
ZoomChange: [ZoomImageWheelState];
|
||||||
|
DirectTransform: [ZoomImageWheelState];
|
||||||
|
ZoomEnabled: [boolean];
|
||||||
Copy: [];
|
Copy: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +89,15 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
|||||||
this.#zoomState = state;
|
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() {
|
cancelZoomAnimation() {
|
||||||
if (this.#animationFrameId !== null) {
|
if (this.#animationFrameId !== null) {
|
||||||
cancelAnimationFrame(this.#animationFrameId);
|
cancelAnimationFrame(this.#animationFrameId);
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ export const TUNABLES = {
|
|||||||
IMAGE_THUMBNAIL: {
|
IMAGE_THUMBNAIL: {
|
||||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user