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_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_mirror": "Mirror",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_rotate_and_flip": "Rotate & Flip",
"editor_reset_all_changes": "Reset all changes",
"email": "Email",
"email_notifications": "Email notifications",

View File

@ -74,7 +74,13 @@
{$t('save')}
</Button>
<!-- 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')}
</Button>
</VStack>

View File

@ -12,6 +12,20 @@
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(() => {
if (!canvasContainer) {
return;
@ -39,7 +53,12 @@
aria-label="Crop area"
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
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
bind:this={transformManager.cropFrame}
@ -126,6 +145,7 @@
max-width: 100%;
height: 100%;
user-select: none;
transition: transform 0.15s ease;
}
.crop-frame {

View File

@ -1,31 +1,41 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
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';
let cropOrientation = $state<'landscape' | 'portrait'>(
transformManager.cropImageSize[0] >= transformManager.cropImageSize[1] ? 'landscape' : 'portrait',
);
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([
const aspectRatios: SelectItem[] = [
{ label: $t('crop_aspect_ratio_free'), value: 'free' },
{ label: $t('crop_aspect_ratio_original'), value: 'original' },
{ label: '1:1', value: '1:1' },
...(cropOrientation === 'landscape' ? horizontalRatios : verticalRatios).map((ratio) => ({
label: ratio,
value: ratio,
})),
]);
{
label: '9:16',
value: '9:16',
},
{
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() {
// transformManager.resetCrop();
// selectedRatio = undefined;
// }
let selectedRatioItem = $state<SelectItem>(aspectRatios[0]);
let selectedRatio = $derived(selectedRatioItem.value);
function selectAspectRatio(ratio: 'original' | 'free' | (typeof aspectRatios)[number]['value']) {
if (ratio === 'original') {
@ -37,71 +47,30 @@
}
function onAspectRatioSelect(ratio: SelectItem) {
selectedRatio = ratio;
selectedRatio = ratio.value;
selectAspectRatio(ratio.value);
}
function setFreeCrop() {
cropMode = 'free';
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);
}
function rotateAspectRatio() {
transformManager.rotateAspectRatio();
}
async function rotateImage(degrees: number) {
await transformManager.rotate(degrees);
rotateCropOrientation();
}
function mirrorImage(axis: 'horizontal' | 'vertical') {
transformManager.mirror(axis);
}
</script>
<div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2 class="uppercase">{$t('crop')}</h2>
<h2>{$t('crop')}</h2>
</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>
<Field disabled={cropMode === 'free'}>
<Select class="w-full" onChange={onAspectRatioSelect} bind:value={selectedRatio} data={aspectRatios} />
<Field>
<Select class="w-full" onChange={onAspectRatioSelect} bind:value={selectedRatioItem} data={aspectRatios} />
</Field>
<IconButton
shape="round"
@ -109,23 +78,17 @@
color="secondary"
icon={mdiRotateRight}
aria-label={$t('reset')}
onclick={rotateCropOrientation}
disabled={cropMode === 'free'}
onclick={rotateAspectRatio}
disabled={selectedRatio === 'free' || selectedRatio === 'original'}
/>
</HStack>
<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>
<HStack>
<Button fullWidth leadingIcon={mdiRotateLeft} onclick={() => rotateImage(-90)}>{$t('rotate_ccw')}</Button>
<Button fullWidth trailingIcon={mdiRotateRight} onclick={() => rotateImage(90)}>{$t('rotate_cw')}</Button>
</HStack>
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<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>
<Button fullWidth leadingIcon={mdiRotateLeft} onclick={() => rotateImage(-90)}></Button>
<Button fullWidth trailingIcon={mdiRotateRight} onclick={() => rotateImage(90)}></Button>
<Button fullWidth leadingIcon={mdiFlipHorizontal} onclick={() => mirrorImage('horizontal')}></Button>
<Button fullWidth trailingIcon={mdiFlipVertical} onclick={() => mirrorImage('vertical')}></Button>
</HStack>
</div>

View File

@ -1,7 +1,14 @@
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetOriginalUrl } from '$lib/utils';
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';
export type CropAspectRatio =
@ -44,6 +51,8 @@ class TransformManager implements EditToolManager {
region = $state({ x: 0, y: 0, width: 100, height: 100 });
imageRotation = $state(0);
mirrorHorizontal = $state(false);
mirrorVertical = $state(false);
normalizedRotation = $derived.by(() => {
const newAngle = this.imageRotation % 360;
return newAngle < 0 ? newAngle + 360 : newAngle;
@ -69,6 +78,18 @@ class TransformManager implements EditToolManager {
checkEdits() {
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 (
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 {
const edits: EditActions = [];
if (this.checkEdits()) {
const { x, y, width, height } = this.region;
if (this.checkCropEdits()) {
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({
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: {
x: Math.round(x / this.cropImageScale),
y: Math.round(y / this.cropImageScale),
width: Math.round(width / this.cropImageScale),
height: Math.round(height / this.cropImageScale),
axis: MirrorAxis.Horizontal,
},
});
}
if (this.mirrorVertical) {
edits.push({
action: EditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
});
}
@ -105,6 +160,8 @@ class TransformManager implements EditToolManager {
async resetAllChanges() {
this.imageRotation = 0;
this.mirrorHorizontal = false;
this.mirrorVertical = false;
await tick();
this.onImageLoad([]);
@ -127,6 +184,17 @@ class TransformManager implements EditToolManager {
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();
this.resizeCanvas();
@ -150,11 +218,25 @@ class TransformManager implements EditToolManager {
this.isDragging = false;
this.overlayEl = null;
this.imageRotation = 0;
this.mirrorHorizontal = false;
this.mirrorVertical = false;
this.region = { x: 0, y: 0, width: 100, height: 100 };
this.cropImageSize = [1000, 1000];
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) {
this.imageRotation += angle;
await tick();
@ -355,12 +437,25 @@ class TransformManager implements EditToolManager {
if (cropEdit) {
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
this.region = {
x: params.x * scale,
y: params.y * scale,
width: params.width * scale,
height: params.height * scale,
x: x * scale,
y: y * scale,
width: width * scale,
height: height * scale,
};
} else {
this.region = {
@ -934,6 +1029,16 @@ class TransformManager implements EditToolManager {
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();