feat: wip new web editor interface

This commit is contained in:
bwees
2025-12-04 00:33:31 -06:00
parent dd2c19233d
commit 62030c7e4c
8 changed files with 157 additions and 152 deletions

View File

@@ -17,6 +17,14 @@
"add_a_title": "Add a title",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"editor_crop_tool_h2_mirror": "Mirror",
"mirror_horizontal": "Horizontal",
"mirror_vertical": "Vertical",
"rotate_ccw": "CCW 90°",
"rotate_cw": "CW 90°",
"crop_aspect_ratio_free": "Free",
"crop_aspect_ratio_fixed": "Fixed",
"crop_aspect_ratio_original": "Original",
"add_exclusion_pattern": "Add exclusion pattern",
"add_location": "Add location",
"add_more_users": "Add more users",

View File

@@ -42,7 +42,7 @@
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import CropArea from './editor/transform-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';

View File

@@ -1,144 +0,0 @@
<script lang="ts">
import { transformManager, type CropAspectRatio } from '$lib/managers/edit/transform-manager.svelte';
import { IconButton } from '@immich/ui';
import { mdiBackupRestore, mdiCropFree, mdiRotateLeft, mdiRotateRight, mdiSquareOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import CropPreset from './crop-preset.svelte';
let rotateHorizontal = $derived([90, 270].includes(transformManager.normalizedRotation));
const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
const icon_7_5 = `M200-200q-33 0-56.5-23.5T120-280v-400q0-33 23.5-56.5T200-760h560q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H200Zm0-80h560v-400H200v400Zm0 0v-400 400Z`;
interface Size {
icon: string;
name: CropAspectRatio;
viewBox: string;
rotate?: boolean;
}
let sizes: Size[] = [
{
icon: mdiCropFree,
name: 'free',
viewBox: '0 0 24 24',
rotate: false,
},
{
name: '1:1',
icon: mdiSquareOutline,
viewBox: '0 0 24 24',
rotate: false,
},
{
name: '16:9',
icon: icon_16_9,
viewBox: '50 -700 840 400',
},
{
name: '4:3',
icon: icon_4_3,
viewBox: '0 0 24 24',
},
{
name: '3:2',
icon: icon_3_2,
viewBox: '50 -720 840 480',
},
{
name: '7:5',
icon: icon_7_5,
viewBox: '50 -760 840 560',
},
{
name: '9:16',
icon: icon_16_9,
viewBox: '50 -700 840 400',
rotate: true,
},
{
name: '3:4',
icon: icon_4_3,
viewBox: '0 0 24 24',
rotate: true,
},
{
name: '2:3',
icon: icon_3_2,
viewBox: '50 -720 840 480',
rotate: true,
},
{
name: '5:7',
icon: icon_7_5,
viewBox: '50 -760 840 560',
rotate: true,
},
{
name: 'reset',
icon: mdiBackupRestore,
viewBox: '0 0 24 24',
rotate: false,
},
];
let sizesRows = $derived([
sizes.filter((s) => s.rotate === false),
sizes.filter((s) => s.rotate === undefined),
sizes.filter((s) => s.rotate === true),
]);
function selectType(size: CropAspectRatio) {
if (size === 'reset') {
transformManager.cropAspectRatio = 'free';
let cropImageSizeM = transformManager.cropImageSize;
let cropImageScaleM = transformManager.cropImageScale;
transformManager.region = {
x: 0,
y: 0,
width: cropImageSizeM[0] * cropImageScaleM - 1,
height: cropImageSizeM[1] * cropImageScaleM - 1,
};
size = 'free';
}
transformManager.setAspectRatio(size);
}
</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>
</div>
{#each sizesRows as sizesRow, index (index)}
<ul class="flex-wrap flex-row flex gap-x-6 py-2 justify-evenly">
{#each sizesRow as size (size.name)}
<CropPreset {size} selectedSize={transformManager.cropAspectRatio} {rotateHorizontal} {selectType} />
{/each}
</ul>
{/each}
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2 class="uppercase">{$t('editor_crop_tool_h2_rotation')}</h2>
</div>
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
<li>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('anti_clockwise')}
onclick={() => transformManager.rotate(-90)}
icon={mdiRotateLeft}
/>
</li>
<li>
<IconButton
shape="round"
variant="ghost"
color="secondary"
aria-label={$t('clockwise')}
onclick={() => transformManager.rotate(90)}
icon={mdiRotateRight}
/>
</li>
</ul>
</div>

View File

@@ -0,0 +1,131 @@
<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 { 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([
{ label: $t('crop_aspect_ratio_original'), value: 'original' },
{ label: '1:1', value: '1:1' },
...(cropOrientation === 'landscape' ? horizontalRatios : verticalRatios).map((ratio) => ({
label: ratio,
value: ratio,
})),
]);
function resetCrop() {
transformManager.resetCrop();
selectedRatio = undefined;
}
function selectAspectRatio(ratio: 'original' | 'free' | (typeof aspectRatios)[number]['value']) {
if (ratio === 'original') {
const [width, height] = transformManager.cropImageSize;
ratio = `${width}:${height}`;
}
transformManager.setAspectRatio(ratio);
}
function onAspectRatioSelect(ratio: SelectItem) {
selectedRatio = ratio;
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);
}
}
async function rotateImage(degrees: number) {
await transformManager.rotate(degrees);
rotateCropOrientation();
}
</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>
</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>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiRotateRight}
aria-label={$t('reset')}
onclick={rotateCropOrientation}
disabled={cropMode === 'free'}
/>
</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>
</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>
</HStack>
</div>

