mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00: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
8 changed files with 200 additions and 202 deletions
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue