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

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 { 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) => {

View 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>

View File

@@ -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> = [];

View File

@@ -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,

View File

@@ -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} />

View File

@@ -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')}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -1,4 +1,3 @@
import { writable } from 'svelte/store';
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
export const isSelectingAllAssets = writable(false);

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,
});
};

View 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>);
}
}

View File

@@ -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) {