feat(web): rotate image

This commit is contained in:
Jason Rasmussen
2025-02-13 17:02:44 -05:00
parent dbbefde98d
commit 9cd0871178
24 changed files with 441 additions and 53 deletions
@@ -13,6 +13,7 @@ type ActionMap = {
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
[AssetAction.ROTATE]: { asset: AssetResponseDto; counterclockwise: boolean };
};
export type Action = {
@@ -0,0 +1,96 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction, ExifOrientation } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
counterclockwise?: boolean;
menuItem?: boolean;
}
let { asset, onAction, counterclockwise = false, menuItem }: Props = $props();
const icon = $derived(counterclockwise ? mdiRotateLeft : mdiRotateRight);
const text = $derived(counterclockwise ? $t('rotate_left') : $t('rotate_right'));
const getNextOrientation = (current: number) => {
switch (current) {
case -1:
case 0:
case ExifOrientation.Horizontal: {
return ExifOrientation.Rotate90CW;
}
case ExifOrientation.Rotate90CW: {
return ExifOrientation.Rotate180;
}
case ExifOrientation.Rotate180: {
return ExifOrientation.Rotate270CW;
}
case ExifOrientation.Rotate270CW: {
return ExifOrientation.Horizontal;
}
case ExifOrientation.MirrorHorizontal: {
return ExifOrientation.MirrorHorizontalRotate90CW;
}
case ExifOrientation.MirrorHorizontalRotate90CW: {
return ExifOrientation.MirrorVertical;
}
case ExifOrientation.MirrorVertical: {
return ExifOrientation.MirrorHorizontalRotate270CW;
}
case ExifOrientation.MirrorHorizontalRotate270CW: {
return ExifOrientation.MirrorVertical;
}
default: {
return current;
}
}
};
const handleRotate = async () => {
const current = Number(asset.exifInfo?.orientation);
if (!current && current !== 0) {
return;
}
const orientation = counterclockwise
? getNextOrientation(getNextOrientation(getNextOrientation(current)))
: getNextOrientation(current);
try {
const data = await updateAsset({ id: asset.id, updateAssetDto: { orientation } });
// TODO: remove if/when there is immediate UI feedback on image rotation (css animation)
notificationController.show({ message: text, type: NotificationType.Info });
onAction({ type: AssetAction.ROTATE, asset: data, counterclockwise });
} catch (error) {
handleError(error, $t('errors.unable_to_rotate_image'));
}
};
</script>
<svelte:window use:shortcut={{ shortcut: { key: 'r', shift: counterclockwise }, onShortcut: handleRotate }} />
{#if menuItem}
<MenuOption {icon} onClick={handleRotate} {text} />
{:else}
<CircleIconButton
color="opaque"
icon={counterclockwise ? mdiRotateLeft : mdiRotateRight}
title={text}
onclick={handleRotate}
/>
{/if}
@@ -7,14 +7,15 @@
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import RotateAction from '$lib/components/asset-viewer/actions/rotate-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
@@ -22,6 +23,7 @@
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import {
AssetJobName,
@@ -45,9 +47,8 @@
mdiPresentationPlay,
mdiUpload,
} from '@mdi/js';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
@@ -87,6 +88,9 @@
const sharedLink = getSharedLink();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
let canRotate = $derived(
asset.type === AssetTypeEnum.Image && !asset.livePhotoVideoId && asset.exifInfo?.orientation !== undefined,
);
// $: showEditorButton =
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
@@ -180,6 +184,11 @@
<SetProfilePictureAction {asset} />
{/if}
<ArchiveAction {asset} {onAction} />
{#if canRotate}
<RotateAction {asset} {onAction} counterclockwise menuItem />
<RotateAction {asset} {onAction} menuItem />
{/if}
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
@@ -43,10 +43,10 @@
import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
type HasAsset = boolean;
@@ -190,7 +190,7 @@
}
};
const onAssetUpdate = (assetUpdate: AssetResponseDto) => {
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
@@ -198,8 +198,8 @@
onMount(async () => {
unsubscribes.push(
websocketEvents.on('on_upload_success', onAssetUpdate),
websocketEvents.on('on_asset_update', onAssetUpdate),
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
);
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -377,6 +377,7 @@
case AssetAction.KEEP_THIS_DELETE_OTHERS:
case AssetAction.UNSTACK: {
closeViewer();
break;
}
}
@@ -483,7 +484,7 @@
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
checksum={previewStackedAsset.checksum}
cacheKey={previewStackedAsset.thumbhash}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
@@ -500,7 +501,7 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
checksum={asset.checksum}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -529,7 +530,7 @@
{:else}
<VideoViewer
assetId={asset.id}
checksum={asset.checksum}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
@@ -50,7 +50,7 @@
img = new Image();
await tick();
img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum });
img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
img.addEventListener('load', () => onImageLoad(true));
img.addEventListener('error', (error) => {
@@ -40,7 +40,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
checksum: asset.checksum,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
@@ -50,7 +50,7 @@ describe('PhotoViewer component', () => {
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum });
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
@@ -59,7 +59,7 @@ describe('PhotoViewer component', () => {
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum });
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original image when shared link download permission is false', () => {
@@ -70,7 +70,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
checksum: asset.checksum,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
@@ -84,7 +84,7 @@ describe('PhotoViewer component', () => {
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
checksum: asset.checksum,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
@@ -70,19 +70,19 @@
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new Image();
img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum);
img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash);
}
}
};
const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => {
const getAssetUrl = (id: string, useOriginal: boolean, cacheKey: string | null) => {
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum });
return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
}
return useOriginal
? getAssetOriginalUrl({ id, checksum })
: getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum });
? getAssetOriginalUrl({ id, cacheKey })
: getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
};
copyImage = async () => {
@@ -144,7 +144,7 @@
loader?.removeEventListener('error', onerror);
};
});
let isWebCompatible = $derived(isWebCompatibleImage(asset));
let isWebCompatible = $derived(isWebCompatibleImage(asset) && !asset?.exifInfo?.orientation);
let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
// when true, will force loading of the original image
@@ -158,7 +158,7 @@
preload(useOriginalImage, preloadAssets);
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum));
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
</script>
<svelte:window
@@ -13,7 +13,7 @@
interface Props {
assetId: string;
loopVideo: boolean;
checksum: string;
cacheKey: string | null;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
@@ -24,7 +24,7 @@
let {
assetId,
loopVideo,
checksum,
cacheKey,
onPreviousAsset = () => {},
onNextAsset = () => {},
onVideoEnded = () => {},
@@ -39,7 +39,7 @@
onMount(() => {
if (videoPlayer) {
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
forceMuted = false;
videoPlayer.load();
}
@@ -106,7 +106,7 @@
onclose={() => onClose()}
muted={forceMuted || $videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
@@ -6,7 +6,7 @@
interface Props {
assetId: string;
projectionType: string | null | undefined;
checksum: string;
cacheKey: string | null;
loopVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
@@ -18,7 +18,7 @@
let {
assetId,
projectionType,
checksum,
cacheKey,
loopVideo,
onPreviousAsset,
onClose,
@@ -33,7 +33,7 @@
{:else}
<VideoNativeViewer
{loopVideo}
{checksum}
{cacheKey}
{assetId}
{onPreviousAsset}
{onNextAsset}
@@ -327,7 +327,7 @@
{/if}
<ImageThumbnail
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, checksum: asset.checksum })}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -339,7 +339,7 @@
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
@@ -352,7 +352,7 @@
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
{assetStore}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
@@ -34,6 +34,8 @@
{ key: ['i'], action: $t('show_or_hide_info') },
{ key: ['s'], action: $t('stack_selected_photos') },
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
{ key: ['r'], action: $t('rotate_right') },
{ key: ['⇧', 'r'], action: $t('rotate_left') },
{ key: ['⇧', 'd'], action: $t('download') },
{ key: ['Space'], action: $t('play_or_pause_video') },
{ key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
+13
View File
@@ -10,6 +10,19 @@ export enum AssetAction {
ADD_TO_ALBUM = 'add-to-album',
UNSTACK = 'unstack',
KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
ROTATE = 'rotate',
}
// copied from the server because numeric enums lose their names
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
Rotate180 = 3,
MirrorVertical = 4,
MirrorHorizontalRotate270CW = 5,
Rotate90CW = 6,
MirrorHorizontalRotate90CW = 7,
Rotate270CW = 8,
}
export enum AppRoute {
+11 -9
View File
@@ -180,28 +180,30 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
return getBaseUrl() + url.pathname + url.search + url.hash;
};
export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => {
type AssetUrlOptions = { id: string; cacheKey?: string | null };
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, checksum } = options;
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum });
const { id, cacheKey } = options;
return createUrl(getAssetOriginalPath(id), { key: getKey(), c: cacheKey });
};
export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => {
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, size, checksum } = options;
return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum });
const { id, size, cacheKey } = options;
return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: cacheKey });
};
export const getAssetPlaybackUrl = (options: string | { id: string; checksum?: string }) => {
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
if (typeof options === 'string') {
options = { id: options };
}
const { id, checksum } = options;
return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum });
const { id, cacheKey } = options;
return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: cacheKey });
};
export const getProfileImageUrl = (user: UserResponseDto) =>