1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

fix(web): high resolution image on zoom (#9818)

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Michel Heusschen 2024-05-28 13:15:50 +02:00 committed by GitHub
parent 3f6e61d073
commit e459d524a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 154 additions and 74 deletions

View 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`,
}),
);
});
});

View File

@ -3,7 +3,7 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.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 { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/actions/shortcut';
@ -24,14 +24,12 @@
export let haveFadeTransition = true;
let imgElement: HTMLDivElement;
let assetFileUrl: string = '';
let assetData: string;
let abortController: AbortController;
let hasZoomed = false;
let copyImageToClipboard: (source: string) => Promise<Blob>;
let canCopyImagesToClipboard: () => boolean;
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);
@ -42,12 +40,6 @@
});
}
$: {
preload({ preloadAssets, loadOriginal: loadOriginalByDefault });
}
$: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum);
onMount(async () => {
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
@ -56,30 +48,42 @@
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
});
$: void loadAssetData({ loadOriginal: loadOriginalByDefault, checksum: asset.checksum });
onDestroy(() => {
$boundingBoxesArray = [];
abortController?.abort();
});
const preload = ({
preloadAssets,
loadOriginal,
}: {
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 loadAssetData = async ({ loadOriginal, checksum }: { loadOriginal: boolean; checksum: string }) => {
try {
abortController?.abort();
abortController = new AbortController();
const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => {
const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum);
// side effect, only flag imageLoaded when url is different
imageLoaded = assetFileUrl === assetUrl;
return assetUrl;
// TODO: Use sdk once it supports signals
const res = await downloadRequest({
url: getAssetFileUrl(asset.id, !loadOriginal, false, checksum),
signal: abortController.signal,
});
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 () => {
@ -88,7 +92,7 @@
}
try {
await copyImageToClipboard(assetFileUrl);
await copyImageToClipboard(assetData);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
@ -117,7 +121,12 @@
zoomImageWheelState.subscribe((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) => {
@ -137,53 +146,41 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
{#if imageError}
<div class="h-full flex items-center justify-center">Error loading image</div>
{/if}
<div bind:this={element} class="relative h-full select-none">
<img
style="display:none"
src={assetFileUrl}
alt={getAltText(asset)}
on:load={() => (imageLoaded = true)}
on:error={() => (imageError = imageLoaded = true)}
/>
<div
bind:this={element}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="relative h-full select-none"
>
{#if !imageLoaded}
<div class:hidden={imageLoaded} class="flex h-full items-center justify-center">
<div class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
{#key assetFileUrl}
<div
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}
{:else}
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
bind:this={$photoViewer}
src={assetFileUrl}
src={assetData}
alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
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>
{/key}
{/if}
<img
bind:this={$photoViewer}
src={assetData}
alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: 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}
</div>