chore: revise frontend UI

pull/24155/head
bwees 2025-12-16 11:05:18 -06:00
parent 4ae8b27089
commit 79dcb3cb5e
No known key found for this signature in database
5 changed files with 193 additions and 101 deletions

View File

@ -935,9 +935,7 @@
"editor": "Editor", "editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?", "editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_rotate_and_flip": "Rotate & Flip",
"editor_crop_tool_h2_mirror": "Mirror",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_reset_all_changes": "Reset all changes", "editor_reset_all_changes": "Reset all changes",
"email": "Email", "email": "Email",
"email_notifications": "Email notifications", "email_notifications": "Email notifications",

View File

@ -74,7 +74,13 @@
{$t('save')} {$t('save')}
</Button> </Button>
<!-- TODO make this clear all edits --> <!-- TODO make this clear all edits -->
<Button fullWidth leadingIcon={mdiRefresh} color="danger" onclick={() => editManager.resetAllChanges()}> <Button
fullWidth
leadingIcon={mdiRefresh}
color="danger"
onclick={() => editManager.resetAllChanges()}
disabled={!editManager.hasChanges}
>
{$t('editor_reset_all_changes')} {$t('editor_reset_all_changes')}
</Button> </Button>
</VStack> </VStack>

View File

@ -12,6 +12,20 @@
let canvasContainer = $state<HTMLElement | null>(null); let canvasContainer = $state<HTMLElement | null>(null);
// use CSS transforms to mirror the image
let imageTransform = $derived.by(() => {
const transforms: string[] = [];
if (transformManager.mirrorHorizontal) {
transforms.push('scaleX(-1)');
}
if (transformManager.mirrorVertical) {
transforms.push('scaleY(-1)');
}
return transforms.join(' ');
});
$effect(() => { $effect(() => {
if (!canvasContainer) { if (!canvasContainer) {
return; return;
@ -39,7 +53,12 @@
aria-label="Crop area" aria-label="Crop area"
type="button" type="button"
> >
<img draggable="false" src={transformManager.imgElement?.src} alt={$getAltText(toTimelineAsset(asset))} /> <img
draggable="false"
src={transformManager.imgElement?.src}
alt={$getAltText(toTimelineAsset(asset))}
style={imageTransform ? `transform: ${imageTransform}` : ''}
/>
<div <div
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`} class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
bind:this={transformManager.cropFrame} bind:this={transformManager.cropFrame}
@ -126,6 +145,7 @@
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
user-select: none; user-select: none;
transition: transform 0.15s ease;
} }
.crop-frame { .crop-frame {

View File

@ -1,31 +1,41 @@
<script lang="ts"> <script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte'; import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { Button, Field, HStack, IconButton, Select, type SelectItem } from '@immich/ui'; import { Button, Field, HStack, IconButton, Select, type SelectItem } from '@immich/ui';
import { mdiCursorMove, mdiFlipHorizontal, mdiFlipVertical, mdiLock, mdiRotateLeft, mdiRotateRight } from '@mdi/js'; import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let cropOrientation = $state<'landscape' | 'portrait'>( const aspectRatios: SelectItem[] = [
transformManager.cropImageSize[0] >= transformManager.cropImageSize[1] ? 'landscape' : 'portrait', { label: $t('crop_aspect_ratio_free'), value: 'free' },
);
let cropMode = $state<'free' | 'fixed'>('free');
let selectedRatio = $state<SelectItem | undefined>();
const horizontalRatios = ['4:3', '3:2', '7:5', '16:9'];
const verticalRatios = ['3:4', '2:3', '5:7', '9:16'];
let aspectRatios: SelectItem[] = $derived([
{ label: $t('crop_aspect_ratio_original'), value: 'original' }, { label: $t('crop_aspect_ratio_original'), value: 'original' },
{ label: '1:1', value: '1:1' }, {
...(cropOrientation === 'landscape' ? horizontalRatios : verticalRatios).map((ratio) => ({ label: '9:16',
label: ratio, value: '9:16',
value: ratio, },
})), {
]); label: '5:7',
value: '5:7',
},
{
label: '4:5',
value: '4:5',
},
{
label: '3:4',
value: '3:4',
},
{
label: '2:3',
value: '2:3',
},
{
label: 'Square',
value: '1:1',
},
];
// function resetCrop() { let selectedRatioItem = $state<SelectItem>(aspectRatios[0]);
// transformManager.resetCrop();
// selectedRatio = undefined; let selectedRatio = $derived(selectedRatioItem.value);
// }
function selectAspectRatio(ratio: 'original' | 'free' | (typeof aspectRatios)[number]['value']) { function selectAspectRatio(ratio: 'original' | 'free' | (typeof aspectRatios)[number]['value']) {
if (ratio === 'original') { if (ratio === 'original') {
@ -37,71 +47,30 @@
} }
function onAspectRatioSelect(ratio: SelectItem) { function onAspectRatioSelect(ratio: SelectItem) {
selectedRatio = ratio; selectedRatio = ratio.value;
selectAspectRatio(ratio.value); selectAspectRatio(ratio.value);
} }
function setFreeCrop() { function rotateAspectRatio() {
cropMode = 'free'; transformManager.rotateAspectRatio();
selectAspectRatio('free');
}
function setFixedCrop() {
cropMode = 'fixed';
if (!selectedRatio) {
selectedRatio = aspectRatios[0];
}
selectAspectRatio(selectedRatio.value);
}
function rotateCropOrientation() {
const newOrientation = cropOrientation === 'landscape' ? 'portrait' : 'landscape';
cropOrientation = newOrientation;
// convert the selected ratio to the new orientation
if (selectedRatio && selectedRatio.value !== 'free' && selectedRatio.value !== 'original') {
const [width, height] = selectedRatio.value.split(':');
const newRatio = `${height}:${width}`;
selectedRatio = aspectRatios.find((ratio) => ratio.value === newRatio);
selectAspectRatio(newRatio);
}
} }
async function rotateImage(degrees: number) { async function rotateImage(degrees: number) {
await transformManager.rotate(degrees); await transformManager.rotate(degrees);
rotateCropOrientation(); }
function mirrorImage(axis: 'horizontal' | 'vertical') {
transformManager.mirror(axis);
} }
</script> </script>
<div class="mt-3 px-4"> <div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm"> <div class="flex h-10 w-full items-center justify-between text-sm">
<h2 class="uppercase">{$t('crop')}</h2> <h2>{$t('crop')}</h2>
</div> </div>
<HStack gap={0} class="mb-4">
<Button
leadingIcon={mdiCursorMove}
shape="rectangle"
class="rounded-l-md"
onclick={setFreeCrop}
color={cropMode === 'free' ? 'primary' : 'secondary'}
fullWidth
>
{$t('crop_aspect_ratio_free')}
</Button>
<Button
leadingIcon={mdiLock}
shape="rectangle"
class="rounded-r-md"
color={cropMode === 'fixed' ? 'primary' : 'secondary'}
onclick={setFixedCrop}
fullWidth
>
{$t('crop_aspect_ratio_fixed')}
</Button>
</HStack>
<HStack> <HStack>
<Field disabled={cropMode === 'free'}> <Field>
<Select class="w-full" onChange={onAspectRatioSelect} bind:value={selectedRatio} data={aspectRatios} /> <Select class="w-full" onChange={onAspectRatioSelect} bind:value={selectedRatioItem} data={aspectRatios} />
</Field> </Field>
<IconButton <IconButton
shape="round" shape="round"
@ -109,23 +78,17 @@
color="secondary" color="secondary"
icon={mdiRotateRight} icon={mdiRotateRight}
aria-label={$t('reset')} aria-label={$t('reset')}
onclick={rotateCropOrientation} onclick={rotateAspectRatio}
disabled={cropMode === 'free'} disabled={selectedRatio === 'free' || selectedRatio === 'original'}
/> />
</HStack> </HStack>
<div class="flex h-10 w-full items-center justify-between text-sm mt-2"> <div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<h2 class="uppercase">{$t('editor_crop_tool_h2_rotation')}</h2> <h2>{$t('editor_rotate_and_flip')}</h2>
</div> </div>
<HStack> <HStack>
<Button fullWidth leadingIcon={mdiRotateLeft} onclick={() => rotateImage(-90)}>{$t('rotate_ccw')}</Button> <Button fullWidth leadingIcon={mdiRotateLeft} onclick={() => rotateImage(-90)}></Button>
<Button fullWidth trailingIcon={mdiRotateRight} onclick={() => rotateImage(90)}>{$t('rotate_cw')}</Button> <Button fullWidth trailingIcon={mdiRotateRight} onclick={() => rotateImage(90)}></Button>
</HStack> <Button fullWidth leadingIcon={mdiFlipHorizontal} onclick={() => mirrorImage('horizontal')}></Button>
<div class="flex h-10 w-full items-center justify-between text-sm mt-2"> <Button fullWidth trailingIcon={mdiFlipVertical} onclick={() => mirrorImage('vertical')}></Button>
<h2 class="uppercase">{$t('editor_crop_tool_h2_mirror')}</h2>
</div>
<HStack>
<Button fullWidth leadingIcon={mdiFlipHorizontal} onclick={() => rotateImage(-90)}>{$t('mirror_horizontal')}</Button
>
<Button fullWidth trailingIcon={mdiFlipVertical} onclick={() => rotateImage(90)}>{$t('mirror_vertical')}</Button>
</HStack> </HStack>
</div> </div>

View File

@ -1,7 +1,14 @@
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils'; import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { EditAction, type AssetResponseDto, type CropParameters, type RotateParameters } from '@immich/sdk'; import {
EditAction,
MirrorAxis,
type AssetResponseDto,
type CropParameters,
type MirrorParameters,
type RotateParameters,
} from '@immich/sdk';
import { tick } from 'svelte'; import { tick } from 'svelte';
export type CropAspectRatio = export type CropAspectRatio =
@ -44,6 +51,8 @@ class TransformManager implements EditToolManager {
region = $state({ x: 0, y: 0, width: 100, height: 100 }); region = $state({ x: 0, y: 0, width: 100, height: 100 });
imageRotation = $state(0); imageRotation = $state(0);
mirrorHorizontal = $state(false);
mirrorVertical = $state(false);
normalizedRotation = $derived.by(() => { normalizedRotation = $derived.by(() => {
const newAngle = this.imageRotation % 360; const newAngle = this.imageRotation % 360;
return newAngle < 0 ? newAngle + 360 : newAngle; return newAngle < 0 ? newAngle + 360 : newAngle;
@ -69,6 +78,18 @@ class TransformManager implements EditToolManager {
checkEdits() { checkEdits() {
const originalImgSize = this.cropImageSize.map((el) => el * this.cropImageScale); const originalImgSize = this.cropImageSize.map((el) => el * this.cropImageScale);
return (
Math.abs(originalImgSize[0] - this.region.width) > 2 ||
Math.abs(originalImgSize[1] - this.region.height) > 2 ||
this.mirrorHorizontal ||
this.mirrorVertical ||
this.normalizedRotation !== 0
);
}
checkCropEdits() {
const originalImgSize = this.cropImageSize.map((el) => el * this.cropImageScale);
return ( return (
Math.abs(originalImgSize[0] - this.region.width) > 2 || Math.abs(originalImgSize[1] - this.region.height) > 2 Math.abs(originalImgSize[0] - this.region.width) > 2 || Math.abs(originalImgSize[1] - this.region.height) > 2
); );
@ -77,16 +98,50 @@ class TransformManager implements EditToolManager {
getEdits(): EditActions { getEdits(): EditActions {
const edits: EditActions = []; const edits: EditActions = [];
if (this.checkEdits()) { if (this.checkCropEdits()) {
const { x, y, width, height } = this.region; let { x, y, width, height } = this.region;
// Convert from display coordinates to original image coordinates
x = Math.round(x / this.cropImageScale);
y = Math.round(y / this.cropImageScale);
width = Math.round(width / this.cropImageScale);
height = Math.round(height / this.cropImageScale);
// Transform crop coordinates to account for mirroring
// The preview shows the mirrored image, but crop is applied before mirror on the server
// So we need to "unmirror" the crop coordinates
const [imgWidth, imgHeight] = this.cropImageSize;
if (this.mirrorHorizontal) {
x = imgWidth - x - width;
}
if (this.mirrorVertical) {
y = imgHeight - y - height;
}
edits.push({ edits.push({
action: EditAction.Crop, action: EditAction.Crop,
parameters: { x, y, width, height },
});
}
// Mirror edits come before rotate in array so that compose applies rotate first, then mirror
// This matches CSS where parent has rotate and child img has mirror transforms
if (this.mirrorHorizontal) {
edits.push({
action: EditAction.Mirror,
parameters: { parameters: {
x: Math.round(x / this.cropImageScale), axis: MirrorAxis.Horizontal,
y: Math.round(y / this.cropImageScale), },
width: Math.round(width / this.cropImageScale), });
height: Math.round(height / this.cropImageScale), }
if (this.mirrorVertical) {
edits.push({
action: EditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
}, },
}); });
} }
@ -105,6 +160,8 @@ class TransformManager implements EditToolManager {
async resetAllChanges() { async resetAllChanges() {
this.imageRotation = 0; this.imageRotation = 0;
this.mirrorHorizontal = false;
this.mirrorVertical = false;
await tick(); await tick();
this.onImageLoad([]); this.onImageLoad([]);
@ -127,6 +184,17 @@ class TransformManager implements EditToolManager {
this.imageRotation = (rotateEdit.parameters as RotateParameters).angle; this.imageRotation = (rotateEdit.parameters as RotateParameters).angle;
} }
// set mirror state from edits
const mirrorEdits = edits.filter((e) => e.action === 'mirror');
for (const mirrorEdit of mirrorEdits) {
const axis = (mirrorEdit.parameters as MirrorParameters).axis;
if (axis === MirrorAxis.Horizontal) {
this.mirrorHorizontal = true;
} else if (axis === MirrorAxis.Vertical) {
this.mirrorVertical = true;
}
}
await tick(); await tick();
this.resizeCanvas(); this.resizeCanvas();
@ -150,11 +218,25 @@ class TransformManager implements EditToolManager {
this.isDragging = false; this.isDragging = false;
this.overlayEl = null; this.overlayEl = null;
this.imageRotation = 0; this.imageRotation = 0;
this.mirrorHorizontal = false;
this.mirrorVertical = false;
this.region = { x: 0, y: 0, width: 100, height: 100 }; this.region = { x: 0, y: 0, width: 100, height: 100 };
this.cropImageSize = [1000, 1000]; this.cropImageSize = [1000, 1000];
this.cropImageScale = 1; this.cropImageScale = 1;
} }
mirror(axis: 'horizontal' | 'vertical') {
if (this.imageRotation % 180 !== 0) {
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
}
if (axis === 'horizontal') {
this.mirrorHorizontal = !this.mirrorHorizontal;
} else {
this.mirrorVertical = !this.mirrorVertical;
}
}
async rotate(angle: number) { async rotate(angle: number) {
this.imageRotation += angle; this.imageRotation += angle;
await tick(); await tick();
@ -355,12 +437,25 @@ class TransformManager implements EditToolManager {
if (cropEdit) { if (cropEdit) {
const params = cropEdit.parameters as CropParameters; const params = cropEdit.parameters as CropParameters;
// eslint-disable-next-line prefer-const
let { x, y, width, height } = params;
// Transform crop coordinates to account for mirroring
// The stored coordinates are for the original image, but we display mirrored
// So we need to mirror the crop coordinates to match the preview
if (this.mirrorHorizontal) {
x = img.width - x - width;
}
if (this.mirrorVertical) {
y = img.height - y - height;
}
// Convert from absolute pixel coordinates to display coordinates // Convert from absolute pixel coordinates to display coordinates
this.region = { this.region = {
x: params.x * scale, x: x * scale,
y: params.y * scale, y: y * scale,
width: params.width * scale, width: width * scale,
height: params.height * scale, height: height * scale,
}; };
} else { } else {
this.region = { this.region = {
@ -934,6 +1029,16 @@ class TransformManager implements EditToolManager {
height: this.cropImageSize[1] * this.cropImageScale - 1, height: this.cropImageSize[1] * this.cropImageScale - 1,
}; };
} }
rotateAspectRatio() {
const aspectRatio = this.cropAspectRatio;
if (aspectRatio === 'free' || aspectRatio === 'reset') {
return;
}
const [widthRatio, heightRatio] = aspectRatio.split(':');
this.setAspectRatio(`${heightRatio}:${widthRatio}`);
}
} }
export const transformManager = new TransformManager(); export const transformManager = new TransformManager();