mirror of
https://github.com/immich-app/immich.git
synced 2025-03-01 15:11:21 +01:00
fix(web): high resolution image on zoom (#9818)
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
3f6e61d073
commit
e459d524a4
2 changed files with 154 additions and 74 deletions
83
web/src/lib/components/asset-viewer/photo-viewer.spec.ts
Normal file
83
web/src/lib/components/asset-viewer/photo-viewer.spec.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import * as utils from '$lib/utils';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||||
|
import type { Mock, MockInstance } from 'vitest';
|
||||||
|
import PhotoViewer from './photo-viewer.svelte';
|
||||||
|
|
||||||
|
vi.mock('$lib/utils', async (originalImport) => {
|
||||||
|
const meta = await originalImport<typeof import('$lib/utils')>();
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
downloadRequest: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PhotoViewer component', () => {
|
||||||
|
let downloadRequestMock: MockInstance;
|
||||||
|
let createObjectURLMock: Mock<[obj: Blob], string>;
|
||||||
|
let asset: AssetResponseDto;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
downloadRequestMock = vi.spyOn(utils, 'downloadRequest').mockResolvedValue({
|
||||||
|
data: new Blob(),
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
createObjectURLMock = vi.fn();
|
||||||
|
window.URL.createObjectURL = createObjectURLMock;
|
||||||
|
asset = assetFactory.build({ originalPath: 'image.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initially shows a loading spinner', () => {
|
||||||
|
render(PhotoViewer, { asset });
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads and shows a photo', async () => {
|
||||||
|
createObjectURLMock.mockReturnValueOnce('url-one');
|
||||||
|
render(PhotoViewer, { asset });
|
||||||
|
|
||||||
|
expect(downloadRequestMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=${asset.checksum}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
|
||||||
|
expect(screen.getByRole('img')).toHaveAttribute('src', 'url-one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads high resolution photo when zoomed', async () => {
|
||||||
|
createObjectURLMock.mockReturnValueOnce('url-one');
|
||||||
|
render(PhotoViewer, { asset });
|
||||||
|
createObjectURLMock.mockReturnValueOnce('url-two');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
|
||||||
|
await fireEvent(window, new CustomEvent('zoomImage'));
|
||||||
|
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
|
||||||
|
expect(downloadRequestMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=false&c=${asset.checksum}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads photo when checksum changes', async () => {
|
||||||
|
const { component } = render(PhotoViewer, { asset });
|
||||||
|
createObjectURLMock.mockReturnValueOnce('url-two');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument());
|
||||||
|
component.$set({ asset: { ...asset, checksum: 'new-checksum' } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', 'url-two'));
|
||||||
|
expect(downloadRequestMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: `/api/asset/file/${asset.id}?isThumb=false&isWeb=true&c=new-checksum`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,7 +3,7 @@
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||||
import { getAssetFileUrl, handlePromiseError } from '$lib/utils';
|
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
|
@ -24,14 +24,12 @@
|
||||||
export let haveFadeTransition = true;
|
export let haveFadeTransition = true;
|
||||||
|
|
||||||
let imgElement: HTMLDivElement;
|
let imgElement: HTMLDivElement;
|
||||||
let assetFileUrl: string = '';
|
let assetData: string;
|
||||||
|
let abortController: AbortController;
|
||||||
|
let hasZoomed = false;
|
||||||
let copyImageToClipboard: (source: string) => Promise<Blob>;
|
let copyImageToClipboard: (source: string) => Promise<Blob>;
|
||||||
let canCopyImagesToClipboard: () => boolean;
|
let canCopyImagesToClipboard: () => boolean;
|
||||||
let imageLoaded: boolean = false;
|
let imageLoaded: boolean = false;
|
||||||
let imageError: boolean = false;
|
|
||||||
// set to true when am image has been zoomed, to force loading of the original image regardless
|
|
||||||
// of app settings
|
|
||||||
let forceLoadOriginal: boolean = false;
|
|
||||||
|
|
||||||
const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
|
const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
|
||||||
|
|
||||||
|
@ -42,12 +40,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
|
||||||
preload({ preloadAssets, loadOriginal: loadOriginalByDefault });
|
|
||||||
}
|
|
||||||
|
|
||||||
$: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum);
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||||
// TODO: Move to regular import once the package correctly supports ESM.
|
// TODO: Move to regular import once the package correctly supports ESM.
|
||||||
|
@ -56,30 +48,42 @@
|
||||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
|
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$: void loadAssetData({ loadOriginal: loadOriginalByDefault, checksum: asset.checksum });
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
|
abortController?.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
const preload = ({
|
const loadAssetData = async ({ loadOriginal, checksum }: { loadOriginal: boolean; checksum: string }) => {
|
||||||
preloadAssets,
|
try {
|
||||||
loadOriginal,
|
abortController?.abort();
|
||||||
}: {
|
abortController = new AbortController();
|
||||||
preloadAssets: AssetResponseDto[] | null;
|
|
||||||
loadOriginal: boolean;
|
|
||||||
}) => {
|
|
||||||
for (const preloadAsset of preloadAssets || []) {
|
|
||||||
if (preloadAsset.type === AssetTypeEnum.Image) {
|
|
||||||
let img = new Image();
|
|
||||||
img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => {
|
// TODO: Use sdk once it supports signals
|
||||||
const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum);
|
const res = await downloadRequest({
|
||||||
// side effect, only flag imageLoaded when url is different
|
url: getAssetFileUrl(asset.id, !loadOriginal, false, checksum),
|
||||||
imageLoaded = assetFileUrl === assetUrl;
|
signal: abortController.signal,
|
||||||
return assetUrl;
|
});
|
||||||
|
|
||||||
|
assetData = window.URL.createObjectURL(res.data);
|
||||||
|
imageLoaded = true;
|
||||||
|
|
||||||
|
if (!preloadAssets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preloadAsset of preloadAssets) {
|
||||||
|
if (preloadAsset.type === AssetTypeEnum.Image) {
|
||||||
|
await downloadRequest({
|
||||||
|
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
imageLoaded = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const doCopy = async () => {
|
const doCopy = async () => {
|
||||||
|
@ -88,7 +92,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyImageToClipboard(assetFileUrl);
|
await copyImageToClipboard(assetData);
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
message: 'Copied image to clipboard.',
|
message: 'Copied image to clipboard.',
|
||||||
|
@ -117,7 +121,12 @@
|
||||||
|
|
||||||
zoomImageWheelState.subscribe((state) => {
|
zoomImageWheelState.subscribe((state) => {
|
||||||
photoZoomState.set(state);
|
photoZoomState.set(state);
|
||||||
forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false;
|
|
||||||
|
if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) {
|
||||||
|
hasZoomed = true;
|
||||||
|
|
||||||
|
handlePromiseError(loadAssetData({ loadOriginal: true, checksum: asset.checksum }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||||
|
@ -137,53 +146,41 @@
|
||||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{#if imageError}
|
|
||||||
<div class="h-full flex items-center justify-center">Error loading image</div>
|
<div
|
||||||
{/if}
|
bind:this={element}
|
||||||
<div bind:this={element} class="relative h-full select-none">
|
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||||
<img
|
class="relative h-full select-none"
|
||||||
style="display:none"
|
>
|
||||||
src={assetFileUrl}
|
|
||||||
alt={getAltText(asset)}
|
|
||||||
on:load={() => (imageLoaded = true)}
|
|
||||||
on:error={() => (imageError = imageLoaded = true)}
|
|
||||||
/>
|
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div class:hidden={imageLoaded} class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if !imageError}
|
{:else}
|
||||||
{#key assetFileUrl}
|
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
|
||||||
<div
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
bind:this={imgElement}
|
|
||||||
class:hidden={!imageLoaded}
|
|
||||||
class="h-full w-full"
|
|
||||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
|
||||||
>
|
|
||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
|
||||||
<img
|
|
||||||
src={assetFileUrl}
|
|
||||||
alt={getAltText(asset)}
|
|
||||||
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<img
|
<img
|
||||||
bind:this={$photoViewer}
|
src={assetData}
|
||||||
src={assetFileUrl}
|
|
||||||
alt={getAltText(asset)}
|
alt={getAltText(asset)}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
|
||||||
? 'object-contain'
|
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
{/if}
|
||||||
<div
|
<img
|
||||||
class="absolute border-solid border-white border-[3px] rounded-lg"
|
bind:this={$photoViewer}
|
||||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
src={assetData}
|
||||||
/>
|
alt={getAltText(asset)}
|
||||||
{/each}
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
</div>
|
? 'object-contain'
|
||||||
{/key}
|
: slideshowLookCssMapping[$slideshowLook]}"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
||||||
|
<div
|
||||||
|
class="absolute border-solid border-white border-[3px] rounded-lg"
|
||||||
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Reference in a new issue