refactor: asset navbar (#25480)

This commit is contained in:
Jason Rasmussen
2026-01-23 16:19:46 -05:00
committed by GitHub
parent 984fb12ada
commit b52e8cd570
17 changed files with 228 additions and 170 deletions
+4 -11
View File
@@ -1,19 +1,12 @@
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core'; import { createZoomImageWheel } from '@zoom-image/core';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const state = get(photoZoomState); const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: state,
});
const unsubscribes = [ const unsubscribes = [
photoZoomState.subscribe((state) => zoomInstance.setState(state)), assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
zoomInstance.subscribe(({ state }) => { zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
photoZoomState.set(state);
}),
]; ];
const stopIfDisabled = (event: Event) => { const stopIfDisabled = (event: Event) => {
@@ -0,0 +1,30 @@
<script lang="ts">
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
import type { EventCallback } from '$lib/utils/base-event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: EventCallback<Events, K>;
};
const props: Props = $props();
const unsubscribes: Array<() => void> = [];
onMount(() => {
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
if (!listener) {
continue;
}
unsubscribes.push(assetViewerManager.on(event, listener));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
});
</script>
+3 -3
View File
@@ -2,9 +2,9 @@
import { eventManager, type Events } from '$lib/managers/event-manager.svelte'; import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
type Props = Partial<{ type Props = {
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void; [K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
}>; };
const props: Props = $props(); const props: Props = $props();
const unsubscribes: Array<() => void> = []; const unsubscribes: Array<() => void> = [];
@@ -8,15 +8,8 @@ import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
describe('AssetViewerNavBar component', () => { describe('AssetViewerNavBar component', () => {
const additionalProps = { const additionalProps = {
showCopyButton: false,
showZoomButton: false,
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
preAction: () => {}, preAction: () => {},
onZoomImage: () => {},
onAction: () => {}, onAction: () => {},
onEdit: () => {},
onPlaySlideshow: () => {}, onPlaySlideshow: () => {},
onClose: () => {}, onClose: () => {},
playOriginalVideo: false, playOriginalVideo: false,
@@ -23,12 +23,9 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service'; import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getSharedLink, withoutIcons } from '$lib/utils'; import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions'; import type { OnUndoDelete } from '$lib/utils/actions';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetTypeEnum, AssetTypeEnum,
@@ -38,15 +35,12 @@
type PersonResponseDto, type PersonResponseDto,
type StackResponseDto, type StackResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui'; import { CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import { import {
mdiArrowLeft, mdiArrowLeft,
mdiCompare, mdiCompare,
mdiContentCopy,
mdiDotsVertical, mdiDotsVertical,
mdiImageSearch, mdiImageSearch,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiPresentationPlay, mdiPresentationPlay,
mdiUpload, mdiUpload,
mdiVideoOutline, mdiVideoOutline,
@@ -59,8 +53,6 @@
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
stack?: StackResponseDto | null; stack?: StackResponseDto | null;
showSlideshow?: boolean; showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
preAction: PreAction; preAction: PreAction;
onAction: OnAction; onAction: OnAction;
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
@@ -76,8 +68,6 @@
person = null, person = null,
stack = null, stack = null,
showSlideshow = false, showSlideshow = false,
onZoomImage,
onCopyImage,
preAction, preAction,
onAction, onAction,
onUndoDelete = undefined, onUndoDelete = undefined,
@@ -89,35 +79,18 @@
const isOwner = $derived($user && asset.ownerId === $user?.id); const isOwner = $derived($user && asset.ownerId === $user?.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked); const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const isImage = $derived(asset.type === AssetTypeEnum.Image);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Cast } = $derived(getGlobalActions($t)); const { Cast } = $derived(getGlobalActions($t));
const { Close, ZoomIn, ZoomOut } = $derived({ const Close: ActionItem = $derived({
Close: {
title: $t('go_back'), title: $t('go_back'),
type: $t('assets'), type: $t('assets'),
icon: mdiArrowLeft, icon: mdiArrowLeft,
$if: () => !!onClose, $if: () => !!onClose,
onAction: () => onClose?.(), onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }], shortcuts: [{ key: 'Escape' }],
}, });
ZoomIn: {
title: $t('zoom_image'),
icon: mdiMagnifyPlusOutline,
$if: () => isImage && $photoZoomState && $photoZoomState.currentZoom <= 1,
onAction: () => onZoomImage(),
},
ZoomOut: {
title: $t('zoom_image'),
icon: mdiMagnifyMinusOutline,
$if: () => $photoZoomState && $photoZoomState.currentZoom > 1,
onAction: () => onZoomImage(),
},
} satisfies Record<string, ActionItem>);
const { const {
Share, Share,
@@ -129,6 +102,9 @@
Unfavorite, Unfavorite,
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info, Info,
Edit, Edit,
RefreshFacesJob, RefreshFacesJob,
@@ -153,6 +129,9 @@
Unfavorite, Unfavorite,
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info, Info,
Edit, Edit,
RefreshFacesJob, RefreshFacesJob,
@@ -177,18 +156,7 @@
<ActionButton action={StopMotionPhoto} /> <ActionButton action={StopMotionPhoto} />
<ActionButton action={ZoomIn} /> <ActionButton action={ZoomIn} />
<ActionButton action={ZoomOut} /> <ActionButton action={ZoomOut} />
<ActionButton action={Copy} />
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiContentCopy}
aria-label={$t('copy_image')}
onclick={() => onCopyImage?.()}
/>
{/if}
<ActionButton action={SharedLinkDownload} /> <ActionButton action={SharedLinkDownload} />
<ActionButton action={Info} /> <ActionButton action={Info} />
<ActionButton action={Favorite} /> <ActionButton action={Favorite} />
@@ -70,7 +70,6 @@
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (asset: AssetResponseDto) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
} }
let { let {
@@ -86,7 +85,6 @@
onUndoDelete, onUndoDelete,
onClose, onClose,
onRandom, onRandom,
copyImage = $bindable(),
}: Props = $props(); }: Props = $props();
const { setAssetId } = assetViewingStore; const { setAssetId } = assetViewingStore;
@@ -110,7 +108,6 @@
let unsubscribes: (() => void)[] = []; let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null); let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
let playOriginalVideo = $state($alwaysLoadOriginalVideo); let playOriginalVideo = $state($alwaysLoadOriginalVideo);
const setPlayOriginalVideo = (value: boolean) => { const setPlayOriginalVideo = (value: boolean) => {
@@ -445,8 +442,6 @@
{person} {person}
{stack} {stack}
showSlideshow={true} showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
preAction={handlePreAction} preAction={handlePreAction}
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
@@ -481,8 +476,6 @@
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"> <div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'} {#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer <PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }} cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
@@ -515,13 +508,11 @@
{playOriginalVideo} {playOriginalVideo}
/> />
{:else if viewerKind === 'ImagePanaramaViewer'} {:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} /> <ImagePanoramaViewer {asset} />
{:else if viewerKind === 'CropArea'} {:else if viewerKind === 'CropArea'}
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer <PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor} {cursor}
onPreviousAsset={() => navigateAsset('previous')} onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')} onNextAsset={() => navigateAsset('next')}
@@ -8,10 +8,9 @@
type Props = { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
zoomToggle?: (() => void) | null;
}; };
let { asset, zoomToggle = $bindable() }: Props = $props(); let { asset }: Props = $props();
const loadAssetData = async (id: string) => { const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -23,7 +22,7 @@
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer bind:zoomToggle panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} /> <PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
{:catch} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
{/await} {/await}
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } 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 { import {
EquirectangularAdapter, EquirectangularAdapter,
Viewer, Viewer,
@@ -32,17 +33,9 @@
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean; navbar?: boolean;
zoomToggle?: (() => void) | null;
}; };
let { let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
@@ -103,11 +96,8 @@
} }
}); });
zoomToggle = () => { const onZoom = () => {
if (!viewer) { viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
return;
}
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
}; };
let hasChangedResolution: boolean = false; let hasChangedResolution: boolean = false;
@@ -156,11 +146,8 @@
}); });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin); const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100] // zoomLevel is 0-100
photoZoomState.set({ assetViewerManager.zoom = zoomLevel / 50;
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) { if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original // Replace the preview with the original
@@ -181,13 +168,11 @@
viewer.destroy(); viewer.destroy();
} }
boundingBoxesUnsubscribe(); boundingBoxesUnsubscribe();
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1. assetViewerManager.zoom = 1;
photoZoomState.set({
...$photoZoomState,
currentZoom: 1,
});
}); });
</script> </script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} /> <AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div> <div class="h-full w-full mb-0" bind:this={container}></div>
@@ -4,15 +4,15 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants'; import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte'; import { imageManager } from '$lib/managers/ImageManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -35,8 +35,6 @@
sharedLink?: SharedLinkResponseDto | undefined; sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null; onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null; onNextAsset?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
} }
let { let {
@@ -46,8 +44,6 @@
sharedLink = undefined, sharedLink = undefined,
onPreviousAsset = null, onPreviousAsset = null,
onNextAsset = null, onNextAsset = null,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props(); }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
@@ -59,64 +55,63 @@
let loader = $state<HTMLImageElement>(); let loader = $state<HTMLImageElement>();
photoZoomState.set({ assetViewerManager.zoomState = {
currentRotation: 0, currentRotation: 0,
currentZoom: 1, currentZoom: 1,
enable: true, enable: true,
currentPositionX: 0, currentPositionX: 0,
currentPositionY: 0, currentPositionY: 0,
}); };
onDestroy(() => { onDestroy(() => {
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
let ocrBoxes = $derived( let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement ocrManager.showOverlay && assetViewerManager.imgRef
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement) ? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
: [], : [],
); );
let isOcrActive = $derived(ocrManager.showOverlay); let isOcrActive = $derived(ocrManager.showOverlay);
copyImage = async () => { const onCopy = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) { if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
return; return;
} }
try { try {
await copyImageToClipboard($photoViewerImgElement); await copyImageToClipboard(assetViewerManager.imgRef);
toastManager.info($t('copied_image_to_clipboard')); toastManager.info($t('copied_image_to_clipboard'));
} catch (error) { } catch (error) {
handleError(error, $t('copy_error')); handleError(error, $t('copy_error'));
} }
}; };
zoomToggle = () => { const onZoom = () => {
photoZoomState.set({ assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
...$photoZoomState,
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
});
}; };
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => { $effect(() => {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) { if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
zoomToggle(); onZoom();
} }
}); });
// TODO move to action + command palette
const onCopyShortcut = (event: KeyboardEvent) => { const onCopyShortcut = (event: KeyboardEvent) => {
if (globalThis.getSelection()?.type === 'Range') { if (globalThis.getSelection()?.type === 'Range') {
return; return;
} }
event.preventDefault(); event.preventDefault();
handlePromiseError(copyImage());
handlePromiseError(onCopy());
}; };
const onSwipe = (event: SwipeCustomEvent) => { const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) { if (assetViewerManager.zoom > 1) {
return; return;
} }
@@ -133,7 +128,7 @@
} }
}; };
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1)); const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
$effect(() => { $effect(() => {
if (imageLoaderUrl) { if (imageLoaderUrl) {
@@ -167,7 +162,7 @@
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl)); onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived( let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
); );
let containerWidth = $state(0); let containerWidth = $state(0);
@@ -187,13 +182,14 @@
}); });
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} />
<svelte:document <svelte:document
use:shortcuts={[ use:shortcuts={[
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }, { shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true },
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true }, { shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]} ]}
/> />
{#if imageError} {#if imageError}
@@ -228,7 +224,7 @@
/> />
{/if} {/if}
<img <img
bind:this={$photoViewerImgElement} bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl} src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None class="h-full w-full {$slideshowState === SlideshowState.None
@@ -237,7 +233,7 @@
draggable="false" draggable="false"
/> />
<!-- eslint-disable-next-line svelte/require-each-key --> <!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} {#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
<div <div
class="absolute border-solid border-white border-3 rounded-lg" class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -250,7 +246,7 @@
</div> </div>
{#if isFaceEditMode.value} {#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -61,7 +61,7 @@
const handleCreatePerson = async () => { const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement); const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, assetViewerManager.imgRef);
onCreatePerson(newFeaturePhoto); onCreatePerson(newFeaturePhoto);
@@ -2,7 +2,6 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -25,6 +24,7 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props { interface Props {
assetId: string; assetId: string;
@@ -269,7 +269,7 @@
hidden={face.person.isHidden} hidden={face.person.isHidden}
/> />
{:else} {:else}
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)} {#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
<ImageThumbnail <ImageThumbnail
curve curve
shadow shadow
@@ -1,8 +1,26 @@
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted'; import { PersistedLocalStorage } from '$lib/utils/persisted';
import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false); const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager { export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
Copy: [];
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false); isShowActivityPanel = $state(false);
isPlayingMotionPhoto = $state(false); isPlayingMotionPhoto = $state(false);
isShowEditor = $state(false); isShowEditor = $state(false);
@@ -11,10 +29,44 @@ export class AssetViewerManager {
return isShowDetailPanel.current; return isShowDetailPanel.current;
} }
get zoomState() {
return this.#zoomState;
}
set zoomState(state: ZoomImageWheelState) {
this.#zoomState = state;
this.emit('ZoomChange', state);
}
get zoom() {
return this.#zoomState.currentZoom;
}
set zoom(zoom: number) {
this.zoomState = { ...this.zoomState, currentZoom: zoom };
}
canZoomIn() {
return this.hasListeners('Zoom') && this.zoom <= 1;
}
canZoomOut() {
return this.hasListeners('Zoom') && this.zoom > 1;
}
canCopyImage() {
return canCopyImageToClipboard() && !!assetViewerManager.imgRef;
}
private set isShowDetailPanel(value: boolean) { private set isShowDetailPanel(value: boolean) {
isShowDetailPanel.current = value; isShowDetailPanel.current = value;
} }
onZoomChange(state: ZoomImageWheelState) {
// bypass event emitter to avoid loop
this.#zoomState = state;
}
toggleActivityPanel() { toggleActivityPanel() {
this.closeDetailPanel(); this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel; this.isShowActivityPanel = !this.isShowActivityPanel;
+27
View File
@@ -27,6 +27,7 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { import {
mdiAlertOutline, mdiAlertOutline,
mdiCogRefreshOutline, mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline, mdiDatabaseRefreshOutline,
mdiDownload, mdiDownload,
mdiDownloadBox, mdiDownloadBox,
@@ -35,6 +36,8 @@ import {
mdiHeartOutline, mdiHeartOutline,
mdiImageRefreshOutline, mdiImageRefreshOutline,
mdiInformationOutline, mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiMotionPauseOutline, mdiMotionPauseOutline,
mdiMotionPlayOutline, mdiMotionPlayOutline,
mdiShareVariantOutline, mdiShareVariantOutline,
@@ -125,6 +128,27 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
onAction: () => assetViewerManager.toggleDetailPanel(), onAction: () => assetViewerManager.toggleDetailPanel(),
}; };
const ZoomIn: ActionItem = {
title: $t('zoom_image'),
icon: mdiMagnifyPlusOutline,
$if: () => assetViewerManager.canZoomIn(),
onAction: () => assetViewerManager.emit('Zoom'),
};
const ZoomOut: ActionItem = {
title: $t('zoom_image'),
icon: mdiMagnifyMinusOutline,
$if: () => assetViewerManager.canZoomOut(),
onAction: () => assetViewerManager.emit('Zoom'),
};
const Copy: ActionItem = {
title: $t('copy_image'),
icon: mdiContentCopy,
$if: () => assetViewerManager.canCopyImage(),
onAction: () => assetViewerManager.emit('Copy'),
};
const Info: ActionItem = { const Info: ActionItem = {
title: $t('info'), title: $t('info'),
icon: mdiInformationOutline, icon: mdiInformationOutline,
@@ -185,6 +209,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite, Unfavorite,
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Edit, Edit,
RefreshFacesJob, RefreshFacesJob,
RefreshMetadataJob, RefreshMetadataJob,
@@ -1,4 +1,3 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
export const isSelectingAllAssets = writable(false); export const isSelectingAllAssets = writable(false);
-25
View File
@@ -1,25 +0,0 @@
import type { ZoomImageWheelState } from '@zoom-image/core';
import { derived, writable } from 'svelte/store';
export const photoZoomState = writable<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export const photoZoomTransform = derived(
photoZoomState,
($state) => `translate(${$state.currentPositionX}px,${$state.currentPositionY}px) scale(${$state.currentZoom})`,
);
export const resetZoomState = () => {
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
};
@@ -0,0 +1,50 @@
type BaseEvents = Record<string, unknown[]>;
export type EventCallback<Events extends BaseEvents, T extends keyof Events> = (
...args: Events[T]
) => Promise<void> | void;
export type EventItem<Events extends BaseEvents, T extends keyof Events = keyof Events> = {
id: number;
event: T;
callback: EventCallback<Events, T>;
};
let count = 1;
const nextId = () => count++;
const noop = () => {};
export class BaseEventManager<Events extends BaseEvents> {
#callbacks: EventItem<Events>[] = $state([]);
on<T extends keyof Events>(event: T, callback?: EventCallback<Events, T>) {
if (!callback) {
return noop;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const item = { id: nextId(), event, callback } as EventItem<Events, any>;
this.#callbacks.push(item);
return () => {
this.#callbacks = this.#callbacks.filter((current) => current.id !== item.id);
};
}
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
const listeners = this.getListeners(event);
for (const listener of listeners) {
void listener(...params);
}
}
hasListeners<T extends keyof Events>(event: T) {
return this.#callbacks.some((item) => item.event === event);
}
private getListeners<T extends keyof Events>(event: T) {
return this.#callbacks
.filter((item) => item.event === event)
.map((item) => item.callback as EventCallback<Events, T>);
}
}
+2 -2
View File
@@ -76,9 +76,9 @@ export const zoomImageToBase64 = async (
face: AssetFaceResponseDto, face: AssetFaceResponseDto,
assetId: string, assetId: string,
assetType: AssetTypeEnum, assetType: AssetTypeEnum,
photoViewer: HTMLImageElement | null, photoViewer: HTMLImageElement | undefined,
): Promise<string | null> => { ): Promise<string | null> => {
let image: HTMLImageElement | null = null; let image: HTMLImageElement | undefined;
if (assetType === AssetTypeEnum.Image) { if (assetType === AssetTypeEnum.Image) {
image = photoViewer; image = photoViewer;
} else if (assetType === AssetTypeEnum.Video) { } else if (assetType === AssetTypeEnum.Video) {