mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c29a76e67 |
@@ -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.getByTestId('preview');
|
const imgLocator = page.locator('[data-viewer-content] img[data-testid="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;
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
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';
|
||||||
|
|
||||||
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||||
@@ -9,66 +8,22 @@ 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 }) => {
|
||||||
let zoomInstance = createZoomImageWheel(node, {
|
const zoomInstance = createZoomImageWheel(node, {
|
||||||
maxZoom: MAX_ZOOM,
|
maxZoom: 10,
|
||||||
initialState: assetViewerManager.zoomState,
|
initialState: assetViewerManager.zoomState,
|
||||||
zoomTarget: options?.zoomTarget,
|
zoomTarget: options?.zoomTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
let needsResync = false;
|
const unsubscribes = [
|
||||||
|
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||||
const createInstance = () => {
|
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||||
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.
|
||||||
@@ -186,8 +141,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTML
|
|||||||
if (options?.zoomTarget) {
|
if (options?.zoomTarget) {
|
||||||
options.zoomTarget.style.willChange = '';
|
options.zoomTarget.style.willChange = '';
|
||||||
}
|
}
|
||||||
unsubscribeManager();
|
for (const unsubscribe of unsubscribes) {
|
||||||
unsubscribeStore?.();
|
unsubscribe();
|
||||||
|
}
|
||||||
zoomInstance.cleanup();
|
zoomInstance.cleanup();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -447,6 +447,8 @@
|
|||||||
!assetViewerManager.isShowEditor,
|
!assetViewerManager.isShowEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasSidePanel = $derived(showDetailPanel || assetViewerManager.isShowEditor);
|
||||||
|
|
||||||
const onSwipe = (event: SwipeCustomEvent) => {
|
const onSwipe = (event: SwipeCustomEvent) => {
|
||||||
if (assetViewerManager.zoom > 1) {
|
if (assetViewerManager.zoom > 1) {
|
||||||
return;
|
return;
|
||||||
@@ -473,7 +475,7 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
id="immich-asset-viewer"
|
||||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
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"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
>
|
>
|
||||||
@@ -511,13 +513,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
{#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">
|
<div
|
||||||
|
class={[
|
||||||
|
'my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start',
|
||||||
|
hasSidePanel && 'portrait:row-[1/3]',
|
||||||
|
]}
|
||||||
|
>
|
||||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
<!-- Asset Viewer -->
|
||||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div
|
||||||
|
data-viewer-content
|
||||||
|
class={['z-[-1] relative col-start-1 col-span-4 row-span-full', hasSidePanel && 'portrait:row-[1/3]']}
|
||||||
|
>
|
||||||
{#if viewerKind === 'StackVideoViewer'}
|
{#if viewerKind === 'StackVideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
asset={previewStackedAsset!}
|
asset={previewStackedAsset!}
|
||||||
@@ -581,10 +591,57 @@
|
|||||||
<OcrButton />
|
<OcrButton />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
{#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">
|
<div
|
||||||
|
class={[
|
||||||
|
'my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end',
|
||||||
|
hasSidePanel && 'portrait:row-[1/3]',
|
||||||
|
]}
|
||||||
|
>
|
||||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -593,58 +650,23 @@
|
|||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
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"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
{#if showDetailPanel}
|
{#if showDetailPanel}
|
||||||
<div class="w-90 h-full">
|
<div class="relative portrait:w-full landscape:w-[min(22.5rem,30vw)] landscape:min-w-56 h-full">
|
||||||
<DetailPanel {asset} currentAlbum={album} />
|
<DetailPanel {asset} currentAlbum={album} />
|
||||||
</div>
|
</div>
|
||||||
{:else if assetViewerManager.isShowEditor}
|
{:else if assetViewerManager.isShowEditor}
|
||||||
<div class="w-100 h-full">
|
<div class="landscape:w-[min(25rem,30vw)] landscape:min-w-56 h-full">
|
||||||
<EditorPanel {asset} onClose={closeEditor} />
|
<EditorPanel {asset} onClose={closeEditor} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
|
|||||||
@@ -340,7 +340,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<div class="p-1">
|
<div class="shrink-0 p-1">
|
||||||
<Icon icon={mdiPencil} size="20" />
|
<Icon icon={mdiPencil} size="20" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -352,20 +352,21 @@
|
|||||||
<Icon icon={mdiCalendar} size="24" />
|
<Icon icon={mdiCalendar} size="24" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-1">
|
<div class="shrink-0 p-1">
|
||||||
<Icon icon={mdiPencil} size="20" />
|
<Icon icon={mdiPencil} size="20" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-4 py-4">
|
<div class="flex gap-4 py-4">
|
||||||
<div><Icon icon={mdiImageOutline} size="24" /></div>
|
<div class="shrink-0"><Icon icon={mdiImageOutline} size="24" /></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
|
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
|
||||||
{asset.originalFileName}
|
{asset.originalFileName}
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
class="shrink-0"
|
||||||
icon={mdiInformationOutline}
|
icon={mdiInformationOutline}
|
||||||
aria-label={$t('show_file_location')}
|
aria-label={$t('show_file_location')}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -405,7 +406,7 @@
|
|||||||
|
|
||||||
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
|
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
|
||||||
<div class="flex gap-4 py-4">
|
<div class="flex gap-4 py-4">
|
||||||
<div><Icon icon={mdiCamera} size="24" /></div>
|
<div class="shrink-0"><Icon icon={mdiCamera} size="24" /></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if asset.exifInfo?.make || asset.exifInfo?.model}
|
{#if asset.exifInfo?.make || asset.exifInfo?.model}
|
||||||
@@ -439,7 +440,7 @@
|
|||||||
|
|
||||||
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
|
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
|
||||||
<div class="flex gap-4 py-4">
|
<div class="flex gap-4 py-4">
|
||||||
<div><Icon icon={mdiCameraIris} size="24" /></div>
|
<div class="shrink-0"><Icon icon={mdiCameraIris} size="24" /></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if asset.exifInfo?.lensModel}
|
{#if asset.exifInfo?.lensModel}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
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';
|
||||||
@@ -248,8 +247,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
<ZoomMinimap {containerWidth} {containerHeight} {asset} {sharedLink} />
|
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
<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
|
<section
|
||||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||||
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
|
class="absolute top-0 h-full w-full 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 place-items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ const createDefaultZoomState = (): ZoomImageWheelState => ({
|
|||||||
export type Events = {
|
export type Events = {
|
||||||
Zoom: [];
|
Zoom: [];
|
||||||
ZoomChange: [ZoomImageWheelState];
|
ZoomChange: [ZoomImageWheelState];
|
||||||
DirectTransform: [ZoomImageWheelState];
|
|
||||||
ZoomEnabled: [boolean];
|
|
||||||
Copy: [];
|
Copy: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,15 +87,6 @@ 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,8 +31,4 @@ 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