chore: revise frontend UI
parent
4ae8b27089
commit
79dcb3cb5e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue