mirror of
https://github.com/immich-app/immich.git
synced 2024-12-27 10:58:13 +02:00
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>
This commit is contained in:
parent
def5f59242
commit
4b49d3a85d
@ -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 });
|
||||
|
57
e2e/src/web/specs/photo-viewer.e2e-spec.ts
Normal file
57
e2e/src/web/specs/photo-viewer.e2e-spec.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
31
web/src/lib/actions/zoom-image.ts
Normal file
31
web/src/lib/actions/zoom-image.ts
Normal file
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
$: 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;
|
||||
}
|
||||
|
||||
for (const preloadAsset of preloadAssets) {
|
||||
const preload = (useOriginal: boolean, preloadAssets?: AssetResponseDto[]) => {
|
||||
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,
|
||||
});
|
||||
let img = new Image();
|
||||
img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum);
|
||||
}
|
||||
}
|
||||
} 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'
|
||||
|
@ -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>();
|
||||
|
Loading…
Reference in New Issue
Block a user