mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 137ef8cf51 |
@@ -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[data-testid="preview"]');
|
||||
const imgLocator = page.getByTestId('preview');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
|
||||
@@ -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 type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||
@@ -8,22 +9,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.
|
||||
@@ -141,9 +186,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = '';
|
||||
}
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
unsubscribeManager();
|
||||
unsubscribeStore?.();
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -447,8 +447,6 @@
|
||||
!assetViewerManager.isShowEditor,
|
||||
);
|
||||
|
||||
const hasSidePanel = $derived(showDetailPanel || assetViewerManager.isShowEditor);
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
@@ -475,7 +473,7 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] portrait:grid-rows-[64px_1fr_auto] overflow-hidden bg-black touch-none"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
@@ -513,21 +511,13 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div
|
||||
class={[
|
||||
'my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start',
|
||||
hasSidePanel && 'portrait:row-[1/3]',
|
||||
]}
|
||||
>
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div
|
||||
data-viewer-content
|
||||
class={['z-[-1] relative col-start-1 col-span-4 row-span-full', hasSidePanel && 'portrait:row-[1/3]']}
|
||||
>
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
@@ -591,57 +581,10 @@
|
||||
<OcrButton />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div
|
||||
id="stack-slideshow"
|
||||
class="absolute bottom-0 max-w-[calc(100%-5rem)] col-span-4 col-start-1 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
class="relative inline-flex flex-row flex-nowrap max-w-full overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||
onmouseleave={() => (previewStackedAsset = undefined)}
|
||||
>
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
isFaceEditMode.value = false;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div
|
||||
class={[
|
||||
'my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end',
|
||||
hasSidePanel && 'portrait:row-[1/3]',
|
||||
]}
|
||||
>
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -650,23 +593,58 @@
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="overflow-y-auto transition-all bg-light
|
||||
landscape:row-start-1 landscape:row-span-4 landscape:dark:border-l landscape:dark:border-s-immich-dark-gray
|
||||
portrait:col-span-full portrait:row-start-3 portrait:max-h-[40dvh] portrait:dark:border-t portrait:dark:border-t-immich-dark-gray"
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<div class="relative portrait:w-full landscape:w-[min(22.5rem,30vw)] landscape:min-w-56 h-full">
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<div class="landscape:w-[min(25rem,30vw)] landscape:min-w-56 h-full">
|
||||
<div class="w-100 h-full">
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
isFaceEditMode.value = false;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
{#if stackedAsset.id === asset.id}
|
||||
<div class="w-full flex place-items-center place-content-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
|
||||
@@ -340,7 +340,7 @@
|
||||
</div>
|
||||
|
||||
{#if isOwner}
|
||||
<div class="shrink-0 p-1">
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -352,21 +352,20 @@
|
||||
<Icon icon={mdiCalendar} size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 p-1">
|
||||
<div class="p-1">
|
||||
<Icon icon={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4 py-4">
|
||||
<div class="shrink-0"><Icon icon={mdiImageOutline} size="24" /></div>
|
||||
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
|
||||
{asset.originalFileName}
|
||||
{#if isOwner}
|
||||
<IconButton
|
||||
class="shrink-0"
|
||||
icon={mdiInformationOutline}
|
||||
aria-label={$t('show_file_location')}
|
||||
size="small"
|
||||
@@ -406,7 +405,7 @@
|
||||
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div class="shrink-0"><Icon icon={mdiCamera} size="24" /></div>
|
||||
<div><Icon icon={mdiCamera} size="24" /></div>
|
||||
|
||||
<div>
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model}
|
||||
@@ -440,7 +439,7 @@
|
||||
|
||||
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div class="shrink-0"><Icon icon={mdiCameraIris} size="24" /></div>
|
||||
<div><Icon icon={mdiCameraIris} size="24" /></div>
|
||||
|
||||
<div>
|
||||
{#if asset.exifInfo?.lensModel}
|
||||
|
||||
@@ -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';
|
||||
@@ -247,6 +248,8 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 h-full w-full overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
||||
>
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user