View File

@@ -1,4 +1,4 @@
import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte';
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
@@ -33,7 +33,7 @@ export class EditManager {
{
type: EditToolType.Transform,
icon: mdiCropRotate,
component: CropTool,
component: TransformTool,
manager: transformManager,
},
];

View File

@@ -40,7 +40,7 @@ class TransformManager implements EditToolManager {
cropFrame = $state<HTMLElement | null>(null);
cropImageSize = $state([1000, 1000]);
cropImageScale = $state(1);
cropAspectRatio = $state('free' as CropAspectRatio);
cropAspectRatio = $state("free");
region = $state({ x: 0, y: 0, width: 100, height: 100 });
imageRotation = $state(0);
@@ -52,7 +52,7 @@ class TransformManager implements EditToolManager {
edits = $derived.by(() => this.getEdits());
setAspectRatio(aspectRatio: CropAspectRatio) {
setAspectRatio(aspectRatio: string) {
this.cropAspectRatio = aspectRatio;
if (!this.imgElement || !this.cropAreaEl) {
@@ -161,7 +161,7 @@ class TransformManager implements EditToolManager {
this.onImageLoad();
}
recalculateCrop(aspectRatio: CropAspectRatio = this.cropAspectRatio): CropSettings {
recalculateCrop(aspectRatio: string = this.cropAspectRatio): CropSettings {
if (!this.cropAreaEl) {
return this.region;
}
@@ -226,7 +226,7 @@ class TransformManager implements EditToolManager {
requestAnimationFrame(animate);
}
keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio = this.cropAspectRatio) {
keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: string = this.cropAspectRatio) {
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
if (widthRatio && heightRatio) {
@@ -240,7 +240,7 @@ class TransformManager implements EditToolManager {
adjustDimensions(
newWidth: number,
newHeight: number,
aspectRatio: CropAspectRatio,
aspectRatio: string,
xLimit: number,
yLimit: number,
minSize: number,
@@ -924,6 +924,16 @@ class TransformManager implements EditToolManager {
this.isInteracting = !toDark;
}
resetCrop() {
this.cropAspectRatio = 'free';
this.region = {
x: 0,
y: 0,
width: this.cropImageSize[0] * this.cropImageScale - 1,
height: this.cropImageSize[1] * this.cropImageScale - 1,
};
}
}
export const transformManager = new TransformManager();