From 79dcb3cb5e690f32cc9545ee9cb6f63ebd0e78aa Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 16 Dec 2025 11:05:18 -0600 Subject: [PATCH] chore: revise frontend UI --- i18n/en.json | 4 +- .../asset-viewer/editor/editor-panel.svelte | 8 +- .../editor/transform-tool/crop-area.svelte | 22 ++- .../transform-tool/transform-tool.svelte | 133 +++++++----------- .../managers/edit/transform-manager.svelte.ts | 127 +++++++++++++++-- 5 files changed, 193 insertions(+), 101 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 2579e58b00..b0a0a403ba 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 1a02897885..3e845a5baa 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -74,7 +74,13 @@ {$t('save')} - diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 1a00e4124b..d120586711 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -12,6 +12,20 @@ let canvasContainer = $state(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" > - {$getAltText(toTimelineAsset(asset))} + {$getAltText(toTimelineAsset(asset))}
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(); - - 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(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); }
-

{$t('crop')}

+

{$t('crop')}

- - - - - -
-

{$t('editor_crop_tool_h2_rotation')}

+

{$t('editor_rotate_and_flip')}

- - - -
-

{$t('editor_crop_tool_h2_mirror')}

-
- - - + + + +
diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index cc2e0992e8..e5a6762607 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -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();