+ import { onMount, afterUpdate, onDestroy, tick } from 'svelte';
+ import { t } from 'svelte-i18n';
+ import { getAssetOriginalUrl } from '$lib/utils';
+ import { handleError } from '$lib/utils/handle-error';
+ import { getAltText } from '$lib/utils/thumbnail-util';
+
+ import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store';
+ import { draw } from './drawing';
+ import { onImageLoad, resizeCanvas } from './image-loading';
+ import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
+ import { recalculateCrop, animateCropChange } from './crop-settings';
+ import {
+ changedOriention,
+ cropAspectRatio,
+ cropSettings,
+ resetGlobalCropStore,
+ rotateDegrees,
+ } from '$lib/stores/asset-editor.store';
+
+ export let asset;
+ let img: HTMLImageElement;
+
+ $: imgElement.set(img);
+
+ cropAspectRatio.subscribe((value) => {
+ if (!img || !$cropAreaEl) {
+ return;
+ }
+ const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true);
+ if (newCrop) {
+ animateCropChange($cropSettings, newCrop, () => draw($cropSettings));
+ }
+ });
+
+ onMount(async () => {
+ resetGlobalCropStore();
+ img = new Image();
+ await tick();
+
+ img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum });
+
+ img.addEventListener('load', () => onImageLoad(true));
+ img.addEventListener('error', (error) => {
+ handleError(error, $t('error_loading_image'));
+ });
+
+ window.addEventListener('mousemove', handleMouseMove);
+ });
+
+ onDestroy(() => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ resetCropStore();
+ resetGlobalCropStore();
+ });
+
+ afterUpdate(() => {
+ resizeCanvas();
+ });
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte
new file mode 100644
index 0000000000000..667191274faaa
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts
new file mode 100644
index 0000000000000..a0390d2d4d47e
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts
@@ -0,0 +1,159 @@
+import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store';
+import { get } from 'svelte/store';
+import { cropAreaEl } from './crop-store';
+import { checkEdits } from './mouse-handlers';
+
+export function recalculateCrop(
+ crop: CropSettings,
+ canvas: HTMLElement,
+ aspectRatio: CropAspectRatio,
+ returnNewCrop = false,
+): CropSettings | null {
+ const canvasW = canvas.clientWidth;
+ const canvasH = canvas.clientHeight;
+
+ let newWidth = crop.width;
+ let newHeight = crop.height;
+
+ const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio);
+
+ if (w > canvasW) {
+ newWidth = canvasW;
+ newHeight = canvasW / (w / h);
+ } else if (h > canvasH) {
+ newHeight = canvasH;
+ newWidth = canvasH * (w / h);
+ } else {
+ newWidth = w;
+ newHeight = h;
+ }
+
+ const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth));
+ const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight));
+
+ const newCrop = {
+ width: newWidth,
+ height: newHeight,
+ x: newX,
+ y: newY,
+ };
+
+ if (returnNewCrop) {
+ setTimeout(() => {
+ checkEdits();
+ }, 1);
+ return newCrop;
+ } else {
+ crop.width = newWidth;
+ crop.height = newHeight;
+ crop.x = newX;
+ crop.y = newY;
+ return null;
+ }
+}
+
+export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) {
+ const cropArea = get(cropAreaEl);
+ if (!cropArea) {
+ return;
+ }
+
+ const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
+ if (!cropFrame) {
+ return;
+ }
+
+ const startTime = performance.now();
+ const initialCrop = { ...crop };
+
+ const animate = (currentTime: number) => {
+ const elapsedTime = currentTime - startTime;
+ const progress = Math.min(elapsedTime / duration, 1);
+
+ crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress;
+ crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress;
+ crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress;
+ crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress;
+
+ draw();
+
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ }
+ };
+
+ requestAnimationFrame(animate);
+}
+
+export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) {
+ const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
+
+ if (widthRatio && heightRatio) {
+ const calculatedWidth = (newHeight * widthRatio) / heightRatio;
+ return { newWidth: calculatedWidth, newHeight };
+ }
+
+ return { newWidth, newHeight };
+}
+
+export function adjustDimensions(
+ newWidth: number,
+ newHeight: number,
+ aspectRatio: CropAspectRatio,
+ xLimit: number,
+ yLimit: number,
+ minSize: number,
+) {
+ let w = newWidth;
+ let h = newHeight;
+
+ let aspectMultiplier: number;
+
+ if (aspectRatio === 'free') {
+ aspectMultiplier = newWidth / newHeight;
+ } else {
+ const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
+ aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight;
+ }
+
+ if (aspectRatio !== 'free') {
+ h = w / aspectMultiplier;
+ }
+
+ if (w > xLimit) {
+ w = xLimit;
+ if (aspectRatio !== 'free') {
+ h = w / aspectMultiplier;
+ }
+ }
+ if (h > yLimit) {
+ h = yLimit;
+ if (aspectRatio !== 'free') {
+ w = h * aspectMultiplier;
+ }
+ }
+
+ if (w < minSize) {
+ w = minSize;
+ if (aspectRatio !== 'free') {
+ h = w / aspectMultiplier;
+ }
+ }
+ if (h < minSize) {
+ h = minSize;
+ if (aspectRatio !== 'free') {
+ w = h * aspectMultiplier;
+ }
+ }
+
+ if (aspectRatio !== 'free' && w / h !== aspectMultiplier) {
+ if (w < minSize) {
+ h = w / aspectMultiplier;
+ }
+ if (h < minSize) {
+ w = h * aspectMultiplier;
+ }
+ }
+
+ return { newWidth: w, newHeight: h };
+}
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts
new file mode 100644
index 0000000000000..8e27d41f21926
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts
@@ -0,0 +1,27 @@
+import { writable } from 'svelte/store';
+
+export const darkenLevel = writable(0.65);
+export const isResizingOrDragging = writable(false);
+export const animationFrame = writable
| null>(null);
+export const canvasCursor = writable('default');
+export const dragOffset = writable({ x: 0, y: 0 });
+export const resizeSide = writable('');
+export const imgElement = writable(null);
+export const cropAreaEl = writable(null);
+export const isDragging = writable(false);
+
+export const overlayEl = writable(null);
+export const cropFrame = writable(null);
+
+export function resetCropStore() {
+ darkenLevel.set(0.65);
+ isResizingOrDragging.set(false);
+ animationFrame.set(null);
+ canvasCursor.set('default');
+ dragOffset.set({ x: 0, y: 0 });
+ resizeSide.set('');
+ imgElement.set(null);
+ cropAreaEl.set(null);
+ isDragging.set(false);
+ overlayEl.set(null);
+}
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte
new file mode 100644
index 0000000000000..dba3be5d671ff
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte
@@ -0,0 +1,151 @@
+
+
+
+
+
{$t('editor_crop_tool_h2_aspect_ratios').toUpperCase()}
+
+ {#each sizesRows as sizesRow}
+
+ {#each sizesRow as size (size.name)}
+
+ {/each}
+
+ {/each}
+
+
{$t('editor_crop_tool_h2_rotation').toUpperCase()}
+
+
+ - rotate(false)} icon={mdiRotateLeft} />
+ - rotate(true)} icon={mdiRotateRight} />
+
+
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts
new file mode 100644
index 0000000000000..85e7f4b1c408d
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts
@@ -0,0 +1,40 @@
+import type { CropSettings } from '$lib/stores/asset-editor.store';
+import { get } from 'svelte/store';
+import { cropFrame, overlayEl } from './crop-store';
+
+export function draw(crop: CropSettings) {
+ const mCropFrame = get(cropFrame);
+
+ if (!mCropFrame) {
+ return;
+ }
+
+ mCropFrame.style.left = `${crop.x}px`;
+ mCropFrame.style.top = `${crop.y}px`;
+ mCropFrame.style.width = `${crop.width}px`;
+ mCropFrame.style.height = `${crop.height}px`;
+
+ drawOverlay(crop);
+}
+
+export function drawOverlay(crop: CropSettings) {
+ const overlay = get(overlayEl);
+ if (!overlay) {
+ return;
+ }
+
+ overlay.style.clipPath = `
+ polygon(
+ 0% 0%,
+ 0% 100%,
+ 100% 100%,
+ 100% 0%,
+ 0% 0%,
+ ${crop.x}px ${crop.y}px,
+ ${crop.x + crop.width}px ${crop.y}px,
+ ${crop.x + crop.width}px ${crop.y + crop.height}px,
+ ${crop.x}px ${crop.y + crop.height}px,
+ ${crop.x}px ${crop.y}px
+ )
+ `;
+}
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts
new file mode 100644
index 0000000000000..bce90efd9e1f7
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts
@@ -0,0 +1,117 @@
+import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store';
+import { get } from 'svelte/store';
+import { cropAreaEl, cropFrame, imgElement } from './crop-store';
+import { draw } from './drawing';
+
+export function onImageLoad(resetSize: boolean = false) {
+ const img = get(imgElement);
+ const cropArea = get(cropAreaEl);
+
+ if (!cropArea || !img) {
+ return;
+ }
+
+ const containerWidth = cropArea.clientWidth ?? 0;
+ const containerHeight = cropArea.clientHeight ?? 0;
+
+ const scale = calculateScale(img, containerWidth, containerHeight);
+
+ cropImageSize.set([img.width, img.height]);
+
+ if (resetSize) {
+ cropSettings.update((crop) => {
+ crop.x = 0;
+ crop.y = 0;
+ crop.width = img.width * scale;
+ crop.height = img.height * scale;
+ return crop;
+ });
+ } else {
+ const cropFrameEl = get(cropFrame);
+ cropFrameEl?.classList.add('transition');
+ cropSettings.update((crop) => normalizeCropArea(crop, img, scale));
+ cropFrameEl?.classList.add('transition');
+ cropFrameEl?.addEventListener('transitionend', () => {
+ cropFrameEl?.classList.remove('transition');
+ });
+ }
+ cropImageScale.set(scale);
+
+ img.style.width = `${img.width * scale}px`;
+ img.style.height = `${img.height * scale}px`;
+
+ draw(get(cropSettings));
+}
+
+export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number {
+ const imageAspectRatio = img.width / img.height;
+ let scale: number;
+
+ if (imageAspectRatio > 1) {
+ scale = containerWidth / img.width;
+ if (img.height * scale > containerHeight) {
+ scale = containerHeight / img.height;
+ }
+ } else {
+ scale = containerHeight / img.height;
+ if (img.width * scale > containerWidth) {
+ scale = containerWidth / img.width;
+ }
+ }
+
+ return scale;
+}
+
+export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) {
+ const prevScale = get(cropImageScale);
+ const scaleRatio = scale / prevScale;
+
+ crop.x *= scaleRatio;
+ crop.y *= scaleRatio;
+ crop.width *= scaleRatio;
+ crop.height *= scaleRatio;
+
+ crop.width = Math.min(crop.width, img.width * scale);
+ crop.height = Math.min(crop.height, img.height * scale);
+ crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width));
+ crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height));
+
+ return crop;
+}
+
+export function resizeCanvas() {
+ const img = get(imgElement);
+ const cropArea = get(cropAreaEl);
+
+ if (!cropArea || !img) {
+ return;
+ }
+
+ const containerWidth = cropArea?.clientWidth ?? 0;
+ const containerHeight = cropArea?.clientHeight ?? 0;
+ const imageAspectRatio = img.width / img.height;
+
+ let scale;
+ if (imageAspectRatio > 1) {
+ scale = containerWidth / img.width;
+ if (img.height * scale > containerHeight) {
+ scale = containerHeight / img.height;
+ }
+ } else {
+ scale = containerHeight / img.height;
+ if (img.width * scale > containerWidth) {
+ scale = containerWidth / img.width;
+ }
+ }
+
+ img.style.width = `${img.width * scale}px`;
+ img.style.height = `${img.height * scale}px`;
+
+ const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
+ if (cropFrame) {
+ cropFrame.style.width = `${img.width * scale}px`;
+ cropFrame.style.height = `${img.height * scale}px`;
+ }
+
+ draw(get(cropSettings));
+}
diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts
new file mode 100644
index 0000000000000..656fd09294abb
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts
@@ -0,0 +1,536 @@
+import {
+ cropAspectRatio,
+ cropImageScale,
+ cropImageSize,
+ cropSettings,
+ cropSettingsChanged,
+ normaizedRorateDegrees,
+ rotateDegrees,
+ showCancelConfirmDialog,
+ type CropSettings,
+} from '$lib/stores/asset-editor.store';
+import { get } from 'svelte/store';
+import { adjustDimensions, keepAspectRatio } from './crop-settings';
+import {
+ canvasCursor,
+ cropAreaEl,
+ dragOffset,
+ isDragging,
+ isResizingOrDragging,
+ overlayEl,
+ resizeSide,
+} from './crop-store';
+import { draw } from './drawing';
+
+export function handleMouseDown(e: MouseEvent) {
+ const canvas = get(cropAreaEl);
+ if (!canvas) {
+ return;
+ }
+
+ const crop = get(cropSettings);
+ const { mouseX, mouseY } = getMousePosition(e);
+
+ const {
+ onLeftBoundary,
+ onRightBoundary,
+ onTopBoundary,
+ onBottomBoundary,
+ onTopLeftCorner,
+ onTopRightCorner,
+ onBottomLeftCorner,
+ onBottomRightCorner,
+ } = isOnCropBoundary(mouseX, mouseY, crop);
+
+ if (
+ onTopLeftCorner ||
+ onTopRightCorner ||
+ onBottomLeftCorner ||
+ onBottomRightCorner ||
+ onLeftBoundary ||
+ onRightBoundary ||
+ onTopBoundary ||
+ onBottomBoundary
+ ) {
+ setResizeSide(mouseX, mouseY);
+ } else if (isInCropArea(mouseX, mouseY, crop)) {
+ startDragging(mouseX, mouseY);
+ }
+
+ document.body.style.userSelect = 'none';
+ window.addEventListener('mouseup', handleMouseUp);
+}
+
+export function handleMouseMove(e: MouseEvent) {
+ const canvas = get(cropAreaEl);
+ if (!canvas) {
+ return;
+ }
+
+ const resizeSideValue = get(resizeSide);
+ const { mouseX, mouseY } = getMousePosition(e);
+
+ if (get(isDragging)) {
+ moveCrop(mouseX, mouseY);
+ } else if (resizeSideValue) {
+ resizeCrop(mouseX, mouseY);
+ } else {
+ updateCursor(mouseX, mouseY);
+ }
+}
+
+export function handleMouseUp() {
+ window.removeEventListener('mouseup', handleMouseUp);
+ document.body.style.userSelect = '';
+ stopInteraction();
+}
+
+function getMousePosition(e: MouseEvent) {
+ let offsetX = e.clientX;
+ let offsetY = e.clientY;
+ const clienRect = getBoundingClientRectCached(get(cropAreaEl));
+ const rotateDeg = get(normaizedRorateDegrees);
+
+ if (rotateDeg == 90) {
+ offsetX = e.clientY - (clienRect?.top ?? 0);
+ offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
+ } else if (rotateDeg == 180) {
+ offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
+ offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
+ } else if (rotateDeg == 270) {
+ offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
+ offsetY = e.clientX - (clienRect?.left ?? 0);
+ } else if (rotateDeg == 0) {
+ offsetX -= clienRect?.left ?? 0;
+ offsetY -= clienRect?.top ?? 0;
+ }
+ return { mouseX: offsetX, mouseY: offsetY };
+}
+
+type BoundingClientRect = ReturnType;
+let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = {
+ data: null,
+ time: 0,
+};
+rotateDegrees.subscribe(() => {
+ getBoundingClientRectCache.time = 0;
+});
+function getBoundingClientRectCached(el: HTMLElement | null) {
+ if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) {
+ getBoundingClientRectCache = {
+ time: Date.now(),
+ data: el?.getBoundingClientRect() ?? null,
+ };
+ }
+ return getBoundingClientRectCache.data;
+}
+
+function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) {
+ const { x, y, width, height } = crop;
+ const sensitivity = 10;
+ const cornerSensitivity = 15;
+
+ const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0;
+ if (outOfBound) {
+ return {
+ onLeftBoundary: false,
+ onRightBoundary: false,
+ onTopBoundary: false,
+ onBottomBoundary: false,
+ onTopLeftCorner: false,
+ onTopRightCorner: false,
+ onBottomLeftCorner: false,
+ onBottomRightCorner: false,
+ };
+ }
+
+ const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height;
+ const onRightBoundary =
+ mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height;
+ const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width;
+ const onBottomBoundary =
+ mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width;
+
+ const onTopLeftCorner =
+ mouseX >= x - cornerSensitivity &&
+ mouseX <= x + cornerSensitivity &&
+ mouseY >= y - cornerSensitivity &&
+ mouseY <= y + cornerSensitivity;
+ const onTopRightCorner =
+ mouseX >= x + width - cornerSensitivity &&
+ mouseX <= x + width + cornerSensitivity &&
+ mouseY >= y - cornerSensitivity &&
+ mouseY <= y + cornerSensitivity;
+ const onBottomLeftCorner =
+ mouseX >= x - cornerSensitivity &&
+ mouseX <= x + cornerSensitivity &&
+ mouseY >= y + height - cornerSensitivity &&
+ mouseY <= y + height + cornerSensitivity;
+ const onBottomRightCorner =
+ mouseX >= x + width - cornerSensitivity &&
+ mouseX <= x + width + cornerSensitivity &&
+ mouseY >= y + height - cornerSensitivity &&
+ mouseY <= y + height + cornerSensitivity;
+
+ return {
+ onLeftBoundary,
+ onRightBoundary,
+ onTopBoundary,
+ onBottomBoundary,
+ onTopLeftCorner,
+ onTopRightCorner,
+ onBottomLeftCorner,
+ onBottomRightCorner,
+ };
+}
+
+function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) {
+ const { x, y, width, height } = crop;
+ return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
+}
+
+function setResizeSide(mouseX: number, mouseY: number) {
+ const crop = get(cropSettings);
+ const {
+ onLeftBoundary,
+ onRightBoundary,
+ onTopBoundary,
+ onBottomBoundary,
+ onTopLeftCorner,
+ onTopRightCorner,
+ onBottomLeftCorner,
+ onBottomRightCorner,
+ } = isOnCropBoundary(mouseX, mouseY, crop);
+
+ if (onTopLeftCorner) {
+ resizeSide.set('top-left');
+ } else if (onTopRightCorner) {
+ resizeSide.set('top-right');
+ } else if (onBottomLeftCorner) {
+ resizeSide.set('bottom-left');
+ } else if (onBottomRightCorner) {
+ resizeSide.set('bottom-right');
+ } else if (onLeftBoundary) {
+ resizeSide.set('left');
+ } else if (onRightBoundary) {
+ resizeSide.set('right');
+ } else if (onTopBoundary) {
+ resizeSide.set('top');
+ } else if (onBottomBoundary) {
+ resizeSide.set('bottom');
+ }
+}
+
+function startDragging(mouseX: number, mouseY: number) {
+ isDragging.set(true);
+ const crop = get(cropSettings);
+ isResizingOrDragging.set(true);
+ dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y });
+ fadeOverlay(false);
+}
+
+function moveCrop(mouseX: number, mouseY: number) {
+ const cropArea = get(cropAreaEl);
+ if (!cropArea) {
+ return;
+ }
+
+ const crop = get(cropSettings);
+ const { x, y } = get(dragOffset);
+
+ let newX = mouseX - x;
+ let newY = mouseY - y;
+
+ newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX));
+ newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY));
+
+ cropSettings.update((crop) => {
+ crop.x = newX;
+ crop.y = newY;
+ return crop;
+ });
+
+ draw(crop);
+}
+
+function resizeCrop(mouseX: number, mouseY: number) {
+ const canvas = get(cropAreaEl);
+ const crop = get(cropSettings);
+ const resizeSideValue = get(resizeSide);
+ if (!canvas || !resizeSideValue) {
+ return;
+ }
+ fadeOverlay(false);
+
+ const { x, y, width, height } = crop;
+ const minSize = 50;
+ let newWidth = width;
+ let newHeight = height;
+ switch (resizeSideValue) {
+ case 'left': {
+ newWidth = width + x - mouseX;
+ newHeight = height;
+ if (newWidth >= minSize && mouseX >= 0) {
+ const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
+ cropSettings.update((crop) => {
+ crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth));
+ crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
+ crop.x = Math.max(0, x + width - crop.width);
+ return crop;
+ });
+ }
+ break;
+ }
+ case 'right': {
+ newWidth = mouseX - x;
+ newHeight = height;
+ if (newWidth >= minSize && mouseX <= canvas.clientWidth) {
+ const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
+ cropSettings.update((crop) => {
+ crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x));
+ crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
+ return crop;
+ });
+ }
+ break;
+ }
+ case 'top': {
+ newHeight = height + y - mouseY;
+ newWidth = width;
+ if (newHeight >= minSize && mouseY >= 0) {
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth,
+ canvas.clientHeight,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.y = Math.max(0, y + height - h);
+ crop.width = w;
+ crop.height = h;
+ return crop;
+ });
+ }
+ break;
+ }
+ case 'bottom': {
+ newHeight = mouseY - y;
+ newWidth = width;
+ if (newHeight >= minSize && mouseY <= canvas.clientHeight) {
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth,
+ canvas.clientHeight - y,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.width = w;
+ crop.height = h;
+ return crop;
+ });
+ }
+ break;
+ }
+ case 'top-left': {
+ newWidth = width + x - Math.max(mouseX, 0);
+ newHeight = height + y - Math.max(mouseY, 0);
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth,
+ canvas.clientHeight,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.width = w;
+ crop.height = h;
+ crop.x = Math.max(0, x + width - crop.width);
+ crop.y = Math.max(0, y + height - crop.height);
+ return crop;
+ });
+ break;
+ }
+ case 'top-right': {
+ newWidth = Math.max(mouseX, 0) - x;
+ newHeight = height + y - Math.max(mouseY, 0);
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth - x,
+ y + height,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.width = w;
+ crop.height = h;
+ crop.y = Math.max(0, y + height - crop.height);
+ return crop;
+ });
+ break;
+ }
+ case 'bottom-left': {
+ newWidth = width + x - Math.max(mouseX, 0);
+ newHeight = Math.max(mouseY, 0) - y;
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth,
+ canvas.clientHeight - y,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.width = w;
+ crop.height = h;
+ crop.x = Math.max(0, x + width - crop.width);
+ return crop;
+ });
+ break;
+ }
+ case 'bottom-right': {
+ newWidth = Math.max(mouseX, 0) - x;
+ newHeight = Math.max(mouseY, 0) - y;
+ const { newWidth: w, newHeight: h } = adjustDimensions(
+ newWidth,
+ newHeight,
+ get(cropAspectRatio),
+ canvas.clientWidth - x,
+ canvas.clientHeight - y,
+ minSize,
+ );
+ cropSettings.update((crop) => {
+ crop.width = w;
+ crop.height = h;
+ return crop;
+ });
+ break;
+ }
+ }
+
+ cropSettings.update((crop) => {
+ crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width));
+ crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height));
+ return crop;
+ });
+
+ draw(crop);
+}
+
+function updateCursor(mouseX: number, mouseY: number) {
+ const canvas = get(cropAreaEl);
+ if (!canvas) {
+ return;
+ }
+
+ const crop = get(cropSettings);
+ const rotateDeg = get(normaizedRorateDegrees);
+
+ let {
+ onLeftBoundary,
+ onRightBoundary,
+ onTopBoundary,
+ onBottomBoundary,
+ onTopLeftCorner,
+ onTopRightCorner,
+ onBottomLeftCorner,
+ onBottomRightCorner,
+ } = isOnCropBoundary(mouseX, mouseY, crop);
+
+ if (rotateDeg == 90) {
+ [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
+ onLeftBoundary,
+ onTopBoundary,
+ onRightBoundary,
+ onBottomBoundary,
+ ];
+
+ [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
+ onBottomLeftCorner,
+ onTopLeftCorner,
+ onTopRightCorner,
+ onBottomRightCorner,
+ ];
+ } else if (rotateDeg == 180) {
+ [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
+ [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
+
+ [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
+ [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
+ } else if (rotateDeg == 270) {
+ [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
+ onRightBoundary,
+ onBottomBoundary,
+ onLeftBoundary,
+ onTopBoundary,
+ ];
+
+ [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
+ onTopRightCorner,
+ onBottomRightCorner,
+ onBottomLeftCorner,
+ onTopLeftCorner,
+ ];
+ }
+ if (onTopLeftCorner || onBottomRightCorner) {
+ setCursor('nwse-resize');
+ } else if (onTopRightCorner || onBottomLeftCorner) {
+ setCursor('nesw-resize');
+ } else if (onLeftBoundary || onRightBoundary) {
+ setCursor('ew-resize');
+ } else if (onTopBoundary || onBottomBoundary) {
+ setCursor('ns-resize');
+ } else if (isInCropArea(mouseX, mouseY, crop)) {
+ setCursor('move');
+ } else {
+ setCursor('default');
+ }
+
+ function setCursor(cursorName: string) {
+ if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) {
+ canvasCursor.set(cursorName);
+ document.body.style.cursor = cursorName;
+ canvas.style.cursor = cursorName;
+ }
+ }
+}
+
+function stopInteraction() {
+ isResizingOrDragging.set(false);
+ isDragging.set(false);
+ resizeSide.set('');
+ fadeOverlay(true); // Darken the background
+
+ setTimeout(() => {
+ checkEdits();
+ }, 1);
+}
+
+export function checkEdits() {
+ const cropImageSizeParams = get(cropSettings);
+ const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale));
+ const changed =
+ Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 ||
+ Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2;
+ cropSettingsChanged.set(changed);
+}
+
+function fadeOverlay(toDark: boolean) {
+ const overlay = get(overlayEl);
+ const cropFrame = document.querySelector('.crop-frame');
+
+ if (toDark) {
+ overlay?.classList.remove('light');
+ cropFrame?.classList.remove('resizing');
+ } else {
+ overlay?.classList.add('light');
+ cropFrame?.classList.add('resizing');
+ }
+
+ isResizingOrDragging.set(!toDark);
+}
diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
new file mode 100644
index 0000000000000..1adef3273502d
--- /dev/null
+++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ {#each editTypes as etype (etype.name)}
+ -
+ selectType(etype.name)}
+ />
+
+ {/each}
+
+
+
+
+
+{#if $showCancelConfirmDialog}
+ {
+ $showCancelConfirmDialog = false;
+ }}
+ onConfirm={() => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog())}
+ />
+{/if}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index f424e60a66be5..5b2d9d393a2e7 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -359,6 +359,7 @@
"allow_edits": "Allow edits",
"allow_public_user_to_download": "Allow public user to download",
"allow_public_user_to_upload": "Allow public user to upload",
+ "anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
"api_key_empty": "Your API Key name shouldn't be empty",
@@ -434,6 +435,7 @@
"clear_all_recent_searches": "Clear all recent searches",
"clear_message": "Clear message",
"clear_value": "Clear value",
+ "clockwise": "Сlockwise",
"close": "Close",
"collapse": "Collapse",
"collapse_all": "Collapse all",
@@ -535,6 +537,11 @@
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edited": "Edited",
+ "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_rotation": "Rotation",
"email": "Email",
"empty_trash": "Empty trash",
"empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!",
diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json
index 6a31d297af4bd..1a55ab009dd43 100644
--- a/web/src/lib/i18n/ru.json
+++ b/web/src/lib/i18n/ru.json
@@ -360,6 +360,7 @@
"allow_edits": "Разрешить редактирование",
"allow_public_user_to_download": "Разрешить скачивание публичным пользователям",
"allow_public_user_to_upload": "Разрешить публичным пользователям загружать файлы",
+ "anti_clockwise": "Против часовой",
"api_key": "API Ключ",
"api_key_description": "Это значение будет показано только один раз. Пожалуйста, убедитесь, что скопировали его перед закрытием окна.",
"api_key_empty": "Ваш API ключ не должен быть пустым",
@@ -441,6 +442,7 @@
"clear_all_recent_searches": "Очистить все недавние результаты поиска",
"clear_message": "Очистить сообщение",
"clear_value": "Очистить значение",
+ "clockwise": "По часовой",
"close": "Закрыть",
"collapse": "Свернуть",
"collapse_all": "Свернуть всё",
@@ -550,6 +552,10 @@
"edit_user": "Редактировать пользователя",
"edited": "Отредактировано",
"editor": "Редактор",
+ "editor_close_without_save_prompt": "Изменения не будут сохранены",
+ "editor_close_without_save_title": "Закрыть редактор?",
+ "editor_crop_tool_h2_aspect_ratios": "Соотношения сторон",
+ "editor_crop_tool_h2_rotation": "Вращение",
"email": "Электронная почта",
"empty": "",
"empty_album": "Пустой альбом",
diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts
new file mode 100644
index 0000000000000..4d2f8977ee592
--- /dev/null
+++ b/web/src/lib/stores/asset-editor.store.ts
@@ -0,0 +1,73 @@
+import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte';
+import { mdiCropRotate } from '@mdi/js';
+import { derived, get, writable } from 'svelte/store';
+
+//---------crop
+export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 });
+export const cropImageSize = writable([1000, 1000]);
+export const cropImageScale = writable(1);
+export const cropAspectRatio = writable('free');
+export const cropSettingsChanged = writable(false);
+//---------rotate
+export const rotateDegrees = writable(0);
+export const normaizedRorateDegrees = derived(rotateDegrees, (v) => {
+ const newAngle = v % 360;
+ return newAngle < 0 ? newAngle + 360 : newAngle;
+});
+export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0);
+//-----other
+export const showCancelConfirmDialog = writable(false);
+
+export const editTypes = [
+ {
+ name: 'crop',
+ icon: mdiCropRotate,
+ component: CropTool,
+ changesFlag: cropSettingsChanged,
+ },
+];
+
+export function closeEditorCofirm(closeCallback: CallableFunction) {
+ if (get(hasChanges)) {
+ showCancelConfirmDialog.set(closeCallback);
+ } else {
+ closeCallback();
+ }
+}
+
+export const hasChanges = derived(
+ editTypes.map((t) => t.changesFlag),
+ ($flags) => {
+ return $flags.some(Boolean);
+ },
+);
+
+export function resetGlobalCropStore() {
+ cropSettings.set({ x: 0, y: 0, width: 100, height: 100 });
+ cropImageSize.set([1000, 1000]);
+ cropImageScale.set(1);
+ cropAspectRatio.set('free');
+ cropSettingsChanged.set(false);
+ showCancelConfirmDialog.set(false);
+ rotateDegrees.set(0);
+}
+
+export type CropAspectRatio =
+ | '1:1'
+ | '16:9'
+ | '4:3'
+ | '3:2'
+ | '7:5'
+ | '9:16'
+ | '3:4'
+ | '2:3'
+ | '5:7'
+ | 'free'
+ | 'reset';
+
+export type CropSettings = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};