mirror of https://github.com/immich-app/immich.git
feat: photo-viewer; use <img> instead of blob urls, simplify/refactor, avoid window.events (#9883)
* Photoviewer * make copyImage/zoomToggle optional * Add e2e test * lint * Accept bo0tzz suggestion Co-authored-by: bo0tzz <git@bo0tzz.me> * Bad merge and review comments * unused import --------- Co-authored-by: bo0tzz <git@bo0tzz.me> Co-authored-by: Alex <alex.tran1502@gmail.com>pull/10026/head
parent
def5f59242
commit
4b49d3a85d
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue