diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index d246293d94..2b26537677 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -323,6 +323,40 @@ export const utils = { return body as AssetMediaResponseDto; }, + replaceAsset: async ( + accessToken: string, + assetId: string, + dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: AssetData }, + ) => { + const _dto = { + deviceAssetId: 'test-1', + deviceId: 'test', + fileCreatedAt: new Date().toISOString(), + fileModifiedAt: new Date().toISOString(), + ...dto, + }; + + const assetData = dto?.assetData?.bytes || makeRandomImage(); + const filename = dto?.assetData?.filename || 'example.png'; + + if (dto?.assetData?.bytes) { + console.log(`Uploading ${filename}`); + } + + const builder = request(app) + .put(`/assets/${assetId}/original`) + .attach('assetData', assetData, filename) + .set('Authorization', `Bearer ${accessToken}`); + + for (const [key, value] of Object.entries(_dto)) { + void builder.field(key, String(value)); + } + + const { body } = await builder; + + return body as AssetMediaResponseDto; + }, + createImageFile: (path: string) => { if (!existsSync(dirname(path))) { mkdirSync(dirname(path), { recursive: true }); diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts new file mode 100644 index 0000000000..f825b10315 --- /dev/null +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -0,0 +1,57 @@ +import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk'; +import { Page, expect, test } from '@playwright/test'; +import { utils } from 'src/utils'; + +function imageLocator(page: Page) { + return page.getByAltText('Image taken on').locator('visible=true'); +} +test.describe('Photo Viewer', () => { + let admin: LoginResponseDto; + let asset: AssetMediaResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + asset = await utils.createAsset(admin.accessToken); + }); + + test.beforeEach(async ({ context, page }) => { + // before each test, login as user + await utils.setAuthCookies(context, admin.accessToken); + await page.goto('/photos'); + await page.waitForLoadState('networkidle'); + }); + + test('initially shows a loading spinner', async ({ page }) => { + await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { + // slow down the request for thumbnail, so spiner has chance to show up + await new Promise((f) => setTimeout(f, 2000)); + await route.continue(); + }); + await page.goto(`/photos/${asset.id}`); + await page.waitForLoadState('load'); + // this is the spinner + await page.waitForSelector('svg[role=status]'); + await expect(page.getByRole('status')).toBeVisible(); + }); + + test('loads high resolution photo when zoomed', async ({ page }) => { + await page.goto(`/photos/${asset.id}`); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); + const box = await imageLocator(page).boundingBox(); + expect(box).toBeTruthy; + const { x, y, width, height } = box!; + await page.mouse.move(x + width / 2, y + height / 2); + await page.mouse.wheel(0, -1); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + }); + + test('reloads photo when checksum changes', async ({ page }) => { + await page.goto(`/photos/${asset.id}`); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); + const initialSrc = await imageLocator(page).getAttribute('src'); + await utils.replaceAsset(admin.accessToken, asset.id); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc); + }); +}); diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts new file mode 100644 index 0000000000..24a1a25ac1 --- /dev/null +++ b/web/src/lib/actions/zoom-image.ts @@ -0,0 +1,31 @@ +import { photoZoomState, zoomed } from '$lib/stores/zoom-image.store'; +import { useZoomImageWheel } from '@zoom-image/svelte'; +import { get } from 'svelte/store'; + +export { zoomed } from '$lib/stores/zoom-image.store'; + +export const zoomImageAction = (node: HTMLElement) => { + const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); + + createZoomImage(node, { + maxZoom: 10, + wheelZoomRatio: 0.2, + }); + + const state = get(photoZoomState); + if (state) { + setZoomImageState(state); + } + + const unsubscribes = [ + zoomed.subscribe((state) => setZoomImageState({ currentZoom: state ? 2 : 1 })), + zoomImageState.subscribe((state) => photoZoomState.set(state)), + ]; + return { + destroy() { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }, + }; +}; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index a8b48993c9..2dfb841e93 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -51,6 +51,8 @@ export let showShareButton: boolean; export let showSlideshow = false; export let hasStackChildren = false; + export let onZoomImage: () => void; + export let onCopyImage: () => void; $: isOwner = $user && asset.ownerId === $user?.id; @@ -144,22 +146,11 @@ hideMobile={true} icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline} title={$t('zoom_image')} - on:click={() => { - const zoomImage = new CustomEvent('zoomImage'); - window.dispatchEvent(zoomImage); - }} + on:click={onZoomImage} /> {/if} {#if showCopyButton} - <CircleIconButton - color="opaque" - icon={mdiContentCopy} - title={$t('copy_image')} - on:click={() => { - const copyEvent = new CustomEvent('copyImage'); - window.dispatchEvent(copyEvent); - }} - /> + <CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} /> {/if} {#if !isOwner && showDownloadButton} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b7da50e69a..30e6222f28 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -59,6 +59,7 @@ import VideoViewer from './video-wrapper-viewer.svelte'; import { navigate } from '$lib/utils/navigation'; import { websocketEvents } from '$lib/stores/websocket'; + import { canCopyImagesToClipboard } from 'copy-image-clipboard'; import { t } from 'svelte-i18n'; export let assetStore: AssetStore | null = null; @@ -98,7 +99,6 @@ let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let enableDetailPanel = asset.hasMetadata; let shouldShowShareModal = !asset.isTrashed; - let canCopyImagesToClipboard: boolean; let slideshowStateUnsubscribe: () => void; let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; @@ -107,6 +107,8 @@ let numberOfComments: number; let fullscreenElement: Element; let unsubscribe: () => void; + let zoomToggle = () => void 0; + let copyImage: () => Promise<void>; $: isFullScreen = fullscreenElement !== null; $: { @@ -227,11 +229,6 @@ await handleGetAllAlbums(); } - // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 - // TODO: Move to regular import once the package correctly supports ESM. - const module = await import('copy-image-clipboard'); - canCopyImagesToClipboard = module.canCopyImagesToClipboard(); - if (asset.stackCount && asset.stack) { $stackAssetsStore = asset.stack; $stackAssetsStore = [...$stackAssetsStore, asset].sort( @@ -568,7 +565,7 @@ {asset} {album} isMotionPhotoPlaying={shouldPlayMotionPhoto} - showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image} + showCopyButton={canCopyImagesToClipboard() && asset.type === AssetTypeEnum.Image} showZoomButton={asset.type === AssetTypeEnum.Image} showMotionPlayButton={!!asset.livePhotoVideoId} showDownloadButton={shouldShowDownloadButton} @@ -576,6 +573,8 @@ showSlideshow={!!assetStore} hasStackChildren={$stackAssetsStore.length > 0} showShareButton={shouldShowShareModal} + onZoomImage={zoomToggle} + onCopyImage={copyImage} on:back={closeViewer} on:showDetail={showDetailInfoHandler} on:download={() => downloadFile(asset)} @@ -623,6 +622,8 @@ {#key previewStackedAsset.id} {#if previewStackedAsset.type === AssetTypeEnum.Image} <PhotoViewer + bind:zoomToggle + bind:copyImage asset={previewStackedAsset} {preloadAssets} on:close={closeViewer} @@ -665,7 +666,7 @@ .endsWith('.insp'))} <PanoramaViewer {asset} /> {:else} - <PhotoViewer {asset} {preloadAssets} on:close={closeViewer} /> + <PhotoViewer bind:zoomToggle bind:copyImage {asset} {preloadAssets} on:close={closeViewer} /> {/if} {:else} <VideoViewer diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts deleted file mode 100644 index 1543e8bc03..0000000000 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -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/assets/${asset.id}/thumbnail?size=preview&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/assets/${asset.id}/original?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/assets/${asset.id}/thumbnail?size=preview&c=new-checksum`, - }), - ); - }); -}); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index f531a3ee4c..4ce8c77d30 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -1,103 +1,83 @@ <script lang="ts"> + import { shortcuts } from '$lib/actions/shortcut'; import { photoViewer } from '$lib/stores/assets.store'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { downloadRequest, getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; - import { shortcuts } from '$lib/actions/shortcut'; - import { type AssetResponseDto, AssetTypeEnum, AssetMediaSize } from '@immich/sdk'; - import { useZoomImageWheel } from '@zoom-image/svelte'; - import { onDestroy, onMount } from 'svelte'; + import { getAltText } from '$lib/utils/thumbnail-util'; + import { AssetTypeEnum, type AssetResponseDto, AssetMediaSize } from '@immich/sdk'; + import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; + import { canCopyImagesToClipboard, copyImageToClipboard } from 'copy-image-clipboard'; + import { onDestroy } from 'svelte'; + import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; - import { getAltText } from '$lib/utils/thumbnail-util'; - import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { t } from 'svelte-i18n'; - const { slideshowState, slideshowLook } = slideshowStore; - export let asset: AssetResponseDto; - export let preloadAssets: AssetResponseDto[] | null = null; + export let preloadAssets: AssetResponseDto[] | undefined = undefined; export let element: HTMLDivElement | undefined = undefined; export let haveFadeTransition = true; - let imgElement: HTMLDivElement; - let assetData: string; - let abortController: AbortController; - let hasZoomed = false; - let copyImageToClipboard: (source: string) => Promise<Blob>; - let canCopyImagesToClipboard: () => boolean; + export let copyImage: (() => Promise<void>) | null = null; + export let zoomToggle: (() => void) | null = null; + + const { slideshowState, slideshowLook } = slideshowStore; + + let assetFileUrl: string = ''; let imageLoaded: boolean = false; + let imageError: boolean = false; + let forceUseOriginal: boolean = false; - const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset); + $: isWebCompatible = isWebCompatibleImage(asset); + $: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile; + $: useOriginalImage = useOriginalByDefault || forceUseOriginal; + // when true, will force loading of the original image + $: forceUseOriginal = forceUseOriginal || ($photoZoomState.currentZoom > 1 && isWebCompatible); - $: if (imgElement) { - createZoomImageWheel(imgElement, { - maxZoom: 10, - wheelZoomRatio: 0.2, - }); - } + $: preload(useOriginalImage, preloadAssets); + $: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, 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. - const module = await import('copy-image-clipboard'); - copyImageToClipboard = module.copyImageToClipboard; - canCopyImagesToClipboard = module.canCopyImagesToClipboard; + photoZoomState.set({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, }); - - $: void loadAssetData({ loadOriginal: loadOriginalByDefault, checksum: asset.checksum }); + $zoomed = false; onDestroy(() => { $boundingBoxesArray = []; - abortController?.abort(); }); - const loadAssetData = async ({ loadOriginal, checksum }: { loadOriginal: boolean; checksum: string }) => { - try { - abortController?.abort(); - abortController = new AbortController(); - - // TODO: Use sdk once it supports signals - const res = await downloadRequest({ - url: loadOriginal - ? getAssetOriginalUrl({ id: asset.id, checksum }) - : getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, checksum }), - signal: abortController.signal, - }); - - assetData = window.URL.createObjectURL(res.data); - imageLoaded = true; - - if (!preloadAssets) { - return; + const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => { + for (const preloadAsset of preloadAssets || []) { + if (preloadAsset.type === AssetTypeEnum.Image) { + let img = new Image(); + img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum); } - - for (const preloadAsset of preloadAssets) { - if (preloadAsset.type === AssetTypeEnum.Image) { - await downloadRequest({ - url: loadOriginal - ? getAssetOriginalUrl(preloadAsset.id) - : getAssetThumbnailUrl({ id: preloadAsset.id, size: AssetMediaSize.Preview }), - signal: abortController.signal, - }); - } - } - } catch { - imageLoaded = false; } }; - const doCopy = async () => { + const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => { + return useOriginal + ? getAssetOriginalUrl({ id, checksum }) + : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum }); + }; + + copyImage = async () => { if (!canCopyImagesToClipboard()) { return; } try { - await copyImageToClipboard(assetData); + await copyImageToClipboard(assetFileUrl); notificationController.show({ type: NotificationType.Info, message: $t('copied_image_to_clipboard'), @@ -112,60 +92,46 @@ } }; - const doZoomImage = () => { - setZoomImageWheelState({ - currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1, - }); + zoomToggle = () => { + $zoomed = $zoomed ? false : true; }; - const { - createZoomImage: createZoomImageWheel, - zoomImageState: zoomImageWheelState, - setZoomImageState: setZoomImageWheelState, - } = useZoomImageWheel(); - - zoomImageWheelState.subscribe((state) => { - photoZoomState.set(state); - - if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) { - hasZoomed = true; - - handlePromiseError(loadAssetData({ loadOriginal: true, checksum: asset.checksum })); - } - }); - const onCopyShortcut = (event: KeyboardEvent) => { if (window.getSelection()?.type === $t('range')) { return; } event.preventDefault(); - handlePromiseError(doCopy()); + handlePromiseError(copyImage()); }; </script> <svelte:window - on:copyImage={doCopy} - on:zoomImage={doZoomImage} + on:wheel|preventDefault|nonpassive use:shortcuts={[ { shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> - -<div - bind:this={element} - transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} - class="relative h-full select-none" -> +{#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={imageLoaderUrl} + alt={getAltText(asset)} + on:load={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} + on:error={() => (imageError = imageLoaded = true)} + /> {#if !imageLoaded} <div class="flex h-full items-center justify-center"> <LoadingSpinner /> </div> - {:else} - <div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}> + {:else if !imageError} + <div use:zoomImageAction class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}> {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} <img - src={assetData} + src={assetFileUrl} alt={getAltText(asset)} class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg" draggable="false" @@ -173,7 +139,7 @@ {/if} <img bind:this={$photoViewer} - src={assetData} + src={assetFileUrl} alt={getAltText(asset)} class="h-full w-full {$slideshowState === SlideshowState.None ? 'object-contain' diff --git a/web/src/lib/stores/zoom-image.store.ts b/web/src/lib/stores/zoom-image.store.ts index 2c6ee18972..c31092c4f7 100644 --- a/web/src/lib/stores/zoom-image.store.ts +++ b/web/src/lib/stores/zoom-image.store.ts @@ -2,3 +2,4 @@ import type { ZoomImageWheelState } from '@zoom-image/core'; import { writable } from 'svelte/store'; export const photoZoomState = writable<ZoomImageWheelState>(); +export const zoomed = writable<boolean>();