1
0
Fork 0
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:
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 { 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>