mirror of
https://github.com/immich-app/immich.git
synced 2026-02-11 11:27:56 +03:00
refactor: asset navbar (#25480)
This commit is contained in:
@@ -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 { get } from 'svelte/store';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const state = get(photoZoomState);
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: state,
|
||||
});
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
|
||||
const unsubscribes = [
|
||||
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => {
|
||||
photoZoomState.set(state);
|
||||
}),
|
||||
assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
const stopIfDisabled = (event: Event) => {
|
||||
|
||||
30
web/src/lib/components/AssetViewerEvents.svelte
Normal file
30
web/src/lib/components/AssetViewerEvents.svelte
Normal file
@@ -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>
|
||||
@@ -2,9 +2,9 @@
|
||||
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = Partial<{
|
||||
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void;
|
||||
}>;
|
||||
type Props = {
|
||||
[K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
|
||||
};
|
||||
|
||||
const props: Props = $props();
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
@@ -8,15 +8,8 @@ import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
||||
|
||||
describe('AssetViewerNavBar component', () => {
|
||||
const additionalProps = {
|
||||
showCopyButton: false,
|
||||
showZoomButton: false,
|
||||
showDownloadButton: false,
|
||||
showMotionPlayButton: false,
|
||||
showShareButton: false,
|
||||
preAction: () => {},
|
||||
onZoomImage: () => {},
|
||||
onAction: () => {},
|
||||
onEdit: () => {},
|
||||
onPlaySlideshow: () => {},
|
||||
onClose: () => {},
|
||||
playOriginalVideo: false,
|
||||
|
||||
@@ -23,12 +23,9 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.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 { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
@@ -38,15 +35,12 @@
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
|
||||
import { CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCompare,
|
||||
mdiContentCopy,
|
||||
mdiDotsVertical,
|
||||
mdiImageSearch,
|
||||
mdiMagnifyMinusOutline,
|
||||
mdiMagnifyPlusOutline,
|
||||
mdiPresentationPlay,
|
||||
mdiUpload,
|
||||
mdiVideoOutline,
|
||||
@@ -59,8 +53,6 @@
|
||||
person?: PersonResponseDto | null;
|
||||
stack?: StackResponseDto | null;
|
||||
showSlideshow?: boolean;
|
||||
onZoomImage: () => void;
|
||||
onCopyImage?: () => Promise<void>;
|
||||
preAction: PreAction;
|
||||
onAction: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
@@ -76,8 +68,6 @@
|
||||
person = null,
|
||||
stack = null,
|
||||
showSlideshow = false,
|
||||
onZoomImage,
|
||||
onCopyImage,
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete = undefined,
|
||||
@@ -89,35 +79,18 @@
|
||||
|
||||
const isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
||||
const isImage = $derived(asset.type === AssetTypeEnum.Image);
|
||||
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
|
||||
const { Close, ZoomIn, ZoomOut } = $derived({
|
||||
Close: {
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: mdiArrowLeft,
|
||||
$if: () => !!onClose,
|
||||
onAction: () => onClose?.(),
|
||||
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 Close: ActionItem = $derived({
|
||||
title: $t('go_back'),
|
||||
type: $t('assets'),
|
||||
icon: mdiArrowLeft,
|
||||
$if: () => !!onClose,
|
||||
onAction: () => onClose?.(),
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
const {
|
||||
Share,
|
||||
@@ -129,6 +102,9 @@
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Info,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
@@ -153,6 +129,9 @@
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Info,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
@@ -177,18 +156,7 @@
|
||||
<ActionButton action={StopMotionPhoto} />
|
||||
<ActionButton action={ZoomIn} />
|
||||
<ActionButton action={ZoomOut} />
|
||||
|
||||
{#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={Copy} />
|
||||
<ActionButton action={SharedLinkDownload} />
|
||||
<ActionButton action={Info} />
|
||||
<ActionButton action={Favorite} />
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onClose?: (asset: AssetResponseDto) => void;
|
||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||
copyImage?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -86,7 +85,6 @@
|
||||
onUndoDelete,
|
||||
onClose,
|
||||
onRandom,
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
@@ -110,7 +108,6 @@
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let zoomToggle = $state(() => void 0);
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
|
||||
const setPlayOriginalVideo = (value: boolean) => {
|
||||
@@ -445,8 +442,6 @@
|
||||
{person}
|
||||
{stack}
|
||||
showSlideshow={true}
|
||||
onZoomImage={zoomToggle}
|
||||
onCopyImage={copyImage}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
@@ -481,8 +476,6 @@
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
@@ -515,13 +508,11 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} />
|
||||
<ImagePanoramaViewer {asset} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
zoomToggle?: (() => void) | null;
|
||||
};
|
||||
|
||||
let { asset, zoomToggle = $bindable() }: Props = $props();
|
||||
let { asset }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
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')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer bind:zoomToggle panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
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 { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import {
|
||||
EquirectangularAdapter,
|
||||
Viewer,
|
||||
@@ -32,17 +33,9 @@
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||
navbar?: boolean;
|
||||
zoomToggle?: (() => void) | null;
|
||||
};
|
||||
|
||||
let {
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
plugins = [],
|
||||
navbar = false,
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
@@ -103,11 +96,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
zoomToggle = () => {
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
|
||||
const onZoom = () => {
|
||||
viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
|
||||
};
|
||||
|
||||
let hasChangedResolution: boolean = false;
|
||||
@@ -156,11 +146,8 @@
|
||||
});
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel range: [0, 100]
|
||||
photoZoomState.set({
|
||||
...$photoZoomState,
|
||||
currentZoom: zoomLevel / 50,
|
||||
});
|
||||
// zoomLevel is 0-100
|
||||
assetViewerManager.zoom = zoomLevel / 50;
|
||||
|
||||
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
||||
// Replace the preview with the original
|
||||
@@ -181,13 +168,11 @@
|
||||
viewer.destroy();
|
||||
}
|
||||
boundingBoxesUnsubscribe();
|
||||
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1.
|
||||
photoZoomState.set({
|
||||
...$photoZoomState,
|
||||
currentZoom: 1,
|
||||
});
|
||||
assetViewerManager.zoom = 1;
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.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 { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.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 { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -35,8 +35,6 @@
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: (() => void) | null;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -46,8 +44,6 @@
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
@@ -59,64 +55,63 @@
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
photoZoomState.set({
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
ocrManager.showOverlay && $photoViewerImgElement
|
||||
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
|
||||
ocrManager.showOverlay && assetViewerManager.imgRef
|
||||
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
|
||||
: [],
|
||||
);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
|
||||
copyImage = async () => {
|
||||
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard($photoViewerImgElement);
|
||||
await copyImageToClipboard(assetViewerManager.imgRef);
|
||||
toastManager.info($t('copied_image_to_clipboard'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('copy_error'));
|
||||
}
|
||||
};
|
||||
|
||||
zoomToggle = () => {
|
||||
photoZoomState.set({
|
||||
...$photoZoomState,
|
||||
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
|
||||
});
|
||||
const onZoom = () => {
|
||||
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
||||
};
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
|
||||
zoomToggle();
|
||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO move to action + command palette
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (globalThis.getSelection()?.type === 'Range') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
handlePromiseError(copyImage());
|
||||
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if ($photoZoomState.currentZoom > 1) {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,7 +128,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
@@ -167,7 +162,7 @@
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
@@ -187,13 +182,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
|
||||
<svelte:document
|
||||
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: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
@@ -228,7 +224,7 @@
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewerImgElement}
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
@@ -237,7 +233,7 @@
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- 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
|
||||
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;"
|
||||
@@ -250,7 +246,7 @@
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
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 { handleError } from '$lib/utils/handle-error';
|
||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||
@@ -61,7 +61,7 @@
|
||||
const handleCreatePerson = async () => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -25,6 +24,7 @@
|
||||
import { fly } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
@@ -269,7 +269,7 @@
|
||||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{:else}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
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 type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
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);
|
||||
isPlayingMotionPhoto = $state(false);
|
||||
isShowEditor = $state(false);
|
||||
@@ -11,10 +29,44 @@ export class AssetViewerManager {
|
||||
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) {
|
||||
isShowDetailPanel.current = value;
|
||||
}
|
||||
|
||||
onZoomChange(state: ZoomImageWheelState) {
|
||||
// bypass event emitter to avoid loop
|
||||
this.#zoomState = state;
|
||||
}
|
||||
|
||||
toggleActivityPanel() {
|
||||
this.closeDetailPanel();
|
||||
this.isShowActivityPanel = !this.isShowActivityPanel;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiCogRefreshOutline,
|
||||
mdiContentCopy,
|
||||
mdiDatabaseRefreshOutline,
|
||||
mdiDownload,
|
||||
mdiDownloadBox,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
mdiHeartOutline,
|
||||
mdiImageRefreshOutline,
|
||||
mdiInformationOutline,
|
||||
mdiMagnifyMinusOutline,
|
||||
mdiMagnifyPlusOutline,
|
||||
mdiMotionPauseOutline,
|
||||
mdiMotionPlayOutline,
|
||||
mdiShareVariantOutline,
|
||||
@@ -125,6 +128,27 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
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 = {
|
||||
title: $t('info'),
|
||||
icon: mdiInformationOutline,
|
||||
@@ -185,6 +209,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
Unfavorite,
|
||||
PlayMotionPhoto,
|
||||
StopMotionPhoto,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
50
web/src/lib/utils/base-event-manager.svelte.ts
Normal file
50
web/src/lib/utils/base-event-manager.svelte.ts
Normal file
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,9 @@ export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetId: string,
|
||||
assetType: AssetTypeEnum,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
photoViewer: HTMLImageElement | undefined,
|
||||
): Promise<string | null> => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
let image: HTMLImageElement | undefined;
|
||||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = photoViewer;
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
|
||||
Reference in New Issue
Block a user